Round 7 (516dffa..45aa9e1, WARN: 12 MAJOR, 0 BLOCKER) and the fence defect
found while writing its review.md. Each fix began with a test that failed on
0f400d3 (v6.3.0); suite 2208 (2206/0/2), 14 tests more.
- Tests-only (operator 2026-10-11): the plan's Files: alone decides, and the
text says so (red-proof, trekexecute Sub-step B, step-implementer,
HANDOVER-CONTRACTS). red-proof no longer reads the step's diff, so a change
already in the tree when the step began no longer counts against it, and a
plan placed outside .claude/ reads the same. The round-6 test that had the
diff overrule the plan is rewritten to the new rule.
- The Files: reader keeps a plain file beside a backticked path
(", Dockerfile", "the CLI entry src/cli.mjs", "and Makefile"). Measured on
the 520 Files: fields in 182 plans: no field reads differently.
- Red proof reports a command its timeout killed as killed after the timeout,
not "could not start"; the release check tells a timeout from output past
its buffer and from another signal. One reading of a stop
(criteria-runner stoppedBy) for red proof, the criteria runner and the
release check.
- A trailing `# comment` is dropped in a backticked check too, and in a
command passed as written, so the words screened are the words run.
- The release check reads a relative --review in --repo.
- The 15-minute timeout is defined once (criteria-runner.mjs).
- The findings check reads each cited file once per validation.
- One isMain helper (lib/util/is-main.mjs) for every CLI: the 31 copies of
the realpath guard and the 20 modules still on the file:// guard, which
exited 0 having done nothing under a symlink or a path with a space.
- The Bash guard writes its no-one-to-ask refusal once, on a direct condition.
- The findings check ends a ```json block only at a fence on its own line, so
three backticks inside a JSON string no longer end it (also in 6.2.0).
- Brief-conformance notes: a test for test-lock green on exit 127 (it
passed at once; it fails when 127 is read as could-not-run); the prose now
names the git revision `@~1` as the one leading-@ exception.
Not changed: the example plan's steps 1-2 keep their tests-only warning
(operator decision 2026-10-09; the r5 test records it). Brief note 4 (a
Regression: whose runner is not installed goes to the fix rounds) is a
routing question for the operator.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
99 lines
4.4 KiB
JavaScript
99 lines
4.4 KiB
JavaScript
// lib/profiles/phase-signal-resolver.mjs
|
|
// v5.1.1 — extract per-phase signal from a brief's frontmatter.
|
|
//
|
|
// Decision A wiring: commands invoke this helper via Bash CLI shim
|
|
// (`node lib/profiles/phase-signal-resolver.mjs --brief <path> --phase <name> --json`)
|
|
// to obtain the {effort, model} pair for a specific pipeline phase.
|
|
//
|
|
// Sole source of truth for PHASE_SIGNAL_PHASES + EFFORT_LEVELS is
|
|
// lib/validators/brief-validator.mjs — re-imported here so the helper
|
|
// cannot drift from the schema validator.
|
|
|
|
import { readFileSync, existsSync } from 'node:fs';
|
|
import { parseDocument } from '../util/frontmatter.mjs';
|
|
import { PHASE_SIGNAL_PHASES, EFFORT_LEVELS } from '../validators/brief-validator.mjs';
|
|
import { BASE_ALLOWED_MODELS } from '../validators/profile-validator.mjs';
|
|
import { isMain } from '../util/is-main.mjs';
|
|
|
|
/**
|
|
* Resolve a brief's phase_signal entry for one phase.
|
|
*
|
|
* @param {object|null} briefFrontmatter Parsed YAML frontmatter dict (or null/undefined).
|
|
* @param {string} phase One of PHASE_SIGNAL_PHASES; anything else returns null.
|
|
* @returns {{effort?: string, model?: string} | null}
|
|
*
|
|
* Never throws. Returns null on:
|
|
* - Falsy / non-object frontmatter
|
|
* - phase not in PHASE_SIGNAL_PHASES (e.g. 'brief' or 'continue')
|
|
* - Missing phase_signals array
|
|
* - No entry for the requested phase
|
|
*
|
|
* Returns partial `{effort}` (with `model: undefined`) when the signal omits model.
|
|
* `effort` is gated against EFFORT_LEVELS and `model` against BASE_ALLOWED_MODELS;
|
|
* values outside those allowlists are dropped (treated as absent).
|
|
*/
|
|
export function resolvePhaseSignal(briefFrontmatter, phase) {
|
|
if (!briefFrontmatter || typeof briefFrontmatter !== 'object') return null;
|
|
if (typeof phase !== 'string' || !PHASE_SIGNAL_PHASES.includes(phase)) return null;
|
|
const signals = briefFrontmatter.phase_signals;
|
|
if (!Array.isArray(signals)) return null;
|
|
for (const entry of signals) {
|
|
if (entry && typeof entry === 'object' && entry.phase === phase) {
|
|
const out = {};
|
|
if ('effort' in entry && EFFORT_LEVELS.includes(entry.effort)) out.effort = entry.effort;
|
|
// MAJOR fix (S4): gate `model` against BASE_ALLOWED_MODELS, mirroring the
|
|
// effort gate above. brief-validator already rejects out-of-allowlist
|
|
// models at validation time; this is defense-in-depth so a brief that
|
|
// slipped validation cannot hand a junk model to an agent spawn.
|
|
if ('model' in entry && BASE_ALLOWED_MODELS.includes(entry.model)) out.model = entry.model;
|
|
return out;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Convenience wrapper: read a brief file, parse, and resolve a single phase.
|
|
* Returns null on any read or parse failure (graceful degradation).
|
|
*/
|
|
export function resolvePhaseSignalFromFile(briefPath, phase) {
|
|
if (typeof briefPath !== 'string' || briefPath.length === 0) return null;
|
|
if (!existsSync(briefPath)) return null;
|
|
let text;
|
|
try { text = readFileSync(briefPath, 'utf-8'); } catch { return null; }
|
|
const doc = parseDocument(text);
|
|
if (!doc || !doc.valid) return null;
|
|
const fm = doc.parsed && doc.parsed.frontmatter;
|
|
return resolvePhaseSignal(fm, phase);
|
|
}
|
|
|
|
// CLI shim — mirrors lib/validators/brief-validator.mjs:168 pattern.
|
|
// Footgun guard (v5.9): this shim's `model` output is brief-signal-only — it
|
|
// never consults the profile layer. For command wiring, the composed resolver
|
|
// CLI (`resolver.mjs --resolve-phase-model`, brief > profile > default) is the
|
|
// single resolution source for {effort, model}. Do not re-wire commands/*.md
|
|
// Bash blocks back to this shim.
|
|
if (isMain(import.meta.url)) {
|
|
const args = process.argv.slice(2);
|
|
const getArg = (name) => {
|
|
const i = args.indexOf(name);
|
|
return i >= 0 && i + 1 < args.length ? args[i + 1] : null;
|
|
};
|
|
const briefPath = getArg('--brief');
|
|
const phase = getArg('--phase');
|
|
if (!briefPath || !phase) {
|
|
process.stderr.write('Usage: phase-signal-resolver.mjs --brief <path> --phase <name> [--json]\n');
|
|
process.exit(2);
|
|
}
|
|
const result = resolvePhaseSignalFromFile(briefPath, phase);
|
|
if (args.includes('--json')) {
|
|
process.stdout.write(JSON.stringify(result) + '\n');
|
|
} else if (result === null) {
|
|
process.stdout.write('null\n');
|
|
} else {
|
|
const effort = 'effort' in result ? result.effort : '';
|
|
const model = 'model' in result ? result.model : '';
|
|
process.stdout.write(`effort=${effort} model=${model}\n`);
|
|
}
|
|
process.exit(0);
|
|
}
|