mirror of
https://github.com/garrytan/gstack.git
synced 2026-11-08 17:58:13 +01:00
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:
1 parent
27c9843654
commit
8759ee58aa
8 files changed
+298
-148
No files matched your search
@@ -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}`;
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
\`\`\`
|
||||
|
||||
|
||||
Reference in new issue
Block a user