feat(trekexecute): couple the executor to the review gate, the plan approval and the step checks
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>
This commit is contained in:
parent
4ee711a6ac
commit
920fbd3c47
15 changed files with 1310 additions and 64 deletions
|
|
@ -8,9 +8,13 @@
|
|||
// cross-check in trekexecute, not by this per-validation shape check.
|
||||
// The same holds for the wave-1 step-loop fields (per step: start_sha, report_path,
|
||||
// red_proof, test_lock, fix_rounds 0..5; per run: decisions): shape-checked when
|
||||
// present, never required.
|
||||
// present, never required. So are the wave-2 coupling fields (per step: step_check
|
||||
// from K9, regression from K10; per run: plan_approval, arch_rules, scope_sweep,
|
||||
// simplify, review) and the key of plan_verification.failed_criteria: the runner
|
||||
// says `exitCode`, progress says `exit_code`, and only `exit_code` is accepted (C15).
|
||||
|
||||
import { readFileSync, existsSync } from 'node:fs';
|
||||
import { readFileSync, existsSync, realpathSync } from 'node:fs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { issue, fail } from '../util/result.mjs';
|
||||
|
||||
export const PROGRESS_REQUIRED_TOP = ['schema_version', 'plan', 'plan_version', 'mode', 'status', 'total_steps', 'current_step', 'steps'];
|
||||
|
|
@ -28,6 +32,17 @@ export const PROGRESS_MAX_FIX_ROUNDS = 5;
|
|||
export const PROGRESS_RED_PROOF_VALUES = ['proven', 'rejected', 'not-run'];
|
||||
export const PROGRESS_TEST_LOCK_VALUES = ['locked', 'released', 'not-locked'];
|
||||
|
||||
// Wave 2 (order 6) coupling fields. Each value is the exit of a contract CLI said
|
||||
// in words; "not-measured" / "not-run" are never green, and "absent" means the plan
|
||||
// declared nothing (no `Regression:` field), so nothing new happened.
|
||||
export const PROGRESS_STEP_CHECK_VALUES = ['clean', 'findings', 'not-measured']; // K9 exit 0 / 1 / 2
|
||||
export const PROGRESS_REGRESSION_VALUES = ['passed', 'failed', 'unrunnable', 'not-run', 'absent']; // K10
|
||||
export const PROGRESS_PLAN_APPROVAL_VALUES = ['approved', 'not-checked']; // K2; not-checked = a session spec
|
||||
export const PROGRESS_ARCH_RULES_VALUES = ['pass', 'violation', 'not-measured']; // K5 exit 0 / 1 / 2
|
||||
export const PROGRESS_SCOPE_SWEEP_VALUES = ['pass', 'deviation', 'not-measured']; // K6 exit 0 / 1 / 2
|
||||
export const PROGRESS_SIMPLIFY_VALUES = ['applied', 'clean', 'reverted', 'not-run'];
|
||||
export const PROGRESS_REVIEW_GATE_EXITS = [0, 1, 2]; // K7: ALLOW/WARN, BLOCK, could not decide
|
||||
|
||||
function checkStepFields(k, s, errors) {
|
||||
const set = (v) => v !== undefined && v !== null;
|
||||
if (s.fix_rounds !== undefined &&
|
||||
|
|
@ -47,6 +62,50 @@ function checkStepFields(k, s, errors) {
|
|||
if (set(s.test_lock) && !PROGRESS_TEST_LOCK_VALUES.includes(s.test_lock)) {
|
||||
errors.push(issue('PROGRESS_TEST_LOCK_INVALID', `steps["${k}"].test_lock "${s.test_lock}" not in [${PROGRESS_TEST_LOCK_VALUES.join(', ')}]`));
|
||||
}
|
||||
if (set(s.step_check) && !PROGRESS_STEP_CHECK_VALUES.includes(s.step_check)) {
|
||||
errors.push(issue('PROGRESS_STEP_CHECK_INVALID', `steps["${k}"].step_check ${JSON.stringify(s.step_check)} not in [${PROGRESS_STEP_CHECK_VALUES.join(', ')}]`));
|
||||
}
|
||||
if (set(s.regression) && !PROGRESS_REGRESSION_VALUES.includes(s.regression)) {
|
||||
errors.push(issue('PROGRESS_REGRESSION_INVALID', `steps["${k}"].regression ${JSON.stringify(s.regression)} not in [${PROGRESS_REGRESSION_VALUES.join(', ')}]`));
|
||||
}
|
||||
}
|
||||
|
||||
const isStr = (v) => typeof v === 'string' && v.trim() !== '';
|
||||
|
||||
// Wave 2 (order 6) run-level fields; all optional, shape-checked when set.
|
||||
function checkCouplingFields(p, errors) {
|
||||
const set = (v) => v !== undefined && v !== null;
|
||||
const oneOf = (field, values, code) => {
|
||||
if (set(p[field]) && !values.includes(p[field])) {
|
||||
errors.push(issue(code, `${field} ${JSON.stringify(p[field])} not in [${values.join(', ')}]`));
|
||||
}
|
||||
};
|
||||
oneOf('plan_approval', PROGRESS_PLAN_APPROVAL_VALUES, 'PROGRESS_PLAN_APPROVAL_INVALID');
|
||||
oneOf('arch_rules', PROGRESS_ARCH_RULES_VALUES, 'PROGRESS_ARCH_RULES_INVALID');
|
||||
oneOf('simplify', PROGRESS_SIMPLIFY_VALUES, 'PROGRESS_SIMPLIFY_INVALID');
|
||||
const sw = p.scope_sweep;
|
||||
if (set(sw) && !(typeof sw === 'object' && PROGRESS_SCOPE_SWEEP_VALUES.includes(sw.status) &&
|
||||
Array.isArray(sw.outside) && sw.outside.every(isStr))) {
|
||||
errors.push(issue('PROGRESS_SCOPE_SWEEP_INVALID',
|
||||
`scope_sweep must be {status: ${PROGRESS_SCOPE_SWEEP_VALUES.join('|')}, outside: [paths]}`));
|
||||
}
|
||||
const rv = p.review;
|
||||
if (set(rv) && !(typeof rv === 'object' && Number.isInteger(rv.round) && rv.round >= 1 &&
|
||||
PROGRESS_REVIEW_GATE_EXITS.includes(rv.gate_exit))) {
|
||||
errors.push(issue('PROGRESS_REVIEW_INVALID', 'review must be {round: integer >= 1, gate_exit: 0|1|2, ...}'));
|
||||
}
|
||||
// C15: the criteria runner reports `exitCode`; progress.json carries `exit_code`
|
||||
// (an integer, or null when the criterion never ran). The executor translates;
|
||||
// the runner's key here means the translation was skipped.
|
||||
const fc = p.plan_verification?.failed_criteria;
|
||||
if (fc !== undefined && fc !== null) {
|
||||
const ok = Array.isArray(fc) && fc.every((c) => c && typeof c === 'object' && !('exitCode' in c) &&
|
||||
'exit_code' in c && (c.exit_code === null || Number.isInteger(c.exit_code)));
|
||||
if (!ok) {
|
||||
errors.push(issue('PROGRESS_FAILED_CRITERIA_KEY',
|
||||
'plan_verification.failed_criteria entries carry exit_code (integer or null), never the runner key exitCode'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The run-level decision log (P4): every decision the executor took on its own,
|
||||
|
|
@ -129,6 +188,7 @@ export function validateProgressObject(parsed, opts = {}) {
|
|||
}
|
||||
|
||||
checkDecisions(parsed.decisions, errors);
|
||||
checkCouplingFields(parsed, errors);
|
||||
|
||||
return { valid: errors.length === 0, errors, warnings, parsed };
|
||||
}
|
||||
|
|
@ -152,7 +212,17 @@ export function validateProgress(filePath, opts = {}) {
|
|||
return validateProgressContent(text, opts);
|
||||
}
|
||||
|
||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||
// The real paths on both sides: a `file://` comparison fails when the path holds a
|
||||
// space or runs through a symlink, and the CLI then exits 0 having checked nothing.
|
||||
function isMain() {
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMain()) {
|
||||
const args = process.argv.slice(2);
|
||||
const filePath = args.find(a => !a.startsWith('--'));
|
||||
if (!filePath) {
|
||||
|
|
|
|||
108
lib/verification/regression-check.mjs
Normal file
108
lib/verification/regression-check.mjs
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
#!/usr/bin/env node
|
||||
// lib/verification/regression-check.mjs
|
||||
// K10 at execution time: run ONE step's `Regression:` command through the
|
||||
// criteria runner's own screens, before the step's checkpoint.
|
||||
//
|
||||
// node lib/verification/regression-check.mjs --plan <plan.md> --step <N> [--cwd <dir>] [--json]
|
||||
// 0 = the step's `Regression:` passed, or the step has no `Regression:`
|
||||
// field (`status: "absent"` — nothing new happens, K10)
|
||||
// 1 = it failed, the executor denylist blocked it, or it is not runnable
|
||||
// (no command, or a command outside the test-runner allowlist): the
|
||||
// step is not done, and that is a finding for the fix rounds
|
||||
// 2 = could not run: bad usage, a plan that cannot be read, no step N
|
||||
// JSON on stdout: {step, status, command, exit_code, output}
|
||||
// status: passed | failed | blocked | unrunnable | absent | error
|
||||
//
|
||||
// The command is the field's first backticked span — the same reading as the
|
||||
// runner's `## Verification` bullets — and it goes through the same two
|
||||
// screens: the allowlist of test runners, then the plugin's PreToolUse
|
||||
// denylist. The plan validator rejects a `Regression:` that cannot run once K10
|
||||
// is in it (wave 2, order 9); this is the run-time check, so a plan validated
|
||||
// before that is never read as green.
|
||||
//
|
||||
// The runner reports `exitCode`; this CLI writes `exit_code`, the key
|
||||
// progress.json uses (C15). Nothing else is translated.
|
||||
//
|
||||
// The command runs the way the runner runs it (`/bin/sh -c`), but without
|
||||
// NODE_TEST_CONTEXT in its environment (red-proof.mjs `commandEnv`): measured
|
||||
// 2026-10-08, a red `node --test` regression read as passed when this CLI was
|
||||
// itself started under `node --test`, because the nested run reported to the
|
||||
// parent and exited 0.
|
||||
|
||||
function exec(command, { cwd, timeoutMs }) {
|
||||
const r = spawnSync('/bin/sh', ['-c', command], { cwd, encoding: 'utf8', timeout: timeoutMs, env: commandEnv() });
|
||||
return { status: r.status, stdout: r.stdout ?? '', stderr: r.stderr ?? '' };
|
||||
}
|
||||
|
||||
import { readFileSync, realpathSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { sliceSteps, parseStepFields } from '../parsers/plan-schema.mjs';
|
||||
import { firstCommand, runCriteria } from './criteria-runner.mjs';
|
||||
import { commandEnv } from './red-proof.mjs';
|
||||
|
||||
export const REGRESSION_FIELD = 'Regression';
|
||||
const EXIT = Object.freeze({ passed: 0, absent: 0, failed: 1, blocked: 1, unrunnable: 1, error: 2 });
|
||||
|
||||
/**
|
||||
* @param {{ planText: string, step: number, cwd?: string, run?: Function }} o
|
||||
* @returns {{ step: number, status: string, command: string|null, exit_code: number|null, output: string, exit: 0|1|2 }}
|
||||
*/
|
||||
export function checkRegression({ planText, step, cwd = process.cwd(), run = (c, o) => runCriteria(c, { ...o, exec }) }) {
|
||||
const base = { step, command: null, exit_code: null, output: '' };
|
||||
const found = sliceSteps(planText).find((s) => Number(s.n) === step);
|
||||
if (!found) return { ...base, status: 'error', output: `no "### Step ${step}:" in the plan`, exit: EXIT.error };
|
||||
const field = parseStepFields(found.body).get(REGRESSION_FIELD);
|
||||
if (!field) return { ...base, status: 'absent', output: 'no Regression: field — nothing to run', exit: EXIT.absent };
|
||||
const { command, reason } = firstCommand(field.text);
|
||||
const [r] = run([{ label: `R${step}`, text: field.text, command, reason }], { cwd });
|
||||
return {
|
||||
step,
|
||||
status: r.status,
|
||||
command: r.command ?? null,
|
||||
exit_code: r.exitCode ?? null,
|
||||
output: r.output ?? '',
|
||||
exit: EXIT[r.status] ?? EXIT.error,
|
||||
};
|
||||
}
|
||||
|
||||
export function main(argv) {
|
||||
let plan; let step; let cwd = process.cwd(); let asJson = false;
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--plan' && argv[i + 1]) plan = argv[++i];
|
||||
else if (a === '--step' && argv[i + 1]) step = Number(argv[++i]);
|
||||
else if (a === '--cwd' && argv[i + 1]) cwd = resolve(argv[++i]);
|
||||
else if (a === '--json') asJson = true;
|
||||
else {
|
||||
process.stderr.write(`regression-check: unknown argument ${a}\n`);
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
const say = (r) => {
|
||||
const { exit, ...rest } = r;
|
||||
process.stdout.write(asJson ? `${JSON.stringify(rest)}\n`
|
||||
: `regression step ${rest.step}: ${rest.status}${rest.command ? ` — \`${rest.command}\`` : ''}${rest.exit_code === null ? '' : ` (exit ${rest.exit_code})`}\n`);
|
||||
return exit;
|
||||
};
|
||||
if (!plan || !Number.isInteger(step) || step < 1) {
|
||||
process.stderr.write('Usage: regression-check.mjs --plan <plan.md> --step <N> [--cwd <dir>] [--json]\n');
|
||||
return 2;
|
||||
}
|
||||
let planText;
|
||||
try { planText = readFileSync(plan, 'utf8'); } catch (e) {
|
||||
return say({ step, status: 'error', command: null, exit_code: null, output: `cannot read ${plan}: ${e.message}`, exit: 2 });
|
||||
}
|
||||
return say(checkRegression({ planText, step, cwd }));
|
||||
}
|
||||
|
||||
function isMain() {
|
||||
try {
|
||||
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (isMain()) process.exitCode = main(process.argv.slice(2));
|
||||
Loading…
Add table
Add a link
Reference in a new issue