Skill Tracer
Skills › gstack › gstack › free-text-file.ts

free-text-file.ts

61 lines · scripts/resolvers/free-text-file.ts at 3f79d81

1/**
2 * Free text never enters a shell command (CEO-12).
3 *
4 * {{FREE_TEXT_FILE:BODY_FILE=body}} → bash that creates one private
5 * {{FREE_TEXT_FILE:TITLE_FILE=title:BODY_FILE=body}} mktemp file per variable,
6 * plus the write rule.
7 *
8 * Bodies, titles, messages, briefs, prompts, error output and reviewer or diff
9 * text go into a `mktemp` file that the agent writes with its own file-write
10 * tool; the next block passes the file (`--body-file`, `-F body=@file`,
11 * `"$(cat "$FILE")"`). There is no heredoc fallback: a host that cannot write
12 * the file leaves the post unsent and says so.
13 *
14 * Location (ENG-14, probed against the pinned Claude Code 2.1.284): the
15 * project's own `.gstack/tmp/`, kept out of git through `.git/info/exclude`.
16 * Claude Code's Write tool writes there under acceptEdits without a prompt and
17 * keeps mktemp's 0600 mode; `$TMPDIR` and the gstack state root are outside
18 * the project, so even acceptEdits asks for them and headless sessions are
19 * refused. In default mode every Write asks once, wherever the file lives.
20 */
21import type { TemplateContext } from './types';
23export const FREE_TEXT_DIR = '"$(git rev-parse --show-toplevel 2>/dev/null || pwd)/.gstack/tmp"';
25export interface FreeTextFile { variable: string; stem: string }
27function parse(args: string[] | undefined): FreeTextFile[] {
28 const files = (args ?? []).map(a => {
29 const [variable, stem] = a.split('=');
30 return { variable, stem: stem || 'text' };
31 }).filter(f => /^[A-Z_][A-Z0-9_]*$/.test(f.variable));
32 return files.length ? files : [{ variable: 'TEXT_FILE', stem: 'text' }];
35/** Bash lines that create the private files and print each path and basename. */
36export function freeTextFileBash(files: FreeTextFile[]): string {
37 return [
38 `_GT=${FREE_TEXT_DIR}`,
39 'mkdir -p "$_GT" && chmod 700 "$_GT" || { echo "Not sent: cannot create $_GT for the text file." >&2; exit 1; }',
40 `_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"; }`,
41 ...files.map(f => `${f.variable}=$(mktemp "\${_GT:?}/${f.stem}.XXXXXX") || { echo "Not sent: mktemp failed in $_GT." >&2; exit 1; }; echo "${f.variable}: $${f.variable} (name: \${${f.variable}##*/})"`),
42 ].join('\n');
45export 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.';
47/**
48 * First lines of a later block: rebuild each path from the printed basename
49 * (`placeholder`) and refuse, with the manual command, when a write never
50 * happened. `manual` is echoed inside double quotes: escape `"` and `$(`.
51 */
52export function freeTextFileUse(files: Array<{ variable: string; placeholder: string }>, manual: string): string {
53 return [
54 ...files.map(f => `${f.variable}=${FREE_TEXT_DIR.slice(0, -1)}/${f.placeholder}"`),
55 `${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; }`,
56 ].join('\n');
59export function generateFreeTextFile(_ctx: TemplateContext, args?: string[]): string {
60 return `\`\`\`bash\n${freeTextFileBash(parse(args))}\n\`\`\`\n\n${FREE_TEXT_WRITE_RULE}`;