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 <title>/<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>
This commit is contained in:
garrytananddgrant committed 2026-10-06 20:34:58 +00:00
1 parent 27c9843654
commit 8759ee58aa
8 files changed
+298 -148

No files matched your search

+36 -29
View File
@@ -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.
+61
View File
@@ -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}`;
}
+2
View File
@@ -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,
@@ -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.
+9 -7
View File
@@ -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}
\`\`\`
+28 -16
View File
@@ -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.
+83 -45
View File
@@ -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
+56 -35
View File
@@ -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