/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>
171 lines
8.3 KiB
JavaScript
171 lines
8.3 KiB
JavaScript
// lib/validators/progress-validator.mjs
|
|
// Validate progress.json shape + resume-readiness.
|
|
// Forward-compat: unknown keys are tolerated silently, so additive-optional fields land
|
|
// WITHOUT a schema_version bump. `iterations_remaining` (trekexecute's recovery/retry budget
|
|
// signal) is shape-checked here only when present (non-negative integer); it is NOT in
|
|
// PROGRESS_REQUIRED_TOP, so legacy progress.json without it still validates. The field's
|
|
// liveness (never-decremented) is enforced deterministically by the Phase 7.5 completion-gate
|
|
// 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.
|
|
|
|
import { readFileSync, existsSync } from 'node:fs';
|
|
import { issue, fail } from '../util/result.mjs';
|
|
|
|
export const PROGRESS_REQUIRED_TOP = ['schema_version', 'plan', 'plan_version', 'mode', 'status', 'total_steps', 'current_step', 'steps'];
|
|
export const PROGRESS_VALID_STATUSES = ['pending', 'in_progress', 'completed', 'failed', 'partial', 'stopped'];
|
|
// Step statuses /trekexecute stores. `blocked` is the Step 0 sandbox sentinel; `stopped` above
|
|
// is the escalation / dirty-tree / entry-condition stop. Both are states other readers already
|
|
// know (lib/util/session-orders.mjs ENDED_BAD).
|
|
export const PROGRESS_STEP_STATUSES = ['completed', 'in_progress', 'failed', 'pending', 'deferred', 'skipped', 'blocked'];
|
|
|
|
// Wave 1 (P8/P9) per-step fields, all optional on read so an older progress.json
|
|
// still resumes; `null` means "not set yet". fix_rounds is the step's place on
|
|
// the upward ladder: 0 = passed its first control, 1-3 = fix rounds, 4 = sent up
|
|
// to the plan, 5 = sent up to the operator. There is no sixth round.
|
|
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'];
|
|
|
|
function checkStepFields(k, s, errors) {
|
|
const set = (v) => v !== undefined && v !== null;
|
|
if (s.fix_rounds !== undefined &&
|
|
!(Number.isInteger(s.fix_rounds) && s.fix_rounds >= 0 && s.fix_rounds <= PROGRESS_MAX_FIX_ROUNDS)) {
|
|
errors.push(issue('PROGRESS_FIX_ROUNDS_INVALID',
|
|
`steps["${k}"].fix_rounds=${JSON.stringify(s.fix_rounds)} must be an integer 0..${PROGRESS_MAX_FIX_ROUNDS}`));
|
|
}
|
|
if (set(s.start_sha) && !(typeof s.start_sha === 'string' && /^[0-9a-f]{7,64}$/.test(s.start_sha))) {
|
|
errors.push(issue('PROGRESS_START_SHA_INVALID', `steps["${k}"].start_sha must be a git sha, got ${JSON.stringify(s.start_sha)}`));
|
|
}
|
|
if (set(s.report_path) && !(typeof s.report_path === 'string' && s.report_path.trim() !== '')) {
|
|
errors.push(issue('PROGRESS_REPORT_PATH_INVALID', `steps["${k}"].report_path must be a non-empty path`));
|
|
}
|
|
if (set(s.red_proof) && !PROGRESS_RED_PROOF_VALUES.includes(s.red_proof)) {
|
|
errors.push(issue('PROGRESS_RED_PROOF_INVALID', `steps["${k}"].red_proof "${s.red_proof}" not in [${PROGRESS_RED_PROOF_VALUES.join(', ')}]`));
|
|
}
|
|
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(', ')}]`));
|
|
}
|
|
}
|
|
|
|
// The run-level decision log (P4): every decision the executor took on its own,
|
|
// with what it costs if it is wrong. Optional; when present, every entry carries both.
|
|
function checkDecisions(decisions, errors) {
|
|
if (decisions === undefined) return;
|
|
const ok = Array.isArray(decisions) && decisions.every((d) => d && typeof d === 'object' &&
|
|
typeof d.decision === 'string' && d.decision.trim() !== '' &&
|
|
typeof d.cost_if_wrong === 'string' && d.cost_if_wrong.trim() !== '');
|
|
if (!ok) errors.push(issue('PROGRESS_DECISION_INVALID', 'decisions must be an array of {decision, cost_if_wrong} with both non-empty'));
|
|
}
|
|
|
|
export function validateProgressContent(jsonText, opts = {}) {
|
|
let parsed;
|
|
try { parsed = JSON.parse(jsonText); }
|
|
catch (e) {
|
|
return fail(issue('PROGRESS_PARSE_ERROR', `Cannot parse JSON: ${e.message}`));
|
|
}
|
|
|
|
return validateProgressObject(parsed, opts);
|
|
}
|
|
|
|
export function validateProgressObject(parsed, opts = {}) {
|
|
const errors = [];
|
|
const warnings = [];
|
|
|
|
if (typeof parsed !== 'object' || parsed === null) {
|
|
return fail(issue('PROGRESS_NOT_OBJECT', 'Progress payload is not an object'));
|
|
}
|
|
|
|
for (const k of PROGRESS_REQUIRED_TOP) {
|
|
if (!(k in parsed)) {
|
|
errors.push(issue('PROGRESS_MISSING_FIELD', `Required field missing: ${k}`));
|
|
}
|
|
}
|
|
|
|
if (parsed.schema_version !== undefined && parsed.schema_version !== '1') {
|
|
errors.push(issue('PROGRESS_SCHEMA_MISMATCH', `schema_version "${parsed.schema_version}" not supported (expected "1")`));
|
|
}
|
|
|
|
if (parsed.status !== undefined && !PROGRESS_VALID_STATUSES.includes(parsed.status)) {
|
|
errors.push(issue('PROGRESS_BAD_STATUS', `status "${parsed.status}" not in [${PROGRESS_VALID_STATUSES.join(', ')}]`));
|
|
}
|
|
|
|
if (typeof parsed.total_steps === 'number' && typeof parsed.current_step === 'number') {
|
|
if (parsed.current_step < 0 || parsed.current_step > parsed.total_steps) {
|
|
errors.push(issue('PROGRESS_STEP_RANGE', `current_step=${parsed.current_step} outside [0, ${parsed.total_steps}]`));
|
|
}
|
|
}
|
|
|
|
// Additive-optional: iterations_remaining is trekexecute's recovery/retry budget signal.
|
|
// When present it must be a non-negative integer; absence is valid (backward-compat).
|
|
if (parsed.iterations_remaining !== undefined &&
|
|
!(Number.isInteger(parsed.iterations_remaining) && parsed.iterations_remaining >= 0)) {
|
|
errors.push(issue(
|
|
'PROGRESS_ITERATIONS_REMAINING_INVALID',
|
|
`iterations_remaining=${parsed.iterations_remaining} must be a non-negative integer`,
|
|
));
|
|
}
|
|
|
|
if (parsed.steps && typeof parsed.steps === 'object') {
|
|
const stepKeys = Object.keys(parsed.steps);
|
|
if (typeof parsed.total_steps === 'number' && stepKeys.length !== parsed.total_steps) {
|
|
warnings.push(issue(
|
|
'PROGRESS_STEP_COUNT_MISMATCH',
|
|
`total_steps=${parsed.total_steps} but steps map has ${stepKeys.length} entries`,
|
|
));
|
|
}
|
|
for (const k of stepKeys) {
|
|
const s = parsed.steps[k];
|
|
if (s === null || typeof s !== 'object') {
|
|
errors.push(issue('PROGRESS_STEP_SHAPE', `steps["${k}"] is not an object`));
|
|
continue;
|
|
}
|
|
if (s.status !== undefined && !PROGRESS_STEP_STATUSES.includes(s.status)) {
|
|
warnings.push(issue('PROGRESS_STEP_BAD_STATUS', `steps["${k}"].status "${s.status}" unrecognized`));
|
|
}
|
|
checkStepFields(k, s, errors);
|
|
}
|
|
}
|
|
|
|
checkDecisions(parsed.decisions, errors);
|
|
|
|
return { valid: errors.length === 0, errors, warnings, parsed };
|
|
}
|
|
|
|
export function checkResumeReadiness(progressObj) {
|
|
const errors = [];
|
|
if (progressObj.status === 'completed') {
|
|
return { valid: false, errors: [issue('PROGRESS_ALREADY_DONE', 'Run is already completed; nothing to resume')], warnings: [], parsed: progressObj };
|
|
}
|
|
if (typeof progressObj.current_step !== 'number') {
|
|
errors.push(issue('PROGRESS_NO_CURRENT', 'No current_step in progress.json'));
|
|
}
|
|
return { valid: errors.length === 0, errors, warnings: [], parsed: progressObj };
|
|
}
|
|
|
|
export function validateProgress(filePath, opts = {}) {
|
|
if (!existsSync(filePath)) return fail(issue('PROGRESS_NOT_FOUND', `File not found: ${filePath}`));
|
|
let text;
|
|
try { text = readFileSync(filePath, 'utf-8'); }
|
|
catch (e) { return fail(issue('PROGRESS_READ_ERROR', `Cannot read ${filePath}: ${e.message}`)); }
|
|
return validateProgressContent(text, opts);
|
|
}
|
|
|
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
const args = process.argv.slice(2);
|
|
const filePath = args.find(a => !a.startsWith('--'));
|
|
if (!filePath) {
|
|
process.stderr.write('Usage: progress-validator.mjs [--quick] <progress.json>\n');
|
|
process.exit(2);
|
|
}
|
|
const r = validateProgress(filePath);
|
|
if (args.includes('--json')) {
|
|
process.stdout.write(JSON.stringify({ valid: r.valid, errors: r.errors, warnings: r.warnings }, null, 2) + '\n');
|
|
} else {
|
|
process.stdout.write(`progress-validator: ${r.valid ? 'PASS' : 'FAIL'} ${filePath}\n`);
|
|
for (const e of r.errors) process.stderr.write(` ERROR [${e.code}] ${e.message}\n`);
|
|
for (const w of r.warnings) process.stderr.write(` WARN [${w.code}] ${w.message}\n`);
|
|
}
|
|
process.exit(r.valid ? 0 : 1);
|
|
}
|