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

@ -72,9 +72,12 @@ Execute these phases in order. Do not skip phases.
### Phase 1 — Parse mode and validate input ### Phase 1 — Parse mode and validate input
Run the arg-parser via Bash: Run the arg-parser via Bash, with the arguments as the one line inside the
quoted heredoc (on stdin, never as shell words):
``` ```
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview -- "$@" node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'
$ARGUMENTS
VOYAGE_ARGS_EOF_7F3C9E
``` ```
Pull the structured flags from its JSON output. Reject unknown flags. If Pull the structured flags from its JSON output. Reject unknown flags. If

View file

@ -32,9 +32,16 @@ See **Handover 7** in `docs/HANDOVER-CONTRACTS.md` for the full schema.
Parse `$ARGUMENTS` with the shared arg-parser. Run first, exactly like this: Parse `$ARGUMENTS` with the shared arg-parser. Run first, exactly like this:
```bash ```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekcontinue -- $ARGUMENTS node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekcontinue --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'
$ARGUMENTS
VOYAGE_ARGS_EOF_7F3C9E
``` ```
The arguments reach the parser as the one line inside the quoted heredoc, on
stdin, never as shell words. It prints the parse result as JSON. On exit 1,
print its `errors` and stop. When it prints no JSON, say that the parser did
not run and stop.
Read `flags` and `positional` from the JSON it prints. Dispatch the usage Read `flags` and `positional` from the JSON it prints. Dispatch the usage
block ONLY when one of these two conditions equals exactly true (no substring block ONLY when one of these two conditions equals exactly true (no substring
search, no "contains" check): search, no "contains" check):

View file

@ -31,14 +31,21 @@ See **Handover 7** in `docs/HANDOVER-CONTRACTS.md` for the schema.
## Phase 0 — `--help` handling ## Phase 0 — `--help` handling
Parse `$ARGUMENTS` with the arg parser. Run this via the Bash tool at runtime Parse `$ARGUMENTS` with the arg parser. Run this via the Bash tool at runtime
(NOT eager-exec): (NOT eager-exec), exactly as written: the arguments reach the parser as the one
line inside the quoted heredoc, on stdin, never as shell words:
```bash ```bash
setopt nonomatch 2>/dev/null || true setopt nonomatch 2>/dev/null || true
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekendsession -- $ARGUMENTS node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekendsession --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'
$ARGUMENTS
VOYAGE_ARGS_EOF_7F3C9E
``` ```
The `setopt nonomatch` line is no longer needed for the arguments (the quoted
heredoc expands no glob); it stays harmless.
It prints the parse result as JSON. On exit 1, print its `errors` and stop. It prints the parse result as JSON. On exit 1, print its `errors` and stop.
When it prints no JSON, say that the parser did not run and stop.
Print the usage block below and exit cleanly ONLY when one of these two Print the usage block below and exit cleanly ONLY when one of these two
conditions equals exactly true (no substring search, no "contains" check): conditions equals exactly true (no substring search, no "contains" check):
@ -95,8 +102,9 @@ Decision tree:
## Phase 2 — Required args check (headless-safe) ## Phase 2 — Required args check (headless-safe)
Read the parse result from Phase 0: `<next-brief-path>` is `positional[0]` Read the parse result from Phase 0: `<next-brief-path>` is `positional[0]`
and `<next-label>` is `positional[1]`. Both are required. If either is and `<next-label>` is the parser's `tail` (the rest of the text as typed after
missing or empty: the brief path, one outer quote pair removed, so a label with spaces stays
whole). Both are required. If either is missing or empty:
``` ```
Error: missing required args. Error: missing required args.

View file

@ -46,10 +46,13 @@ command executes inline.
## Phase 1 — Parse mode and validate input ## Phase 1 — Parse mode and validate input
Parse the arguments with the shared arg-parser. Run it first, exactly like Parse the arguments with the shared arg-parser. Run it first, exactly like
this, before anything else: this, before anything else (the arguments reach the parser as the one line
inside the quoted heredoc, on stdin, never as shell words):
```bash ```bash
node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview -- $ARGUMENTS node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'
$ARGUMENTS
VOYAGE_ARGS_EOF_7F3C9E
``` ```
It prints the parse as JSON on stdout. It also checks the required flag It prints the parse as JSON on stdout. It also checks the required flag

