fix(commands): Phase 0 gives the arguments to the parser on stdin, never as shell words

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kjell Tore Guttormsen 2026-10-26 15:55:21 +01:00
commit 2c0a98c1ba
Signed by: ktg
SSH key fingerprint: SHA256:JakMjO6FTBBzN0Bhfj9saOoEjaFxlSdYuZQQpM/lF9Q
8 changed files with 157 additions and 29 deletions

View file

@ -1225,7 +1225,7 @@ test('[D67] the End to end section says to record run_seen, also when no app was
// now carries disable-model-invocation, so the model cannot start it.
const ENDSESSION = 'commands/trekendsession.md';
const ENDSESSION_PARSER_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekendsession -- $ARGUMENTS';
const ENDSESSION_PARSER_LINE = "node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekendsession --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'";
test('[#15] /trekendsession Phase 0 has no "contains --help or -h" check, and its parser line run on fix-header gives no help', () => {
const phase0 = section(read(ENDSESSION), '## Phase 0 — `--help` handling');
@ -1238,24 +1238,26 @@ test('[#15] /trekendsession Phase 0 has no "contains --help or -h" check, and it
const block = bashBlockHolding(phase0, cmd);
assert.ok(block, 'the parser line is not in a runtime ```bash block');
assert.equal(block.split('\n')[0], 'setopt nonomatch 2>/dev/null || true', 'the block does not open with setopt nonomatch');
const plain = runParser(cmd, 'fix-header');
assert.deepEqual(block.split('\n').slice(-2), ['$ARGUMENTS', 'VOYAGE_ARGS_EOF_7F3C9E'],
'the block does not end with $ARGUMENTS as the heredoc body and its closing line');
const plain = runParser(block, 'fix-header');
assert.equal(plain.status, 0, `the parser line refused fix-header: ${plain.stdout}${plain.stderr}`);
const r = JSON.parse(plain.stdout);
assert.equal(r.flags['--help'], undefined, 'fix-header set the --help flag');
assert.deepEqual(r.positional, ['fix-header']);
assert.notEqual(r.positional[0], '-h');
const help = runParser(cmd, '--help');
const help = runParser(block, '--help');
assert.equal(help.status, 0, help.stdout + help.stderr);
assert.equal(JSON.parse(help.stdout).flags['--help'], true, 'control: --help must set the help flag');
const two = runParser(cmd, 'fix-header "Session 2"');
const two = runParser(block, 'fix-header "Session 2"');
assert.equal(two.status, 0, two.stdout + two.stderr);
assert.deepEqual(JSON.parse(two.stdout).positional, ['fix-header', 'Session 2']);
});
test('[#15] /trekendsession Phase 2 reads the brief path and the label from the parser\'s positional', () => {
test('[#15] /trekendsession Phase 2 reads the brief path from the parser\'s positional and the label from its tail', () => {
const phase2 = flat(section(read(ENDSESSION), '## Phase 2 — Required args check (headless-safe)'));
assert.match(phase2, /`positional\[0\]`/, 'Phase 2 does not read <next-brief-path> from positional[0]');
assert.match(phase2, /`positional\[1\]`/, 'Phase 2 does not read <next-label> from positional[1]');
assert.match(phase2, /`<next-label>` is the parser's `tail`/, 'Phase 2 does not read <next-label> from the parser\'s tail');
});
test('[#67] /trekendsession sets disable-model-invocation, and CLAUDE.md names it as the one command that does', () => {
@ -1293,7 +1295,7 @@ test('keep: commands/trekreview.md carries no disable-model-invocation field (/t
// passes the state file, so the state-anchored staleness check runs.
const CONTINUE = 'commands/trekcontinue.md';
const CONTINUE_PARSER_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekcontinue -- $ARGUMENTS';
const CONTINUE_PARSER_LINE = "node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekcontinue --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'";
const CONSISTENCY_LINE = 'node ${CLAUDE_PLUGIN_ROOT}/lib/validators/next-session-prompt-validator.mjs --json --consistency <RESOLVED-PATH-A> <RESOLVED-PATH-B> --state-file <RESOLVED-PATH-STATE>';
test('[#66] /trekcontinue Phase 0 has no parseArgs($ARGUMENTS call, and its parser line run with --help sets the help flag', () => {
@ -1303,19 +1305,21 @@ test('[#66] /trekcontinue Phase 0 has no parseArgs($ARGUMENTS call, and its pars
assert.equal(cmd, CONTINUE_PARSER_LINE, 'the Phase 0 parser line is not the trekcontinue parser call on $ARGUMENTS');
const block = bashBlockHolding(phase0, cmd);
assert.ok(block, 'the parser line is not in a runtime ```bash block');
assert.deepEqual(block.split('\n').slice(-2), ['$ARGUMENTS', 'VOYAGE_ARGS_EOF_7F3C9E'],
'the block does not end with $ARGUMENTS as the heredoc body and its closing line');
assert.match(flat(phase0), /Run first, exactly like this:/);
assert.match(flat(phase0), /Read `flags` and `positional` from the JSON it prints/);
assert.match(flat(phase0), /`flags\['--help'\] === true`/, 'Phase 0 does not dispatch on the parsed --help flag');
assert.match(flat(phase0), /`positional\[0\] === '-h'`/, 'Phase 0 does not dispatch on a lone -h as the first positional');
const help = runParser(cmd, '--help');
const help = runParser(block, '--help');
assert.equal(help.status, 0, help.stdout + help.stderr);
assert.equal(JSON.parse(help.stdout).flags['--help'], true, 'the parser line run with --help does not set the help flag');
const dir = runParser(cmd, '.claude/projects/demo');
const dir = runParser(block, '.claude/projects/demo');
assert.equal(dir.status, 0, dir.stdout + dir.stderr);
const r = JSON.parse(dir.stdout);
assert.equal(r.flags['--help'], undefined, 'a project dir set the --help flag');
assert.deepEqual(r.positional, ['.claude/projects/demo']);
const h = runParser(cmd, '-h');
const h = runParser(block, '-h');
assert.equal(h.status, 0, h.stdout + h.stderr);
assert.equal(JSON.parse(h.stdout).positional[0], '-h');
const p05 = flat(section(read(CONTINUE), '## Phase 0.5 — Cleanup mode dispatch'));
@ -1323,11 +1327,15 @@ test('[#66] /trekcontinue Phase 0 has no parseArgs($ARGUMENTS call, and its pars
assert.match(p05, /After the parser has printed/);
});
test('[#66] sibling: agents/review-orchestrator.md passes the arguments to the parser after --', () => {
test('[#66] sibling: agents/review-orchestrator.md passes the arguments to the parser on stdin through the quoted heredoc', () => {
const p1 = section(read('agents/review-orchestrator.md'), '### Phase 1 — Parse mode and validate input');
const cmd = line(p1, 'parsers/arg-parser.mjs');
assert.equal(cmd, 'node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview -- "$@"');
const r = bash(`set -- --project .claude/projects/demo; ${cmd}`, ROOT);
assert.equal(cmd, "node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'");
const lines = p1.split('\n');
const at = lines.findIndex((l) => l.trim() === cmd);
assert.deepEqual(lines.slice(at + 1, at + 3), ['$ARGUMENTS', 'VOYAGE_ARGS_EOF_7F3C9E'],
'the parser line is not followed by $ARGUMENTS and the closing line');
const r = runParser(lines.slice(at, at + 3).join('\n'), '--project .claude/projects/demo');
assert.equal(r.status, 0, r.stdout + r.stderr);
assert.equal(JSON.parse(r.stdout).flags['--project'], '.claude/projects/demo', 'the parser did not see --project');
});

View file

@ -23,6 +23,12 @@
// to the Pass 3 skip.
// K3 The orchestrator's 4-pass item 3 names the brief-conflict lowering as
// the exception to the catalogue reset.
// ARG /trekendsession and /trekcontinue Phase 0 and /trekreview Phase 1
// give $ARGUMENTS to the arg parser (`--stdin`) as the one body line of
// a quoted heredoc, never as shell words.
// M8 /trekcontinue and /trekendsession Phase 0 stop on the parser's exit 1
// with its `errors`, and stop when the parser prints no JSON.
// M10 /trekendsession Phase 2 reads the label from the parser's `tail`.
// No static import of a new export: the file must load at be0b631.
// Fix the SOURCE a pin guards, not the pin.
@ -202,3 +208,92 @@ test('[K3] orchestrator item 3 names the brief-conflict lowering as the exceptio
assert.match(next, /MAJOR[^.]*\bMINOR\b/, `the exception does not say a MAJOR drops to MINOR: ${next}`);
assert.match(next, /`brief_lowered_from: MAJOR`/, `the exception does not name brief_lowered_from: MAJOR: ${next}`);
});
// --- ARG / M8 / M10 ----------------------------------------------------------------
const ARGS_DELIM = 'VOYAGE_ARGS_EOF_7F3C9E';
const ARGS_OPENER = `<<'${ARGS_DELIM}'`;
/** The text of a `## ` section of `rel` from the line starting with `heading` to the next `## ` line outside a fence. */
function phase(rel, heading) {
const lines = read(rel).split('\n');
const s = lines.findIndex((l) => l.startsWith(heading));
assert.ok(s !== -1, `${rel} must hold a heading starting "${heading}"`);
let fenced = false;
for (let i = s + 1; i < lines.length; i++) {
if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced;
if (!fenced && lines[i].startsWith('## ')) return lines.slice(s, i).join('\n');
}
return lines.slice(s).join('\n');
}
// [file, command, the section that runs the parser]
const PARSER_PHASES = [
['commands/trekendsession.md', 'trekendsession', '## Phase 0 — `--help` handling'],
['commands/trekcontinue.md', 'trekcontinue', '## Phase 0 — `--help` handling'],
['commands/trekreview.md', 'trekreview', '## Phase 1 — Parse mode and validate input'],
];
/** The one runtime ```bash block of the section that runs the arg parser for `cmd`, as lines, with the parser line's index. */
function parserBlock(rel, cmd, heading) {
const text = phase(rel, heading);
const blocks = text.split('\n```bash\n').slice(1).map((b) => b.split('\n```')[0])
.filter((b) => b.includes(`lib/parsers/arg-parser.mjs --command ${cmd}`));
assert.equal(blocks.length, 1, `${rel} ${heading} must hold one \`\`\`bash block that runs the ${cmd} arg parser; found ${blocks.length}`);
const lines = blocks[0].split('\n');
const at = lines.findIndex((l) => l.includes(`lib/parsers/arg-parser.mjs --command ${cmd}`));
return { lines, at };
}
test('[ARG] each of the three parser lines reads the arguments from a quoted heredoc', () => {
for (const [rel, cmd, heading] of PARSER_PHASES) {
const { lines, at } = parserBlock(rel, cmd, heading);
const opener = lines[at].trim();
assert.ok(opener.endsWith(` ${ARGS_OPENER}`),
`${rel}: the parser line does not end with the quoted heredoc opener ${ARGS_OPENER}: ${opener}`);
assert.equal(lines[at + 1], '$ARGUMENTS',
`${rel}: the line inside the heredoc is not $ARGUMENTS alone: ${JSON.stringify(lines[at + 1])}`);
assert.match(opener, new RegExp(`^node \\$\\{CLAUDE_PLUGIN_ROOT\\}/lib/parsers/arg-parser\\.mjs --command ${cmd} --stdin <<`),
`${rel}: the parser line does not read stdin (--command ${cmd} --stdin right after arg-parser.mjs): ${opener}`);
const filled = lines.filter((l) => l.trim() !== '');
assert.ok(lines[at + 2] === ARGS_DELIM && filled[filled.length - 1] === ARGS_DELIM,
`${rel}: the closing line ${ARGS_DELIM} does not follow $ARGUMENTS as the block's last non-empty line: ${JSON.stringify(lines)}`);
}
});
test('[ARG] no line outside the heredoc carries $ARGUMENTS in those blocks', () => {
for (const [rel, cmd, heading] of PARSER_PHASES) {
const { lines } = parserBlock(rel, cmd, heading);
const open = lines.findIndex((l) => l.trimEnd().endsWith(ARGS_OPENER));
assert.ok(open !== -1, `${rel}: the parser block opens no ${ARGS_OPENER} heredoc: ${JSON.stringify(lines)}`);
const close = lines.findIndex((l, i) => i > open && l.trim() === ARGS_DELIM);
assert.ok(close !== -1, `${rel}: the parser block's heredoc has no closing line ${ARGS_DELIM}`);
const outside = lines.filter((l, i) => (i <= open || i >= close) && l.includes('$ARGUMENTS'));
assert.deepEqual(outside, [], `${rel}: a line outside the heredoc carries $ARGUMENTS`);
}
});
const PHASE0 = '## Phase 0 — `--help` handling';
const STOP_COMMANDS = ['commands/trekcontinue.md', 'commands/trekendsession.md'];
test('[M8] /trekcontinue and /trekendsession Phase 0 stop on exit 1 with the parser\'s errors', () => {
for (const rel of STOP_COMMANDS) {
const hits = sentencesWith(phase(rel, PHASE0), 'exit 1');
assert.ok(hits.some((s) => /^On exit 1, print (its|the parser's) `errors` and stop\.$/.test(s)),
`${rel} Phase 0 does not say: on exit 1, print the parser's \`errors\` and stop: ${hits.join(' | ')}`);
}
});
test('[M8] /trekcontinue and /trekendsession Phase 0 stop when the parser prints no JSON', () => {
for (const rel of STOP_COMMANDS) {
const hits = sentencesWith(phase(rel, PHASE0), 'no JSON');
assert.ok(hits.some((s) => /\bprints no JSON\b/.test(s) && /\bthe parser did not run\b/.test(s) && /\band stop\.$/.test(s)),
`${rel} Phase 0 does not say: when the parser prints no JSON, say that it did not run and stop: ${hits.join(' | ')}`);
}
});
test('[M10] /trekendsession Phase 2 reads the label from tail', () => {
const p2 = flat(phase('commands/trekendsession.md', '## Phase 2 — Required args check'));
assert.doesNotMatch(p2, /`positional\[1\]`/, 'Phase 2 still reads <next-label> from positional[1], which splits a label at spaces');
assert.match(p2, /`<next-label>` is the parser's `tail`/, `Phase 2 does not read <next-label> from the parser's tail: ${p2}`);
});

View file

@ -149,13 +149,17 @@ test('plan-halts-without-phase-signals: the validator\'s own code must be in the
// ---- case 5: the missing-flag error comes from code ----
test('review-requires-project: the arg parser, run as trekreview.md spells it, prints the error (exit 1)', () => {
const lines = read('commands/trekreview.md').split('\n').filter((l) =>
/^\s*node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/parsers\/arg-parser\.mjs --command trekreview\b/.test(l));
assert.equal(lines.length, 1, 'trekreview.md must carry exactly one arg-parser line');
const line = lines[0].trim();
assert.match(line, /\$ARGUMENTS/, 'the parser must receive $ARGUMENTS (the Bash tool has no "$@")');
const all = read('commands/trekreview.md').split('\n');
const at = all.map((l, i) =>
(/^\s*node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/parsers\/arg-parser\.mjs --command trekreview\b/.test(l) ? i : -1)).filter((i) => i >= 0);
assert.equal(at.length, 1, 'trekreview.md must carry exactly one arg-parser line');
const line = all[at[0]].trim();
assert.match(line, / --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'$/, 'the parser must read the arguments on stdin from the quoted heredoc');
const block = all.slice(at[0], at[0] + 3).map((l) => l.trim());
assert.deepEqual(block.slice(1), ['$ARGUMENTS', 'VOYAGE_ARGS_EOF_7F3C9E'],
'the parser must receive $ARGUMENTS as the heredoc body (the Bash tool has no "$@")');
const run = (args) => spawnSync('bash', ['-c',
line.split('${CLAUDE_PLUGIN_ROOT}').join(ROOT).split('$ARGUMENTS').join(args)], { encoding: 'utf8' });
block.join('\n').split('${CLAUDE_PLUGIN_ROOT}').join(ROOT).split('$ARGUMENTS').join(args)], { encoding: 'utf8' });
const none = run('');
assert.equal(none.status, 1, `missing --project must exit 1: ${none.stdout} ${none.stderr}`);
assert.deepEqual(JSON.parse(none.stdout).errors.map((e) => e.code), ['ARG_REQUIRED_MISSING']);