// tests/commands/trekcontinue.test.mjs // Regression tests for /trekcontinue (commands/trekcontinue.md). // // Steps 2 + 4 of the v3.4.1 hot-fix plan // (project 2026-05-04-v3.3.1-trekcontinue-fixes). // // Pattern mix: // - Pattern B (tmp-dir, mkdtempSync + try/finally) — fixture builds // - Pattern D (markdown structure) — assertions against command prose // - Hook integration via runHook + pre-bash-executor (Pattern C, Step 4) import { test } from 'node:test'; import { strict as assert } from 'node:assert'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execFileSync, spawnSync } from 'node:child_process'; import { runHook } from '../helpers/hook-helper.mjs'; import { findSection } from '../../lib/parsers/plan-schema.mjs'; const HERE = dirname(fileURLToPath(import.meta.url)); const ROOT = join(HERE, '..', '..'); const COMMAND_FILE = join(ROOT, 'commands', 'trekcontinue.md'); const PRE_BASH = join(ROOT, 'hooks', 'scripts', 'pre-bash-executor.mjs'); function readCommand() { return readFileSync(COMMAND_FILE, 'utf8'); } function extractPhase(commandText, phaseHeader) { // phaseHeader e.g. "## Phase 0 ", "## Phase 1 ", "## Phase 2 " const startIdx = commandText.indexOf(phaseHeader); if (startIdx === -1) return ''; const rest = commandText.slice(startIdx); // Stop at the next "## Phase " (or "## Hard rules" — also a top-level break) const nextPhase = rest.search(/\n## (?:Phase |Hard )/); if (nextPhase === -1) return rest; return rest.slice(0, nextPhase); } function inProgressState(updatedAtIso) { return { schema_version: 1, project: '.claude/projects/2026-05-04-fixture-a', next_session_brief_path: '.claude/projects/2026-05-04-fixture-a/brief.md', next_session_label: 'Session 2: in progress fixture', status: 'in_progress', updated_at: updatedAtIso, }; } function completedState(updatedAtIso) { return { schema_version: 1, project: '.claude/projects/2026-05-04-fixture-b', next_session_brief_path: '.claude/projects/2026-05-04-fixture-b/brief.md', next_session_label: 'Session N: completed fixture', status: 'completed', updated_at: updatedAtIso, }; } // --------------------------------------------------------------- // Step 2 — Bug 1 regression tests (SC-1, SC-2) // --------------------------------------------------------------- test('trekcontinue Bug 1 — Phase 1 documents auto-discovery sort by Date.parse(updated_at) DESC', () => { // Fixture-builds two project dirs and verifies our chosen sort key // matches what Phase 1 prose documents. const root = mkdtempSync(join(tmpdir(), 'trekcontinue-disc-')); try { const projectsRoot = join(root, '.claude', 'projects'); mkdirSync(join(projectsRoot, '2026-05-04-fixture-a'), { recursive: true }); mkdirSync(join(projectsRoot, '2026-05-04-fixture-b'), { recursive: true }); const inProgress = inProgressState('2026-05-04T18:00:00.000Z'); const completed = completedState('2026-05-03T09:00:00.000Z'); writeFileSync( join(projectsRoot, '2026-05-04-fixture-a', '.session-state.local.json'), JSON.stringify(inProgress, null, 2), ); writeFileSync( join(projectsRoot, '2026-05-04-fixture-b', '.session-state.local.json'), JSON.stringify(completed, null, 2), ); // Numeric sort by Date.parse — newest first. const candidates = [ { ...completed, _path: 'b' }, { ...inProgress, _path: 'a' }, ].sort((x, y) => Date.parse(y.updated_at) - Date.parse(x.updated_at)); assert.equal(candidates[0]._path, 'a', 'newest in_progress fixture must win the sort'); const phase1 = extractPhase(readCommand(), '## Phase 1 '); assert.match( phase1, /Date\.parse/, 'Phase 1 prose must document Date.parse-based sort (numeric, not lexicographic)', ); } finally { rmSync(root, { recursive: true, force: true }); } }); test('trekcontinue Bug 1 — Phase 0 dispatches via parsed flags, not substring contains', () => { const phase0 = extractPhase(readCommand(), '## Phase 0 '); // Must NOT use the legacy "contains --help or -h" substring dispatch. assert.doesNotMatch( phase0, /contains\s+`?--help`?\s+or\s+`?-h`?/i, 'Phase 0 must not dispatch via substring `contains` — use parsed flags / positional', ); // Must reference parseArgs / flags['--help'] / positional[0] (parsed-arg dispatch). const referencesParsedDispatch = /flags\[\s*['"]--help['"]\s*\]/.test(phase0) || /positional\[\s*0\s*\]/.test(phase0); assert.ok( referencesParsedDispatch, 'Phase 0 must dispatch via parsed flags["--help"] or positional[0] === "-h"', ); }); test('trekcontinue Bug 1 — Phase 1 documents empty-args path explicitly to auto-discovery', () => { const phase1 = extractPhase(readCommand(), '## Phase 1 '); // Some explicit text mentioning the empty / whitespace path so a future reader // can't misread Phase 0 as "fall through to usage on empty". assert.match( phase1, /\b(empty|whitespace)\b/i, 'Phase 1 must explicitly handle the empty-args case (auto-discovery)', ); assert.match( phase1, /auto-discover/i, 'Phase 1 must reference auto-discovery as the empty-args fallback', ); }); test('trekcontinue Bug 1 sub — Phase 1 emits SC-2 diagnostic for .md positional arg', () => { const phase1 = extractPhase(readCommand(), '## Phase 1 '); // SC-2 verbatim diagnostic strings. assert.match( phase1, /expected.*/i, 'Phase 1 must mention "expected " in the .md-arg diagnostic', ); assert.match( phase1, /did you mean to paste/i, 'Phase 1 must mention "did you mean to paste" in the .md-arg diagnostic', ); // Detection condition must reference .md. assert.match( phase1, /\.md\b/, 'Phase 1 must detect .md positional arg (case for SC-2)', ); }); // --------------------------------------------------------------- // Step 4 — Bug 2 regression tests (SC-3) // --------------------------------------------------------------- test('trekcontinue Bug 2 — pre-bash-executor ALLOWS resolved validator invocation', async () => { // (d-1) Sanity-check that the planned Phase 2 Bash form (validator // invocation with a concrete absolute path) is not blocked by the // marketplace pre-bash-executor hook chain. const cmd = "node lib/validators/session-state-validator.mjs --json /tmp/fixture-not-real/.session-state.local.json"; const { code } = await runHook(PRE_BASH, { tool_name: 'Bash', tool_input: { command: cmd } }); assert.strictEqual(code, 0, 'pre-bash-executor must not block resolved validator invocations'); }); // --------------------------------------------------------------- // Step 8 — Bug 3 regression test (Phase 1.5 consistency wire-up) // --------------------------------------------------------------- test('trekcontinue Bug 3 — Phase 1.5 documents consistency check between Phase 1 and Phase 2', () => { const cmd = readCommand(); // Phase 1.5 must exist literally in the prose between Phase 1 and Phase 2. assert.match(cmd, /## Phase 1\.5 /, 'Phase 1.5 header must be present'); assert.match(cmd, /next-session-prompt-validator/, 'Phase 1.5 must invoke next-session-prompt-validator'); const phase15Idx = cmd.indexOf('## Phase 1.5 '); const phase2Idx = cmd.indexOf('## Phase 2 '); assert.ok(phase15Idx !== -1 && phase2Idx !== -1 && phase15Idx < phase2Idx, 'Phase 1.5 must appear before Phase 2'); }); test('trekcontinue Bug 3 (e) — CLI consistency mode flags producer mismatch in JSON output', () => { const root = mkdtempSync(join(tmpdir(), 'trekcontinue-fm-')); try { const projectDir = join(root, '.claude', 'projects', '2026-05-04-fixture-c'); mkdirSync(projectDir, { recursive: true }); // State file (status: in_progress, updated_at = T-base) const stateUpdatedAt = '2026-05-04T15:00:00.000Z'; writeFileSync( join(projectDir, '.session-state.local.json'), JSON.stringify({ schema_version: 1, project: projectDir, next_session_brief_path: join(projectDir, 'brief.md'), next_session_label: 'Session 2', status: 'in_progress', updated_at: stateUpdatedAt, }, null, 2), ); // Project-dir prompt: produced_by trekexecute at T-1 const projectPrompt = join(projectDir, 'NEXT-SESSION-PROMPT.local.md'); writeFileSync(projectPrompt, '---\nproduced_by: trekexecute\nproduced_at: 2026-05-04T15:30:00.000Z\n---\n\n# Session 2\n'); // Plugin-root prompt: produced_by graceful-handoff at T-0 (newer) const pluginPrompt = join(root, 'NEXT-SESSION-PROMPT.local.md'); writeFileSync(pluginPrompt, '---\nproduced_by: graceful-handoff\nproduced_at: 2026-05-04T15:31:00.000Z\n---\n\n# A2 master\n'); // Both fresh relative to state.updated_at → producer mismatch must hard-fail. let exitCode = 0; let stdout = ''; try { stdout = execFileSync(process.execPath, [ join(ROOT, 'lib', 'validators', 'next-session-prompt-validator.mjs'), '--json', '--consistency', projectPrompt, pluginPrompt, ], { encoding: 'utf-8', cwd: ROOT }); } catch (e) { exitCode = e.status; stdout = e.stdout ? e.stdout.toString() : ''; } assert.notEqual(exitCode, 0, 'consistency CLI must exit non-zero on producer mismatch'); const parsed = JSON.parse(stdout); assert.equal(parsed.valid, false); const mismatch = parsed.errors.find(e => e.code === 'NEXT_SESSION_PROMPT_PRODUCER_MISMATCH'); assert.ok(mismatch, 'must surface NEXT_SESSION_PROMPT_PRODUCER_MISMATCH error'); assert.match(mismatch.message, new RegExp(projectPrompt.replace(/[/\\]/g, '.')), 'error message must reference project-dir prompt path'); assert.match(mismatch.message, new RegExp(pluginPrompt.replace(/[/\\]/g, '.')), 'error message must reference plugin-root prompt path'); assert.match(mismatch.message, /produced_by/i, 'error message must mention produced_by'); } finally { rmSync(root, { recursive: true, force: true }); } }); test('trekcontinue Bug 2 — Phase 2 contains no {state-file-path} or any {curly-template} placeholder', () => { // (d-2) Pattern D structure test. The fix must eliminate the // {state-file-path} placeholder and any other {anything} curly-brace // template syntax from Phase 2 — substitution failures are the // root cause of the path-guard hook crash. const phase2 = extractPhase(readCommand(), '## Phase 2 '); assert.equal( phase2.includes('{state-file-path}'), false, 'Phase 2 must not contain the {state-file-path} placeholder', ); assert.doesNotMatch( phase2, /\{[a-z][a-z0-9-]*\}/, 'Phase 2 must not contain any {lowercase-template} curly-brace placeholder', ); assert.match( phase2, /Read tool/, 'Phase 2 must document the deterministic Read tool flow', ); }); // --------------------------------------------------------------- // Step 10 — Bug 4 regression tests (Phase 0.5 wire-up + cleanup f-1/f-2/f-3) // --------------------------------------------------------------- test('trekcontinue Bug 4 — Phase 0.5 documents cleanup mode dispatch', () => { const cmd = readCommand(); assert.match(cmd, /## Phase 0\.5 /, 'Phase 0.5 header must be present'); // Phase 0.5 must come BETWEEN Phase 0 and Phase 1. const idx05 = cmd.indexOf('## Phase 0.5 '); const idx1 = cmd.indexOf('## Phase 1 '); assert.ok(idx05 !== -1 && idx1 !== -1 && idx05 < idx1, 'Phase 0.5 must appear before Phase 1'); // Must reference cleanupProject and parsed flags['--cleanup']. const phase05 = extractPhase(cmd, '## Phase 0.5 '); assert.match(phase05, /cleanupProject/, 'Phase 0.5 must invoke cleanupProject'); assert.match(phase05, /flags\['--cleanup'\]/, "Phase 0.5 must dispatch via flags['--cleanup']"); // Usage block must document both forms. assert.match(cmd, /--cleanup --confirm/, 'usage must mention --cleanup --confirm'); }); test('trekcontinue Bug 4 (f-1) dry-run lists candidates without deleting', async () => { const { cleanupProject } = await import('../../lib/util/cleanup.mjs'); const root = mkdtempSync(join(tmpdir(), 'trekcontinue-cleanup-')); try { const dir = join(root, 'project-completed'); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, '.session-state.local.json'), JSON.stringify({ schema_version: 1, project: dir, next_session_brief_path: join(dir, 'brief.md'), next_session_label: 'Done', status: 'completed', updated_at: '2026-05-04T16:00:00.000Z', }, null, 2)); writeFileSync(join(dir, 'NEXT-SESSION-PROMPT.local.md'), '---\nproduced_by: trekexecute\nproduced_at: 2026-05-04T16:00:00.000Z\n---\n\n# Done\n'); const r = cleanupProject(dir, { dryRun: true }); assert.equal(r.valid, true, JSON.stringify(r.errors)); assert.equal(r.parsed.wouldDelete.length, 2); assert.equal(readFileSync(join(dir, '.session-state.local.json'), 'utf8').length > 0, true); assert.equal(readFileSync(join(dir, 'NEXT-SESSION-PROMPT.local.md'), 'utf8').length > 0, true); } finally { rmSync(root, { recursive: true, force: true }); } }); test('trekcontinue Bug 4 (f-2) confirm deletes and (f-3) idempotent re-run handles already-clean dir', async () => { const { cleanupProject } = await import('../../lib/util/cleanup.mjs'); const { existsSync } = await import('node:fs'); const root = mkdtempSync(join(tmpdir(), 'trekcontinue-cleanup-')); try { const dir = join(root, 'project-completed'); mkdirSync(dir, { recursive: true }); writeFileSync(join(dir, '.session-state.local.json'), JSON.stringify({ schema_version: 1, project: dir, next_session_brief_path: join(dir, 'brief.md'), next_session_label: 'Done', status: 'completed', updated_at: '2026-05-04T16:00:00.000Z', }, null, 2)); writeFileSync(join(dir, 'NEXT-SESSION-PROMPT.local.md'), '---\nproduced_by: trekexecute\nproduced_at: 2026-05-04T16:00:00.000Z\n---\n\n# Done\n'); // f-2: confirm deletes const r2 = cleanupProject(dir, { dryRun: false, confirm: true }); assert.equal(r2.valid, true, JSON.stringify(r2.errors)); assert.equal(r2.parsed.deleted.length, 2); assert.equal(existsSync(join(dir, '.session-state.local.json')), false); assert.equal(existsSync(join(dir, 'NEXT-SESSION-PROMPT.local.md')), false); // f-3: idempotent re-run on a fully-cleaned dir reports CLEANUP_NO_STATE_FILE // (no state file → nothing to clean) — a deterministic terminal signal, // not a crash. Operators can ignore it. const r3 = cleanupProject(dir, { dryRun: false, confirm: true }); assert.equal(r3.valid, false); assert.ok(r3.errors.find(e => e.code === 'CLEANUP_NO_STATE_FILE')); } finally { rmSync(root, { recursive: true, force: true }); } }); // --------------------------------------------------------------- // Phase 0 runs the arg-parser CLI and reads its JSON (no parseArgs($ARGUMENTS) // function call, which no Bash line runs). // --------------------------------------------------------------- /** Whitespace (line breaks too) collapsed to one space. */ const flat = (s) => s.replace(/\s+/g, ' ').trim(); /** * The text of a heading's section, up to the next heading of the same or a * higher level outside a fence. The heading must exist. */ function sectionText(text, heading) { const level = heading.match(/^#+/)[0].length; const s = findSection(text, heading.replace(/^#+ /, ''), level); assert.ok(s, `missing ${heading}`); const lines = s.text.split('\n'); let fenced = false; for (let i = 1; i < lines.length; i++) { if (/^\s{0,3}(`{3,}|~{3,})/.test(lines[i])) fenced = !fenced; const m = !fenced && lines[i].match(/^(#+) /); if (m && m[1].length < level) return lines.slice(0, i).join('\n'); } return s.text; } /** The single line in `text` that runs `lib/` (and holds `verb`, when given). */ function libLine(text, mod, verb = '') { const esc = mod.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&'); const re = new RegExp(`^\\s*node \\$\\{CLAUDE_PLUGIN_ROOT\\}/lib/${esc}\\b`); const hits = text.split('\n').filter((l) => re.test(l) && l.includes(verb)); assert.equal(hits.length, 1, `expected exactly one \`${mod}${verb ? ` ${verb}` : ''}\` line; found ${hits.length}`); return hits[0].trim(); } /** The runtime ```bash block of `text` that holds `cmd`, or undefined. */ function bashBlockHolding(text, cmd) { return text.split('\n```bash\n').slice(1).map((b) => b.split('\n```')[0]).find((b) => b.includes(cmd)); } /** A Phase 0 parser block run through bash with the plugin root filled in and `args` in place of $ARGUMENTS. */ function runParser(cmd, args) { const filled = cmd.split('$ARGUMENTS').join(args).split('${CLAUDE_PLUGIN_ROOT}').join(ROOT); return spawnSync('bash', ['-c', filled], { cwd: ROOT, encoding: 'utf8' }); } const PHASE0_PARSER_LINE = "node ${CLAUDE_PLUGIN_ROOT}/lib/parsers/arg-parser.mjs --command trekcontinue --stdin <<'VOYAGE_ARGS_EOF_7F3C9E'"; test('trekcontinue Phase 0 has no parseArgs($ARGUMENTS call, and its parser line run with --help sets the help flag', () => { const phase0 = sectionText(readCommand(), '## Phase 0 — `--help` handling'); assert.doesNotMatch(phase0, /parseArgs\(\$ARGUMENTS/, 'Phase 0 still calls parseArgs($ARGUMENTS, ...) as a function'); const cmd = libLine(phase0, 'parsers/arg-parser.mjs'); assert.equal(cmd, PHASE0_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(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(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(block, '-h'); assert.equal(h.status, 0, h.stdout + h.stderr); assert.equal(JSON.parse(h.stdout).positional[0], '-h'); const p05 = flat(sectionText(readCommand(), '## Phase 0.5 — Cleanup mode dispatch')); assert.doesNotMatch(p05, /After `parseArgs` has resolved/); assert.match(p05, /After the parser has printed/); }); test('trekcontinue — stats write through stats-append, never silently skipped', () => { const md = readCommand(); assert.match(md, /node \$\{CLAUDE_PLUGIN_ROOT\}\/lib\/stats\/stats-append\.mjs trekcontinue <<'JSON'/); assert.doesNotMatch(md, /skip (stats )?silently|silently skip/i); assert.doesNotMatch(md, /\$\{CLAUDE_PLUGIN_DATA\}\/trekcontinue-stats\.jsonl/); });