/trekexecute no longer builds steps itself. Each plan step is built by one fresh helper (new agent step-implementer: opus, effort high, no Agent tool) and judged by one controller (a general helper on opus) that reads the diff from a recorded start_sha and gives two verdicts, spec and quality. Fix rounds go up, maximum 5: three fixes by a fresh helper, then back to the plan, then to the operator. No cheaper-model ladder. Proof is a command, never the model's word: - lib/verification/red-proof.mjs: the step's test must fail at the start point and pass at the result (exit 0/1/2; 2 is never green). Reproduces the02243c6red-first measurement (6 of 55) through the shim fixture. - lib/verification/test-integrity.mjs: the floor guard on the diff (removed asserts, skipped/deleted tests, muted checks, stubs, loosened thresholds). Over the last 150 commits: 149 clean, 1 real finding. - lib/verification/done-line.mjs: DONE step N only when the test command exits 0. Test first is required from plan_version 1.8. The test lock (K8) is used through its CLI and reported as not-locked while it is missing. A run stops only on a closed list of four classes; every other call goes into a decision log with its cost if wrong. Without a passing run-the-thing criterion the result is not verified end to end, never green. Handover 5 gains optional per-step start_sha, report_path, red_proof, test_lock, fix_rounds (0-5) and run-level decisions and end_to_end; old progress files still validate and resume. No isolation on the helper: measured, a worktree-isolated subagent branches from the default branch (02f320f), not the executor's HEAD (2c89a6b), so it would miss the earlier steps. Borrowings credited in commands/trekexecute.md ## Credits (Superpowers, ai-native-sdlc, Kiro, GitHub Spec Kit, agent-skills, Cherny). Tests: 1411 -> 1457 (1455 pass, 0 fail, 2 skipped). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
228 lines
13 KiB
JavaScript
228 lines
13 KiB
JavaScript
// tests/commands/trekexecute-progress-schema.test.mjs
|
|
// The progress file /trekexecute's prose tells the executor to write must pass the
|
|
// command's own validator. Measured 2026-10-05: 3 of 4 real progress files used
|
|
// step status `passed` (warnings) and 2 of 4 failed outright on the missing
|
|
// `plan_version`, because the prose and lib/validators/progress-validator.mjs
|
|
// spoke different vocabularies.
|
|
//
|
|
// - SC4: the `### Progress file schema` fence, filled in, validates with 0 errors
|
|
// and 0 warnings for every documented run status.
|
|
// - SC5: every status value the prose instructs the executor to WRITE is in the
|
|
// validator's lists. Excluded on purpose (their own vocabularies):
|
|
// plan_verification.status, the session-state / NEXT-SESSION-PROMPT status,
|
|
// the stats and report `result`, and the tolerant session-orders reader list.
|
|
|
|
import { test } from 'node:test';
|
|
import { strict as assert } from 'node:assert';
|
|
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import * as progressValidator from '../../lib/validators/progress-validator.mjs';
|
|
|
|
const { validateProgress, checkResumeReadiness, PROGRESS_VALID_STATUSES, PROGRESS_STEP_STATUSES } = progressValidator;
|
|
|
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
const ROOT = join(HERE, '..', '..');
|
|
const COMMAND_FILE = join(ROOT, 'commands', 'trekexecute.md');
|
|
const read = () => readFileSync(COMMAND_FILE, 'utf8');
|
|
|
|
// The one sentence that names the legacy word on purpose (resume tolerance for
|
|
// progress files written under the old prose). Matched exactly; nothing else may.
|
|
const LEGACY_SENTENCE = 'A legacy progress file may say `passed` for a finished step; treat it as `completed`.';
|
|
|
|
/** The JSON fence right after `### Progress file schema`, parsed. */
|
|
function schemaObject(text) {
|
|
const at = text.indexOf('### Progress file schema');
|
|
assert.ok(at >= 0, 'trekexecute.md must have "### Progress file schema"');
|
|
const m = /```json\n([\s\S]*?)```/.exec(text.slice(at));
|
|
assert.ok(m, 'the progress schema section must carry a json fence');
|
|
return JSON.parse(m[1]);
|
|
}
|
|
|
|
const alternatives = (v) => v.replace(/^\{|\}$/g, '').split('|').map((x) => x.trim());
|
|
|
|
/** Fill `{a | b}` with its first alternative and free placeholders with a sample. */
|
|
function fill(value) {
|
|
if (typeof value !== 'string' || !/^\{.*\}$/.test(value)) return value;
|
|
if (value.includes('|')) return alternatives(value)[0];
|
|
return value === '{ISO-8601}' ? '2026-10-05T12:00:00Z' : '.claude/projects/x/plan.md';
|
|
}
|
|
|
|
/** The step-finish status: the "Update progress" line that also records the commit. */
|
|
function finishedStepStatus(text) {
|
|
const m = /Update progress: `steps\.\{N\}\.status = "([^"]+)"`, `steps\.\{N\}\.commit/.exec(text);
|
|
assert.ok(m, 'the step-finish "Update progress" line must set steps.{N}.status and .commit');
|
|
return m[1];
|
|
}
|
|
|
|
/** Every status value the prose tells the executor to write, with its line. */
|
|
function writtenStatuses(text) {
|
|
const lines = text.split('\n');
|
|
const out = [];
|
|
const add = (line, value, kind, site) => out.push({ line, value, kind, site });
|
|
const lineOf = (index) => text.slice(0, index).split('\n').length;
|
|
|
|
const schema = schemaObject(text);
|
|
const schemaLine = lineOf(text.indexOf('### Progress file schema'));
|
|
for (const v of alternatives(schema.status)) add(schemaLine, v, 'run', 'schema');
|
|
for (const s of Object.values(schema.steps)) add(schemaLine, s.status, 'step', 'schema');
|
|
|
|
lines.forEach((l, i) => {
|
|
const n = i + 1;
|
|
const fresh = /progress file exists with status (.+?):/.exec(l);
|
|
if (fresh) for (const m of fresh[1].matchAll(/`([^`]+)`/g)) add(n, m[1], 'run', 'fresh-mode');
|
|
for (const m of l.matchAll(/first step (?:with|where) status != `([^`]+)`/g)) add(n, m[1], 'step', 'resume');
|
|
for (const m of l.matchAll(/steps with `status=([a-z_-]+)`/g)) add(n, m[1], 'step', 'phase-7.5');
|
|
for (const m of l.matchAll(/steps\.\{N\}\.status = "([^"]+)"/g)) add(n, m[1], 'step', 'steps.{N}.status');
|
|
if (/update progress/i.test(l)) {
|
|
for (const m of l.matchAll(/`status: "([^"]+)"`/g)) add(n, m[1], 'run', 'update-progress');
|
|
const to = /`status` to ((?:`[a-z_-]+`\/?)+)/.exec(l);
|
|
if (to) for (const m of to[1].matchAll(/`([^`]+)`/g)) add(n, m[1], 'run', 'update-progress');
|
|
}
|
|
});
|
|
// The escalation rule spans a line break: "steps with\n`status: "X"`".
|
|
for (const m of text.matchAll(/steps with\s+`status: "([^"]+)"`/g)) add(lineOf(m.index), m[1], 'step', 'escalation');
|
|
// So does the Step 0 sentinel: "Set step\nstatus = `blocked`".
|
|
for (const m of text.matchAll(/Set step\s+status = [`"]([^`"]+)[`"]/g)) add(lineOf(m.index), m[1], 'step', 'set-step');
|
|
return out;
|
|
}
|
|
|
|
function offenders(entries) {
|
|
return entries.filter(({ value, kind }) =>
|
|
!(kind === 'run' ? PROGRESS_VALID_STATUSES : PROGRESS_STEP_STATUSES).includes(value))
|
|
.map(({ line, value, kind, site }) => `trekexecute.md:${line} ${site} ${kind} status "${value}"`);
|
|
}
|
|
|
|
test('SC4: each documented run status validates with 0 errors and 0 warnings', () => {
|
|
const text = read();
|
|
const schema = schemaObject(text);
|
|
const runStatuses = alternatives(schema.status);
|
|
for (const s of ['stopped', 'partial']) assert.ok(runStatuses.includes(s), `the schema must document run status ${s}`);
|
|
const finished = finishedStepStatus(text);
|
|
const dir = mkdtempSync(join(tmpdir(), 'trekexecute-progress-'));
|
|
try {
|
|
for (const status of runStatuses) {
|
|
const p = Object.fromEntries(Object.entries(schema).map(([k, v]) => [k, fill(v)]));
|
|
const step = Object.fromEntries(Object.entries(schema.steps['1']).map(([k, v]) => [k, fill(v)]));
|
|
p.status = status;
|
|
p.total_steps = 3;
|
|
p.current_step = 3;
|
|
p.steps = { 1: { ...step, status: finished }, 2: { ...step, status: finished }, 3: { ...step, status: finished } };
|
|
const file = join(dir, `progress-${status}.json`);
|
|
writeFileSync(file, JSON.stringify(p));
|
|
const r = validateProgress(file);
|
|
assert.deepEqual(r.errors.map((e) => e.code), [], `run status ${status}: ${JSON.stringify(r.errors)}`);
|
|
assert.deepEqual(r.warnings.map((w) => w.code), [], `run status ${status}: ${JSON.stringify(r.warnings)}`);
|
|
}
|
|
} finally { rmSync(dir, { recursive: true, force: true }); }
|
|
});
|
|
|
|
// Regression guard (a validator contract; resume itself is prose-driven).
|
|
test('SC4: every documented run status but completed stays resumable', () => {
|
|
for (const status of alternatives(schemaObject(read()).status)) {
|
|
const r = checkResumeReadiness({ status, current_step: 1 });
|
|
assert.equal(r.valid, status !== 'completed', `run status ${status}`);
|
|
}
|
|
});
|
|
|
|
test('SC5: every status the prose tells the executor to write is in the validator lists', () => {
|
|
const text = read();
|
|
const entries = writtenStatuses(text);
|
|
const sites = new Set(entries.map((e) => e.site));
|
|
for (const site of ['schema', 'fresh-mode', 'resume', 'phase-7.5', 'set-step', 'steps.{N}.status', 'update-progress', 'escalation']) {
|
|
assert.ok(sites.has(site), `the ${site} extractor found nothing - teach it the new wording`);
|
|
}
|
|
assert.equal(entries.filter((e) => e.site === 'resume').length, 2, 'both resume rules');
|
|
// The Step 0 sentinel's "Set step\nstatus = `blocked`" wraps a line; the guard
|
|
// above was satisfied by another set-step line while this one went unread.
|
|
const setStep = entries.filter((e) => e.site === 'set-step').map((e) => e.value);
|
|
assert.ok(setStep.includes('blocked'), `the Step 0 sentinel must be read; set-step saw: ${setStep.join(', ')}`);
|
|
const updateLines = text.split('\n').filter((l) => /update progress/i.test(l));
|
|
assert.ok(updateLines.length >= 5, `expected >= 5 "Update progress" sites, found ${updateLines.length}`);
|
|
const withValue = new Set(entries.filter((e) => e.site === 'update-progress' || e.site === 'steps.{N}.status').map((e) => e.line));
|
|
const lines = text.split('\n');
|
|
const silent = lines.map((l, i) => [i + 1, l]).filter(([n, l]) => /update progress/i.test(l) && !withValue.has(n));
|
|
assert.deepEqual(silent, [], 'an "Update progress" site whose status the extractor cannot read');
|
|
assert.deepEqual(offenders(entries), []);
|
|
});
|
|
|
|
test('SC5: the legacy word appears only in the resume-tolerance sentence', () => {
|
|
const text = read();
|
|
assert.equal(text.split(LEGACY_SENTENCE).length - 1, 2, 'one tolerance sentence after each resume rule');
|
|
});
|
|
|
|
// Known-positive control: the extractors DO see today's (pre-fix) wording.
|
|
test('SC5 control: the extractors report the old vocabulary', () => {
|
|
const old = [
|
|
'### Progress file schema',
|
|
'```json',
|
|
'{ "status": "{in-progress | completed | failed | stopped}", "steps": { "1": { "status": "pending" } } }',
|
|
'```',
|
|
'- If a progress file exists with status `in-progress` or `failed`: warn that',
|
|
'- If progress file exists: find the first step with status != `passed`.',
|
|
'- **resume**: first step where status != `passed`',
|
|
'Update progress: `steps.{N}.status = "running"`, `current_step = N`, `updated_at = now`.',
|
|
' number of steps with `status=passed` (excludes Step 0 if sandbox_preflight).',
|
|
].join('\n');
|
|
const bad = offenders(writtenStatuses(old)).join('\n');
|
|
for (const word of ['"in-progress"', '"passed"', '"running"']) assert.ok(bad.includes(word), `control must flag ${word}:\n${bad}`);
|
|
});
|
|
|
|
// --- Handover 5, wave 1 (P8/P9): the per-step fields of the step loop ---------
|
|
// fix_rounds counts the rounds a step went through after its first control:
|
|
// 0 = passed first time, 1-3 = fix rounds, 4 = sent up to the plan, 5 = sent up
|
|
// to the operator. There is no sixth round, so 6 is invalid, not a warning.
|
|
// All new fields are optional on read: a progress.json written before them
|
|
// still resumes (SC4 above validates the old shape).
|
|
|
|
const { validateProgressObject } = progressValidator;
|
|
|
|
function withStep(extra) {
|
|
return {
|
|
schema_version: '1', plan: 'plan.md', plan_version: '1.8', mode: 'execute', status: 'in_progress',
|
|
total_steps: 1, current_step: 1,
|
|
steps: { 1: { status: 'in_progress', attempts: 1, ...extra } },
|
|
};
|
|
}
|
|
|
|
const codes = (r) => r.errors.map((e) => e.code);
|
|
|
|
test('H5: fix_rounds 0-5 is accepted, 6 is rejected', () => {
|
|
for (const n of [0, 1, 2, 3, 4, 5]) {
|
|
assert.deepEqual(codes(validateProgressObject(withStep({ fix_rounds: n }))), [], `fix_rounds ${n}`);
|
|
}
|
|
for (const bad of [6, -1, 2.5, '3']) {
|
|
assert.deepEqual(codes(validateProgressObject(withStep({ fix_rounds: bad }))), ['PROGRESS_FIX_ROUNDS_INVALID'], `fix_rounds ${JSON.stringify(bad)}`);
|
|
}
|
|
});
|
|
|
|
test('H5: start_sha, report_path, red_proof and test_lock are shape-checked when present', () => {
|
|
const ok = { start_sha: '2c89a6b580f689070638f3ff83aea82d355f312f', report_path: '.claude/projects/x/steps/step-1.report.md', red_proof: 'proven', test_lock: 'not-locked' };
|
|
assert.deepEqual(codes(validateProgressObject(withStep(ok))), []);
|
|
for (const v of ['proven', 'rejected', 'not-run']) assert.deepEqual(codes(validateProgressObject(withStep({ red_proof: v }))), [], v);
|
|
for (const v of ['locked', 'released', 'not-locked']) assert.deepEqual(codes(validateProgressObject(withStep({ test_lock: v }))), [], v);
|
|
assert.deepEqual(codes(validateProgressObject(withStep({ start_sha: 'HEAD' }))), ['PROGRESS_START_SHA_INVALID']);
|
|
assert.deepEqual(codes(validateProgressObject(withStep({ report_path: '' }))), ['PROGRESS_REPORT_PATH_INVALID']);
|
|
assert.deepEqual(codes(validateProgressObject(withStep({ red_proof: 'green' }))), ['PROGRESS_RED_PROOF_INVALID']);
|
|
assert.deepEqual(codes(validateProgressObject(withStep({ test_lock: 'yes' }))), ['PROGRESS_TEST_LOCK_INVALID']);
|
|
// null = not set yet (the schema fence's fresh step).
|
|
assert.deepEqual(codes(validateProgressObject(withStep({ start_sha: null, report_path: null, red_proof: null, test_lock: null }))), []);
|
|
});
|
|
|
|
test('H5: the run-level decision log is an array of {decision, cost_if_wrong}', () => {
|
|
const run = (decisions) => ({ ...withStep({}), decisions });
|
|
assert.deepEqual(codes(validateProgressObject(run([{ step: 2, decision: 'kept the old name', cost_if_wrong: 'one rename commit' }]))), []);
|
|
assert.deepEqual(codes(validateProgressObject(run([{ step: 2, decision: 'kept the old name' }]))), ['PROGRESS_DECISION_INVALID']);
|
|
assert.deepEqual(codes(validateProgressObject(run('none'))), ['PROGRESS_DECISION_INVALID']);
|
|
});
|
|
|
|
test('H5: the progress schema fence carries the new per-step fields and the decision log', () => {
|
|
const schema = schemaObject(read());
|
|
const step = schema.steps['1'];
|
|
for (const k of ['start_sha', 'report_path', 'red_proof', 'test_lock', 'fix_rounds']) {
|
|
assert.ok(k in step, `the schema's step record must carry ${k}`);
|
|
}
|
|
assert.equal(step.fix_rounds, 0);
|
|
assert.ok(Array.isArray(schema.decisions), 'the schema must carry the run-level decisions log');
|
|
});
|