View file

@ -25,7 +25,7 @@
| trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:2973-2995 | profile, phase_models, profile_source | none | | trekexecute-stats (Phase 9 record) | ts, plan, plan_type, mode, result, steps_total, steps_passed, steps_failed, steps_skipped, failed_at_step | lib/stats/stats-append.mjs (trekexecute.md Phase 9) | trekexecute.md:2973-2995 | profile, phase_models, profile_source | none |
| trekexecute-stats (autonomy events) | ts, event, known_event, payload | lib/stats/event-emit.mjs `emit()` | event-emit.mjs:78-99 | payload.profile, payload.phase_models, payload.profile_source | none | | trekexecute-stats (autonomy events) | ts, event, known_event, payload | lib/stats/event-emit.mjs `emit()` | event-emit.mjs:78-99 | payload.profile, payload.phase_models, payload.profile_source | none |
| trekexecute-stats (PostToolUse Bash) | ts, session_id, command_excerpt, duration_ms, success | hooks/scripts/post-bash-stats.mjs (Bash PostToolUse) | post-bash-stats.mjs:42-54 | none (hook is plugin-level, not profile-aware) | command_excerpt (CWE-212) | | trekexecute-stats (PostToolUse Bash) | ts, session_id, command_excerpt, duration_ms, success | hooks/scripts/post-bash-stats.mjs (Bash PostToolUse) | post-bash-stats.mjs:42-54 | none (hook is plugin-level, not profile-aware) | command_excerpt (CWE-212) |
| trekreview-stats | ts, slug, verdict, counts (BLOCKER/MAJOR/MINOR/SUGGESTION), reviewed_files_count, mode, duration_ms | lib/stats/stats-append.mjs (trekreview.md Phase 8) | trekreview.md:640-690 | profile, phase_models, profile_source | none | | trekreview-stats | ts, slug, verdict, counts (BLOCKER/MAJOR/MINOR/SUGGESTION), reviewed_files_count, mode, duration_ms | lib/stats/stats-append.mjs (trekreview.md Phase 8) | trekreview.md:643-693 | profile, phase_models, profile_source | none |
| trekcontinue-stats | ts, project, next_session_label, status | lib/stats/stats-append.mjs (trekcontinue.md Phase 5) | trekcontinue.md:304-308 | profile, profile_source | none | | trekcontinue-stats | ts, project, next_session_label, status | lib/stats/stats-append.mjs (trekcontinue.md Phase 5) | trekcontinue.md:304-308 | profile, profile_source | none |
| token-usage | ts, session_id, scope, model, tokens_input, tokens_output, tokens_cache_creation, tokens_cache_read, cost_usd, is_estimate, price_table_version | lib/stats/token-usage.mjs `captureTokenUsage` (via hooks/scripts/otel-export.mjs Stop hook, opt-in `VOYAGE_TOKEN_METER`) | token-usage.mjs:buildRecord | n/a (SKAL-2 schema) | session_id (stripped at export) | | token-usage | ts, session_id, scope, model, tokens_input, tokens_output, tokens_cache_creation, tokens_cache_read, cost_usd, is_estimate, price_table_version | lib/stats/token-usage.mjs `captureTokenUsage` (via hooks/scripts/otel-export.mjs Stop hook, opt-in `VOYAGE_TOKEN_METER`) | token-usage.mjs:buildRecord | n/a (SKAL-2 schema) | session_id (stripped at export) |

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. // now carries disable-model-invocation, so the model cannot start it.
const ENDSESSION = 'commands/trekendsession.md'; 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', () => { 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'); 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); const block = bashBlockHolding(phase0, cmd);
assert.ok(block, 'the parser line is not in a runtime ```bash block'); 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'); 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}`); assert.equal(plain.status, 0, `the parser line refused fix-header: ${plain.stdout}${plain.stderr}`);
const r = JSON.parse(plain.stdout); const r = JSON.parse(plain.stdout);
assert.equal(r.flags['--help'], undefined, 'fix-header set the --help flag'); assert.equal(r.flags['--help'], undefined, 'fix-header set the --help flag');
assert.deepEqual(r.positional, ['fix-header']); assert.deepEqual(r.positional, ['fix-header']);
assert.notEqual(r.positional[0], '-h'); 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(help.status, 0, help.stdout + help.stderr);
assert.equal(JSON.parse(help.stdout).flags['--help'], true, 'control: --help must set the help flag'); 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.equal(two.status, 0, two.stdout + two.stderr);
assert.deepEqual(JSON.parse(two.stdout).positional, ['fix-header', 'Session 2']); 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)')); 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\[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', () => { 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. // passes the state file, so the state-anchored staleness check runs.
const CONTINUE = 'commands/trekcontinue.md'; 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>'; 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', () => { 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'); assert.equal(cmd, CONTINUE_PARSER_LINE, 'the Phase 0 parser line is not the trekcontinue parser call on $ARGUMENTS');
const block = bashBlockHolding(phase0, cmd); const block = bashBlockHolding(phase0, cmd);
assert.ok(block, 'the parser line is not in a runtime ```bash block'); 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), /Run first, exactly like this:/);
assert.match(flat(phase0), /Read `flags` and `positional` from the JSON it prints/); 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), /`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'); 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(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'); 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); assert.equal(dir.status, 0, dir.stdout + dir.stderr);
const r = JSON.parse(dir.stdout); const r = JSON.parse(dir.stdout);
assert.equal(r.flags['--help'], undefined, 'a project dir set the --help flag'); assert.equal(r.flags['--help'], undefined, 'a project dir set the --help flag');
assert.deepEqual(r.positional, ['.claude/projects/demo']); 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(h.status, 0, h.stdout + h.stderr);
assert.equal(JSON.parse(h.stdout).positional[0], '-h'); assert.equal(JSON.parse(h.stdout).positional[0], '-h');
const p05 = flat(section(read(CONTINUE), '## Phase 0.5 — Cleanup mode dispatch')); 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/); 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 p1 = section(read('agents/review-orchestrator.md'), '### Phase 1 — Parse mode and validate input');
const cmd = line(p1, 'parsers/arg-parser.mjs'); const cmd = line(p1, 'parsers/arg-parser.mjs');
assert.equal(cmd, 'node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview -- "$@"'); assert.equal(cmd, "node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekreview --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'");
const r = bash(`set -- --project .claude/projects/demo; ${cmd}`, ROOT); 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(r.status, 0, r.stdout + r.stderr);
assert.equal(JSON.parse(r.stdout).flags['--project'], '.claude/projects/demo', 'the parser did not see --project'); 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. // to the Pass 3 skip.
// K3 The orchestrator's 4-pass item 3 names the brief-conflict lowering as // K3 The orchestrator's 4-pass item 3 names the brief-conflict lowering as
// the exception to the catalogue reset. // 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. // No static import of a new export: the file must load at be0b631.
// Fix the SOURCE a pin guards, not the pin. // 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, /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}`); 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 ---- // ---- 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)', () => { 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) => const all = read('commands/trekreview.md').split('\n');
/^\s*node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/parsers\/arg-parser\.mjs --command trekreview\b/.test(l)); const at = all.map((l, i) =>
assert.equal(lines.length, 1, 'trekreview.md must carry exactly one arg-parser line'); (/^\s*node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/parsers\/arg-parser\.mjs --command trekreview\b/.test(l) ? i : -1)).filter((i) => i >= 0);
const line = lines[0].trim(); assert.equal(at.length, 1, 'trekreview.md must carry exactly one arg-parser line');
assert.match(line, /\$ARGUMENTS/, 'the parser must receive $ARGUMENTS (the Bash tool has no "$@")'); 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', 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(''); const none = run('');
assert.equal(none.status, 1, `missing --project must exit 1: ${none.stdout} ${none.stderr}`); 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']); assert.deepEqual(JSON.parse(none.stdout).errors.map((e) => e.code), ['ARG_REQUIRED_MISSING']);