Wave 2, order 6. /trekexecute now: - refuses a plan whose logged approval does not cover its current sha256 (K2, approval-log verify --kind plan), before step 1; - runs the step check (K9) and the step's optional Regression: (K10, new lib/verification/regression-check.mjs through the criteria runner) at step end, before the checkpoint; exit 2 is "not measured", never green; - runs the architecture rules (K5) and the plan-file sweep (K6) after the last step; a violation or a file outside the plan turns completed into partial; - reviews by itself in the closing run (Phase 7.2): run the thing, /simplify, /trekreview, verdict-gate; a BLOCK adds the review's remediation steps after the plan validator accepts them and the operator re-approves the plan, capped at TREKEXECUTE_MAX_REVIEW_ROUNDS (3); - makes the main-merge gate a lock on the verdict: BLOCK or undecided keeps the merge closed; when open, the executor merges and the guard's question is the one question (operator decision 2026-10-08). The --gates pause before the wave merge-back is gone for the same reason. The guard refuses a merge into main and a push in the permission mode that shows no prompts, since no one can be asked (operator decision 2026-10-09; two mode-read exemptions approved by the operator). progress.json gains optional coupling fields (Handover 5); the validator rejects the runner's exitCode in plan_verification.failed_criteria (C15). Pointer lines name the skills test-first and debugging (S2). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
89 lines
4.2 KiB
JavaScript
89 lines
4.2 KiB
JavaScript
// tests/lib/plugin-root-resolution.test.mjs
|
|
// Every `VOYAGE_ROOT=` block in commands/*.md must resolve the installed plugin
|
|
// root, because the node helper that follows it is guarded by
|
|
// `[ -f "$VOYAGE_ROOT/lib/..." ] || exit 2`. A block that never resolves turns
|
|
// the whole phase into a permanent exit 2.
|
|
//
|
|
// Two conditions, both run through bash exactly as the block is written:
|
|
// substituted Claude Code replaces the LITERAL `${CLAUDE_PLUGIN_ROOT}` in a
|
|
// plugin command's text (measured in recorded sessions: commands
|
|
// ran `node <cache>/voyage/5.11.1/lib/...`). The Bash tool's env
|
|
// does not carry the variable. A `${CLAUDE_PLUGIN_ROOT:-}` form is
|
|
// not that literal, so it is never replaced.
|
|
// unsubstituted the text arrived as written; the fallback must find the newest
|
|
// installed version under cache/<marketplace>/voyage/<version>/,
|
|
// not the version-less parent directory, and by version number,
|
|
// not by name or mtime (measured: mtime put 5.11.0 above 5.11.1).
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { execFileSync } from 'node:child_process';
|
|
import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, utimesSync } from 'node:fs';
|
|
import { join, dirname } from 'node:path';
|
|
import { tmpdir } from 'node:os';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = join(HERE, '..', '..');
|
|
const LITERAL = '${CLAUDE_PLUGIN_ROOT}';
|
|
|
|
// Each block runs from its `VOYAGE_ROOT=` line through the first `esac`.
|
|
function resolutionBlocks() {
|
|
const blocks = [];
|
|
for (const f of readdirSync(join(ROOT, 'commands')).filter(n => n.endsWith('.md')).sort()) {
|
|
const lines = readFileSync(join(ROOT, 'commands', f), 'utf-8').split('\n');
|
|
lines.forEach((line, i) => {
|
|
if (!line.startsWith('VOYAGE_ROOT=')) return;
|
|
const end = lines.findIndex((l, j) => j > i && l.trim() === 'esac');
|
|
assert.ok(end > i, `commands/${f}:${i + 1}: VOYAGE_ROOT block has no esac`);
|
|
blocks.push({ where: `commands/${f}:${i + 1}`, text: lines.slice(i, end + 1).join('\n') });
|
|
});
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
function run(blockText, home) {
|
|
const env = { PATH: process.env.PATH, HOME: home };
|
|
return execFileSync('bash', ['-c', `${blockText}\nprintf '%s' "$VOYAGE_ROOT"`], { env, encoding: 'utf-8' });
|
|
}
|
|
|
|
// trekexecute blocks 3 and 4 are Sub-step D4 (step check + regression) and Phase 7's K5/K6 checks (wave 2, order 6).
|
|
test('commands carry exactly the six known VOYAGE_ROOT blocks', () => {
|
|
assert.deepEqual(resolutionBlocks().map(b => b.where.split(':')[0]), [
|
|
'commands/trekexecute.md', 'commands/trekexecute.md', 'commands/trekexecute.md', 'commands/trekexecute.md',
|
|
'commands/trekresearch.md', 'commands/trekreview.md',
|
|
]);
|
|
});
|
|
|
|
test('substituted: every block takes the root Claude Code wrote into the text', () => {
|
|
const home = mkdtempSync(join(tmpdir(), 'vr-home-'));
|
|
const root = '/opt/fake-cache/mkt/voyage/9.9.9';
|
|
try {
|
|
for (const b of resolutionBlocks()) {
|
|
assert.ok(b.text.includes(LITERAL), `${b.where}: no literal ${LITERAL} for Claude Code to substitute`);
|
|
assert.equal(run(b.text.split(LITERAL).join(root), home), root, b.where);
|
|
}
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
test('unsubstituted: every block falls back to the highest installed version directory', () => {
|
|
const home = mkdtempSync(join(tmpdir(), 'vr-home-'));
|
|
const base = join(home, '.claude', 'plugins', 'cache', 'mkt', 'voyage');
|
|
try {
|
|
// Created highest first and given the OLDEST mtime, so neither name order
|
|
// ("5.9.0" > "5.11.1") nor mtime order picks the right one by accident.
|
|
const versions = ['5.11.1', '5.9.0', '5.10.1'];
|
|
versions.forEach((v, i) => {
|
|
mkdirSync(join(base, v, 'lib'), { recursive: true });
|
|
const t = new Date(Date.UTC(2026, 0, 1 + i));
|
|
utimesSync(join(base, v), t, t);
|
|
});
|
|
for (const b of resolutionBlocks()) {
|
|
assert.equal(run(b.text, home), join(base, '5.11.1'), b.where);
|
|
}
|
|
} finally {
|
|
rmSync(home, { recursive: true, force: true });
|
|
}
|
|
});
|