From 8759ee58aa7d197d4db04a47c59bc84d5cd4b12f Mon Sep 17 00:00:00 2001 From: garrytan Date: Tue, 6 Oct 2026 20:34:58 +0000 Subject: [PATCH] fix: reply, issue and spec text travel as agent-written files, never through a heredoc Replaces the quoted-heredoc bodies with CEO-12's file mechanism: a bash block creates a private mktemp file in the project's .gstack/tmp/ (kept out of git through .git/info/exclude), the agent writes the text with its file-write tool, and the next block posts with -F body=@file, --body-file or "$(cat file)". A heredoc ends early at any body line equal to its delimiter; a file has no such line. When the file was never written the block refuses with the path and the command to send it by hand, and it deletes the file after a successful post. - review/greptile-triage.md: replies; the fetched JSON moves from fixed /tmp/greptile_*.json paths to a mktemp directory; REPO and PR are re-derived per block instead of read from a previous shell. - Test-failure issues: title and body files, platform case on github/gitlab, assignee only when it is a valid login. - /spec: one draft file feeds the audit hash, every redaction scan, the issue and the archive; the archive no longer expands /<body> inside an unquoted heredoc; the decision-log JSON is built by jq from files. Location verified against Claude Code 2.1.284 (Dockerfile.ci pin): project-local .gstack/tmp is writable under acceptEdits with no prompt and keeps mode 0600; $TMPDIR and the gstack state root prompt even under acceptEdits. Co-authored-by: dgrant <dgrant@users.noreply.github.com> --- review/greptile-triage.md | 65 +++++---- scripts/resolvers/free-text-file.ts | 61 +++++++++ scripts/resolvers/index.ts | 2 + .../preamble/generate-test-failure-triage.ts | 39 +++--- scripts/resolvers/redact-doc.ts | 16 ++- ship/sections/tests.md | 44 +++--- spec/sections/gate-and-file.md | 128 ++++++++++++------ spec/sections/gate-and-file.md.tmpl | 91 ++++++++----- 8 files changed, 298 insertions(+), 148 deletions(-) create mode 100644 scripts/resolvers/free-text-file.ts 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 `<greptile-dir>`. + **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=<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' -<reply text> -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 `<reply-file-name>` and the comment's raw numeric `id` from the fetched JSON for `<comment-id>`; use a value only when it is digits (`<comment-id>`) or letters, digits, `.`, `_` and `-` (`<reply-file-name>`). 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' -<reply text> -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/<reply-file-name>" +[ -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/<comment-id>/replies -F body=@$REPLY_FILE" >&2; exit 1; } +gh api "repos/$REPO/pulls/$PR_NUMBER/comments/<comment-id>/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/<reply-file-name>" +[ -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<string, ResolverFn> = { 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 -- <failing-test-file> + git log --format="%an (%ae)" -1 -- "<failing-test-file>" # Who last touched the production code the test covers? (often the actual breaker) - git log --format="%an (%ae)" -1 -- <source-file-under-test> + git log --format="%an (%ae)" -1 -- "<source-file-under-test>" \`\`\` 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: <test-name> -GSTACK_ISSUE -) -ISSUE_BODY=$(cat <<'GSTACK_ISSUE' -Failing on <current-branch>; 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: <test name>\`. Body file (Markdown; the error goes in a \`~~~\` fence so backticks in it stay literal): + +\`\`\`text +Failing on <current branch>; pre-existing. **Error:** ~~~ -<first 10 lines> +<first 10 lines of the failure> ~~~ **Last modified by:** <author> **Noticed by:** gstack /ship on <date> -GSTACK_ISSUE -) -# GitHub: -gh issue create --title "$ISSUE_TITLE" --body "$ISSUE_BODY" --assignee "<github-username>" -# GitLab: -glab issue create -t "$ISSUE_TITLE" -d "$ISSUE_BODY" -a "<gitlab-username>" +\`\`\` + +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: '<title-file-name>' }, { variable: 'BODY_FILE', placeholder: '<body-file-name>' }], 'gh issue create --title \\"\\$(cat $TITLE_FILE)\\" --body-file $BODY_FILE')} +case "<platform>" in + github) gh issue create --title "$(cat "$TITLE_FILE")" --body-file "$BODY_FILE" --assignee "<github-username>" ;; + gitlab) glab issue create -t "$(cat "$TITLE_FILE")" -d "$(cat "$BODY_FILE")" -a "<gitlab-username>" ;; + *) 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 \`<redact-file-name>\`. \`\`\`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' -<the exact ${sink.noun} goes here> -REDACT_BODY_EOF +REDACT_FILE=${FREE_TEXT_DIR.slice(0, -1)}/<redact-file-name>" +[ -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 -- <failing-test-file> + git log --format="%an (%ae)" -1 -- "<failing-test-file>" # Who last touched the production code the test covers? (often the actual breaker) - git log --format="%an (%ae)" -1 -- <source-file-under-test> + git log --format="%an (%ae)" -1 -- "<source-file-under-test>" ``` 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: <test-name> -GSTACK_ISSUE -) -ISSUE_BODY=$(cat <<'GSTACK_ISSUE' -Failing on <current-branch>; 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: <test name>`. Body file (Markdown; the error goes in a `~~~` fence so backticks in it stay literal): + +```text +Failing on <current branch>; pre-existing. **Error:** ~~~ -<first 10 lines> +<first 10 lines of the failure> ~~~ **Last modified by:** <author> **Noticed by:** gstack /ship on <date> -GSTACK_ISSUE -) -# GitHub: -gh issue create --title "$ISSUE_TITLE" --body "$ISSUE_BODY" --assignee "<github-username>" -# GitLab: -glab issue create -t "$ISSUE_TITLE" -d "$ISSUE_BODY" -a "<gitlab-username>" +``` + +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/<title-file-name>" +BODY_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<body-file-name>" +[ -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 "<platform>" in + github) gh issue create --title "$(cat "$TITLE_FILE")" --body-file "$BODY_FILE" --assignee "<github-username>" ;; + gitlab) glab issue create -t "$(cat "$TITLE_FILE")" -d "$(cat "$BODY_FILE")" -a "<gitlab-username>" ;; + *) 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' "<the final draft body>" > /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 +`<redact-file-name>`: + +```bash +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) @@ -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 `<redact-file-name>`. ```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' -<the exact the spec body goes here> -REDACT_BODY_EOF +REDACT_FILE="$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp/<redact-file-name>" +[ -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' -<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