diff --git a/review/greptile-triage.md b/review/greptile-triage.md index 862ceb242..dad456996 100644 --- a/review/greptile-triage.md +++ b/review/greptile-triage.md @@ -6,24 +6,23 @@ Shared reference for fetching, filtering, and classifying Greptile review commen ## Fetch -Run these commands to detect the PR and fetch comments. Both API calls run in parallel. +Run this block to detect the PR and fetch comments. Both API calls run in parallel into a private `mktemp` directory. ```bash REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner' 2>/dev/null) PR_NUMBER=$(gh pr view --json number --jq '.number' 2>/dev/null) -``` - -**If either fails or is empty:** Skip Greptile triage silently. This integration is additive — the workflow works without it. - -```bash -# Fetch line-level review comments AND top-level PR comments in parallel -gh api repos/$REPO/pulls/$PR_NUMBER/comments \ - --jq '.[] | select(.user.login == "greptile-apps[bot]") | select(.position != null) | {id: .id, path: .path, line: .line, body: .body, html_url: .html_url, source: "line-level"}' > /tmp/greptile_line.json & -gh api repos/$REPO/issues/$PR_NUMBER/comments \ - --jq '.[] | select(.user.login == "greptile-apps[bot]") | {id: .id, body: .body, html_url: .html_url, source: "top-level"}' > /tmp/greptile_top.json & +[ -n "$REPO" ] && [ -n "$PR_NUMBER" ] || { echo "GREPTILE: skip (no PR, or gh unavailable)"; exit 0; } +GREPTILE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/gstack-greptile.XXXXXX") || { echo "GREPTILE: skip (mktemp failed)"; exit 0; } +gh api "repos/$REPO/pulls/$PR_NUMBER/comments" \ + --jq '.[] | select(.user.login == "greptile-apps[bot]") | select(.position != null) | {id: .id, path: .path, line: .line, body: .body, html_url: .html_url, source: "line-level"}' > "$GREPTILE_DIR/line.json" & +gh api "repos/$REPO/issues/$PR_NUMBER/comments" \ + --jq '.[] | select(.user.login == "greptile-apps[bot]") | {id: .id, body: .body, html_url: .html_url, source: "top-level"}' > "$GREPTILE_DIR/top.json" & wait +echo "GREPTILE_DIR: $GREPTILE_DIR" ``` +**If it prints `GREPTILE: skip`:** Skip Greptile triage silently. This integration is additive — the workflow works without it. Later blocks run in fresh shells: substitute the printed `GREPTILE_DIR` path for ``. + **If API errors or zero Greptile comments across both endpoints:** Skip silently. The `position != null` filter on line-level comments automatically skips outdated comments from force-pushed code. @@ -34,8 +33,9 @@ machine-raw (you need them for reply POSTs and file reads), but read BODY text i context only through the trust envelope: ```bash -jq -r '"--- comment id \(.id) (\(.path // "top-level")) ---\n\(.body)"' /tmp/greptile_line.json | ~/.claude/skills/gstack/bin/gstack-issue-guard --stdin --source greptile-line 2>/dev/null || true -jq -r '"--- comment id \(.id) (top-level) ---\n\(.body)"' /tmp/greptile_top.json | ~/.claude/skills/gstack/bin/gstack-issue-guard --stdin --source greptile-top 2>/dev/null || true +GREPTILE_DIR= +jq -r '"--- comment id \(.id) (\(.path // "top-level")) ---\n\(.body)"' "$GREPTILE_DIR/line.json" | ~/.claude/skills/gstack/bin/gstack-issue-guard --stdin --source greptile-line 2>/dev/null || true +jq -r '"--- comment id \(.id) (top-level) ---\n\(.body)"' "$GREPTILE_DIR/top.json" | ~/.claude/skills/gstack/bin/gstack-issue-guard --stdin --source greptile-top 2>/dev/null || true ``` (The per-comment id headers travel INSIDE the envelope so multi-line bodies @@ -94,28 +94,35 @@ For each non-suppressed comment: ## Reply APIs -When replying to Greptile comments, use the correct endpoint based on comment source. +Reply text quotes commit SHAs, diff lines and reviewer text, so it never goes into a shell command: in double quotes the shell runs every backtick span (the reply posts as "Fixed in .", and text from the diff or the comment runs on this machine), and a heredoc ends early at any line equal to its delimiter. The text travels as a file instead. -**Pass the reply body through a quoted heredoc, never inside double quotes.** Replies quote commit SHAs, diff lines and reviewer text in backticks. Inside `"..."` the shell runs every backtick span as a command: the reply posts with the text missing ("Fixed in ."), and untrusted text from the diff or the comment runs on this machine. The quoted delimiter (`'GSTACK_REPLY'`) makes the shell expand nothing, and expanding `"$REPLY_BODY"` afterwards never re-runs backticks inside its value. +**1. Create the reply file:** -**Line-level comments** (from `pulls/$PR/comments`): ```bash -REPLY_BODY=$(cat <<'GSTACK_REPLY' - -GSTACK_REPLY -) -gh api repos/$REPO/pulls/$PR_NUMBER/comments/$COMMENT_ID/replies \ - -f body="$REPLY_BODY" +_GT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp" +mkdir -p "$_GT" && chmod 700 "$_GT" || { echo "Not sent: cannot create $_GT for the text file." >&2; exit 1; } +_EX=$(git rev-parse --git-path info/exclude 2>/dev/null) && mkdir -p "$(dirname "$_EX")" && { grep -qxF '/.gstack/tmp/' "$_EX" 2>/dev/null || echo '/.gstack/tmp/' >> "$_EX"; } +REPLY_FILE=$(mktemp "$_GT/reply.XXXXXX") && echo "REPLY_FILE: $REPLY_FILE (name: ${REPLY_FILE##*/})" ``` -**Top-level comments** (from `issues/$PR/comments`): +**2. Write the reply** (a template below) into the printed file with your file-write tool (Claude Code's Write tool needs a Read of the empty file first), exactly as it should appear. The text never goes into a shell command, heredoc or quoted argument. If a write fails or is refused, do not send: print the cause, the file path and the command below for sending by hand. + +**3. Post it.** Substitute the printed name for `` and the comment's raw numeric `id` from the fetched JSON for ``; use a value only when it is digits (``) or letters, digits, `.`, `_` and `-` (``). Otherwise do not run the block: print the value and the manual command. The block deletes the file after a successful post. + +Line-level comments (from `pulls/$PR/comments`): ```bash -REPLY_BODY=$(cat <<'GSTACK_REPLY' - -GSTACK_REPLY -) -gh api repos/$REPO/issues/$PR_NUMBER/comments \ - -f body="$REPLY_BODY" +REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner') && PR_NUMBER=$(gh pr view --json number --jq '.number') || { echo "Not sent: gh could not resolve the repository and PR." >&2; exit 1; } +REPLY_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/" +[ -s "$REPLY_FILE" ] || { echo "Not sent: $REPLY_FILE is missing or empty, so the text was never written. Write it, then send by hand: gh api repos/$REPO/pulls/$PR_NUMBER/comments//replies -F body=@$REPLY_FILE" >&2; exit 1; } +gh api "repos/$REPO/pulls/$PR_NUMBER/comments//replies" -F "body=@$REPLY_FILE" >/dev/null && rm -f "$REPLY_FILE" +``` + +Top-level comments (from `issues/$PR/comments`): +```bash +REPO=$(gh repo view --json nameWithOwner --jq '.nameWithOwner') && PR_NUMBER=$(gh pr view --json number --jq '.number') || { echo "Not sent: gh could not resolve the repository and PR." >&2; exit 1; } +REPLY_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/" +[ -s "$REPLY_FILE" ] || { echo "Not sent: $REPLY_FILE is missing or empty, so the text was never written. Write it, then send by hand: gh pr comment $PR_NUMBER --body-file $REPLY_FILE" >&2; exit 1; } +gh api "repos/$REPO/issues/$PR_NUMBER/comments" -F "body=@$REPLY_FILE" >/dev/null && rm -f "$REPLY_FILE" ``` **If a reply POST fails** (e.g., PR was closed, no write permission): warn and continue. Do not stop the workflow for a failed reply. diff --git a/scripts/resolvers/free-text-file.ts b/scripts/resolvers/free-text-file.ts new file mode 100644 index 000000000..b0e0dd264 --- /dev/null +++ b/scripts/resolvers/free-text-file.ts @@ -0,0 +1,61 @@ +/** + * Free text never enters a shell command (CEO-12). + * + * {{FREE_TEXT_FILE:BODY_FILE=body}} → bash that creates one private + * {{FREE_TEXT_FILE:TITLE_FILE=title:BODY_FILE=body}} mktemp file per variable, + * plus the write rule. + * + * Bodies, titles, messages, briefs, prompts, error output and reviewer or diff + * text go into a `mktemp` file that the agent writes with its own file-write + * tool; the next block passes the file (`--body-file`, `-F body=@file`, + * `"$(cat "$FILE")"`). There is no heredoc fallback: a host that cannot write + * the file leaves the post unsent and says so. + * + * Location (ENG-14, probed against the pinned Claude Code 2.1.284): the + * project's own `.gstack/tmp/`, kept out of git through `.git/info/exclude`. + * Claude Code's Write tool writes there under acceptEdits without a prompt and + * keeps mktemp's 0600 mode; `$TMPDIR` and the gstack state root are outside + * the project, so even acceptEdits asks for them and headless sessions are + * refused. In default mode every Write asks once, wherever the file lives. + */ +import type { TemplateContext } from './types'; + +export const FREE_TEXT_DIR = '"$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp"'; + +export interface FreeTextFile { variable: string; stem: string } + +function parse(args: string[] | undefined): FreeTextFile[] { + const files = (args ?? []).map(a => { + const [variable, stem] = a.split('='); + return { variable, stem: stem || 'text' }; + }).filter(f => /^[A-Z_][A-Z0-9_]*$/.test(f.variable)); + return files.length ? files : [{ variable: 'TEXT_FILE', stem: 'text' }]; +} + +/** Bash lines that create the private files and print each path and basename. */ +export function freeTextFileBash(files: FreeTextFile[]): string { + return [ + `_GT=${FREE_TEXT_DIR}`, + 'mkdir -p "$_GT" && chmod 700 "$_GT" || { echo "Not sent: cannot create $_GT for the text file." >&2; exit 1; }', + `_EX=$(git rev-parse --git-path info/exclude 2>/dev/null) && mkdir -p "$(dirname "$_EX")" && { grep -qxF '/.gstack/tmp/' "$_EX" 2>/dev/null || echo '/.gstack/tmp/' >> "$_EX"; }`, + ...files.map(f => `${f.variable}=$(mktemp "$_GT/${f.stem}.XXXXXX") && echo "${f.variable}: $${f.variable} (name: \${${f.variable}##*/})"`), + ].join('\n'); +} + +export const FREE_TEXT_WRITE_RULE = 'Write the text into each printed file with your file-write tool (Claude Code\'s Write tool needs a Read of the empty file first), exactly as it should appear. The text never goes into a shell command, heredoc or quoted argument. If a write fails or is refused, do not send: print the cause, the file path and the command below for sending by hand.'; + +/** + * First lines of a later block: rebuild each path from the printed basename + * (`placeholder`) and refuse, with the manual command, when a write never + * happened. `manual` is echoed inside double quotes: escape `"` and `$(`. + */ +export function freeTextFileUse(files: Array<{ variable: string; placeholder: string }>, manual: string): string { + return [ + ...files.map(f => `${f.variable}=${FREE_TEXT_DIR.slice(0, -1)}/${f.placeholder}"`), + `${files.map(f => `[ -s "$${f.variable}" ]`).join(' && ')} || { echo "Not sent: ${files.map(f => `$${f.variable}`).join(' or ')} is missing or empty, so the text was never written. Write it, then send by hand: ${manual}" >&2; exit 1; }`, + ].join('\n'); +} + +export function generateFreeTextFile(_ctx: TemplateContext, args?: string[]): string { + return `\`\`\`bash\n${freeTextFileBash(parse(args))}\n\`\`\`\n\n${FREE_TEXT_WRITE_RULE}`; +} diff --git a/scripts/resolvers/index.ts b/scripts/resolvers/index.ts index 7b5c1ed35..4aeaff4dc 100644 --- a/scripts/resolvers/index.ts +++ b/scripts/resolvers/index.ts @@ -38,6 +38,7 @@ import { generateGBrainContextLoad, generateGBrainSaveResults, generateBrainPref import { generateTasksSectionEmit, generateTasksSectionAggregate } from './tasks-section'; import { SECTION, SECTION_INDEX } from './sections'; import { generateRedactInvocationBlock } from './redact-doc'; +import { generateFreeTextFile } from './free-text-file'; import { FOREGROUND_DISPATCH_NOTE } from './constants'; import { generateThirdPartyActions } from './third-party-actions'; import { generateAsideSetup, generateAsideCookbook, generateAsideResearch, generateUntrustedContentWarning, asideExecPrelude } from './aside'; @@ -65,6 +66,7 @@ export const RESOLVERS: Record = { CODEX_REVIEW_MODEL_CONFIG_FLAG: generateCodexReviewModelConfigFlag, CLAUDE_MODEL_FLAG: generateClaudeModelFlag, REDACT_INVOCATION_BLOCK: generateRedactInvocationBlock, + FREE_TEXT_FILE: generateFreeTextFile, THIRD_PARTY_ACTIONS: generateThirdPartyActions, DESIGN_DOC_DISCOVERY: generateDesignDocDiscovery, SHARED_LIBS_RUBRIC: generateSharedLibsRubric, diff --git a/scripts/resolvers/preamble/generate-test-failure-triage.ts b/scripts/resolvers/preamble/generate-test-failure-triage.ts index ab3602d99..9b1b55f19 100644 --- a/scripts/resolvers/preamble/generate-test-failure-triage.ts +++ b/scripts/resolvers/preamble/generate-test-failure-triage.ts @@ -1,4 +1,5 @@ +import { FREE_TEXT_WRITE_RULE, freeTextFileBash, freeTextFileUse } from '../free-text-file'; export function generateTestFailureTriage(): string { return `## Test Failure Ownership Triage @@ -62,34 +63,40 @@ Ask with AskUserQuestion in the AskUserQuestion Format. List each failure the sa - Find who likely broke it. Check BOTH the test file AND the production code it tests: \`\`\`bash # Who last touched the failing test? - git log --format="%an (%ae)" -1 -- + git log --format="%an (%ae)" -1 -- "" # Who last touched the production code the test covers? (often the actual breaker) - git log --format="%an (%ae)" -1 -- + git log --format="%an (%ae)" -1 -- "" \`\`\` If these are different people, prefer the production code author — they likely introduced the regression. -- Create an issue assigned to that person; run only your platform's create line. Test names and errors stay inside the quoted heredocs (column 0): in a double-quoted argument their backticks run as commands. +- Create an issue assigned to that person. Its title and body carry test names and error output, so they travel as files, never inside a command: \`\`\`bash -ISSUE_TITLE=$(cat <<'GSTACK_ISSUE' -Pre-existing test failure: -GSTACK_ISSUE -) -ISSUE_BODY=$(cat <<'GSTACK_ISSUE' -Failing on ; pre-existing. +${freeTextFileBash([{ variable: 'TITLE_FILE', stem: 'issue-title' }, { variable: 'BODY_FILE', stem: 'issue-body' }])} +\`\`\` + +${FREE_TEXT_WRITE_RULE} Title file: \`Pre-existing test failure: \`. Body file (Markdown; the error goes in a \`~~~\` fence so backticks in it stay literal): + +\`\`\`text +Failing on ; pre-existing. **Error:** ~~~ - + ~~~ **Last modified by:** **Noticed by:** gstack /ship on -GSTACK_ISSUE -) -# GitHub: -gh issue create --title "$ISSUE_TITLE" --body "$ISSUE_BODY" --assignee "" -# GitLab: -glab issue create -t "$ISSUE_TITLE" -d "$ISSUE_BODY" -a "" +\`\`\` + +Then post with your platform from Step 0 (\`github\` or \`gitlab\`). Substitute the two printed names, and an assignee only when it is a valid login for that platform (GitHub: letters, digits and single hyphens, at most 39 characters); otherwise drop the assignee flag and name the person in the body. + +\`\`\`bash +${freeTextFileUse([{ variable: 'TITLE_FILE', placeholder: '' }, { variable: 'BODY_FILE', placeholder: '' }], 'gh issue create --title \\"\\$(cat $TITLE_FILE)\\" --body-file $BODY_FILE')} +case "" in + github) gh issue create --title "$(cat "$TITLE_FILE")" --body-file "$BODY_FILE" --assignee "" ;; + gitlab) glab issue create -t "$(cat "$TITLE_FILE")" -d "$(cat "$BODY_FILE")" -a "" ;; + *) echo "Not sent: no GitHub or GitLab remote. Files: $TITLE_FILE $BODY_FILE" >&2; false ;; +esac && rm -f "$TITLE_FILE" "$BODY_FILE" \`\`\` - If neither CLI is available or \`--assignee\`/\`-a\` fails (user not in org, etc.), create the issue without assignee and note who should look at it in the body. diff --git a/scripts/resolvers/redact-doc.ts b/scripts/resolvers/redact-doc.ts index 11ba39537..4568459e2 100644 --- a/scripts/resolvers/redact-doc.ts +++ b/scripts/resolvers/redact-doc.ts @@ -14,6 +14,7 @@ * changes land here once. test/redact-doc-resolver.test.ts golden-pins the output. */ import { toShellPath, type TemplateContext } from './types'; +import { FREE_TEXT_DIR } from './free-text-file'; interface SinkSpec { /** What is being scanned, for the prose. */ @@ -66,7 +67,8 @@ esac` : `${scan}\nREDACT_CODE=$?`; return `#### Redaction scan — ${sinkLabel} (${sink.noun}) Run the SAME scan-at-sink procedure shown above (resolve \`$REDACT_VIS\` once and -reuse it; write the exact bytes to \`$REDACT_FILE\`; \`${bin} --from-file "$REDACT_FILE" +reuse it; when ${sink.noun} changed since the last scan, rewrite the same \`$REDACT_FILE\` +with your file-write tool; \`${bin} --from-file "$REDACT_FILE" --repo-visibility "$REDACT_VIS" --json\`), now on ${sink.noun}. Apply the same exit-3/2/0 handling. On exit 3, do NOT ${sink.blockVerb}; HIGH has no skip. Pass the same \`$REDACT_FILE\` downstream so the bytes scanned are the bytes sent.`; @@ -74,8 +76,10 @@ same \`$REDACT_FILE\` downstream so the bytes scanned are the bytes sent.`; return `#### Redaction scan — ${sinkLabel} (${sink.noun}) -Scan-at-sink on the EXACT bytes that will be sent: write to a temp file, scan that -file, pass the SAME file downstream. Never scan a string then re-render it. +Scan-at-sink on the EXACT bytes that will be sent: they live in the private file +you wrote with your file-write tool, the scan reads that file, and the SAME file goes +downstream. Never scan a string then re-render it, and never put the text in a shell +command. Substitute the file's printed name for \`\`. \`\`\`bash ${outsideGate ? 'command -v bun >/dev/null 2>&1 || { echo "ERROR: bun unavailable — refusing unscanned outside dispatch." >&2; exit 1; }' : 'command -v bun >/dev/null 2>&1 || echo "redaction scan skipped — bun not on PATH"'} @@ -85,10 +89,8 @@ REDACT_VIS=$(~/.claude/skills/gstack/bin/gstack-config get redact_repo_visibilit [ -z "$REDACT_VIS" ] && REDACT_VIS=$(gh repo view --json visibility -q .visibility 2>/dev/null | tr 'A-Z' 'a-z') [ -z "$REDACT_VIS" ] && REDACT_VIS=$(glab repo view -F json 2>/dev/null | grep -o '"visibility":"[^"]*"' | head -1 | sed 's/.*:"//;s/"//' | tr 'A-Z' 'a-z') REDACT_VIS="\${REDACT_VIS:-unknown}" -REDACT_FILE=$(mktemp "\${TMPDIR:-/tmp}/gstack-redact.XXXXXX") || { echo "ERROR: mktemp failed — refusing to send ${sink.noun} unscanned." >&2; exit 1; } -cat > "$REDACT_FILE" <<'REDACT_BODY_EOF' - -REDACT_BODY_EOF +REDACT_FILE=${FREE_TEXT_DIR.slice(0, -1)}/" +[ -s "$REDACT_FILE" ] || { echo "ERROR: $REDACT_FILE is missing or empty — write ${sink.noun} into it first; refusing to send it unscanned." >&2; exit 1; } ${scanAndGate} \`\`\` diff --git a/ship/sections/tests.md b/ship/sections/tests.md index 44b7efb2f..b5030d8b6 100644 --- a/ship/sections/tests.md +++ b/ship/sections/tests.md @@ -291,34 +291,46 @@ Ask with AskUserQuestion in the AskUserQuestion Format. List each failure the sa - Find who likely broke it. Check BOTH the test file AND the production code it tests: ```bash # Who last touched the failing test? - git log --format="%an (%ae)" -1 -- + git log --format="%an (%ae)" -1 -- "" # Who last touched the production code the test covers? (often the actual breaker) - git log --format="%an (%ae)" -1 -- + git log --format="%an (%ae)" -1 -- "" ``` If these are different people, prefer the production code author — they likely introduced the regression. -- Create an issue assigned to that person; run only your platform's create line. Test names and errors stay inside the quoted heredocs (column 0): in a double-quoted argument their backticks run as commands. +- Create an issue assigned to that person. Its title and body carry test names and error output, so they travel as files, never inside a command: ```bash -ISSUE_TITLE=$(cat <<'GSTACK_ISSUE' -Pre-existing test failure: -GSTACK_ISSUE -) -ISSUE_BODY=$(cat <<'GSTACK_ISSUE' -Failing on ; pre-existing. +_GT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp" +mkdir -p "$_GT" && chmod 700 "$_GT" || { echo "Not sent: cannot create $_GT for the text file." >&2; exit 1; } +_EX=$(git rev-parse --git-path info/exclude 2>/dev/null) && mkdir -p "$(dirname "$_EX")" && { grep -qxF '/.gstack/tmp/' "$_EX" 2>/dev/null || echo '/.gstack/tmp/' >> "$_EX"; } +TITLE_FILE=$(mktemp "$_GT/issue-title.XXXXXX") && echo "TITLE_FILE: $TITLE_FILE (name: ${TITLE_FILE##*/})" +BODY_FILE=$(mktemp "$_GT/issue-body.XXXXXX") && echo "BODY_FILE: $BODY_FILE (name: ${BODY_FILE##*/})" +``` + +Write the text into each printed file with your file-write tool (Claude Code's Write tool needs a Read of the empty file first), exactly as it should appear. The text never goes into a shell command, heredoc or quoted argument. If a write fails or is refused, do not send: print the cause, the file path and the command below for sending by hand. Title file: `Pre-existing test failure: `. Body file (Markdown; the error goes in a `~~~` fence so backticks in it stay literal): + +```text +Failing on ; pre-existing. **Error:** ~~~ - + ~~~ **Last modified by:** **Noticed by:** gstack /ship on -GSTACK_ISSUE -) -# GitHub: -gh issue create --title "$ISSUE_TITLE" --body "$ISSUE_BODY" --assignee "" -# GitLab: -glab issue create -t "$ISSUE_TITLE" -d "$ISSUE_BODY" -a "" +``` + +Then post with your platform from Step 0 (`github` or `gitlab`). Substitute the two printed names, and an assignee only when it is a valid login for that platform (GitHub: letters, digits and single hyphens, at most 39 characters); otherwise drop the assignee flag and name the person in the body. + +```bash +TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/" +BODY_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/" +[ -s "$TITLE_FILE" ] && [ -s "$BODY_FILE" ] || { echo "Not sent: $TITLE_FILE or $BODY_FILE is missing or empty, so the text was never written. Write it, then send by hand: gh issue create --title \"\$(cat $TITLE_FILE)\" --body-file $BODY_FILE" >&2; exit 1; } +case "" in + github) gh issue create --title "$(cat "$TITLE_FILE")" --body-file "$BODY_FILE" --assignee "" ;; + gitlab) glab issue create -t "$(cat "$TITLE_FILE")" -d "$(cat "$BODY_FILE")" -a "" ;; + *) echo "Not sent: no GitHub or GitLab remote. Files: $TITLE_FILE $BODY_FILE" >&2; false ;; +esac && rm -f "$TITLE_FILE" "$BODY_FILE" ``` - If neither CLI is available or `--assignee`/`-a` fails (user not in org, etc.), create the issue without assignee and note who should look at it in the body. diff --git a/spec/sections/gate-and-file.md b/spec/sections/gate-and-file.md index 78d7730ad..520f69cf0 100644 --- a/spec/sections/gate-and-file.md +++ b/spec/sections/gate-and-file.md @@ -27,15 +27,29 @@ AskUserQuestion: A) edit, B) acknowledge and proceed, C) cancel. **On a PUBLIC r option B is disabled** — force A or C. This pass is fail-soft (LLM judgment); the 4.5b regex is the deterministic backstop and runs after it. -**Audit trail (always):** append a content-free record — no spec text, only the -categories that fired plus a sha256 of the body: +**Write the final draft into a private file once.** The audit record, the +redaction scans, the outside reviewer, the issue and the archive all read this +one file; the draft never goes into a shell command: ```bash -printf '%s' "" > /tmp/spec-semantic-$$.txt +_GT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp" +mkdir -p "$_GT" && chmod 700 "$_GT" || { echo "Not sent: cannot create $_GT for the text file." >&2; exit 1; } +_EX=$(git rev-parse --git-path info/exclude 2>/dev/null) && mkdir -p "$(dirname "$_EX")" && { grep -qxF '/.gstack/tmp/' "$_EX" 2>/dev/null || echo '/.gstack/tmp/' >> "$_EX"; } +REDACT_FILE=$(mktemp "$_GT/spec.XXXXXX") && echo "REDACT_FILE: $REDACT_FILE (name: ${REDACT_FILE##*/})" +``` + +Write the text into each printed file with your file-write tool (Claude Code's Write tool needs a Read of the empty file first), exactly as it should appear. The text never goes into a shell command, heredoc or quoted argument. If a write fails or is refused, do not send: print the cause, the file path and the command below for sending by hand. + +**Audit trail (always):** append a content-free record — no spec text, only the +categories that fired plus a sha256 of the body. Substitute the printed name for +``: + +```bash +REDACT_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/" +[ -s "$REDACT_FILE" ] || { echo "No audit record: $REDACT_FILE is missing or empty; write the draft into it first." >&2; exit 1; } bun ~/.claude/skills/gstack/lib/redact-audit-log.ts \ "{\"repo_visibility\":\"$REDACT_VIS\",\"outcome\":\"\",\"categories_flagged\":[<...>],\"spec_archive_path\":\"\"}" \ - /tmp/spec-semantic-$$.txt -rm -f /tmp/spec-semantic-$$.txt + "$REDACT_FILE" ``` ### Phase 4.5b: Fail-closed redaction (PRECEDES dispatch) @@ -47,8 +61,10 @@ before dispatching to the outside reviewer: #### Redaction scan — pre-codex (the spec body) -Scan-at-sink on the EXACT bytes that will be sent: write to a temp file, scan that -file, pass the SAME file downstream. Never scan a string then re-render it. +Scan-at-sink on the EXACT bytes that will be sent: they live in the private file +you wrote with your file-write tool, the scan reads that file, and the SAME file goes +downstream. Never scan a string then re-render it, and never put the text in a shell +command. Substitute the file's printed name for ``. ```bash command -v bun >/dev/null 2>&1 || { echo "ERROR: bun unavailable — refusing unscanned outside dispatch." >&2; exit 1; } @@ -58,10 +74,8 @@ REDACT_VIS=$(~/.claude/skills/gstack/bin/gstack-config get redact_repo_visibilit [ -z "$REDACT_VIS" ] && REDACT_VIS=$(gh repo view --json visibility -q .visibility 2>/dev/null | tr 'A-Z' 'a-z') [ -z "$REDACT_VIS" ] && REDACT_VIS=$(glab repo view -F json 2>/dev/null | grep -o '"visibility":"[^"]*"' | head -1 | sed 's/.*:"//;s/"//' | tr 'A-Z' 'a-z') REDACT_VIS="${REDACT_VIS:-unknown}" -REDACT_FILE=$(mktemp "${TMPDIR:-/tmp}/gstack-redact.XXXXXX") || { echo "ERROR: mktemp failed — refusing to send the spec body unscanned." >&2; exit 1; } -cat > "$REDACT_FILE" <<'REDACT_BODY_EOF' - -REDACT_BODY_EOF +REDACT_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/" +[ -s "$REDACT_FILE" ] || { echo "ERROR: $REDACT_FILE is missing or empty — write the spec body into it first; refusing to send it unscanned." >&2; exit 1; } if REDACT_JSON=$("$HOME/.claude/skills/gstack/bin/gstack-redact" --from-file "$REDACT_FILE" --repo-visibility "$REDACT_VIS" --self-email "$(git config user.email 2>/dev/null)" --json); then REDACT_CODE=0; else REDACT_CODE=$?; fi case "$REDACT_CODE" in 0) ;; # Only a successful scan may reach an outside or downstream sink. @@ -239,25 +253,42 @@ never saw, and the issue is world-readable): #### Redaction scan — pre-issue (the issue body you're about to file) Run the SAME scan-at-sink procedure shown above (resolve `$REDACT_VIS` once and -reuse it; write the exact bytes to `$REDACT_FILE`; `~/.claude/skills/gstack/bin/gstack-redact --from-file "$REDACT_FILE" +reuse it; when the issue body you're about to file changed since the last scan, rewrite the same `$REDACT_FILE` +with your file-write tool; `~/.claude/skills/gstack/bin/gstack-redact --from-file "$REDACT_FILE" --repo-visibility "$REDACT_VIS" --json`), now on the issue body you're about to file. Apply the same exit-3/2/0 handling. On exit 3, do NOT file the issue; HIGH has no skip. Pass the same `$REDACT_FILE` downstream so the bytes scanned are the bytes sent. -If `gh` is available and authenticated, file from the scanned temp file. Keep the title inside the quoted heredoc (backticks in a double-quoted title would run as shell commands): +If `gh` is available and authenticated, file from the scanned file. The title and +the one-line approach for the decision log are free text too, so they go into their +own private files: ```bash -ISSUE_TITLE=$(cat <<'GSTACK_TITLE' - -GSTACK_TITLE -) -ISSUE_URL=$(gh issue create --title "$ISSUE_TITLE" --body-file "$REDACT_FILE") -ISSUE_NUMBER=$(echo "$ISSUE_URL" | sed -E 's|.*/issues/([0-9]+)$|\1|') -echo "Filed: $ISSUE_URL" -~/.claude/skills/gstack/bin/gstack-decision-log '{"decision":"Spec filed #ISSUE_NUMBER: TITLE","rationale":"APPROACH","scope":"issue","issue":"ISSUE_NUMBER","source":"skill","confidence":7}' 2>/dev/null || true +_GT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp" +mkdir -p "$_GT" && chmod 700 "$_GT" || { echo "Not sent: cannot create $_GT for the text file." >&2; exit 1; } +_EX=$(git rev-parse --git-path info/exclude 2>/dev/null) && mkdir -p "$(dirname "$_EX")" && { grep -qxF '/.gstack/tmp/' "$_EX" 2>/dev/null || echo '/.gstack/tmp/' >> "$_EX"; } +TITLE_FILE=$(mktemp "$_GT/title.XXXXXX") && echo "TITLE_FILE: $TITLE_FILE (name: ${TITLE_FILE##*/})" +APPROACH_FILE=$(mktemp "$_GT/approach.XXXXXX") && echo "APPROACH_FILE: $APPROACH_FILE (name: ${APPROACH_FILE##*/})" ``` -The last line records the spec as a durable, issue-scoped cross-session decision so a future session (or `/ship` closing the issue) inherits the core approach and why, not just the issue link. Non-interactive, best-effort (`|| true`). Substitute `ISSUE_NUMBER` (from the filed issue), `TITLE` (the issue title), and `APPROACH` (the one core approach/decision the spec settled). Only fires when the issue was actually filed. +Write the text into each printed file with your file-write tool (Claude Code's Write tool needs a Read of the empty file first), exactly as it should appear. The text never goes into a shell command, heredoc or quoted argument. If a write fails or is refused, do not send: print the cause, the file path and the command below for sending by hand. + +Then file, substituting the three printed names: + +```bash +REDACT_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<redact-file-name>" +TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>" +APPROACH_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<approach-file-name>" +[ -s "$REDACT_FILE" ] && [ -s "$TITLE_FILE" ] || { echo "Not filed: $REDACT_FILE or $TITLE_FILE is missing or empty. Write them, then file by hand: gh issue create --title \"\$(cat $TITLE_FILE)\" --body-file $REDACT_FILE" >&2; exit 1; } +ISSUE_URL=$(gh issue create --title "$(cat "$TITLE_FILE")" --body-file "$REDACT_FILE") || exit 1 +ISSUE_NUMBER=$(echo "$ISSUE_URL" | sed -E 's|.*/issues/([0-9]+)$|\1|') +echo "Filed: $ISSUE_URL (ISSUE_NUMBER: $ISSUE_NUMBER)" +[ -s "$APPROACH_FILE" ] && ~/.claude/skills/gstack/bin/gstack-decision-log "$(jq -cn --arg n "$ISSUE_NUMBER" --rawfile t "$TITLE_FILE" --rawfile a "$APPROACH_FILE" \ + '{decision: ("Spec filed #" + $n + ": " + ($t | rtrimstr("\n"))), rationale: ($a | rtrimstr("\n")), scope: "issue", issue: $n, source: "skill", confidence: 7}')" 2>/dev/null || true +rm -f "$APPROACH_FILE" +``` + +The last line records the spec as a durable, issue-scoped cross-session decision so a future session (or `/ship` closing the issue) inherits the core approach and why, not just the issue link. Non-interactive, best-effort (`|| true`). The approach file holds the one core approach/decision the spec settled. Only fires when the issue was actually filed. If `gh` is not available, print: "`gh` not authenticated — title and body below for paste into https://github.com/{owner}/{repo}/issues/new with zero @@ -273,44 +304,51 @@ is consumed by `/ship` for auto-close. #### Redaction scan — pre-archive (the body about to be archived) Run the SAME scan-at-sink procedure shown above (resolve `$REDACT_VIS` once and -reuse it; write the exact bytes to `$REDACT_FILE`; `~/.claude/skills/gstack/bin/gstack-redact --from-file "$REDACT_FILE" +reuse it; when the body about to be archived changed since the last scan, rewrite the same `$REDACT_FILE` +with your file-write tool; `~/.claude/skills/gstack/bin/gstack-redact --from-file "$REDACT_FILE" --repo-visibility "$REDACT_VIS" --json`), now on the body about to be archived. Apply the same exit-3/2/0 handling. On exit 3, do NOT write the archive; HIGH has no skip. Pass the same `$REDACT_FILE` downstream so the bytes scanned are the bytes sent. -**Sanitized body to the archive.** If auto-redact fired, the `<body>` below -MUST be the sanitized body (`$REDACT_FILE`), not the original draft — one body for -all sinks. The user's on-disk source draft keeps the original. +**Sanitized body to the archive.** If auto-redact fired, the archived body MUST be +the sanitized body (`$REDACT_FILE`), not the original draft — one body for all sinks. +The user's on-disk source draft keeps the original. Title and body are copied from +their files, never expanded by the shell. Resolve the archive path via the existing `gstack-paths` helper (handles -`GSTACK_HOME`, `CLAUDE_PLUGIN_DATA`, Windows fallback): +`GSTACK_HOME`, `CLAUDE_PLUGIN_DATA`, Windows fallback). Substitute the printed file +names and the filed issue number for `<issue-number>` (digits, or empty when no +issue was filed): ```bash +REDACT_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<redact-file-name>" +TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>" +[ -s "$REDACT_FILE" ] && [ -s "$TITLE_FILE" ] || { echo "Not archived: $REDACT_FILE or $TITLE_FILE is missing or empty." >&2; exit 1; } +ISSUE_NUMBER=<issue-number> +ISSUE_URL=$([ -n "$ISSUE_NUMBER" ] && gh issue view "$ISSUE_NUMBER" --json url -q .url 2>/dev/null) GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}" SLUG=$(~/.claude/skills/gstack/bin/gstack-slug --get SLUG) ARCHIVE_DIR="$GSTACK_STATE_ROOT/projects/$SLUG/specs" mkdir -p "$ARCHIVE_DIR" -SLUG_TITLE=$(echo "<title>" | tr ' ' '-' | tr -cd 'a-zA-Z0-9-' | tr A-Z a-z | cut -c1-60) +SLUG_TITLE=$(head -1 "$TITLE_FILE" | tr ' ' '-' | tr -cd 'a-zA-Z0-9-' | tr A-Z a-z | cut -c1-60) ARCHIVE_NAME="$(date +%Y%m%d-%H%M%S)-$$-${SLUG_TITLE}.md" ARCHIVE_PATH="$ARCHIVE_DIR/$ARCHIVE_NAME" # Atomic write: tmp → rename -cat > "$ARCHIVE_PATH.tmp" <<EOF ---- -spec_issue_number: ${ISSUE_NUMBER:-} -spec_issue_url: ${ISSUE_URL:-} -spec_filed_at: $(date -u +%Y-%m-%dT%H:%M:%SZ) -spec_branch: $(git branch --show-current 2>/dev/null || echo unknown) -spec_plan_mode: ${GSTACK_PLAN_MODE:-unset} -spec_executed: ${WILL_EXECUTE:-false} -spec_worktree_path: ---- - -# <title> - -<body> -EOF -mv "$ARCHIVE_PATH.tmp" "$ARCHIVE_PATH" -echo "Archived: $ARCHIVE_PATH" +{ + printf -- '---\n' + printf 'spec_issue_number: %s\n' "$ISSUE_NUMBER" + printf 'spec_issue_url: %s\n' "$ISSUE_URL" + printf 'spec_filed_at: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf 'spec_branch: %s\n' "$(git branch --show-current 2>/dev/null || echo unknown)" + printf 'spec_plan_mode: %s\n' "${GSTACK_PLAN_MODE:-unset}" + printf 'spec_executed: %s\n' "${WILL_EXECUTE:-false}" + printf 'spec_worktree_path:\n---\n\n# ' + head -1 "$TITLE_FILE" + printf '\n' + cat "$REDACT_FILE" +} > "$ARCHIVE_PATH.tmp" +mv "$ARCHIVE_PATH.tmp" "$ARCHIVE_PATH" && rm -f "$TITLE_FILE" +echo "Archived: $ARCHIVE_PATH (SLUG_TITLE: $SLUG_TITLE)" ``` The PID suffix and atomic rename prevent collisions when two `/spec` invocations diff --git a/spec/sections/gate-and-file.md.tmpl b/spec/sections/gate-and-file.md.tmpl index e989a1050..1b2a26313 100644 --- a/spec/sections/gate-and-file.md.tmpl +++ b/spec/sections/gate-and-file.md.tmpl @@ -25,15 +25,22 @@ AskUserQuestion: A) edit, B) acknowledge and proceed, C) cancel. **On a PUBLIC r option B is disabled** — force A or C. This pass is fail-soft (LLM judgment); the 4.5b regex is the deterministic backstop and runs after it. +**Write the final draft into a private file once.** The audit record, the +redaction scans, the outside reviewer, the issue and the archive all read this +one file; the draft never goes into a shell command: + +{{FREE_TEXT_FILE:REDACT_FILE=spec}} + **Audit trail (always):** append a content-free record — no spec text, only the -categories that fired plus a sha256 of the body: +categories that fired plus a sha256 of the body. Substitute the printed name for +`<redact-file-name>`: ```bash -printf '%s' "<the final draft body>" > /tmp/spec-semantic-$$.txt +REDACT_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<redact-file-name>" +[ -s "$REDACT_FILE" ] || { echo "No audit record: $REDACT_FILE is missing or empty; write the draft into it first." >&2; exit 1; } bun ~/.claude/skills/gstack/lib/redact-audit-log.ts \ "{\"repo_visibility\":\"$REDACT_VIS\",\"outcome\":\"<clean|flagged>\",\"categories_flagged\":[<...>],\"spec_archive_path\":\"\"}" \ - /tmp/spec-semantic-$$.txt -rm -f /tmp/spec-semantic-$$.txt + "$REDACT_FILE" ``` ### Phase 4.5b: Fail-closed redaction (PRECEDES dispatch) @@ -118,20 +125,28 @@ never saw, and the issue is world-readable): {{REDACT_INVOCATION_BLOCK:pre-issue:brief}} -If `gh` is available and authenticated, file from the scanned temp file. Keep the title inside the quoted heredoc (backticks in a double-quoted title would run as shell commands): +If `gh` is available and authenticated, file from the scanned file. The title and +the one-line approach for the decision log are free text too, so they go into their +own private files: + +{{FREE_TEXT_FILE:TITLE_FILE=title:APPROACH_FILE=approach}} + +Then file, substituting the three printed names: ```bash -ISSUE_TITLE=$(cat <<'GSTACK_TITLE' -<title> -GSTACK_TITLE -) -ISSUE_URL=$(gh issue create --title "$ISSUE_TITLE" --body-file "$REDACT_FILE") +REDACT_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<redact-file-name>" +TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>" +APPROACH_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<approach-file-name>" +[ -s "$REDACT_FILE" ] && [ -s "$TITLE_FILE" ] || { echo "Not filed: $REDACT_FILE or $TITLE_FILE is missing or empty. Write them, then file by hand: gh issue create --title \"\$(cat $TITLE_FILE)\" --body-file $REDACT_FILE" >&2; exit 1; } +ISSUE_URL=$(gh issue create --title "$(cat "$TITLE_FILE")" --body-file "$REDACT_FILE") || exit 1 ISSUE_NUMBER=$(echo "$ISSUE_URL" | sed -E 's|.*/issues/([0-9]+)$|\1|') -echo "Filed: $ISSUE_URL" -~/.claude/skills/gstack/bin/gstack-decision-log '{"decision":"Spec filed #ISSUE_NUMBER: TITLE","rationale":"APPROACH","scope":"issue","issue":"ISSUE_NUMBER","source":"skill","confidence":7}' 2>/dev/null || true +echo "Filed: $ISSUE_URL (ISSUE_NUMBER: $ISSUE_NUMBER)" +[ -s "$APPROACH_FILE" ] && ~/.claude/skills/gstack/bin/gstack-decision-log "$(jq -cn --arg n "$ISSUE_NUMBER" --rawfile t "$TITLE_FILE" --rawfile a "$APPROACH_FILE" \ + '{decision: ("Spec filed #" + $n + ": " + ($t | rtrimstr("\n"))), rationale: ($a | rtrimstr("\n")), scope: "issue", issue: $n, source: "skill", confidence: 7}')" 2>/dev/null || true +rm -f "$APPROACH_FILE" ``` -The last line records the spec as a durable, issue-scoped cross-session decision so a future session (or `/ship` closing the issue) inherits the core approach and why, not just the issue link. Non-interactive, best-effort (`|| true`). Substitute `ISSUE_NUMBER` (from the filed issue), `TITLE` (the issue title), and `APPROACH` (the one core approach/decision the spec settled). Only fires when the issue was actually filed. +The last line records the spec as a durable, issue-scoped cross-session decision so a future session (or `/ship` closing the issue) inherits the core approach and why, not just the issue link. Non-interactive, best-effort (`|| true`). The approach file holds the one core approach/decision the spec settled. Only fires when the issue was actually filed. If `gh` is not available, print: "`gh` not authenticated — title and body below for paste into https://github.com/{owner}/{repo}/issues/new with zero @@ -146,39 +161,45 @@ is consumed by `/ship` for auto-close. {{REDACT_INVOCATION_BLOCK:pre-archive:brief}} -**Sanitized body to the archive.** If auto-redact fired, the `<body>` below -MUST be the sanitized body (`$REDACT_FILE`), not the original draft — one body for -all sinks. The user's on-disk source draft keeps the original. +**Sanitized body to the archive.** If auto-redact fired, the archived body MUST be +the sanitized body (`$REDACT_FILE`), not the original draft — one body for all sinks. +The user's on-disk source draft keeps the original. Title and body are copied from +their files, never expanded by the shell. Resolve the archive path via the existing `gstack-paths` helper (handles -`GSTACK_HOME`, `CLAUDE_PLUGIN_DATA`, Windows fallback): +`GSTACK_HOME`, `CLAUDE_PLUGIN_DATA`, Windows fallback). Substitute the printed file +names and the filed issue number for `<issue-number>` (digits, or empty when no +issue was filed): ```bash +REDACT_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<redact-file-name>" +TITLE_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<title-file-name>" +[ -s "$REDACT_FILE" ] && [ -s "$TITLE_FILE" ] || { echo "Not archived: $REDACT_FILE or $TITLE_FILE is missing or empty." >&2; exit 1; } +ISSUE_NUMBER=<issue-number> +ISSUE_URL=$([ -n "$ISSUE_NUMBER" ] && gh issue view "$ISSUE_NUMBER" --json url -q .url 2>/dev/null) GSTACK_STATE_ROOT=$(~/.claude/skills/gstack/bin/gstack-paths --get GSTACK_STATE_ROOT); : "${GSTACK_STATE_ROOT:?gstack-paths failed; reinstall with ./setup or /gstack-upgrade}" SLUG=$(~/.claude/skills/gstack/bin/gstack-slug --get SLUG) ARCHIVE_DIR="$GSTACK_STATE_ROOT/projects/$SLUG/specs" mkdir -p "$ARCHIVE_DIR" -SLUG_TITLE=$(echo "<title>" | tr ' ' '-' | tr -cd 'a-zA-Z0-9-' | tr A-Z a-z | cut -c1-60) +SLUG_TITLE=$(head -1 "$TITLE_FILE" | tr ' ' '-' | tr -cd 'a-zA-Z0-9-' | tr A-Z a-z | cut -c1-60) ARCHIVE_NAME="$(date +%Y%m%d-%H%M%S)-$$-${SLUG_TITLE}.md" ARCHIVE_PATH="$ARCHIVE_DIR/$ARCHIVE_NAME" # Atomic write: tmp → rename -cat > "$ARCHIVE_PATH.tmp" <<EOF ---- -spec_issue_number: ${ISSUE_NUMBER:-} -spec_issue_url: ${ISSUE_URL:-} -spec_filed_at: $(date -u +%Y-%m-%dT%H:%M:%SZ) -spec_branch: $(git branch --show-current 2>/dev/null || echo unknown) -spec_plan_mode: ${GSTACK_PLAN_MODE:-unset} -spec_executed: ${WILL_EXECUTE:-false} -spec_worktree_path: ---- - -# <title> - -<body> -EOF -mv "$ARCHIVE_PATH.tmp" "$ARCHIVE_PATH" -echo "Archived: $ARCHIVE_PATH" +{ + printf -- '---\n' + printf 'spec_issue_number: %s\n' "$ISSUE_NUMBER" + printf 'spec_issue_url: %s\n' "$ISSUE_URL" + printf 'spec_filed_at: %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" + printf 'spec_branch: %s\n' "$(git branch --show-current 2>/dev/null || echo unknown)" + printf 'spec_plan_mode: %s\n' "${GSTACK_PLAN_MODE:-unset}" + printf 'spec_executed: %s\n' "${WILL_EXECUTE:-false}" + printf 'spec_worktree_path:\n---\n\n# ' + head -1 "$TITLE_FILE" + printf '\n' + cat "$REDACT_FILE" +} > "$ARCHIVE_PATH.tmp" +mv "$ARCHIVE_PATH.tmp" "$ARCHIVE_PATH" && rm -f "$TITLE_FILE" +echo "Archived: $ARCHIVE_PATH (SLUG_TITLE: $SLUG_TITLE)" ``` The PID suffix and atomic rename prevent collisions when two `/spec` invocations