🎩 You're Invited:Meet the Socket team at Black Hat in Las Vegas, August 3-6.RSVP
Sign In

@touchskyer/opc

Package Overview
Dependencies
Maintainers
1
Versions
25
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@touchskyer/opc - npm Package Compare versions

Comparing version
0.10.5
to
0.10.6
+141
bin/hooks/opc-pre-tool-budget.mjs
#!/usr/bin/env node
import { existsSync, readFileSync, realpathSync } from "node:fs";
import { homedir } from "node:os";
import { join, resolve, sep } from "node:path";
import { fileURLToPath } from "node:url";
import {
MAX_NODE_WALL_MS,
budgetPaths,
claimToolSlot,
createStopMarker,
ensureBudgetContext,
readSessionRegistry,
resolveCurrentRun,
} from "../lib/runaway-guard.mjs";
const DENY_OUTPUT = {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "OPC accidental-runaway circuit breaker tripped for the current node/run. Stop and report. Recovery requires an external terminal transition or stop.",
},
};
function nonEmptyString(value) {
return typeof value === "string" && value.length > 0;
}
function allow() {
return { allowed: true };
}
function deny(reason) {
return { allowed: false, reason, output: DENY_OUTPUT };
}
function cwdMatchesProject(cwd, projectRoot) {
if (!nonEmptyString(cwd)) throw new Error("hook input is missing cwd");
const canonicalCwd = realpathSync(cwd);
const canonicalRoot = realpathSync(projectRoot);
return canonicalCwd === canonicalRoot || canonicalCwd.startsWith(`${canonicalRoot}${sep}`);
}
function readFlowState(sessionDir) {
const state = JSON.parse(readFileSync(join(sessionDir, "flow-state.json"), "utf8"));
if (!state || typeof state !== "object" || Array.isArray(state)) {
throw new Error("flow state must be a JSON object");
}
return state;
}
function validNow(value) {
if (!nonEmptyString(value)) return false;
const parsed = new Date(value);
return Number.isFinite(parsed.getTime()) && parsed.toISOString() === value;
}
export function evaluatePreToolUse(
input,
{ home = homedir(), now = new Date().toISOString() } = {},
) {
const sessionId = input?.session_id;
if (!nonEmptyString(sessionId)) return allow();
let registry;
try {
registry = readSessionRegistry(sessionId, home);
} catch (error) {
return deny(error.message);
}
if (!registry) return allow();
try {
if (!cwdMatchesProject(input.cwd, registry.projectRoot)) return allow();
const state = readFlowState(registry.sessionDir);
if (state.status === "completed" || state.status === "stopped") return allow();
if (state.autoMode === undefined || state.autoMode === false) return allow();
if (state.autoMode !== true || state.status !== undefined) {
return deny("auto flow state is invalid");
}
if (state._claudeSessionId !== sessionId) {
return deny("auto flow session identity mismatch");
}
if (!nonEmptyString(input.tool_use_id) || !nonEmptyString(input.tool_name)) {
return deny("hook input is missing tool identity");
}
if (input.agent_id !== undefined && !nonEmptyString(input.agent_id)) {
return deny("hook input has invalid agent identity");
}
if (!validNow(now)) return deny("hook timestamp is invalid");
const run = resolveCurrentRun(state);
if (!run) return deny("cannot resolve current run");
const paths = budgetPaths(registry.sessionDir, state.currentNode, run.runKey);
if (existsSync(paths.stop)) return deny("current run is already stopped");
ensureBudgetContext(paths, state.currentNode, run);
const elapsedMs = Date.parse(now) - Date.parse(run.startedAt);
if (elapsedMs < 0) return deny("current run starts in the future");
if (elapsedMs >= MAX_NODE_WALL_MS) {
createStopMarker(registry.sessionDir, state, {
reason: "wall-time-budget",
now,
});
return deny("wall-time budget reached");
}
const evidence = {
sessionId,
toolUseId: input.tool_use_id,
toolName: input.tool_name,
...(input.agent_id ? { agentId: input.agent_id } : {}),
claimedAt: now,
};
if (claimToolSlot(paths, evidence) !== null) return allow();
createStopMarker(registry.sessionDir, state, {
reason: "tool-call-budget",
now,
});
return deny("tool-call budget reached");
} catch (error) {
return deny(error.message);
}
}
function runCli() {
let input;
try {
input = JSON.parse(readFileSync(0, "utf8"));
} catch {
return;
}
const result = evaluatePreToolUse(input);
if (!result.allowed) process.stdout.write(JSON.stringify(result.output));
}
if (process.argv[1] && fileURLToPath(import.meta.url) === resolve(process.argv[1])) {
runCli();
}
import { after, describe, test } from "node:test";
import assert from "node:assert/strict";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { execFile, spawnSync } from "node:child_process";
import {
atomicCreateJson,
budgetPaths,
resolveCurrentRun,
writeSessionRegistry,
} from "../lib/runaway-guard.mjs";
import { evaluatePreToolUse } from "./opc-pre-tool-budget.mjs";
const roots = [];
const now = "2026-08-06T00:10:00.000Z";
const hookFile = fileURLToPath(new URL("./opc-pre-tool-budget.mjs", import.meta.url));
function tempRoot(name) {
const root = mkdtempSync(join(tmpdir(), `opc-pre-tool-${name}-`));
roots.push(root);
return root;
}
function setupFlow(name, stateOverrides = {}, registryOverrides = {}) {
const root = tempRoot(name);
const home = join(root, "home");
const projectRoot = join(root, "project");
const sessionDir = join(root, "session");
const cwd = join(projectRoot, "nested");
mkdirSync(home, { recursive: true });
mkdirSync(cwd, { recursive: true });
mkdirSync(sessionDir, { recursive: true });
const sessionId = `session-${name}`;
const state = {
entryNode: "build",
currentNode: "build",
totalSteps: 0,
history: [],
flowStartedAt: "2026-08-06T00:00:00.000Z",
autoMode: true,
_claudeSessionId: sessionId,
...stateOverrides,
};
writeFileSync(join(sessionDir, "flow-state.json"), JSON.stringify(state));
const registry = {
sessionId,
sessionDir,
projectRoot,
registeredAt: "2026-08-06T00:00:00.000Z",
...registryOverrides,
};
const registryFile = writeSessionRegistry(registry, home);
const input = {
session_id: sessionId,
cwd,
tool_use_id: "tool-1",
tool_name: "Bash",
};
return { root, home, projectRoot, sessionDir, cwd, sessionId, state, registry, registryFile, input };
}
function evaluate(input, home, timestamp = now) {
return evaluatePreToolUse(input, { home, now: timestamp });
}
function assertDenied(result) {
assert.equal(result.allowed, false);
assert.deepEqual(result.output, {
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "OPC accidental-runaway circuit breaker tripped for the current node/run. Stop and report. Recovery requires an external terminal transition or stop.",
},
});
}
after(() => {
for (const root of roots) rmSync(root, { recursive: true, force: true });
});
describe("PreToolUse activation", () => {
test("allows sessions without a registry with zero side effects", () => {
const home = tempRoot("unregistered");
assert.deepEqual(evaluate({ session_id: "missing" }, home), { allowed: true });
assert.deepEqual(evaluate(null, home), { allowed: true });
assert.equal(existsSync(join(home, ".opc")), false);
});
test("allows out-of-project, interactive, completed, and stopped flows", () => {
const outside = setupFlow("outside");
const outsideCwd = join(outside.root, "other");
mkdirSync(outsideCwd);
assert.deepEqual(evaluate({ ...outside.input, cwd: outsideCwd }, outside.home), { allowed: true });
assert.equal(existsSync(join(outside.sessionDir, "node-budget")), false);
const rootCwd = setupFlow("root-cwd");
assert.deepEqual(
evaluate({ ...rootCwd.input, cwd: rootCwd.projectRoot }, rootCwd.home),
{ allowed: true },
);
for (const [name, stateOverrides] of [
["interactive", { autoMode: undefined }],
["completed", { status: "completed" }],
["stopped", { status: "stopped" }],
]) {
const flow = setupFlow(name, stateOverrides);
assert.deepEqual(evaluate(flow.input, flow.home), { allowed: true });
assert.equal(existsSync(join(flow.sessionDir, "node-budget")), false);
}
});
test("fails closed for registry, path, state, status, identity, run, and tool corruption", () => {
const badRegistry = setupFlow("bad-registry");
writeFileSync(badRegistry.registryFile, "bad-json");
assertDenied(evaluate(badRegistry.input, badRegistry.home));
const badPath = setupFlow("bad-path", {}, { projectRoot: join(tempRoot("absent"), "missing") });
assertDenied(evaluate(badPath.input, badPath.home));
const badCwd = setupFlow("bad-cwd");
assertDenied(evaluate({ ...badCwd.input, cwd: "" }, badCwd.home));
assertDenied(evaluate({ ...badCwd.input, cwd: "relative" }, badCwd.home));
const missingState = setupFlow("missing-state");
rmSync(join(missingState.sessionDir, "flow-state.json"));
assertDenied(evaluate(missingState.input, missingState.home));
const corruptState = setupFlow("corrupt-state");
writeFileSync(join(corruptState.sessionDir, "flow-state.json"), "bad-json");
assertDenied(evaluate(corruptState.input, corruptState.home));
for (const [name, value] of [
["null-state", null],
["string-state", "invalid"],
["array-state", []],
]) {
const flow = setupFlow(name);
writeFileSync(join(flow.sessionDir, "flow-state.json"), JSON.stringify(value));
assertDenied(evaluate(flow.input, flow.home));
}
for (const [name, overrides, inputOverrides] of [
["bad-status", { status: "unknown" }, {}],
["bad-auto-mode", { autoMode: "yes" }, {}],
["bad-identity", { _claudeSessionId: "other" }, {}],
["bad-run", { currentNode: "review" }, {}],
["bad-tool-id", {}, { tool_use_id: "" }],
["bad-tool-name", {}, { tool_name: null }],
["bad-agent-id", {}, { agent_id: 42 }],
]) {
const flow = setupFlow(name, overrides);
assertDenied(evaluate({ ...flow.input, ...inputOverrides }, flow.home));
}
});
});
describe("PreToolUse budgets", () => {
test("freezes context and claims aggregate slots through the 100th call", () => {
const flow = setupFlow("slots");
const first = evaluate({ ...flow.input, agent_id: "agent-1" }, flow.home);
assert.deepEqual(first, { allowed: true });
const run = resolveCurrentRun(flow.state);
const paths = budgetPaths(flow.sessionDir, "build", run.runKey);
assert.deepEqual(JSON.parse(readFileSync(paths.context, "utf8")), {
nodeId: "build",
runId: "run_1",
runKey: run.runKey,
startedAt: flow.state.flowStartedAt,
maxWallTimeSeconds: 1800,
maxToolCalls: 100,
});
assert.deepEqual(JSON.parse(readFileSync(join(paths.slots, "000001.json"), "utf8")), {
sessionId: flow.sessionId,
toolUseId: "tool-1",
toolName: "Bash",
agentId: "agent-1",
claimedAt: now,
});
for (let index = 2; index < 100; index++) {
atomicCreateJson(join(paths.slots, `${String(index).padStart(6, "0")}.json`), { seeded: true });
}
assert.deepEqual(evaluate({ ...flow.input, tool_use_id: "tool-100" }, flow.home), { allowed: true });
assert.equal(readdirSync(paths.slots).length, 100);
const denied = evaluate({ ...flow.input, tool_use_id: "tool-101" }, flow.home);
assertDenied(denied);
assert.equal(JSON.parse(readFileSync(paths.stop, "utf8")).reason, "tool-call-budget");
assertDenied(evaluate({ ...flow.input, tool_name: "Read", tool_use_id: "tool-102" }, flow.home));
assert.equal(readdirSync(paths.slots).length, 100);
});
test("trips at the absolute wall-time boundary", () => {
const flow = setupFlow("wall");
assert.deepEqual(
evaluate(flow.input, flow.home, "2026-08-06T00:29:59.999Z"),
{ allowed: true },
);
const denied = evaluate(
{ ...flow.input, tool_use_id: "tool-boundary" },
flow.home,
"2026-08-06T00:30:00.000Z",
);
assertDenied(denied);
const paths = budgetPaths(flow.sessionDir, "build", resolveCurrentRun(flow.state).runKey);
assert.equal(JSON.parse(readFileSync(paths.stop, "utf8")).reason, "wall-time-budget");
assert.equal(readdirSync(paths.slots).length, 1);
});
test("fails closed on invalid time, future run, corrupt context, and slot I/O failure", () => {
for (const [name, timestamp] of [
["empty-now", ""],
["invalid-now", "not-a-date"],
["normalized-now", "2026-02-30T00:10:00.000Z"],
]) {
const flow = setupFlow(name);
assertDenied(evaluate(flow.input, flow.home, timestamp));
}
const future = setupFlow("future", { flowStartedAt: "2026-08-06T00:11:00.000Z" });
assertDenied(evaluate(future.input, future.home));
const corrupt = setupFlow("bad-context");
assert.deepEqual(evaluate(corrupt.input, corrupt.home), { allowed: true });
const corruptPaths = budgetPaths(corrupt.sessionDir, "build", resolveCurrentRun(corrupt.state).runKey);
writeFileSync(corruptPaths.context, "bad-json");
assertDenied(evaluate({ ...corrupt.input, tool_use_id: "tool-2" }, corrupt.home));
const blockedSlots = setupFlow("blocked-slots");
assert.deepEqual(evaluate(blockedSlots.input, blockedSlots.home), { allowed: true });
const blockedPaths = budgetPaths(blockedSlots.sessionDir, "build", resolveCurrentRun(blockedSlots.state).runKey);
rmSync(blockedPaths.slots, { recursive: true });
writeFileSync(blockedPaths.slots, "not-a-directory");
assertDenied(evaluate({ ...blockedSlots.input, tool_use_id: "tool-2" }, blockedSlots.home));
});
test("a new run ignores the previous run marker", () => {
const flow = setupFlow("new-run");
const initialRun = resolveCurrentRun(flow.state);
const initialPaths = budgetPaths(flow.sessionDir, "build", initialRun.runKey);
atomicCreateJson(initialPaths.stop, { reason: "old" });
assertDenied(evaluate(flow.input, flow.home));
const nextState = {
...flow.state,
totalSteps: 1,
history: [{
nodeId: "build",
runId: "run_1",
timestamp: "2026-08-06T00:05:00.000Z",
}],
};
writeFileSync(join(flow.sessionDir, "flow-state.json"), JSON.stringify(nextState));
assert.deepEqual(evaluate({ ...flow.input, tool_use_id: "tool-new" }, flow.home), { allowed: true });
const nextPaths = budgetPaths(flow.sessionDir, "build", resolveCurrentRun(nextState).runKey);
assert.equal(existsSync(nextPaths.stop), false);
assert.equal(readdirSync(nextPaths.slots).length, 1);
});
});
describe("PreToolUse CLI contract", () => {
test("allows exactly 100 of 105 parallel hook invocations", async () => {
const flow = setupFlow("cli-parallel", {
flowStartedAt: new Date().toISOString(),
});
const outputs = await Promise.all(Array.from({ length: 105 }, (_, index) =>
new Promise((resolve, reject) => {
const child = execFile(process.execPath, [hookFile], {
encoding: "utf8",
env: { ...process.env, HOME: flow.home },
}, (error, stdout, stderr) => {
if (error) {
reject(error);
return;
}
assert.equal(stderr, "");
resolve(stdout);
});
child.stdin.end(JSON.stringify({
...flow.input,
tool_use_id: `parallel-${index}`,
}));
})));
assert.equal(outputs.filter((output) => output === "").length, 100);
const denials = outputs.filter((output) => output !== "");
assert.equal(denials.length, 5);
for (const output of denials) {
assertDenied({ allowed: false, output: JSON.parse(output) });
}
const paths = budgetPaths(flow.sessionDir, "build", resolveCurrentRun(flow.state).runKey);
assert.equal(readdirSync(paths.slots).length, 100);
assert.equal(JSON.parse(readFileSync(paths.stop, "utf8")).reason, "tool-call-budget");
});
test("emits official deny JSON with exit zero", () => {
const flow = setupFlow("cli-deny");
const paths = budgetPaths(flow.sessionDir, "build", resolveCurrentRun(flow.state).runKey);
atomicCreateJson(paths.stop, { reason: "existing" });
const result = spawnSync(process.execPath, [hookFile], {
input: JSON.stringify(flow.input),
encoding: "utf8",
env: { ...process.env, HOME: flow.home },
});
assert.equal(result.status, 0);
assert.equal(result.stderr, "");
assertDenied({ allowed: false, output: JSON.parse(result.stdout) });
});
test("emits nothing for allow and malformed input", () => {
const home = tempRoot("cli-allow");
for (const input of [JSON.stringify({ session_id: "missing" }), "bad-json"]) {
const result = spawnSync(process.execPath, [hookFile], {
input,
encoding: "utf8",
env: { ...process.env, HOME: home },
});
assert.equal(result.status, 0);
assert.equal(result.stdout, "");
assert.equal(result.stderr, "");
}
});
});
// Brief lint — mechanical quality gate for build briefs.
// Ensures briefs are concrete, unambiguous, and mechanically executable.
// Depends on: util.mjs (getFlag).
import { readFileSync } from "fs";
import { getFlag } from "./util.mjs";
// ── Section extraction ─────────────────────────────────────────
function extractSections(text) {
const sections = {};
const parts = text.split(/^## /m);
for (const part of parts) {
if (!part.trim()) continue;
const nlIdx = part.indexOf("\n");
const header = nlIdx >= 0 ? part.slice(0, nlIdx).trim() : part.trim();
const body = nlIdx >= 0 ? part.slice(nlIdx + 1) : "";
sections[header] = body;
}
return sections;
}
// ── Vague design words — these must not appear in a resolved brief ──
const VAGUE_DESIGN_WORDS = /\b(appropriate|suitable|nice|good|proper|warm color|cool tone|pleasant|attractive|clean look|modern feel|professional appearance)\b/i;
// ── Incompleteness markers ──
const INCOMPLETE_MARKERS = /\b(etc\.?|and more|as needed|and so on|to be determined|TBD|TODO)\b/i;
// ── Hex color pattern ──
const HEX_COLOR = /#[0-9a-fA-F]{3,8}\b/g;
// ── Quantified value pattern (numbers with units) ──
const QUANTIFIED_VALUE = /\d+\s*(px|rem|em|ms|s|%|:1|vw|vh|fr)\b/i;
// ── Has bullet items ──
const HAS_BULLETS = /^-\s+/m;
// ── Has concrete data (numbers, quoted strings, specific values) ──
const HAS_CONCRETE_DATA = /(\d{2,}|["'][^"']{2,}["']|¥|€|\$|\£|https?:\/\/)/;
// ── Run all checks ─────────────────────────────────────────────
export function runBriefLint(text, opts = {}) {
const sections = extractSections(text);
const failures = [];
const warnings = [];
let checksRun = 0;
const fail = (check, msg) => failures.push({ check, message: msg });
const warn = (check, msg) => warnings.push({ check, message: msg });
// ── 1. file-plan-exists ──
checksRun++;
const filePlanSection = sections["File Plan"];
if (filePlanSection === undefined) {
fail("file-plan-exists", "No '## File Plan' section found");
} else if (!HAS_BULLETS.test(filePlanSection)) {
fail("file-plan-exists", "'## File Plan' section has no bullet items");
}
// ── 2. file-plan-complete ──
checksRun++;
if (filePlanSection) {
const incompleteMatch = filePlanSection.match(INCOMPLETE_MARKERS);
if (incompleteMatch) {
fail("file-plan-complete", `File Plan contains '${incompleteMatch[0]}' — list every file explicitly`);
}
}
// ── 3. tech-decisions-resolved ──
checksRun++;
const techSection = sections["Technology Decisions"];
if (techSection === undefined) {
fail("tech-decisions-resolved", "No '## Technology Decisions' section found");
} else {
// Must have at least one version number (e.g. v4.4.0, 2.1, @18)
const hasVersion = /v?\d+\.\d+(\.\d+)?|@\d+/i.test(techSection);
if (!hasVersion) {
fail("tech-decisions-resolved", "Technology Decisions has no version numbers — specify library@version for every dependency");
}
// Reject open-ended tech choices
const OPEN_ENDED_TECH = /\b(use a |choose a |pick a |select a |find a |some |any )(library|framework|package|tool|solution|charting|component|module)\b/i;
const openMatch = techSection.match(OPEN_ENDED_TECH);
if (openMatch) {
fail("tech-decisions-resolved", `Open-ended tech choice: '${openMatch[0]}' — name the specific library and version`);
}
// Reject bare language/runtime names without a specific library
// "Use JavaScript v1.0 via CDN" is not a tech decision — a real one names a library (Chart.js, React, D3)
const BARE_LANG = /\b(use |via )(JavaScript|TypeScript|Python|Ruby|Go|Rust|Java|HTML|CSS)\b/i;
const bareLangMatch = techSection.match(BARE_LANG);
if (bareLangMatch) {
// Only flag if there's no actual library name nearby (heuristic: no npm-style package name)
const hasLibrary = /\b[a-z][\w-]*\.js\b|@[\w-]+\/[\w-]+|\b(chart|react|vue|angular|d3|tailwind|bootstrap|express|next|nuxt|vite|webpack|rollup|esbuild|axios|lodash|moment|dayjs|three|gsap|framer)[\w.-]*/i.test(techSection);
if (!hasLibrary) {
fail("tech-decisions-resolved", `Bare language '${bareLangMatch[0]}' without specific library — name the npm package, CDN library, or framework`);
}
}
// Must have at least one source reference (CDN URL, npm package, or file path)
const hasSource = /https?:\/\/|cdn\.|unpkg|jsdelivr|cdnjs|npm |yarn |pnpm |node_modules|from ['"]|import ['"]|require\(/i.test(techSection);
if (!hasSource) {
warn("tech-decisions-source", "No CDN URL, npm package, or import reference found — consider adding source for each dependency");
}
}
// ── 4. tokens-resolved (skipped for functional tier) ──
const isFunctional = opts.tier === "functional";
const hexColors = text.match(HEX_COLOR) || [];
if (!isFunctional) {
checksRun++;
if (hexColors.length < 3) {
fail("tokens-resolved", `Found ${hexColors.length} hex color values — need ≥3 resolved design tokens`);
}
}
// ── 5. no-vague-design (skipped for functional tier) ──
if (!isFunctional) {
checksRun++;
const vagueMatch = text.match(VAGUE_DESIGN_WORDS);
if (vagueMatch) {
fail("no-vague-design", `Brief contains vague design term '${vagueMatch[0]}' — resolve to specific values`);
}
}
// ── 6. data-fixtures (skipped for functional tier) ──
// Accepts "Component Inventory" OR "API Contract" OR "Data Contract"
if (!isFunctional) {
checksRun++;
const dataKey = Object.keys(sections).find(k =>
k === "Component Inventory" || k.toLowerCase().includes("api contract") || k.toLowerCase().includes("data contract")
);
const dataSection = dataKey ? sections[dataKey] : undefined;
if (dataSection === undefined) {
fail("data-fixtures", "No '## Component Inventory' or '## API Contract' section found");
} else if (!HAS_CONCRETE_DATA.test(dataSection)) {
fail("data-fixtures", `${dataKey} has no concrete data (numbers, quoted strings, URLs) — add specific mock values`);
}
}
// ── 7. constraints-quantified ──
checksRun++;
const constraintsKey = Object.keys(sections).find(k =>
k.toLowerCase().startsWith("constraint")
);
const constraintsSection = constraintsKey ? sections[constraintsKey] : undefined;
if (constraintsSection === undefined) {
fail("constraints-quantified", "No '## Constraints' section found");
} else if (!QUANTIFIED_VALUE.test(constraintsSection)) {
fail("constraints-quantified", "Constraints section has no quantified values (px, rem, ms, ratio) — add measurable thresholds");
}
// ── 8. iteration-delta ──
checksRun++;
if (opts.hasPriorFindings) {
const deltaKey = Object.keys(sections).find(k =>
k.toLowerCase().includes("iteration") || k.toLowerCase().includes("delta")
);
if (!deltaKey) {
fail("iteration-delta", "Gate returned ITERATE but brief has no '## Iteration Delta' section — list specific changes from prior findings");
}
}
// ── Warnings ──
// W1: tokens section name variant
const tokensKey = Object.keys(sections).find(k =>
k.toLowerCase().includes("token") || k.toLowerCase().includes("design token")
);
if (!tokensKey && hexColors.length >= 3) {
warn("tokens-section-name", "Hex colors found but no dedicated tokens section — consider adding '## Design Tokens'");
}
// W2: no file count estimates — upgraded to failure (brief protocol requires estimates)
checksRun++;
if (filePlanSection && !/~?\d+\s*(lines?|loc)/i.test(filePlanSection)) {
fail("file-plan-estimates", "File Plan has no line count estimates — add ~N lines per file");
}
const passed = checksRun - failures.length;
return { passed, failures, warnings, checksRun };
}
// ══════════════════════════════════════════════════════════════
// cmdBriefLint — main command
// ══════════════════════════════════════════════════════════════
export function cmdBriefLint(args) {
const file = args[0];
const hasPriorFindings = args.includes("--has-prior-findings");
const tier = getFlag(args, "tier") || undefined;
if (!file) {
console.error("Usage: opc-harness brief-lint <file> [--has-prior-findings] [--tier functional|polished|delightful]");
process.exit(1);
}
let text;
try {
text = readFileSync(file, "utf8");
} catch (err) {
if (err.code === "ENOENT") {
console.error(`File not found: ${file}`);
} else {
console.error(`Cannot read ${file}: ${err.message}`);
}
process.exit(1);
}
const result = runBriefLint(text, { hasPriorFindings, tier });
const ok = result.failures.length === 0;
// Human-readable output to stderr
if (ok) {
console.error(`✅ brief-lint: ${result.passed} checks passed, ${result.warnings.length} warning${result.warnings.length !== 1 ? "s" : ""}`);
} else {
console.error(`❌ brief-lint: ${result.failures.length} failure${result.failures.length !== 1 ? "s" : ""}, ${result.warnings.length} warning${result.warnings.length !== 1 ? "s" : ""}`);
}
for (const f of result.failures) {
console.error(` ❌ ${f.check}: ${f.message}`);
}
for (const w of result.warnings) {
console.error(` ⚠️ ${w.check}: ${w.message}`);
}
// Machine-readable JSON to stdout
console.log(JSON.stringify({
pass: ok,
checksRun: result.checksRun,
checksPassed: result.passed,
failures: result.failures,
warnings: result.warnings,
}, null, 2));
process.exit(ok ? 0 : 1);
}
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
import { join } from "path";
import { parseEvaluation } from "./eval-parser.mjs";
import { parseStructuredFindings, structuredSeverityName } from "./structured-findings.mjs";
const OUT = "cumulative-findings.md";
function readJson(path) {
try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
}
function listRunDirs(nodeDir) {
if (!existsSync(nodeDir)) return [];
try {
return readdirSync(nodeDir)
.filter((name) => /^run_\d+$/.test(name))
.sort((a, b) => Number(a.slice(4)) - Number(b.slice(4)));
} catch { return []; }
}
function listEvalFiles(runDir) {
if (!existsSync(runDir)) return [];
try {
return readdirSync(runDir)
.filter((name) => /^eval-.*\.md$/.test(name))
.sort()
.map((name) => ({ name, path: join(runDir, name) }));
} catch { return []; }
}
function fmtFinding(f) {
const loc = f.file && f.line ? ` (${f.file}:${f.line})` : "";
return ` - ${f.severity}: ${f.issue}${loc}`;
}
function fmtStructuredFinding(f) {
const loc = f.location ? ` (${f.location})` : "";
const status = f.status ? `, status ${f.status}` : "";
return ` - ${structuredSeverityName(f.severity)}: ${f.title}${loc}${status}`;
}
function parsedHasTitle(parsed, title) {
const wanted = String(title || "").toLowerCase();
return parsed.findings.some((f) => String(f.issue || "").toLowerCase().includes(wanted));
}
function fixText(raw) {
if (typeof raw === "string") return raw;
if (!raw || typeof raw !== "object") return "";
return raw.title || raw.summary || raw.description || raw.fix || raw.file || JSON.stringify(raw);
}
function fixArrays(handshake) {
if (!handshake || typeof handshake !== "object") return [];
return [
handshake.fixes_applied,
handshake.fixesApplied,
handshake.hotfixes,
].filter(Array.isArray).flat();
}
function readRunSummary(dir, entry) {
const nodeId = entry.nodeId || entry.node;
const runId = entry.runId || entry.run || "";
const nodeDir = join(dir, "nodes", nodeId);
const runDir = runId ? join(nodeDir, runId) : join(nodeDir, listRunDirs(nodeDir).at(-1) || "");
const handshake = readJson(join(nodeDir, "handshake.json"));
const runHandshake = readJson(join(runDir, "handshake.json"));
return { nodeId, runId, nodeDir, runDir, handshake, runHandshake };
}
function orderedEntries(dir, state) {
const nodes = [];
const seenNodes = new Set();
const addNode = (entry) => {
const nodeId = entry?.nodeId || entry?.node;
if (!nodeId || seenNodes.has(nodeId)) return;
seenNodes.add(nodeId);
nodes.push(nodeId);
};
addNode({ nodeId: state?.entryNode });
for (const entry of state?.history || []) addNode(entry);
addNode({ nodeId: state?.currentNode });
const nodesDir = join(dir, "nodes");
if (!existsSync(nodesDir)) return nodes.map((nodeId) => ({ nodeId }));
for (const nodeId of readdirSync(nodesDir).sort()) {
const nodeDir = join(nodesDir, nodeId);
if (statSync(nodeDir).isDirectory()) addNode({ nodeId });
}
return nodes.flatMap((nodeId) => {
const runIds = listRunDirs(join(nodesDir, nodeId));
return runIds.length ? runIds.map((runId) => ({ nodeId, runId })) : [{ nodeId }];
});
}
export function collectExecutionFixes(dir) {
const fixes = [];
const nodesDir = join(dir, "nodes");
if (!existsSync(nodesDir)) return fixes;
for (const nodeId of readdirSync(nodesDir).sort()) {
const nodeDir = join(nodesDir, nodeId);
if (!statSync(nodeDir).isDirectory()) continue;
for (const raw of fixArrays(readJson(join(nodeDir, "handshake.json")))) {
const text = fixText(raw);
if (text) fixes.push({ nodeId, runId: null, text });
}
for (const runId of listRunDirs(nodeDir)) {
for (const raw of fixArrays(readJson(join(nodeDir, runId, "handshake.json")))) {
const text = fixText(raw);
if (text) fixes.push({ nodeId, runId, text });
}
}
}
return fixes;
}
function appendNode(lines, summary) {
const { nodeId, runId, nodeDir, runDir, handshake, runHandshake } = summary;
lines.push(`## ${nodeId}${runId ? ` / ${runId}` : ""}`);
const hs = handshake || runHandshake;
if (hs) lines.push(`- Status: ${hs.status || "unknown"}${hs.verdict ? `, verdict: ${hs.verdict}` : ""}`);
if (existsSync(join(nodeDir, "extension-context.md"))) {
lines.push(`- Extension context: nodes/${nodeId}/extension-context.md`);
}
for (const ev of listEvalFiles(runDir)) appendEval(lines, ev);
lines.push("");
}
function appendEval(lines, ev) {
const content = readFileSync(ev.path, "utf8");
const parsed = parseEvaluation(content);
const structured = parseStructuredFindings(content).filter((f) => !parsedHasTitle(parsed, f.title));
if (parsed.findings_count === 0 && structured.length === 0) return;
const counts = { critical: parsed.critical, warning: parsed.warning, suggestion: parsed.suggestion };
for (const f of structured) {
const key = structuredSeverityName(f.severity);
if (Object.hasOwn(counts, key)) counts[key]++;
}
lines.push(`- ${ev.name}: ${counts.critical} critical, ${counts.warning} warning, ${counts.suggestion} suggestion`);
for (const f of parsed.findings) lines.push(fmtFinding(f));
for (const f of structured) lines.push(fmtStructuredFinding(f));
}
export function buildCumulativeFindingsMarkdown(dir, state) {
const lines = ["# OPC Cumulative Findings", ""];
lines.push(`- Current node: ${state?.currentNode || "unknown"}`);
lines.push(`- Flow status: ${state?.status || "in_progress"}`);
lines.push(`- Total steps: ${state?.totalSteps ?? 0}`, "");
for (const entry of orderedEntries(dir, state)) appendNode(lines, readRunSummary(dir, entry));
const fixes = collectExecutionFixes(dir);
if (fixes.length) {
lines.push("## Fixes Applied During Execution");
for (const f of fixes) lines.push(`- [${f.nodeId}${f.runId ? `/${f.runId}` : ""}] ${f.text}`);
lines.push("");
}
return lines.join("\n").replace(/\n{3,}/g, "\n\n");
}
export function writeCumulativeFindings(dir, state) {
writeFileSync(join(dir, OUT), buildCumulativeFindingsMarkdown(dir, state), "utf8");
}
export function readCumulativeFindingsAppend(dir) {
const path = join(dir, OUT);
if (!existsSync(path)) return "";
const content = readFileSync(path, "utf8").trim();
if (!content) return "";
return `## OPC Recovery Context\n\n${content}`;
}
import { existsSync, readFileSync } from "fs";
import { join } from "path";
function upstreamEntries(state, template, currentNode) {
let lastGateIdx = -1;
for (let i = state.history.length - 1; i >= 0; i--) {
const entry = state.history[i];
if (template.nodeTypes?.[entry.nodeId] === "gate" && entry.nodeId !== currentNode) {
lastGateIdx = i;
break;
}
}
const slice = lastGateIdx === -1 ? state.history : state.history.slice(lastGateIdx + 1);
return slice.filter(entry => template.nodeTypes?.[entry.nodeId] !== "gate");
}
function latestRunEntries(entries) {
const seen = new Set();
const latest = [];
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (!entry.runId || seen.has(entry.nodeId)) continue;
seen.add(entry.nodeId);
latest.unshift(entry);
}
return latest;
}
function readVerdict(path) {
try {
return { data: JSON.parse(readFileSync(path, "utf8")) };
} catch (err) {
return { error: `${path} unreadable: ${err.message}` };
}
}
function safeInt(value) {
const n = parseInt(value, 10);
return Number.isFinite(n) ? n : 0;
}
function verdictReason(entry, verdict) {
const aiErrors = safeInt(verdict.aiSmellErrors ?? verdict.ai_smell_errors);
if (aiErrors > 0) {
return `DI AI smell verdict failed in ${entry.nodeId}/${entry.runId}: ${aiErrors} error(s)`;
}
const recommendation = typeof verdict.recommendation === "string"
? verdict.recommendation.toUpperCase()
: null;
if (verdict.pass === false || (recommendation && recommendation !== "PASS")) {
const reasons = Array.isArray(verdict.blockingReasons) && verdict.blockingReasons.length > 0
? ` — ${verdict.blockingReasons.join("; ")}`
: "";
return `DI verdict failed in ${entry.nodeId}/${entry.runId}: ${verdict.recommendation || "non-PASS"}${reasons}`;
}
return null;
}
export function collectDiVerdictReasons(dir, state, template, currentNode) {
const reasons = [];
const entries = latestRunEntries(upstreamEntries(state, template, currentNode));
for (const entry of entries) {
const path = join(dir, "nodes", entry.nodeId, entry.runId, "ext-design-intelligence", "verdict.json");
if (!existsSync(path)) continue;
const loaded = readVerdict(path);
if (loaded.error) {
reasons.push(`DI verdict unreadable in ${entry.nodeId}/${entry.runId} — fail-closed`);
continue;
}
const reason = verdictReason(entry, loaded.data || {});
if (reason) reasons.push(reason);
}
return reasons;
}
// Session-ownership for OPC loops.
//
// Problem this solves: `/opc loop` + context compaction can leave two live
// Claude sessions both driving the SAME loop-state.json in the same session
// dir. The file lock / in_progress guard prevent *state corruption*, but they
// do NOT prevent two drivers doing duplicate WORK (a resumed agent that jumps
// straight to complete-tick bypasses the next-tick in_progress guard entirely).
//
// The fix: bind a loop to exactly one session.
// - LIVE discrimination = the Claude-ancestor (PID + process start time). Each
// Claude Code session is one OS process; walking the harness's parent chain
// finds it. Two live sessions have different Claude PIDs → the non-owner is
// refused. The start time is paired with the PID because a bare PID is not a
// stable identity: over a long run the owner can exit and the OS can recycle
// its PID, which would make isPidAlive lie and permanently BLOCK a valid
// takeover. (pid, start_time) is stable across reuse.
// - The PID survives compaction (compaction does not swap the process), so a
// compact-resumed agent is still recognised as the owner with zero friction.
// - A stable per-loop TOKEN is stamped at init for identity/audit and rotated
// on takeover. Discrimination does not depend on it; it only records *who*
// owns the loop and makes reclaim auditable.
// - Legacy stamps without a recorded start time degrade gracefully to PID-only
// comparison, preserving pre-fix behavior.
//
// Depends on: (none — self-contained)
import { execFileSync } from "child_process";
import { hostname } from "os";
import { createHash, randomBytes } from "crypto";
// ── Liveness ────────────────────────────────────────────────────
export function isPidAlive(pid) {
const n = Number(pid);
if (!Number.isInteger(n) || n <= 1) return false;
try {
process.kill(n, 0);
return true;
} catch (err) {
// EPERM means the process exists but we can't signal it — still alive.
return err && err.code === "EPERM";
}
}
// ── Parent-chain walk to find the owning Claude session ─────────
// Returns { ppid, args } for one pid, or null if ps can't see it.
function psInfo(pid) {
try {
const out = execFileSync("ps", ["-o", "ppid=,args=", "-p", String(pid)], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 2000,
}).trim();
if (!out) return null;
const m = out.match(/^\s*(\d+)\s+(.*)$/s);
if (!m) return null;
return { ppid: Number(m[1]), args: m[2] };
} catch {
return null;
}
}
// A process is the Claude Code CLI if its command line references `claude`
// but is not the harness itself (which is a child of Claude).
function looksLikeClaude(args) {
if (!args) return false;
if (/opc-harness/.test(args)) return false;
return /\bclaude\b/i.test(args);
}
// ── Process start-time (PID-reuse guard) ────────────────────────
// A bare PID is not a stable identity: over a long (e.g. 24h) run the owning
// Claude process can exit and the OS can recycle its PID to an unrelated
// process. `isPidAlive` would then report "alive" and permanently BLOCK a
// legitimate takeover. Pairing the PID with the process START TIME fixes this:
// start time is fixed at exec and differs across a reuse, so (pid, start) is a
// stable instance identity. We treat the `ps -o lstart=` string as an opaque
// token and compare by equality — no parsing, no locale assumptions.
export function psStartTime(pid) {
const n = Number(pid);
if (!Number.isInteger(n) || n <= 1) return null;
try {
const out = execFileSync("ps", ["-o", "lstart=", "-p", String(n)], {
encoding: "utf8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 2000,
}).trim();
return out || null;
} catch {
return null;
}
}
// Walk up from the current harness process until we hit the Claude ancestor.
// Returns its PID, or null when not running under Claude (e.g. a manual CLI
// invocation), in which case ownership binding is disabled (legacy behavior).
export function findClaudeAncestorPid() {
let pid = process.pid;
const seen = new Set();
for (let depth = 0; depth < 40; depth++) {
if (!Number.isInteger(pid) || pid <= 1 || seen.has(pid)) break;
seen.add(pid);
const info = psInfo(pid);
if (!info) break;
if (looksLikeClaude(info.args)) return pid;
pid = info.ppid;
}
return null;
}
// ── Caller identity ─────────────────────────────────────────────
export function resolveCallerIdentity() {
const claude_pid = findClaudeAncestorPid();
return {
claude_pid,
claude_started_at: claude_pid != null ? psStartTime(claude_pid) : null,
host: hostname(),
};
}
// ── Fail-open detection (Hole 3) ────────────────────────────────
// When we cannot resolve the caller's Claude PID (ps unavailable, not running
// under Claude, an unusual harness launch), ownership binding is degraded.
// Drive commands now fail CLOSED (checkOwnership refuses if a live owner is
// recorded), but init cannot bind a loop to a session with no PID, and a
// --force override on drive re-opens the double-drive window. Surface this so
// the operator knows enforcement is running in a degraded mode. Returns a
// warning string when the caller PID is unresolvable, else null.
export function ownershipEnforcementWarning(caller) {
if (caller && caller.claude_pid != null) return null;
return "session-ownership degraded — could not resolve this Claude session's PID " +
"(ps unavailable or not launched under Claude). A loop initialized now cannot be bound " +
"to this session, and drive commands fail closed (refuse when a live owner is recorded). " +
"If you run /opc loop in more than one window, keep only one live.";
}
// ── Owner stamp ─────────────────────────────────────────────────
export function generateOwnerToken() {
return createHash("sha256")
.update(Date.now().toString() + randomBytes(8).toString("hex"))
.digest("hex")
.slice(0, 16);
}
export function makeOwner(caller, token = generateOwnerToken()) {
return {
token,
claude_pid: caller.claude_pid,
claude_started_at: caller.claude_started_at ?? null,
host: caller.host,
claimed_at: new Date().toISOString(),
};
}
// ── Ownership decision ──────────────────────────────────────────
// Returns { decision: "OWNER" | "BLOCKED" | "TAKEOVER", reason, owner? }.
//
// OWNER — caller is the owning session (same live Claude PID), or the loop
// has no owner stamp (legacy loop). If the caller's PID is
// unresolvable, OWNER only when the recorded owner is not provably
// live (otherwise BLOCKED — fail closed).
// BLOCKED — a DIFFERENT Claude session owns the loop and is still alive. The
// caller must not drive it. This is the double-drive bug case.
// TAKEOVER — the owner is gone (dead PID). The caller may reclaim the loop;
// callers should re-stamp _owner via makeOwner() and persist.
export function checkOwnership(state, caller, opts = {}) {
const owner = state && state._owner;
// Legacy loop (pre-ownership) — no stamp to enforce.
if (!owner || owner.claude_pid == null) {
return { decision: "OWNER", reason: "no ownership stamp — legacy loop" };
}
// Caller's Claude PID is unresolvable (ps unavailable, or not launched under
// Claude). We cannot prove this caller IS the owning session. Failing OPEN
// here (the old behavior — return OWNER for everyone) let a second session
// drive the loop undetected: the exact double-drive bug this module exists to
// prevent. Fail CLOSED instead — if the recorded owner instance is provably
// still live (same host, PID alive, start time matches), refuse. A legitimate
// manual/admin caller in a ps-less environment can pass --force to override.
if (caller.claude_pid == null) {
const ownerLive = owner.host === caller.host
&& isPidAlive(owner.claude_pid)
&& startTimeStillMatches(owner);
if (ownerLive && !opts.force) {
return {
decision: "BLOCKED",
reason: `cannot resolve this caller's Claude PID and the loop is owned by a live session ` +
`(pid ${owner.claude_pid}) — refusing to drive it (pass --force if no other session is active)`,
owner,
};
}
return {
decision: opts.force ? "TAKEOVER" : "OWNER",
reason: opts.force
? "forced takeover (caller PID unresolvable)"
: "caller PID unresolvable and owner not provably live — allowing",
owner,
};
}
const sameHost = owner.host === caller.host;
const samePid = sameHost && Number(owner.claude_pid) === Number(caller.claude_pid);
// Start-time discriminates a genuine identity match from a PID collision.
// If both sides recorded a start time and they differ, the PIDs coincide by
// OS reuse — NOT the same process. When either side lacks a start time
// (legacy stamp / ps unavailable) we fall back to PID-only comparison to
// preserve the original behavior rather than over-block.
const startTimesKnown = owner.claude_started_at != null && caller.claude_started_at != null;
const startTimesMatch = !startTimesKnown || owner.claude_started_at === caller.claude_started_at;
if (samePid && startTimesMatch) {
return { decision: "OWNER", reason: "caller is the owning Claude session" };
}
// samePid but start times differ → caller reused the owner's dead PID.
// Fall through to the liveness check, which will (correctly) find the
// recorded owner instance gone and permit takeover.
// Owner is truly live only if its PID is alive AND the process at that PID is
// the same instance that stamped the loop (start time matches). A recycled
// PID passes isPidAlive but fails the start-time check → treated as dead.
const ownerPidAlive = sameHost && isPidAlive(owner.claude_pid);
const ownerInstanceLive = ownerPidAlive && startTimeStillMatches(owner);
if (ownerInstanceLive && !opts.force) {
return {
decision: "BLOCKED",
reason: `loop is owned by a live Claude session (pid ${owner.claude_pid}` +
`${sameHost ? "" : ` on ${owner.host}`}) — refusing to drive it from this session`,
owner,
};
}
// Owner is dead (or --force) — safe to reclaim: a dead owner can't double-drive.
const tokenMatch = opts.callerToken != null && opts.callerToken === owner.token;
return {
decision: "TAKEOVER",
reason: opts.force
? "forced takeover"
: tokenMatch
? "previous owner is gone and caller holds the loop token — reclaiming"
: "previous owner is gone — reclaiming",
owner,
};
}
// Is the process currently at owner.claude_pid still the same instance that
// stamped the loop? Returns true when we cannot tell (legacy stamp with no
// recorded start time) so liveness degrades to PID-only, matching pre-fix
// behavior. Returns false only when we have a recorded start time AND the live
// process reports a different one — i.e. a confirmed PID reuse.
function startTimeStillMatches(owner) {
if (owner.claude_started_at == null) return true; // legacy — can't verify
const current = psStartTime(owner.claude_pid);
if (current == null) return false; // process gone between checks
return current === owner.claude_started_at;
}
// driver-owner.test.mjs — Node.js built-in test runner
// Run: node --test bin/lib/driver-owner.test.mjs
//
// Covers session-ownership discrimination, with emphasis on the PID-reuse guard
// (Hole 2): a recycled dead-owner PID must NOT produce a permanent false BLOCKED.
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import {
isPidAlive,
psStartTime,
makeOwner,
resolveCallerIdentity,
checkOwnership,
ownershipEnforcementWarning,
} from "./driver-owner.mjs";
const HOST = "testhost";
// A PID that is essentially certain not to exist. Above the typical pid_max on
// macOS/Linux, and not 0/1.
const DEAD_PID = 2147483646;
// Helpers to build owner/caller identity records without touching real Claude.
function owner({ pid, start, host = HOST, token = "tok" }) {
return { token, claude_pid: pid, claude_started_at: start ?? null, host, claimed_at: "2026-01-01T00:00:00Z" };
}
function caller({ pid, start, host = HOST }) {
return { claude_pid: pid, claude_started_at: start ?? null, host };
}
describe("isPidAlive", () => {
test("own process is alive", () => {
assert.equal(isPidAlive(process.pid), true);
});
test("pid 1 / invalid → not alive (guarded)", () => {
assert.equal(isPidAlive(1), false);
assert.equal(isPidAlive(-5), false);
assert.equal(isPidAlive("nope"), false);
});
test("unused high pid → not alive", () => {
assert.equal(isPidAlive(DEAD_PID), false);
});
});
describe("psStartTime", () => {
test("own process yields a non-empty start-time string", () => {
const s = psStartTime(process.pid);
assert.equal(typeof s, "string");
assert.ok(s.length > 0);
});
test("stable across calls for the same live process", () => {
assert.equal(psStartTime(process.pid), psStartTime(process.pid));
});
test("invalid pid → null", () => {
assert.equal(psStartTime(1), null);
assert.equal(psStartTime(-1), null);
});
test("dead pid → null", () => {
assert.equal(psStartTime(DEAD_PID), null);
});
});
describe("resolveCallerIdentity", () => {
test("returns host and (pid, start) shape", () => {
const id = resolveCallerIdentity();
assert.ok("claude_pid" in id);
assert.ok("claude_started_at" in id);
assert.equal(typeof id.host, "string");
// When a claude_pid is resolvable its start-time is populated; when not,
// both are null. They must be consistent.
if (id.claude_pid == null) assert.equal(id.claude_started_at, null);
});
});
describe("makeOwner", () => {
test("carries pid, start-time, host, token, claimed_at", () => {
const o = makeOwner({ claude_pid: 42, claude_started_at: "S", host: HOST }, "mytoken");
assert.equal(o.claude_pid, 42);
assert.equal(o.claude_started_at, "S");
assert.equal(o.host, HOST);
assert.equal(o.token, "mytoken");
assert.equal(typeof o.claimed_at, "string");
});
test("missing start-time defaults to null", () => {
const o = makeOwner({ claude_pid: 42, host: HOST }, "t");
assert.equal(o.claude_started_at, null);
});
});
describe("checkOwnership — baseline", () => {
test("legacy loop (no _owner) → OWNER", () => {
assert.equal(checkOwnership({}, caller({ pid: process.pid, start: "x" })).decision, "OWNER");
});
test("owner live but caller pid unresolvable → BLOCKED (fail closed)", () => {
const state = { _owner: owner({ pid: process.pid, start: psStartTime(process.pid) }) };
assert.equal(checkOwnership(state, caller({ pid: null })).decision, "BLOCKED");
});
test("owner live, caller pid unresolvable, --force → TAKEOVER", () => {
const state = { _owner: owner({ pid: process.pid, start: psStartTime(process.pid) }) };
assert.equal(checkOwnership(state, caller({ pid: null }), { force: true }).decision, "TAKEOVER");
});
test("owner dead, caller pid unresolvable → OWNER (not provably live)", () => {
const state = { _owner: owner({ pid: DEAD_PID, start: "old" }) };
assert.equal(checkOwnership(state, caller({ pid: null })).decision, "OWNER");
});
test("same pid + same start-time → OWNER (caller is the owning session)", () => {
const start = psStartTime(process.pid);
const state = { _owner: owner({ pid: process.pid, start }) };
const res = checkOwnership(state, caller({ pid: process.pid, start }));
assert.equal(res.decision, "OWNER");
});
});
describe("checkOwnership — live foreign owner is BLOCKED", () => {
test("different pid, owner instance alive → BLOCKED", () => {
const start = psStartTime(process.pid); // real → owner instance is live
const state = { _owner: owner({ pid: process.pid, start }) };
const res = checkOwnership(state, caller({ pid: process.pid + 1000000, start: "other" }));
assert.equal(res.decision, "BLOCKED");
assert.ok(res.owner);
});
test("--force overrides a live foreign owner → TAKEOVER", () => {
const start = psStartTime(process.pid);
const state = { _owner: owner({ pid: process.pid, start }) };
const res = checkOwnership(state, caller({ pid: process.pid + 1000000, start: "other" }), { force: true });
assert.equal(res.decision, "TAKEOVER");
assert.match(res.reason, /forced/);
});
});
describe("checkOwnership — dead owner is reclaimable", () => {
test("owner pid dead → TAKEOVER", () => {
const state = { _owner: owner({ pid: DEAD_PID, start: "Mon Jan 1 00:00:00 2020" }) };
const res = checkOwnership(state, caller({ pid: process.pid, start: psStartTime(process.pid) }));
assert.equal(res.decision, "TAKEOVER");
});
test("token match is surfaced in the reclaim reason", () => {
const state = { _owner: owner({ pid: DEAD_PID, start: "old", token: "abc" }) };
const res = checkOwnership(state, caller({ pid: process.pid, start: "x" }), { callerToken: "abc" });
assert.equal(res.decision, "TAKEOVER");
assert.match(res.reason, /holds the loop token/);
});
});
describe("checkOwnership — PID reuse guard (Hole 2)", () => {
// The owner recorded a PID that is currently ALIVE, but the recorded start
// time does NOT match the live process at that PID → the original owner
// instance is gone and the PID was recycled. Pre-fix this returned BLOCKED
// (pure isPidAlive); the fix must return TAKEOVER.
test("alive PID but stale start-time → TAKEOVER (not a false BLOCKED)", () => {
const state = { _owner: owner({ pid: process.pid, start: "Sat Jan 1 00:00:00 2000" }) };
// Caller is a different session so we don't short-circuit on samePid.
const res = checkOwnership(state, caller({ pid: process.pid + 1000000, start: "fresh" }));
assert.equal(res.decision, "TAKEOVER");
});
test("caller reused owner's dead PID (samePid, start-time mismatch) → TAKEOVER", () => {
// owner pid == caller pid, but owner's recorded start-time is stale, so the
// caller is a NEW process that happened to get the same PID.
const state = { _owner: owner({ pid: process.pid, start: "Sat Jan 1 00:00:00 2000" }) };
const res = checkOwnership(state, caller({ pid: process.pid, start: psStartTime(process.pid) }));
assert.equal(res.decision, "TAKEOVER");
});
});
describe("checkOwnership — legacy stamp fallback (no start-time)", () => {
// Owner stamped before the start-time field existed. Liveness must degrade to
// PID-only so we neither over-block nor crash.
test("legacy owner, alive pid, foreign caller → BLOCKED (PID-only)", () => {
const state = { _owner: owner({ pid: process.pid, start: null }) };
const res = checkOwnership(state, caller({ pid: process.pid + 1000000, start: "x" }));
assert.equal(res.decision, "BLOCKED");
});
test("legacy owner, dead pid → TAKEOVER", () => {
const state = { _owner: owner({ pid: DEAD_PID, start: null }) };
const res = checkOwnership(state, caller({ pid: process.pid, start: "x" }));
assert.equal(res.decision, "TAKEOVER");
});
test("legacy owner, same pid → OWNER (start-time unknown, PID-only match)", () => {
const state = { _owner: owner({ pid: process.pid, start: null }) };
const res = checkOwnership(state, caller({ pid: process.pid, start: "whatever" }));
assert.equal(res.decision, "OWNER");
});
});
describe("ownershipEnforcementWarning — degraded-mode detection (Hole 3)", () => {
test("caller with a resolvable pid → no warning", () => {
assert.equal(ownershipEnforcementWarning(caller({ pid: process.pid, start: "x" })), null);
});
test("caller with null pid → warning surfaced", () => {
const w = ownershipEnforcementWarning(caller({ pid: null }));
assert.equal(typeof w, "string");
assert.match(w, /degraded/);
});
test("missing/undefined caller → warning (defensive)", () => {
assert.match(ownershipEnforcementWarning(undefined), /degraded/);
});
});
// Tests for the changeScope fix: scope the review-coverage gate to the commits
// the flow actually PRODUCED (--change-commits / flow-state.producedCommits),
// instead of a blind `git diff HEAD~1` that mis-attributes unrelated parallel
// commits and cannot see session-local artifacts the flow never committed.
//
// Two levels:
// 1. Unit — changeScopeDiffFiles(baseDir, changeCommits) in a real temp repo.
// 2. Litmus pair — synthesize CLI verdict flips PASS↔ITERATE on --change-commits
// alone, proving the fix DISABLES the false-positive without disabling the
// gate (a non-empty produced set still bites).
// Plus record-commit (default HEAD, dedup, fail-closed) and init field seeding.
import { test, describe, before, after } from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import os from "node:os";
import { fileURLToPath } from "node:url";
import { execFileSync, execSync } from "node:child_process";
import { changeScopeDiffFiles } from "./eval-commands.mjs";
const TMPBASE = join(os.homedir(), ".opc", "sessions", `changescope-test-${Date.now()}`);
const HARNESS = join(dirname(fileURLToPath(import.meta.url)), "..", "opc-harness.mjs");
// ── git repo builder ────────────────────────────────────────────
// Commit 1 (init): real.ts — the file the review actually inspects.
// Commit 2 (feature): feature-a.ts + feature-b.ts — the "produced" change.
// The eval below references real.ts (exists) but NOT the feature files, so a
// scope of {feature-a, feature-b} is 0/2 covered → the gate must bite.
function makeGitRepo(name) {
const dir = join(TMPBASE, name);
mkdirSync(dir, { recursive: true });
const git = (cmd) => execSync(cmd, { cwd: dir, stdio: ["ignore", "pipe", "ignore"] });
git("git init -q .");
git("git config user.email t@t.t");
git("git config user.name t");
writeFileSync(join(dir, "real.ts"), "export function realThing() { return 1; }\n");
git("git add .");
git("git commit -q -m init");
const initSha = execSync("git rev-parse HEAD", { cwd: dir, encoding: "utf8" }).trim();
writeFileSync(join(dir, "feature-a.ts"), "export const a = 1;\n");
writeFileSync(join(dir, "feature-b.ts"), "export const b = 2;\n");
git("git add .");
git("git commit -q -m feature");
const featureSha = execSync("git rev-parse HEAD", { cwd: dir, encoding: "utf8" }).trim();
return { dir, initSha, featureSha };
}
// A clean, otherwise-passing eval: mandatory skeptic-owner role, suggestion-only
// (🔵), substantive (reasoning + fix + real file:line). Its ONLY possible warning
// source is changeScope — so verdict is a pure litmus for the fix.
const CLEAN_EVAL = `# Skeptic Owner Review
🔵 real.ts:1 — realThing could expose a named constant
**Reasoning:** a named constant would read better than a literal return value.
**Fix:** extract the magic number to a named const.
`;
function makeSession(name, evalText = CLEAN_EVAL) {
const dir = join(TMPBASE, name);
const runDir = join(dir, "nodes", "code-review", "run_1");
mkdirSync(runDir, { recursive: true });
writeFileSync(join(runDir, "eval-skeptic-owner.md"), evalText);
return dir;
}
// Parse harness JSON output. synthesize pretty-prints (multi-line) while most
// commands emit a single compact line, so try the whole stdout first and fall
// back to the last line. Deprecation notices go to stderr, keeping stdout pure.
function parseJson(text) {
const t = String(text || "").trim();
try { return JSON.parse(t); }
catch { return JSON.parse(t.split("\n").at(-1)); }
}
function runHarness(cmd, args) {
try {
const out = execFileSync("node", [HARNESS, cmd, ...args], {
encoding: "utf8", stdio: ["pipe", "pipe", "pipe"],
});
return parseJson(out);
} catch (err) {
try { return parseJson(err.stdout); }
catch { return { error: err.message, stderr: String(err.stderr || "") }; }
}
}
function synthesize(sessionDir, base, changeCommits) {
const args = ["--node", "code-review", "--dir", sessionDir, "--base", base];
if (changeCommits !== undefined) args.push("--change-commits", changeCommits);
return runHarness("synthesize", args);
}
after(() => { try { rmSync(TMPBASE, { recursive: true, force: true }); } catch {} });
// ── Unit: changeScopeDiffFiles ──────────────────────────────────
describe("changeScopeDiffFiles", () => {
let repo;
before(() => { repo = makeGitRepo("unit-repo"); });
test("non-git base → skip with an explicit reason", () => {
const nonGit = join(TMPBASE, "not-a-repo");
mkdirSync(nonGit, { recursive: true });
const r = changeScopeDiffFiles(nonGit, []);
assert.equal(r.skip, true);
assert.match(r.reason, /not a git repository/);
assert.deepEqual(r.files, []);
});
test("empty produced set → skip cleanly, no reason, no files (the fix)", () => {
const r = changeScopeDiffFiles(repo.dir, []);
assert.equal(r.skip, true);
assert.equal(r.reason, null);
assert.deepEqual(r.files, []);
});
test("explicit commit → exactly that commit's files (gate still bites)", () => {
const r = changeScopeDiffFiles(repo.dir, [repo.featureSha]);
assert.equal(r.skip, false);
assert.deepEqual([...r.files].sort(), ["feature-a.ts", "feature-b.ts"]);
});
test("multiple commits → union of their files", () => {
const r = changeScopeDiffFiles(repo.dir, [repo.initSha, repo.featureSha]);
assert.deepEqual([...r.files].sort(), ["feature-a.ts", "feature-b.ts", "real.ts"]);
});
test("unknown sha is skipped, valid siblings retained", () => {
const r = changeScopeDiffFiles(repo.dir, ["deadbeefdeadbeefdeadbeefdeadbeefdeadbeef", repo.featureSha]);
assert.deepEqual([...r.files].sort(), ["feature-a.ts", "feature-b.ts"]);
});
test("null (flag absent) → legacy HEAD~1 diff", () => {
const r = changeScopeDiffFiles(repo.dir, null);
assert.equal(r.skip, false);
// HEAD~1..HEAD is the feature commit.
assert.deepEqual([...r.files].sort(), ["feature-a.ts", "feature-b.ts"]);
});
});
// ── Litmus pair: --change-commits flips the verdict, nothing else does ──
describe("changeScope litmus (synthesize verdict)", () => {
let repo;
before(() => { repo = makeGitRepo("litmus-repo"); });
test("empty --change-commits → PASS (false-positive is gone)", () => {
const s = makeSession("litmus-empty");
const out = synthesize(s, repo.dir, "");
assert.equal(out.verdict, "PASS", JSON.stringify(out));
});
test("--change-commits <featureSha> → ITERATE (gate still enforces scope)", () => {
const s = makeSession("litmus-scoped");
const out = synthesize(s, repo.dir, repo.featureSha);
assert.equal(out.verdict, "ITERATE", JSON.stringify(out));
const joined = JSON.stringify(out);
assert.match(joined, /changed files/);
});
test("legacy: no --change-commits → ITERATE (HEAD~1 fallback preserved)", () => {
const s = makeSession("litmus-legacy");
const out = synthesize(s, repo.dir); // omit the flag entirely
assert.equal(out.verdict, "ITERATE", JSON.stringify(out));
});
test("scoped to a commit the eval DOES cover → PASS", () => {
const s = makeSession("litmus-covered");
// initSha touches real.ts, which the eval references → 1/1 covered.
const out = synthesize(s, repo.dir, repo.initSha);
assert.equal(out.verdict, "PASS", JSON.stringify(out));
});
});
// ── record-commit ───────────────────────────────────────────────
describe("record-commit", () => {
let repo;
before(() => { repo = makeGitRepo("record-repo"); });
function seedState(name, extra = {}) {
const dir = join(TMPBASE, name);
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, "flow-state.json"), JSON.stringify({
version: "1.0", flowTemplate: "build-verify", currentNode: "build",
projectRoot: repo.dir, producedCommits: [], history: [],
_written_by: "opc-harness", ...extra,
}, null, 2));
return dir;
}
test("records explicit --sha, expanded to full sha", () => {
const dir = seedState("rec-explicit");
const out = runHarness("record-commit", ["--dir", dir, "--sha", repo.featureSha.slice(0, 10)]);
assert.equal(out.recorded, true);
assert.equal(out.sha, repo.featureSha);
const state = JSON.parse(readFileSync(join(dir, "flow-state.json"), "utf8"));
assert.deepEqual(state.producedCommits, [repo.featureSha]);
});
test("defaults to HEAD when --sha omitted", () => {
const dir = seedState("rec-head");
const out = runHarness("record-commit", ["--dir", dir]);
assert.equal(out.recorded, true);
assert.equal(out.sha, repo.featureSha); // HEAD of the repo
});
test("dedups a repeated commit", () => {
const dir = seedState("rec-dedup");
runHarness("record-commit", ["--dir", dir, "--sha", repo.featureSha]);
const out = runHarness("record-commit", ["--dir", dir, "--sha", repo.featureSha]);
assert.equal(out.already, true);
const state = JSON.parse(readFileSync(join(dir, "flow-state.json"), "utf8"));
assert.deepEqual(state.producedCommits, [repo.featureSha]);
});
test("accumulates distinct commits in order", () => {
const dir = seedState("rec-many");
runHarness("record-commit", ["--dir", dir, "--sha", repo.initSha]);
runHarness("record-commit", ["--dir", dir, "--sha", repo.featureSha]);
const state = JSON.parse(readFileSync(join(dir, "flow-state.json"), "utf8"));
assert.deepEqual(state.producedCommits, [repo.initSha, repo.featureSha]);
});
test("fail-closed on an invalid sha", () => {
const dir = seedState("rec-bad-sha");
const out = runHarness("record-commit", ["--dir", dir, "--sha", "notacommit"]);
assert.equal(out.recorded, false);
assert.match(out.error, /not a valid commit/);
const state = JSON.parse(readFileSync(join(dir, "flow-state.json"), "utf8"));
assert.deepEqual(state.producedCommits, []);
});
test("fail-closed when flow-state.json is missing", () => {
const dir = join(TMPBASE, "rec-nostate");
mkdirSync(dir, { recursive: true });
const out = runHarness("record-commit", ["--dir", dir]);
assert.equal(out.recorded, false);
assert.match(out.error, /flow-state\.json not found/);
});
});
// ── init seeds the changeScope fields ───────────────────────────
describe("init flow-state fields", () => {
test("init writes baseSha (git floor) and empty producedCommits", () => {
const dir = join(TMPBASE, "init-fields");
const out = runHarness("init", ["--flow", "build-verify", "--dir", dir, "--no-extensions"]);
assert.equal(out.created ?? true, true, JSON.stringify(out));
const state = JSON.parse(readFileSync(join(dir, "flow-state.json"), "utf8"));
assert.ok(Object.prototype.hasOwnProperty.call(state, "baseSha"));
// OPC's own repo is git, so baseSha resolves to a 40-char sha here.
assert.match(state.baseSha, /^[0-9a-f]{40}$/);
assert.deepEqual(state.producedCommits, []);
});
});
import { existsSync, readFileSync } from "fs";
import { join } from "path";
function normalizeCapability(cap) {
if (typeof cap !== "string" || cap.length === 0) return null;
if (/^[a-z][a-z0-9-]*@[1-9]\d*$/.test(cap)) return cap;
if (/^[a-z][a-z0-9-]*$/.test(cap)) return `${cap}@1`;
return cap;
}
function upstreamEntries(state, template, currentNode) {
let lastGateIdx = -1;
for (let i = state.history.length - 1; i >= 0; i--) {
const entry = state.history[i];
if (template.nodeTypes?.[entry.nodeId] === "gate" && entry.nodeId !== currentNode) {
lastGateIdx = i;
break;
}
}
const slice = lastGateIdx === -1 ? state.history : state.history.slice(lastGateIdx + 1);
return slice.filter(entry => template.nodeTypes?.[entry.nodeId] !== "gate");
}
function requestedCaps(entries, template) {
const caps = new Set();
for (const entry of entries) {
for (const cap of template.nodeCapabilities?.[entry.nodeId] || []) {
const normalized = normalizeCapability(cap);
if (normalized) caps.add(normalized);
}
}
return caps;
}
function failureCaps(failure) {
return [
...(Array.isArray(failure.provides) ? failure.provides : []),
...(Array.isArray(failure.disabledCapabilities) ? failure.disabledCapabilities : []),
].map(normalizeCapability).filter(Boolean);
}
function readStartupFailures(dir) {
const path = join(dir, ".ext-registry.json");
if (!existsSync(path)) return { failures: [] };
try {
const data = JSON.parse(readFileSync(path, "utf8"));
return { failures: Array.isArray(data.startupFailures) ? data.startupFailures : [] };
} catch (err) {
return { error: `.ext-registry.json unreadable: ${err.message}` };
}
}
export function collectExtensionStartupReasons(dir, state, template, currentNode) {
const loaded = readStartupFailures(dir);
if (loaded.error) return [loaded.error];
if (loaded.failures.length === 0) return [];
const requested = requestedCaps(upstreamEntries(state, template, currentNode), template);
if (requested.size === 0) return [];
const reasons = [];
for (const failure of loaded.failures) {
const impacted = failureCaps(failure).filter(cap => requested.has(cap));
if (impacted.length === 0) continue;
reasons.push(
`extension startup failed for requested capability ${impacted.join(", ")}: ` +
`${failure.ext || "unknown"}.startup.check [${failure.kind || "error"}] ${failure.message || ""}`
);
}
return reasons;
}
// extensions-design-artifacts.test.mjs — design artifact writer regressions
import { test, afterEach } from "node:test";
import assert from "node:assert/strict";
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { writeDesignArtifacts } from "./extensions.mjs";
let tmp;
afterEach(() => {
if (tmp) rmSync(tmp, { recursive: true, force: true });
tmp = null;
});
test("no-task design preflight writes only di-state", () => {
tmp = mkdtempSync(join(tmpdir(), "opc-design-artifacts-"));
writeDesignArtifacts({
type: "design",
confidence: 0.1,
reason: "no task description provided",
diState: {
version: 1,
preflight: {
status: "no-task",
confidence: 0.1,
reason: "no task description provided",
},
},
}, tmp);
assert.equal(existsSync(join(tmp, "di-state.json")), true);
assert.equal(existsSync(join(tmp, "design-mode.json")), false);
assert.equal(existsSync(join(tmp, "design-brief.md")), false);
const state = JSON.parse(readFileSync(join(tmp, "di-state.json"), "utf8"));
assert.equal(state.preflight.status, "no-task");
});
test("normal design preflight still writes design artifacts", () => {
tmp = mkdtempSync(join(tmpdir(), "opc-design-artifacts-"));
writeDesignArtifacts({
type: "design",
confidence: 0.8,
reason: "dashboard matched via keyword",
selection: { industry: "dashboard", matchScore: 0.8 },
brief: "# Design Brief\nUse compact dashboard tokens.\n",
tokens: { colors: { accent: "#1677ff" } },
diState: {
version: 1,
preflight: { status: "ok", confidence: 0.8, reason: "dashboard matched via keyword" },
},
}, tmp);
assert.equal(existsSync(join(tmp, "di-state.json")), true);
assert.equal(existsSync(join(tmp, "design-mode.json")), true);
assert.equal(existsSync(join(tmp, "design-selection.json")), true);
assert.equal(existsSync(join(tmp, "design-brief.md")), true);
assert.equal(existsSync(join(tmp, "design-tokens.json")), true);
const state = JSON.parse(readFileSync(join(tmp, "di-state.json"), "utf8"));
assert.equal(state.preflight.status, "ok");
const mode = JSON.parse(readFileSync(join(tmp, "design-mode.json"), "utf8"));
assert.equal(mode.mode, "auto");
assert.equal(mode.confidence, 0.8);
});
import { afterEach, test } from "node:test";
import assert from "node:assert/strict";
import fs from "node:fs";
import { syncBuiltinESMExports } from "node:module";
import {
existsSync,
mkdtempSync,
readFileSync,
rmSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { lockFile } from "./file-lock.mjs";
const roots = [];
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
});
function fixture() {
const root = mkdtempSync(join(tmpdir(), "opc-file-lock-"));
roots.push(root);
const target = join(root, "state.json");
return { target, lockPath: `${target}.lock` };
}
test("fresh unreadable lock is treated as busy instead of deleted", () => {
const { target, lockPath } = fixture();
writeFileSync(lockPath, "{");
const lock = lockFile(target, { timeout: 0, command: "contender" });
assert.equal(lock.acquired, false);
assert.equal(existsSync(lockPath), true);
assert.equal(readFileSync(lockPath, "utf8"), "{");
});
test("fresh unreadable lock waits but remains fail-closed", () => {
const { target, lockPath } = fixture();
writeFileSync(lockPath, "{");
const started = Date.now();
const lock = lockFile(target, { timeout: 60, command: "waiting-contender" });
assert.equal(lock.acquired, false);
assert.ok(Date.now() - started >= 50);
assert.equal(existsSync(lockPath), true);
});
test("a disappearing unreadable lock is retried without deletion", () => {
const { target, lockPath } = fixture();
writeFileSync(lockPath, "{");
const originalStatSync = fs.statSync;
let first = true;
fs.statSync = (...args) => {
if (first) {
first = false;
const error = new Error("lock disappeared");
error.code = "ENOENT";
throw error;
}
return originalStatSync(...args);
};
syncBuiltinESMExports();
try {
const lock = lockFile(target, { timeout: 0, command: "racing-contender" });
assert.equal(lock.acquired, false);
assert.equal(existsSync(lockPath), true);
} finally {
fs.statSync = originalStatSync;
syncBuiltinESMExports();
}
});
test("lock publication errors fail closed and clean temporary files", () => {
const { target } = fixture();
const originalLinkSync = fs.linkSync;
fs.linkSync = () => {
const error = new Error("publication denied");
error.code = "EPERM";
throw error;
};
syncBuiltinESMExports();
try {
const lock = lockFile(target, { timeout: 0, command: "publisher" });
assert.equal(lock.acquired, false);
assert.deepEqual(fs.readdirSync(dirname(target)), []);
} finally {
fs.linkSync = originalLinkSync;
syncBuiltinESMExports();
}
});
test("a competing atomic publication reports an unknown holder when unreadable", () => {
const { target } = fixture();
const originalLinkSync = fs.linkSync;
fs.linkSync = () => {
const error = new Error("already published");
error.code = "EEXIST";
throw error;
};
syncBuiltinESMExports();
try {
const lock = lockFile(target, { timeout: 0, command: "publisher" });
assert.equal(lock.acquired, false);
assert.equal(lock.holder.command, "unknown");
} finally {
fs.linkSync = originalLinkSync;
syncBuiltinESMExports();
}
});
test("old unreadable lock is removed as stale", () => {
const { target, lockPath } = fixture();
writeFileSync(lockPath, "{");
const old = new Date(Date.now() - 60_000);
utimesSync(lockPath, old, old);
const lock = lockFile(target, { timeout: 0, command: "replacement" });
assert.equal(lock.acquired, true);
assert.doesNotThrow(() => JSON.parse(readFileSync(lockPath, "utf8")));
lock.release();
assert.equal(existsSync(lockPath), false);
});
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join, dirname } from "node:path";
import os from "node:os";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
import { checkStructuredResults } from "./flow-transition.mjs";
const TMPBASE = join(os.homedir(), ".opc", "sessions", `ft-gates-test-${Date.now()}`);
const HARNESS = join(dirname(fileURLToPath(import.meta.url)), "..", "opc-harness.mjs");
const TEMPLATE_WITH_CAPS = {
nodeTypes: { build: "build", "code-review": "review", gate: "gate" },
nodeCapabilities: {
build: ["design-system-injection@1"],
"code-review": ["visual-consistency-check@1"],
},
};
function makeGateState() {
return {
flowTemplate: "build-verify",
currentNode: "gate",
history: [
{ nodeId: "build", runId: "run_1" },
{ nodeId: "code-review", runId: "run_1" },
{ nodeId: "gate", runId: "run_1" },
],
};
}
function setupDir(name) {
const dir = join(TMPBASE, name);
mkdirSync(join(dir, "nodes", "build"), { recursive: true });
mkdirSync(join(dir, "nodes", "code-review"), { recursive: true });
return dir;
}
function runHarness(cmd, args) {
try {
const output = execFileSync("node", [HARNESS, cmd, ...args], {
encoding: "utf8",
stdio: ["pipe", "pipe", "pipe"],
});
return JSON.parse(output.trim().split("\n").at(-1));
} catch (err) {
const lines = String(err.stdout || "").trim().split("\n");
try { return JSON.parse(lines.at(-1)); } catch {
return { error: err.message, stderr: String(err.stderr || "") };
}
}
}
function createTestDesignSession(name, testPlan) {
const dir = join(TMPBASE, name);
const nodeDir = join(dir, "nodes", "test-design");
const runDir = join(nodeDir, "run_1");
mkdirSync(runDir, { recursive: true });
writeFileSync(join(runDir, "eval-skeptic-owner.md"), "# Skeptic\n**Verdict: APPROVE**\nNo issues.\n");
writeFileSync(join(runDir, "eval-tester.md"), "# Tester\n**Verdict: APPROVE**\nNo issues.\n");
if (testPlan !== null) writeFileSync(join(runDir, "test-plan.md"), testPlan);
writeFileSync(join(nodeDir, "handshake.json"), JSON.stringify({
nodeId: "test-design",
nodeType: "review",
runId: "run_1",
status: "completed",
verdict: "PASS",
summary: "test plan ready",
timestamp: new Date().toISOString(),
artifacts: [
{ type: "eval", path: "run_1/eval-skeptic-owner.md" },
{ type: "eval", path: "run_1/eval-tester.md" },
],
}));
writeFileSync(join(dir, "flow-state.json"), JSON.stringify({
version: "1.0",
flowTemplate: "build-verify",
currentNode: "test-design",
entryNode: "brief",
totalSteps: 3,
maxTotalSteps: 25,
maxLoopsPerEdge: 3,
maxNodeReentry: 5,
edgeCounts: {},
history: [
{ nodeId: "build", runId: "run_1", timestamp: new Date().toISOString() },
{ nodeId: "code-review", runId: "run_1", timestamp: new Date().toISOString() },
{ nodeId: "test-design", runId: "run_1", timestamp: new Date().toISOString() },
],
_written_by: "opc-harness",
_write_nonce: `test-${Date.now()}`,
_last_modified: new Date().toISOString(),
}, null, 2));
return dir;
}
const COMPLETE_TEST_PLAN = `
# Test Plan
## Unit smoke
Run npm test for unit coverage.
Cover module smoke behavior.
Assert basic render success.
## Contract edge case
Validate schema boundaries.
Cover invalid input.
Assert error code stability.
## Integration e2e flow
Run playwright test through the workflow.
Cover multi-step happy path.
Assert persisted state.
## UI visual accessibility
Capture screenshot at desktop and mobile viewport.
Check responsive layout.
Run a11y smoke checks.
## Tier baseline polish
Check typography hierarchy.
Check navigation affordance.
Check dark mode baseline.
`;
const OUT_OF_RANGE_ANCHOR_PLAN = `
${COMPLETE_TEST_PLAN}
### TC-UNIT-001
Priority: P0
Anchor: package.json:9999
Run npm test.
Expect unit suite to pass.
`;
const BULLET_RANGE_ANCHOR_PLAN = `
${COMPLETE_TEST_PLAN}
- **TC-UNIT-002**
Priority: P0
Anchor: package.json:1-1
Run npm test.
Expect unit suite to pass.
`;
const BAD_BULLET_RANGE_ANCHOR_PLAN = `
${COMPLETE_TEST_PLAN}
- **TC-UNIT-003**
Priority: P1
Anchor: package.json:1-9999
Run npm test.
Expect unit suite to pass.
`;
test.after(() => {
try { rmSync(TMPBASE, { recursive: true, force: true }); } catch {}
});
describe("extension startup gate", () => {
test("startup.check ok:false for requested capability blocks gate PASS", () => {
const dir = setupDir("startup-gap");
writeFileSync(join(dir, ".ext-registry.json"), JSON.stringify({
applied: [],
startupFailures: [{
ext: "design-intelligence",
hook: "startup.check",
kind: "ok-false",
message: "startup.check returned ok:false: themes missing",
provides: ["design-system-injection@1"],
}],
}));
const reasons = checkStructuredResults(dir, makeGateState(), TEMPLATE_WITH_CAPS, "gate");
assert.ok(reasons.some(r => r.includes("extension startup failed")));
assert.ok(reasons.some(r => r.includes("design-system-injection@1")));
});
test("startup.check failure for unrelated capability does not block gate", () => {
const dir = setupDir("startup-unrelated");
writeFileSync(join(dir, ".ext-registry.json"), JSON.stringify({
applied: [],
startupFailures: [{ ext: "dataviz-x", kind: "ok-false", provides: ["dataviz-lint@1"] }],
}));
const reasons = checkStructuredResults(dir, makeGateState(), TEMPLATE_WITH_CAPS, "gate");
assert.equal(reasons.some(r => r.includes("extension startup failed")), false);
});
});
describe("test-design transition gate", () => {
test("blocks missing test plan", () => {
const result = transitionWithPlan("test-design-missing-plan", null);
assert.equal(result.allowed, false);
assert.ok(result.reason.includes("test-plan.md missing"));
});
test("blocks incomplete test plan", () => {
const result = transitionWithPlan("test-design-bad-plan", "# Test Plan\n\n## Unit\nonly one line\n");
assert.equal(result.allowed, false);
assert.ok(result.reason.includes("missing layers"));
});
test("allows complete test plan", () => {
const result = transitionWithPlan("test-design-good-plan", COMPLETE_TEST_PLAN);
assert.equal(result.allowed, true, JSON.stringify(result));
assert.equal(result.next, "test-execute");
});
test("blocks out-of-range P0 anchor", () => {
const result = transitionWithPlan("test-design-bad-anchor", OUT_OF_RANGE_ANCHOR_PLAN);
assert.equal(result.allowed, false);
assert.ok(result.reason.includes("line out of range"));
});
test("allows bullet-form test case with valid range anchor", () => {
const result = transitionWithPlan("test-design-bullet-range-anchor", BULLET_RANGE_ANCHOR_PLAN);
assert.equal(result.allowed, true, JSON.stringify(result));
});
test("blocks bullet-form test case with out-of-range range anchor", () => {
const result = transitionWithPlan("test-design-bad-bullet-range-anchor", BAD_BULLET_RANGE_ANCHOR_PLAN);
assert.equal(result.allowed, false);
assert.ok(result.reason.includes("range out of range"));
});
});
function transitionWithPlan(name, plan) {
const dir = createTestDesignSession(name, plan);
return runHarness("transition", [
"--from", "test-design",
"--to", "test-execute",
"--verdict", "PASS",
"--flow", "build-verify",
"--dir", dir,
]);
}
import { existsSync, readFileSync } from "fs";
import { dirname, join, resolve } from "path";
function readJson(path) {
try {
return { data: JSON.parse(readFileSync(path, "utf8")) };
} catch (err) {
return { error: `${path} unreadable: ${err.message}` };
}
}
function jsonPathValue(data, path) {
if (typeof path !== "string" || !path.startsWith("$.")) return { missing: true };
let current = data;
for (const key of path.slice(2).split(".")) {
if (!key || current == null || typeof current !== "object" || !(key in current)) {
return { missing: true };
}
current = current[key];
}
return { value: current };
}
function compare(actual, operator, expected) {
if (operator === "==") return actual === expected;
if (operator === "!=") return actual !== expected;
if (operator === "<") return Number(actual) < Number(expected);
if (operator === "<=") return Number(actual) <= Number(expected);
if (operator === ">") return Number(actual) > Number(expected);
if (operator === ">=") return Number(actual) >= Number(expected);
return false;
}
function sourcePath(baseDir, source) {
if (typeof source !== "string" || source.length === 0) return null;
return source.startsWith("/") ? source : resolve(baseDir, source);
}
function evaluateCheck(check, baseDir) {
const id = check?.id || "unnamed";
const path = sourcePath(baseDir, check?.source);
if (!path || !existsSync(path)) return `${id}: source missing: ${check?.source || ""}`;
const loaded = readJson(path);
if (loaded.error) return `${id}: ${loaded.error}`;
const picked = jsonPathValue(loaded.data, check?.path);
if (picked.missing) return `${id}: path missing: ${check?.path || ""}`;
if (!compare(picked.value, check?.operator, check?.threshold)) {
return `${id}: ${check?.path} ${picked.value} does not satisfy ${check?.operator} ${check?.threshold}`;
}
return null;
}
function evaluateCriteriaFile(path) {
const loaded = readJson(path);
if (loaded.error) return [loaded.error];
const checks = loaded.data?.checks;
if (!Array.isArray(checks) || checks.length === 0) {
return [`${path}: checks must be a non-empty array`];
}
return checks.map(check => evaluateCheck(check, dirname(path))).filter(Boolean);
}
function upstreamEntries(state, template, currentNode) {
let lastGateIdx = -1;
for (let i = state.history.length - 1; i >= 0; i--) {
const entry = state.history[i];
if (template.nodeTypes?.[entry.nodeId] === "gate" && entry.nodeId !== currentNode) {
lastGateIdx = i;
break;
}
}
const slice = lastGateIdx === -1 ? state.history : state.history.slice(lastGateIdx + 1);
return slice.filter(entry => template.nodeTypes?.[entry.nodeId] !== "gate");
}
function latestRunEntries(entries) {
const seen = new Set();
const latest = [];
for (let i = entries.length - 1; i >= 0; i--) {
const entry = entries[i];
if (!entry.runId || seen.has(entry.nodeId)) continue;
seen.add(entry.nodeId);
latest.unshift(entry);
}
return latest;
}
export function findGateCriteriaFiles(dir, state, template, currentNode) {
const paths = new Set();
const rootCriteria = join(dir, "gate-criteria.json");
if (existsSync(rootCriteria)) paths.add(rootCriteria);
const entries = upstreamEntries(state, template, currentNode);
for (const entry of entries) {
const nodeDir = join(dir, "nodes", entry.nodeId);
const nodeCriteria = join(nodeDir, "gate-criteria.json");
if (existsSync(nodeCriteria)) paths.add(nodeCriteria);
}
for (const entry of latestRunEntries(entries)) {
const nodeDir = join(dir, "nodes", entry.nodeId);
const runCriteria = join(nodeDir, entry.runId, "gate-criteria.json");
if (entry.runId && existsSync(runCriteria)) paths.add(runCriteria);
}
return [...paths];
}
export function collectGateCriteriaReasons(dir, state, template, currentNode) {
const reasons = [];
for (const file of findGateCriteriaFiles(dir, state, template, currentNode)) {
reasons.push(...evaluateCriteriaFile(file));
}
return reasons;
}
import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { createHash, createHmac, randomBytes } from "crypto";
import { homedir } from "os";
import { dirname, join } from "path";
const LEDGER_NAME = ".opc-provenance.jsonl";
const KEY_PATH = process.env.OPC_PROVENANCE_KEY_FILE || join(homedir(), ".opc", "provenance-key");
function canonical(value) {
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
if (value && typeof value === "object") {
return `{${Object.keys(value).sort().map(k => `${JSON.stringify(k)}:${canonical(value[k])}`).join(",")}}`;
}
return JSON.stringify(value);
}
function sha256(text) {
return createHash("sha256").update(text).digest("hex");
}
function signingKey() {
if (existsSync(KEY_PATH)) return readFileSync(KEY_PATH);
mkdirSync(dirname(KEY_PATH), { recursive: true });
const key = randomBytes(32).toString("hex");
writeFileSync(KEY_PATH, key, { mode: 0o600 });
return Buffer.from(key);
}
function signPayload(payload) {
return createHmac("sha256", signingKey()).update(canonical(payload)).digest("hex");
}
function ledgerPath(sessionDir) {
return join(sessionDir, LEDGER_NAME);
}
function validateRecord(record, previousHash) {
if (!record || typeof record !== "object") return { ok: false, error: "ledger record is not an object" };
const { signature, recordHash, ...payload } = record;
if (payload.previousHash !== previousHash) return { ok: false, error: "ledger hash chain mismatch" };
if (signature !== signPayload(payload)) return { ok: false, error: "ledger signature mismatch" };
const actualHash = sha256(canonical({ ...payload, signature }));
if (recordHash !== actualHash) return { ok: false, error: "ledger record hash mismatch" };
return { ok: true, payload, recordHash };
}
export function appendProvenanceEvent(sessionDir, event) {
const path = ledgerPath(sessionDir);
const previousHash = latestLedgerHash(sessionDir);
const payload = { version: 1, timestamp: new Date().toISOString(), previousHash, ...event };
const signature = signPayload(payload);
const recordHash = sha256(canonical({ ...payload, signature }));
appendFileSync(path, JSON.stringify({ ...payload, signature, recordHash }) + "\n", { mode: 0o600 });
return { kind: "opc-hmac-ledger", path: LEDGER_NAME, recordHash };
}
export function findProvenanceEvent(sessionDir, recordHash) {
const path = ledgerPath(sessionDir);
if (!recordHash) return { ok: false, error: "missing ledger record hash" };
if (!existsSync(path)) return { ok: false, error: "provenance ledger missing" };
const lines = readFileSync(path, "utf8").split(/\n/).filter(Boolean);
let previousHash = null;
for (const line of lines) {
let record;
try { record = JSON.parse(line); } catch { return { ok: false, error: "provenance ledger corrupt" }; }
const validated = validateRecord(record, previousHash);
if (!validated.ok) return validated;
previousHash = validated.recordHash;
if (validated.recordHash === recordHash) return { ok: true, event: validated.payload };
}
return { ok: false, error: "provenance ledger record not found" };
}
function latestLedgerHash(sessionDir) {
const path = ledgerPath(sessionDir);
if (!existsSync(path)) return null;
const lines = readFileSync(path, "utf8").split(/\n/).filter(Boolean);
let previousHash = null;
for (const line of lines) {
const record = JSON.parse(line);
const validated = validateRecord(record, previousHash);
if (!validated.ok) return null;
previousHash = validated.recordHash;
}
return previousHash;
}
import {
linkSync,
mkdirSync,
readFileSync,
unlinkSync,
writeFileSync,
} from "node:fs";
import { createHash, randomUUID } from "node:crypto";
import { homedir } from "node:os";
import { dirname, isAbsolute, join } from "node:path";
import { atomicWriteSync, runtimeRegistryPath } from "./util.mjs";
export const MAX_NODE_WALL_MS = 30 * 60 * 1000;
export const MAX_TOOL_CALLS = 100;
export const AUTO_MODE_REMINDER = "auto mode — continue without confirmation only while node and repair-edge budgets remain; when the circuit breaker trips, stop and report immediately; do not retry or attempt recovery from the current Claude session";
function sha256(value) {
return createHash("sha256").update(value).digest("hex");
}
function nonEmptyString(value) {
return typeof value === "string" && value.length > 0;
}
function validTimestamp(value) {
if (!nonEmptyString(value)) return false;
const parsed = new Date(value);
return Number.isFinite(parsed.getTime()) && parsed.toISOString() === value;
}
export function registryPath(sessionId, home = homedir()) {
return runtimeRegistryPath(sessionId, home);
}
export function resolveCurrentRun(state) {
if (!state || typeof state !== "object" || Array.isArray(state) ||
!Array.isArray(state.history) || !nonEmptyString(state.currentNode)) {
return null;
}
const tail = state.history.at(-1);
if (tail?.nodeId === state.currentNode &&
nonEmptyString(tail.runId) && validTimestamp(tail.timestamp)) {
return {
runId: tail.runId,
startedAt: tail.timestamp,
runKey: `history:${state.history.length - 1}:${tail.runId}:${tail.timestamp}`,
};
}
if (state.totalSteps === 0 && state.history.length === 0 &&
nonEmptyString(state.entryNode) && state.currentNode === state.entryNode &&
validTimestamp(state.flowStartedAt)) {
return {
runId: "run_1",
startedAt: state.flowStartedAt,
runKey: `initial:${state.entryNode}:${state.flowStartedAt}`,
};
}
return null;
}
export function budgetPaths(sessionDir, nodeId, runKey) {
const key = sha256(JSON.stringify([nodeId, runKey]));
const dir = join(sessionDir, "node-budget", key);
return {
dir,
context: join(dir, "context.json"),
stop: join(dir, "guard-stop.json"),
slots: join(dir, "slots"),
};
}
function validateRegistryRecord(record, expectedSessionId = null) {
if (!record || typeof record !== "object" || Array.isArray(record)) {
throw new Error("session registry must be a JSON object");
}
if (expectedSessionId !== null && record.sessionId !== expectedSessionId) {
throw new Error("session registry session ID mismatch");
}
for (const field of ["sessionId", "sessionDir", "projectRoot", "registeredAt"]) {
if (!nonEmptyString(record[field])) {
throw new Error(`session registry requires non-empty ${field}`);
}
}
if (!isAbsolute(record.sessionDir) || !isAbsolute(record.projectRoot)) {
throw new Error("session registry paths must be absolute");
}
if (!validTimestamp(record.registeredAt)) {
throw new Error("session registry registeredAt must be an ISO timestamp");
}
return record;
}
export function readSessionRegistry(sessionId, home = homedir()) {
const path = registryPath(sessionId, home);
let raw;
try {
raw = readFileSync(path, "utf8");
} catch (error) {
if (error?.code === "ENOENT") return null;
throw new Error(`cannot read session registry '${path}': ${error.message}`);
}
let record;
try {
record = JSON.parse(raw);
} catch (error) {
throw new Error(`cannot parse session registry '${path}': ${error.message}`);
}
return validateRegistryRecord(record, sessionId);
}
export function writeSessionRegistry(record, home = homedir()) {
validateRegistryRecord(record);
const path = registryPath(record.sessionId, home);
mkdirSync(dirname(path), { recursive: true });
atomicWriteSync(path, JSON.stringify(record, null, 2) + "\n");
return path;
}
export function atomicCreateJson(path, value) {
mkdirSync(dirname(path), { recursive: true });
try {
writeFileSync(path, JSON.stringify(value, null, 2) + "\n", {
flag: "wx",
mode: 0o600,
});
return true;
} catch (error) {
if (error?.code === "EEXIST") return false;
throw error;
}
}
export function atomicPublishJson(path, value, publish = linkSync) {
mkdirSync(dirname(path), { recursive: true });
const temp = `${path}.tmp.${process.pid}.${randomUUID()}`;
writeFileSync(temp, JSON.stringify(value, null, 2) + "\n", {
flag: "wx",
mode: 0o600,
});
try {
try {
publish(temp, path);
return true;
} catch (error) {
if (error?.code === "EEXIST") return false;
throw error;
}
} finally {
unlinkSync(temp);
}
}
export function ensureBudgetContext(paths, nodeId, run) {
const expected = {
nodeId,
runId: run.runId,
runKey: run.runKey,
startedAt: run.startedAt,
maxWallTimeSeconds: MAX_NODE_WALL_MS / 1000,
maxToolCalls: MAX_TOOL_CALLS,
};
atomicPublishJson(paths.context, expected);
let actual;
try {
actual = JSON.parse(readFileSync(paths.context, "utf8"));
} catch (error) {
throw new Error(`cannot parse budget context '${paths.context}': ${error.message}`);
}
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
throw new Error(`budget context mismatch for '${paths.context}'`);
}
return actual;
}
export function claimToolSlot(paths, evidence, maxToolCalls = MAX_TOOL_CALLS) {
mkdirSync(paths.slots, { recursive: true });
for (let index = 1; index <= maxToolCalls; index++) {
const slot = join(paths.slots, `${String(index).padStart(6, "0")}.json`);
if (atomicCreateJson(slot, evidence)) return index;
}
return null;
}
export function createStopMarker(
sessionDir,
state,
{ reason, edgeKey, now = new Date().toISOString() },
) {
const run = resolveCurrentRun(state);
if (!run) throw new Error("cannot resolve current run for stop marker");
if (!nonEmptyString(state._claudeSessionId)) {
throw new Error("auto flow is missing _claudeSessionId");
}
const paths = budgetPaths(sessionDir, state.currentNode, run.runKey);
const marker = {
sessionId: state._claudeSessionId,
nodeId: state.currentNode,
runKey: run.runKey,
reason,
...(edgeKey ? { edgeKey } : {}),
createdAt: now,
};
return {
created: atomicCreateJson(paths.stop, marker),
path: paths.stop,
marker,
run,
paths,
};
}
import { describe, test, after } from "node:test";
import assert from "node:assert/strict";
import { execFile } from "node:child_process";
import {
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import {
MAX_NODE_WALL_MS,
MAX_TOOL_CALLS,
atomicCreateJson,
atomicPublishJson,
budgetPaths,
claimToolSlot,
createStopMarker,
ensureBudgetContext,
readSessionRegistry,
registryPath,
resolveCurrentRun,
writeSessionRegistry,
} from "./runaway-guard.mjs";
const roots = [];
function tempRoot(name) {
const root = mkdtempSync(join(tmpdir(), `opc-runaway-${name}-`));
roots.push(root);
return root;
}
after(() => {
for (const root of roots) rmSync(root, { recursive: true, force: true });
});
describe("run identity", () => {
test("distinguishes initial execution from re-entry", () => {
const initial = {
entryNode: "build",
currentNode: "build",
totalSteps: 0,
history: [],
flowStartedAt: "2026-08-06T00:00:00.000Z",
};
assert.deepEqual(resolveCurrentRun(initial), {
runId: "run_1",
startedAt: initial.flowStartedAt,
runKey: `initial:build:${initial.flowStartedAt}`,
});
const reentry = {
...initial,
totalSteps: 2,
history: [{
nodeId: "build",
runId: "run_1",
timestamp: "2026-08-06T00:05:00.000Z",
}],
};
assert.deepEqual(resolveCurrentRun(reentry), {
runId: "run_1",
startedAt: reentry.history[0].timestamp,
runKey: `history:0:run_1:${reentry.history[0].timestamp}`,
});
});
test("fails closed for unverifiable state", () => {
const valid = {
entryNode: "build",
currentNode: "build",
totalSteps: 0,
history: [],
flowStartedAt: "2026-08-06T00:00:00.000Z",
};
for (const state of [
null,
[],
{ ...valid, history: null },
{ ...valid, currentNode: "" },
{ ...valid, currentNode: "review" },
{ ...valid, flowStartedAt: null },
{ ...valid, flowStartedAt: "not-a-date" },
{ ...valid, flowStartedAt: "2026-02-30T00:00:00.000Z" },
{ ...valid, totalSteps: 1 },
{
history: [{ runId: "run_1", timestamp: valid.flowStartedAt }],
},
{
...valid,
totalSteps: 1,
history: [{ nodeId: "build", runId: "run_1" }],
},
{
...valid,
totalSteps: 1,
history: [{ nodeId: "review", runId: "run_1", timestamp: valid.flowStartedAt }],
},
]) {
assert.equal(resolveCurrentRun(state), null);
}
});
});
describe("stable paths and registry", () => {
test("hashes untrusted identity components", () => {
const home = tempRoot("paths");
const registry = registryPath("../../session", home);
assert.equal(dirname(registry), join(home, ".opc", "runtime"));
assert.equal(registry.includes("session"), false);
const first = budgetPaths(home, "build", "initial:build:t0");
const second = budgetPaths(home, "build", "history:0:run_1:t1");
assert.notEqual(first.dir, second.dir);
assert.equal(first.context, join(first.dir, "context.json"));
assert.equal(first.stop, join(first.dir, "guard-stop.json"));
assert.equal(first.slots, join(first.dir, "slots"));
assert.equal(MAX_NODE_WALL_MS, 1_800_000);
assert.equal(MAX_TOOL_CALLS, 100);
});
test("round-trips a validated registry record", () => {
const home = tempRoot("registry");
const record = {
sessionId: "session-a",
sessionDir: join(home, "session"),
projectRoot: join(home, "project"),
registeredAt: "2026-08-06T00:00:00.000Z",
};
const path = writeSessionRegistry(record, home);
assert.equal(path, registryPath(record.sessionId, home));
assert.deepEqual(readSessionRegistry(record.sessionId, home), record);
assert.equal(readSessionRegistry("missing", home), null);
});
test("rejects malformed or mismatched registry data", () => {
const home = tempRoot("registry-invalid");
const valid = {
sessionId: "session-a",
sessionDir: join(home, "session"),
projectRoot: join(home, "project"),
registeredAt: "2026-08-06T00:00:00.000Z",
};
for (const record of [
null,
[],
{ ...valid, sessionId: "" },
{ ...valid, sessionDir: "relative" },
{ ...valid, projectRoot: "" },
{ ...valid, registeredAt: "invalid" },
{ ...valid, registeredAt: "2026-08-06" },
{ ...valid, registeredAt: "2026-02-30T00:00:00.000Z" },
]) {
assert.throws(() => writeSessionRegistry(record, home));
}
const path = registryPath("session-b", home);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, JSON.stringify(valid));
assert.throws(() => readSessionRegistry("session-b", home), /session ID mismatch/);
writeFileSync(path, "not-json");
assert.throws(() => readSessionRegistry("session-b", home), /cannot parse session registry/);
rmSync(path);
mkdirSync(path);
assert.throws(() => readSessionRegistry("session-b", home), /cannot read session registry/);
});
});
describe("atomic budget evidence", () => {
test("atomic JSON creation is first-writer-wins", () => {
const root = tempRoot("atomic-create");
const path = join(root, "nested", "value.json");
assert.equal(atomicCreateJson(path, { winner: 1 }), true);
assert.equal(atomicCreateJson(path, { winner: 2 }), false);
assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), { winner: 1 });
assert.throws(
() => atomicCreateJson(
join(root, "invalid-json.json"),
{ unsupported: 1n },
),
/BigInt/,
);
});
test("atomic publication exposes only a complete winner", () => {
const root = tempRoot("atomic-publish");
const path = join(root, "nested", "context.json");
assert.equal(atomicPublishJson(path, { winner: 1 }), true);
assert.equal(atomicPublishJson(path, { winner: 2 }), false);
assert.deepEqual(JSON.parse(readFileSync(path, "utf8")), { winner: 1 });
assert.deepEqual(readdirSync(dirname(path)), ["context.json"]);
const failed = join(root, "nested", "failed.json");
assert.throws(() => atomicPublishJson(failed, {}, () => {
const error = new Error("publish failed");
error.code = "EPERM";
throw error;
}), /publish failed/);
assert.equal(existsSync(failed), false);
assert.deepEqual(readdirSync(dirname(path)), ["context.json"]);
});
test("parallel processes preserve slot and first-writer invariants", async () => {
const root = tempRoot("parallel");
const slots = join(root, "slots");
const context = join(root, "context.json");
const marker = join(root, "guard-stop.json");
const moduleUrl = new URL("./runaway-guard.mjs", import.meta.url).href;
const worker = `
const [moduleUrl, slots, context, marker, id] = process.argv.slice(1);
const { atomicCreateJson, atomicPublishJson, claimToolSlot } = await import(moduleUrl);
const slot = claimToolSlot({ slots }, { id }, 100);
const contextWon = atomicPublishJson(context, { id });
const markerWon = atomicCreateJson(marker, { id });
process.stdout.write(JSON.stringify({ id, slot, contextWon, markerWon }));
`;
const results = await Promise.all(Array.from({ length: 120 }, (_, id) =>
new Promise((resolve, reject) => {
execFile(process.execPath, [
"--input-type=module",
"--eval",
worker,
moduleUrl,
slots,
context,
marker,
String(id),
], (error, stdout, stderr) => {
if (error) {
reject(new Error(`${error.message}\n${stderr}`));
return;
}
resolve(JSON.parse(stdout));
});
})));
const claims = results.filter(({ slot }) => slot !== null);
assert.equal(claims.length, 100);
assert.equal(new Set(claims.map(({ slot }) => slot)).size, 100);
assert.deepEqual(
claims.map(({ slot }) => slot).sort((a, b) => a - b),
Array.from({ length: 100 }, (_, index) => index + 1),
);
assert.equal(readdirSync(slots).length, 100);
for (const file of readdirSync(slots)) {
assert.doesNotThrow(() => JSON.parse(readFileSync(join(slots, file), "utf8")));
}
const contextWinners = results.filter(({ contextWon }) => contextWon);
assert.equal(contextWinners.length, 1);
assert.deepEqual(
JSON.parse(readFileSync(context, "utf8")),
{ id: contextWinners[0].id },
);
const markerWinners = results.filter(({ markerWon }) => markerWon);
assert.equal(markerWinners.length, 1);
assert.deepEqual(
JSON.parse(readFileSync(marker, "utf8")),
{ id: markerWinners[0].id },
);
});
test("freezes context identity and limits", () => {
const sessionDir = tempRoot("context");
const paths = budgetPaths(sessionDir, "build", "initial:build:t0");
const run = {
runId: "run_1",
runKey: "initial:build:t0",
startedAt: "2026-08-06T00:00:00.000Z",
};
const first = ensureBudgetContext(paths, "build", run);
assert.deepEqual(ensureBudgetContext(paths, "build", run), first);
assert.equal(first.maxWallTimeSeconds, 1800);
assert.equal(first.maxToolCalls, 100);
assert.throws(
() => ensureBudgetContext(paths, "review", run),
/budget context mismatch/,
);
writeFileSync(paths.context, "bad-json");
assert.throws(
() => ensureBudgetContext(paths, "build", run),
/cannot parse budget context/,
);
});
test("claims immutable slots and treats corrupt slots as consumed", () => {
const sessionDir = tempRoot("slots");
const paths = budgetPaths(sessionDir, "build", "run");
assert.equal(claimToolSlot(paths, { toolUseId: "a" }, 3), 1);
writeFileSync(join(paths.slots, "000002.json"), "bad-json");
assert.equal(claimToolSlot(paths, { toolUseId: "b" }, 3), 3);
assert.equal(claimToolSlot(paths, { toolUseId: "c" }, 3), null);
});
test("stop marker preserves the first trigger", () => {
const sessionDir = tempRoot("marker");
const state = {
entryNode: "build",
currentNode: "build",
totalSteps: 0,
history: [],
flowStartedAt: "2026-08-06T00:00:00.000Z",
_claudeSessionId: "session-a",
};
const first = createStopMarker(sessionDir, state, {
reason: "repair-edge-budget",
edgeKey: "review→build",
now: "2026-08-06T00:10:00.000Z",
});
const second = createStopMarker(sessionDir, state, {
reason: "tool-call-budget",
now: "2026-08-06T00:11:00.000Z",
});
assert.equal(first.created, true);
assert.equal(second.created, false);
assert.deepEqual(JSON.parse(readFileSync(first.path, "utf8")), first.marker);
assert.equal(first.marker.reason, "repair-edge-budget");
assert.equal(first.marker.edgeKey, "review→build");
});
test("stop marker requires a current run and auto session identity", () => {
const sessionDir = tempRoot("marker-invalid");
assert.throws(
() => createStopMarker(sessionDir, {
entryNode: "build",
currentNode: "review",
totalSteps: 0,
history: [],
}, { reason: "tool-call-budget" }),
/cannot resolve current run/,
);
assert.throws(
() => createStopMarker(sessionDir, {
entryNode: "build",
currentNode: "build",
totalSteps: 0,
history: [],
flowStartedAt: "2026-08-06T00:00:00.000Z",
}, { reason: "tool-call-budget" }),
/missing _claudeSessionId/,
);
});
});
import { after, describe, test } from "node:test";
import assert from "node:assert/strict";
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
readdirSync,
realpathSync,
rmSync,
symlinkSync,
unlinkSync,
utimesSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { basename, dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { execFile, spawnSync } from "node:child_process";
import {
claimToolSlot,
createStopMarker,
readSessionRegistry,
registryPath,
writeSessionRegistry,
} from "./runaway-guard.mjs";
import { evaluatePreToolUse } from "../hooks/opc-pre-tool-budget.mjs";
const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
const harness = join(repoRoot, "bin", "opc-harness.mjs");
const opcCli = join(repoRoot, "bin", "opc.mjs");
const skillFile = join(repoRoot, "SKILL.md");
const roots = [];
function tempFixture(name, { hookInstalled = true } = {}) {
const root = realpathSync(mkdtempSync(join(tmpdir(), `opc-runaway-integration-${name}-`)));
roots.push(root);
const home = join(root, "home");
const project = join(root, "project");
mkdirSync(home, { recursive: true });
mkdirSync(project, { recursive: true });
if (hookInstalled) installHookFixture(home);
return { root, home, project };
}
function installHookFixture(home) {
const hook = join(home, ".claude", "skills", "opc", "bin", "hooks", "opc-pre-tool-budget.mjs");
mkdirSync(dirname(hook), { recursive: true });
writeFileSync(hook, "#!/usr/bin/env node\n");
const settings = {
hooks: {
PreToolUse: [{ hooks: [{ type: "command", command: `node "${hook}"`, timeout: 10 }] }],
},
};
const settingsPath = join(home, ".claude", "settings.json");
mkdirSync(dirname(settingsPath), { recursive: true });
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
return hook;
}
function parseLastJson(stdout) {
const line = String(stdout || "").trim().split("\n").pop();
if (!line) return null;
try { return JSON.parse(line); } catch { return null; }
}
function run(script, args, { home, cwd, project, path } = {}) {
const result = spawnSync(process.execPath, [script, ...args], {
cwd: cwd || project,
encoding: "utf8",
env: {
...process.env,
...(home ? { HOME: home } : {}),
...(path ? { PATH: path } : {}),
},
});
return { ...result, json: parseLastJson(result.stdout) };
}
function runAsync(script, args, { home, cwd, project, path, env } = {}) {
return new Promise(resolveRun => {
execFile(process.execPath, [script, ...args], {
cwd: cwd || project,
encoding: "utf8",
env: {
...process.env,
...env,
...(home ? { HOME: home } : {}),
...(path ? { PATH: path } : {}),
},
}, (error, stdout, stderr) => {
resolveRun({
status: typeof error?.code === "number" ? error.code : 0,
stdout,
stderr,
json: parseLastJson(stdout),
});
});
});
}
async function waitForFile(path, timeoutMs = 2000) {
const deadline = Date.now() + timeoutMs;
while (!existsSync(path)) {
if (Date.now() >= deadline) throw new Error(`timed out waiting for ${path}`);
await new Promise(resolveWait => setTimeout(resolveWait, 10));
}
}
async function runWithRegistryPublicationFailure(fixture, args, extraEnv = {}) {
const runtime = join(fixture.home, ".opc", "runtime");
mkdirSync(runtime, { recursive: true });
const bin = join(fixture.root, "blocking-bin");
mkdirSync(bin, { recursive: true });
const signal = join(fixture.root, "git-started");
const release = join(fixture.root, "git-release");
const git = join(bin, "git");
writeFileSync(git, `#!/bin/sh\n: > "$OPC_TEST_GIT_SIGNAL"\nwhile [ ! -f "$OPC_TEST_GIT_RELEASE" ]; do /bin/sleep 0.01; done\nif [ "$2" = "--show-toplevel" ]; then printf '%s\\n' "$OPC_TEST_PROJECT_ROOT"; else printf '%040d\\n' 0; fi\n`);
chmodSync(git, 0o755);
const pending = runAsync(harness, args, {
...fixture,
path: `${bin}:${process.env.PATH}`,
env: {
OPC_TEST_GIT_SIGNAL: signal,
OPC_TEST_GIT_RELEASE: release,
OPC_TEST_PROJECT_ROOT: fixture.project,
...extraEnv,
},
});
await waitForFile(signal);
chmodSync(runtime, 0o500);
writeFileSync(release, "go");
try {
return await pending;
} finally {
chmodSync(runtime, 0o700);
}
}
function runInit(fixture, dir, extra = []) {
return run(harness, [
"init",
"--flow", "review",
"--entry", "review",
"--dir", dir,
"--no-extensions",
...extra,
], fixture);
}
function hookInput(fixture, sessionId, toolUseId = "tool-1") {
return {
session_id: sessionId,
cwd: fixture.project,
tool_use_id: toolUseId,
tool_name: "Bash",
};
}
function assertHookDenied(result) {
assert.equal(result.allowed, false);
assert.equal(result.output.hookSpecificOutput.permissionDecision, "deny");
}
after(() => {
for (const root of roots) rmSync(root, { recursive: true, force: true });
});
describe("auto init registry contract", () => {
test("auto init binds state and registry to the Claude session", () => {
const fixture = tempFixture("auto-init");
const dir = join(fixture.project, "session");
const sessionId = "claude-session-a";
const result = runInit(fixture, dir, ["--auto", "--claude-session-id", sessionId]);
assert.equal(result.status, 0, result.stderr);
assert.equal(result.json.created, true, JSON.stringify(result.json));
const state = JSON.parse(readFileSync(join(dir, "flow-state.json"), "utf8"));
assert.equal(state.autoMode, true);
assert.equal(state._claudeSessionId, sessionId);
assert.deepEqual(state.autoRepairCounts, {});
assert.equal(new Date(state.flowStartedAt).toISOString(), state.flowStartedAt);
assert.deepEqual(readSessionRegistry(sessionId, fixture.home), {
sessionId,
sessionDir: dir,
projectRoot: fixture.project,
registeredAt: state.flowStartedAt,
});
assert.deepEqual(evaluatePreToolUse(hookInput(fixture, sessionId), { home: fixture.home }), { allowed: true });
});
test("interactive init needs neither hook nor registry", () => {
const fixture = tempFixture("interactive-init", { hookInstalled: false });
const dir = join(fixture.project, "session");
const result = runInit(fixture, dir);
assert.equal(result.json.created, true, JSON.stringify(result.json));
const state = JSON.parse(readFileSync(join(dir, "flow-state.json"), "utf8"));
assert.equal(state.autoMode, undefined);
assert.equal(state._claudeSessionId, undefined);
assert.equal(state.autoRepairCounts, undefined);
assert.equal(readSessionRegistry("unused", fixture.home), null);
});
test("auto init refuses missing hook or session identity before creating state", () => {
const missingHook = tempFixture("missing-hook", { hookInstalled: false });
const missingHookDir = join(missingHook.root, "session");
const hookResult = runInit(missingHook, missingHookDir, [
"--auto", "--claude-session-id", "session-hook",
]);
assert.equal(hookResult.json.created, false);
assert.match(hookResult.json.error, /PreToolUse hook/i);
assert.equal(existsSync(missingHookDir), false);
const missingId = tempFixture("missing-id");
const missingIdDir = join(missingId.root, "session");
const idResult = runInit(missingId, missingIdDir, ["--auto"]);
assert.equal(idResult.json.created, false);
assert.match(idResult.json.error, /claude-session-id/);
assert.equal(existsSync(missingIdDir), false);
});
test("auto init fails closed when hook settings cannot be parsed", () => {
const fixture = tempFixture("malformed-hook-settings");
writeFileSync(join(fixture.home, ".claude", "settings.json"), "{");
const dir = join(fixture.project, "session");
const result = runInit(fixture, dir, [
"--auto", "--claude-session-id", "session-malformed-settings",
]);
assert.equal(result.json.created, false);
assert.match(result.json.error, /cannot read.*settings\.json/i);
assert.equal(existsSync(dir), false);
});
test("auto init rejects a PreToolUse hook scoped to one tool", () => {
const fixture = tempFixture("scoped-hook");
const settingsPath = join(fixture.home, ".claude", "settings.json");
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
settings.hooks.PreToolUse[0].matcher = "Bash";
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
const dir = join(fixture.project, "session");
const result = runInit(fixture, dir, [
"--auto", "--claude-session-id", "session-scoped-hook",
]);
assert.equal(result.json.created, false);
assert.match(result.json.error, /PreToolUse hook is not installed/);
assert.equal(existsSync(dir), false);
});
test("auto init rejects an asynchronous PreToolUse hook", () => {
const fixture = tempFixture("async-hook");
const settingsPath = join(fixture.home, ".claude", "settings.json");
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
settings.hooks.PreToolUse[0].hooks[0].async = true;
writeFileSync(settingsPath, JSON.stringify(settings, null, 2));
const dir = join(fixture.project, "session");
const result = runInit(fixture, dir, [
"--auto", "--claude-session-id", "session-async-hook",
]);
assert.equal(result.json.created, false);
assert.match(result.json.error, /PreToolUse hook is not installed/);
assert.equal(existsSync(dir), false);
});
test("one Claude session cannot bind two active auto flows but may replace a stopped flow", () => {
const fixture = tempFixture("registry-conflict");
const sessionId = "claude-session-conflict";
const firstDir = join(fixture.project, "first");
const secondDir = join(fixture.project, "second");
assert.equal(runInit(fixture, firstDir, ["--auto", "--claude-session-id", sessionId]).json.created, true);
const conflict = runInit(fixture, secondDir, ["--auto", "--claude-session-id", sessionId]);
assert.equal(conflict.json.created, false);
assert.match(conflict.json.error, /already bound to an active auto flow/);
assert.equal(existsSync(secondDir), false);
const firstStatePath = join(firstDir, "flow-state.json");
const firstState = JSON.parse(readFileSync(firstStatePath, "utf8"));
firstState.status = "stopped";
writeFileSync(firstStatePath, JSON.stringify(firstState, null, 2));
const replacement = runInit(fixture, secondDir, ["--auto", "--claude-session-id", sessionId]);
assert.equal(replacement.json.created, true, JSON.stringify(replacement.json));
assert.equal(readSessionRegistry(sessionId, fixture.home).sessionDir, secondDir);
});
test("auto init fails closed for corrupt, missing-state, or unsafe existing registries", () => {
const corrupt = tempFixture("registry-corrupt-existing");
const corruptId = "claude-session-corrupt-existing";
const corruptPath = registryPath(corruptId, corrupt.home);
mkdirSync(dirname(corruptPath), { recursive: true });
writeFileSync(corruptPath, "{");
const corruptResult = runInit(corrupt, join(corrupt.project, "session"), [
"--auto", "--claude-session-id", corruptId,
]);
assert.equal(corruptResult.json.created, false);
assert.match(corruptResult.json.error, /cannot verify existing session registry/i);
const missing = tempFixture("registry-missing-state");
const missingId = "claude-session-missing-state";
writeSessionRegistry({
sessionId: missingId,
sessionDir: join(missing.project, "missing-session"),
projectRoot: missing.project,
registeredAt: new Date().toISOString(),
}, missing.home);
const missingResult = runInit(missing, join(missing.project, "new-session"), [
"--auto", "--claude-session-id", missingId,
]);
assert.equal(missingResult.json.created, false);
assert.match(missingResult.json.error, /cannot verify existing registered flow/i);
const unsafe = tempFixture("registry-unsafe-existing");
const unsafeId = "claude-session-unsafe-existing";
const interactiveDir = join(unsafe.project, "interactive");
assert.equal(runInit(unsafe, interactiveDir).json.created, true);
writeSessionRegistry({
sessionId: unsafeId,
sessionDir: interactiveDir,
projectRoot: unsafe.project,
registeredAt: new Date().toISOString(),
}, unsafe.home);
const unsafeResult = runInit(unsafe, join(unsafe.project, "new-session"), [
"--auto", "--claude-session-id", unsafeId,
]);
assert.equal(unsafeResult.json.created, false);
assert.match(unsafeResult.json.error, /not safely replaceable/i);
});
test("auto init releases its registry lock when an existing state needs --force", () => {
const fixture = tempFixture("auto-existing-state");
const sessionId = "claude-session-existing-state";
const dir = join(fixture.project, "session");
assert.equal(runInit(fixture, dir).json.created, true);
const result = runInit(fixture, dir, ["--auto", "--claude-session-id", sessionId]);
assert.equal(result.json.created, false);
assert.match(result.json.error, /already exists.*--force/i);
assert.equal(existsSync(`${registryPath(sessionId, fixture.home)}.lock`), false);
assert.equal(readSessionRegistry(sessionId, fixture.home), null);
});
test("registry write failure rolls back a newly created session", () => {
const fixture = tempFixture("registry-failure");
mkdirSync(join(fixture.home, ".opc"), { recursive: true });
writeFileSync(join(fixture.home, ".opc", "runtime"), "not-a-directory");
const dir = join(fixture.project, "session");
const result = runInit(fixture, dir, [
"--auto", "--claude-session-id", "claude-session-registry-failure",
]);
assert.equal(result.json.created, false);
assert.match(result.json.error, /session registry/i);
assert.equal(existsSync(dir), false);
});
test("registry publication failure restores the previous latest implicit session", async () => {
const fixture = tempFixture("registry-latest-rollback");
const prior = run(harness, [
"init",
"--flow", "review",
"--entry", "review",
"--no-extensions",
], fixture);
assert.equal(prior.json.created, true, JSON.stringify(prior.json));
const sessionsDir = dirname(prior.json.dir);
const latest = join(sessionsDir, "latest");
assert.equal(realpathSync(latest), prior.json.dir);
const failed = await runWithRegistryPublicationFailure(fixture, [
"init",
"--flow", "review",
"--entry", "review",
"--no-extensions",
"--auto",
"--claude-session-id", "claude-session-latest-rollback",
]);
assert.equal(failed.json.created, false, JSON.stringify(failed.json));
assert.match(failed.json.error, /cannot write session registry/i);
assert.equal(realpathSync(latest), prior.json.dir);
const sessions = readdirSync(sessionsDir, { withFileTypes: true })
.filter(entry => entry.isDirectory());
assert.equal(sessions.length, 1);
});
test("latest rollback failure preserves the primary registry error", async () => {
const fixture = tempFixture("registry-latest-rollback-failure");
const prior = run(harness, [
"init",
"--flow", "review",
"--entry", "review",
"--no-extensions",
], fixture);
assert.equal(prior.json.created, true, JSON.stringify(prior.json));
const latest = join(dirname(prior.json.dir), "latest");
const preload = join(fixture.root, "fail-latest-rollback.cjs");
writeFileSync(preload, `
const fs = require("node:fs");
const { syncBuiltinESMExports } = require("node:module");
const originalReadlinkSync = fs.readlinkSync;
let matchingReads = 0;
fs.readlinkSync = function(path, ...args) {
if (String(path) === process.env.OPC_TEST_LATEST_LINK) {
matchingReads += 1;
if (matchingReads === 2) {
const error = new Error("rollback read denied");
error.code = "EACCES";
throw error;
}
}
return originalReadlinkSync.call(this, path, ...args);
};
syncBuiltinESMExports();
`);
const nodeOptions = [
process.env.NODE_OPTIONS,
`--require=${preload}`,
].filter(Boolean).join(" ");
const failed = await runWithRegistryPublicationFailure(fixture, [
"init",
"--flow", "review",
"--entry", "review",
"--no-extensions",
"--auto",
"--claude-session-id", "claude-session-latest-rollback-failure",
], {
NODE_OPTIONS: nodeOptions,
OPC_TEST_LATEST_LINK: latest,
});
assert.equal(failed.status, 0, failed.stderr);
assert.equal(failed.json.created, false, JSON.stringify(failed.json));
assert.match(failed.json.error, /cannot write session registry/i);
assert.equal(existsSync(prior.json.dir), true);
});
test("registry publication failure removes latest when no prior session exists", async () => {
const fixture = tempFixture("registry-latest-remove");
const failed = await runWithRegistryPublicationFailure(fixture, [
"init",
"--flow", "review",
"--entry", "review",
"--no-extensions",
"--auto",
"--claude-session-id", "claude-session-latest-remove",
]);
assert.equal(failed.json.created, false, JSON.stringify(failed.json));
assert.match(failed.json.error, /cannot write session registry/i);
const sessionsBase = join(fixture.home, ".opc", "sessions");
const latestLinks = existsSync(sessionsBase)
? readdirSync(sessionsBase).flatMap(projectHash =>
readdirSync(join(sessionsBase, projectHash))
.filter(name => name === "latest")
)
: [];
assert.deepEqual(latestLinks, []);
});
test("registry publication failure restores an existing explicit session", async () => {
const fixture = tempFixture("registry-explicit-rollback");
const dir = join(fixture.project, "session");
const prior = runInit(fixture, dir);
assert.equal(prior.json.created, true, JSON.stringify(prior.json));
const statePath = join(dir, "flow-state.json");
const priorState = readFileSync(statePath, "utf8");
const sentinel = join(dir, "nodes", "sentinel.txt");
writeFileSync(sentinel, "keep");
const failed = await runWithRegistryPublicationFailure(fixture, [
"init",
"--flow", "review",
"--entry", "review",
"--dir", dir,
"--force",
"--no-extensions",
"--auto",
"--claude-session-id", "claude-session-explicit-rollback",
]);
assert.equal(failed.json.created, false, JSON.stringify(failed.json));
assert.match(failed.json.error, /cannot write session registry/i);
assert.equal(readFileSync(statePath, "utf8"), priorState);
assert.equal(readFileSync(sentinel, "utf8"), "keep");
});
test("auto init reports registry lock contention", () => {
const fixture = tempFixture("registry-lock-contention");
const sessionId = "claude-session-lock-contention";
const lockPath = `${registryPath(sessionId, fixture.home)}.lock`;
mkdirSync(dirname(lockPath), { recursive: true });
writeFileSync(lockPath, JSON.stringify({
pid: process.pid,
nonce: "held-by-test",
timestamp: new Date().toISOString(),
command: "test",
}));
const dir = join(fixture.project, "session");
const result = runInit(fixture, dir, [
"--auto", "--claude-session-id", sessionId,
]);
assert.equal(result.json.created, false);
assert.match(result.json.error, /cannot acquire session registry lock/);
assert.equal(existsSync(dir), false);
});
test("concurrent auto init binds exactly one flow to a Claude session", async () => {
const fixture = tempFixture("registry-concurrent");
const sessionId = "claude-session-concurrent";
const firstDir = join(fixture.project, "first");
const secondDir = join(fixture.project, "second");
const args = dir => [
"init",
"--flow", "review",
"--entry", "review",
"--dir", dir,
"--no-extensions",
"--auto",
"--claude-session-id", sessionId,
];
const results = await Promise.all([
runAsync(harness, args(firstDir), fixture),
runAsync(harness, args(secondDir), fixture),
]);
const winners = results.filter(result => result.json?.created === true);
const losers = results.filter(result => result.json?.created === false);
assert.equal(winners.length, 1, JSON.stringify(results.map(result => result.json)));
assert.equal(losers.length, 1, JSON.stringify(results.map(result => result.json)));
assert.match(losers[0].json.error, /already bound to an active auto flow/);
const registry = readSessionRegistry(sessionId, fixture.home);
assert.equal(registry.sessionDir, winners[0].json.dir);
assert.equal(existsSync(winners[0].json.dir), true);
assert.equal(existsSync(winners[0].json.dir === firstDir ? secondDir : firstDir), false);
});
});
describe("session GC registry consistency", () => {
function createImplicitAutoFlow(fixture, sessionId) {
const result = run(harness, [
"init",
"--flow", "review",
"--entry", "review",
"--no-extensions",
"--auto",
"--claude-session-id", sessionId,
], fixture);
assert.equal(result.json?.created, true, JSON.stringify(result.json));
return result.json.dir;
}
function backdateState(sessionDir) {
const old = new Date("2026-07-01T00:00:00.000Z");
utimesSync(join(sessionDir, "flow-state.json"), old, old);
}
test("GC removes an expired interactive session without a registry", () => {
const fixture = tempFixture("gc-interactive");
const created = run(harness, [
"init",
"--flow", "review",
"--entry", "review",
"--no-extensions",
], fixture);
assert.equal(created.json?.created, true, JSON.stringify(created.json));
backdateState(created.json.dir);
const result = run(harness, ["gc", "--max-age", "7"], fixture);
assert.equal(result.status, 0, result.stderr);
assert.equal(existsSync(created.json.dir), false);
assert.match(JSON.stringify(result.json?.deleted || []), new RegExp(basename(created.json.dir)));
});
test("GC preserves an active auto flow with a matching registry", () => {
const fixture = tempFixture("gc-active-auto");
const sessionId = "claude-session-gc-active";
const sessionDir = createImplicitAutoFlow(fixture, sessionId);
backdateState(sessionDir);
const result = run(harness, ["gc", "--max-age", "7"], fixture);
assert.equal(result.status, 0, result.stderr);
assert.equal(existsSync(sessionDir), true);
assert.equal(readSessionRegistry(sessionId, fixture.home).sessionDir, sessionDir);
assert.doesNotMatch(JSON.stringify(result.json?.deleted || []), new RegExp(basename(sessionDir)));
});
test("GC removes a terminal auto flow and its matching registry together", () => {
const fixture = tempFixture("gc-terminal-auto");
const sessionId = "claude-session-gc-terminal";
const sessionDir = createImplicitAutoFlow(fixture, sessionId);
const statePath = join(sessionDir, "flow-state.json");
const state = JSON.parse(readFileSync(statePath, "utf8"));
state.status = "stopped";
writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n");
backdateState(sessionDir);
const result = run(harness, ["gc", "--max-age", "7"], fixture);
assert.equal(result.status, 0, result.stderr);
assert.equal(existsSync(sessionDir), false);
assert.equal(readSessionRegistry(sessionId, fixture.home), null);
assert.match(JSON.stringify(result.json?.deleted || []), new RegExp(basename(sessionDir)));
});
test("GC cannot delete a same-dir auto flow reinitialized after its stale read", async () => {
const fixture = tempFixture("gc-reinit-race");
const sessionId = "claude-session-gc-reinit-race";
const sessionDir = createImplicitAutoFlow(fixture, sessionId);
const statePath = join(sessionDir, "flow-state.json");
const terminal = JSON.parse(readFileSync(statePath, "utf8"));
terminal.status = "stopped";
writeFileSync(statePath, JSON.stringify(terminal, null, 2) + "\n");
backdateState(sessionDir);
const signal = join(fixture.root, "gc-read-stale-state");
const release = join(fixture.root, "gc-resume");
const preload = join(fixture.root, "pause-first-state-read.cjs");
writeFileSync(preload, `
const fs = require("node:fs");
const { syncBuiltinESMExports } = require("node:module");
const originalReadFileSync = fs.readFileSync;
const sleepBuffer = new Int32Array(new SharedArrayBuffer(4));
let paused = false;
fs.readFileSync = function(path, ...args) {
const result = originalReadFileSync.call(this, path, ...args);
if (!paused && String(path) === process.env.OPC_TEST_PAUSE_PATH) {
paused = true;
fs.writeFileSync(process.env.OPC_TEST_PAUSE_SIGNAL, "ready");
while (!fs.existsSync(process.env.OPC_TEST_PAUSE_RELEASE)) {
Atomics.wait(sleepBuffer, 0, 0, 10);
}
}
return result;
};
syncBuiltinESMExports();
`);
const pendingGc = runAsync(harness, ["gc", "--max-age", "7"], {
...fixture,
env: {
NODE_OPTIONS: `--require=${preload}`,
OPC_TEST_PAUSE_PATH: statePath,
OPC_TEST_PAUSE_SIGNAL: signal,
OPC_TEST_PAUSE_RELEASE: release,
},
});
await waitForFile(signal);
const replacement = runInit(fixture, sessionDir, [
"--force", "--auto", "--claude-session-id", sessionId,
]);
assert.equal(replacement.json?.created, true, JSON.stringify(replacement.json));
writeFileSync(release, "resume");
const gc = await pendingGc;
assert.equal(gc.status, 0, gc.stderr);
assert.equal(existsSync(sessionDir), true);
assert.equal(readSessionRegistry(sessionId, fixture.home).sessionDir, sessionDir);
const active = JSON.parse(readFileSync(statePath, "utf8"));
assert.equal(active.status, undefined);
assert.equal(active.autoMode, true);
assert.doesNotMatch(JSON.stringify(gc.json?.deleted || []), new RegExp(basename(sessionDir)));
});
test("GC preserves an auto flow when the registry identity is corrupt", () => {
const fixture = tempFixture("gc-registry-identity");
const sessionId = "claude-session-gc-identity";
const sessionDir = createImplicitAutoFlow(fixture, sessionId);
const path = registryPath(sessionId, fixture.home);
const registry = JSON.parse(readFileSync(path, "utf8"));
registry.sessionId = "different-session";
writeFileSync(path, JSON.stringify(registry, null, 2) + "\n");
backdateState(sessionDir);
const result = run(harness, ["gc", "--max-age", "7"], fixture);
assert.equal(result.status, 0, result.stderr);
assert.equal(existsSync(sessionDir), true);
assert.equal(existsSync(path), true);
assert.match(JSON.stringify(result.json?.errors || []), /registry identity/i);
});
test("GC deletes an orphan after the same session registry moves to a new dir", () => {
const fixture = tempFixture("gc-registry-moved");
const sessionId = "claude-session-gc-moved";
const sessionDir = createImplicitAutoFlow(fixture, sessionId);
const replacementDir = join(fixture.project, "replacement");
mkdirSync(replacementDir);
const path = registryPath(sessionId, fixture.home);
const registry = JSON.parse(readFileSync(path, "utf8"));
registry.sessionDir = replacementDir;
writeFileSync(path, JSON.stringify(registry, null, 2) + "\n");
backdateState(sessionDir);
const result = run(harness, ["gc", "--max-age", "7"], fixture);
assert.equal(result.status, 0, result.stderr);
assert.equal(existsSync(sessionDir), false);
assert.equal(readSessionRegistry(sessionId, fixture.home).sessionDir, replacementDir);
});
test("GC keeps the terminal session when registry cleanup cannot acquire its lock", () => {
const fixture = tempFixture("gc-registry-lock");
const sessionId = "claude-session-gc-lock";
const sessionDir = createImplicitAutoFlow(fixture, sessionId);
const statePath = join(sessionDir, "flow-state.json");
const state = JSON.parse(readFileSync(statePath, "utf8"));
state.status = "completed";
writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n");
backdateState(sessionDir);
const lockPath = `${registryPath(sessionId, fixture.home)}.lock`;
writeFileSync(lockPath, JSON.stringify({
pid: process.pid,
nonce: "held-by-test",
timestamp: new Date().toISOString(),
command: "test",
}));
const result = run(harness, ["gc", "--max-age", "7"], fixture);
assert.equal(result.status, 0, result.stderr);
assert.equal(existsSync(sessionDir), true);
assert.equal(readSessionRegistry(sessionId, fixture.home).sessionDir, sessionDir);
assert.match(JSON.stringify(result.json?.errors || []), /registry lock/i);
});
test("GC preserves an auto flow whose state has no Claude session ID", () => {
const fixture = tempFixture("gc-missing-session-id");
const sessionId = "claude-session-gc-missing-id";
const sessionDir = createImplicitAutoFlow(fixture, sessionId);
const statePath = join(sessionDir, "flow-state.json");
const state = JSON.parse(readFileSync(statePath, "utf8"));
delete state._claudeSessionId;
writeFileSync(statePath, JSON.stringify(state, null, 2) + "\n");
backdateState(sessionDir);
const result = run(harness, ["gc", "--max-age", "7"], fixture);
assert.equal(result.status, 0, result.stderr);
assert.equal(existsSync(sessionDir), true);
assert.match(JSON.stringify(result.json?.errors || []), /missing Claude session ID/i);
});
test("GC preserves an auto flow when its registry JSON is malformed", () => {
const fixture = tempFixture("gc-malformed-registry");
const sessionId = "claude-session-gc-malformed-registry";
const sessionDir = createImplicitAutoFlow(fixture, sessionId);
writeFileSync(registryPath(sessionId, fixture.home), "{");
backdateState(sessionDir);
const result = run(harness, ["gc", "--max-age", "7"], fixture);
assert.equal(result.status, 0, result.stderr);
assert.equal(existsSync(sessionDir), true);
assert.match(JSON.stringify(result.json?.errors || []), /registry.*JSON|JSON.*registry/i);
});
test("GC deletes an expired auto flow when its registry is absent", () => {
const fixture = tempFixture("gc-absent-registry");
const sessionId = "claude-session-gc-absent-registry";
const sessionDir = createImplicitAutoFlow(fixture, sessionId);
unlinkSync(registryPath(sessionId, fixture.home));
backdateState(sessionDir);
const result = run(harness, ["gc", "--max-age", "7"], fixture);
assert.equal(result.status, 0, result.stderr);
assert.equal(existsSync(sessionDir), false);
assert.match(JSON.stringify(result.json?.deleted || []), new RegExp(basename(sessionDir)));
});
test("GC preserves an expired session whose flow state is malformed", () => {
const fixture = tempFixture("gc-malformed-state");
const sessionId = "claude-session-gc-malformed-state";
const sessionDir = createImplicitAutoFlow(fixture, sessionId);
const statePath = join(sessionDir, "flow-state.json");
writeFileSync(statePath, "{");
backdateState(sessionDir);
const result = run(harness, ["gc", "--max-age", "7"], fixture);
assert.equal(result.status, 0, result.stderr);
assert.equal(existsSync(sessionDir), true);
assert.match(JSON.stringify(result.json?.errors || []), /cannot read expired session/i);
});
});
describe("external recovery", () => {
for (const recovery of [
{ name: "goto", flow: "review", entry: "review", args: ["goto", "gate"] },
{ name: "skip", flow: "review", entry: "review", args: ["skip"] },
{ name: "pass", flow: "pre-release", entry: "gate-acceptance", args: ["pass"] },
{ name: "stop", flow: "review", entry: "review", args: ["stop"] },
]) {
test(`${recovery.name} restores hook allowance without deleting old evidence`, () => {
const fixture = tempFixture(`recovery-${recovery.name}`);
const dir = join(fixture.project, "session");
const sessionId = `claude-session-recovery-${recovery.name}`;
const init = run(harness, [
"init",
"--flow", recovery.flow,
"--entry", recovery.entry,
"--dir", dir,
"--no-extensions",
"--auto",
"--claude-session-id", sessionId,
], fixture);
assert.equal(init.json.created, true, JSON.stringify(init.json));
const state = JSON.parse(readFileSync(join(dir, "flow-state.json"), "utf8"));
const stopped = createStopMarker(dir, state, { reason: "tool-call-budget" });
assert.equal(claimToolSlot(stopped.paths, { toolUseId: "before-recovery" }), 1);
assertHookDenied(evaluatePreToolUse(hookInput(fixture, sessionId), { home: fixture.home }));
const recovered = run(harness, [...recovery.args, "--dir", dir], fixture);
assert.equal(recovered.status, 0, recovered.stderr);
assert.equal(recovered.json?.error, undefined, JSON.stringify(recovered.json));
assert.deepEqual(
evaluatePreToolUse(hookInput(fixture, sessionId, "after-recovery"), { home: fixture.home }),
{ allowed: true },
);
assert.equal(existsSync(stopped.path), true);
assert.equal(readdirSync(stopped.paths.slots).length, 1);
});
}
});
function installFakeJq(root) {
const bin = join(root, "bin");
mkdirSync(bin, { recursive: true });
const jq = join(bin, "jq");
writeFileSync(jq, "#!/bin/sh\nexit 0\n");
chmodSync(jq, 0o755);
return `${bin}:${process.env.PATH}`;
}
describe("hook installation", () => {
test("install-hooks registers PreToolUse without jq after a normal install", () => {
const fixture = tempFixture("install-hooks-no-jq", { hookInstalled: false });
const noJqPath = join(fixture.root, "no-jq-bin");
mkdirSync(noJqPath);
const installed = run(opcCli, ["install"], fixture);
const hooks = run(opcCli, ["install-hooks"], { ...fixture, path: noJqPath });
assert.equal(installed.status, 0, installed.stderr);
assert.match(installed.stdout, /auto-flow guards/);
assert.equal(hooks.status, 0, hooks.stderr);
assert.match(hooks.stdout, /jq not found/);
const settings = JSON.parse(readFileSync(join(fixture.home, ".claude", "settings.json"), "utf8"));
assert.equal(settings.hooks.PreToolUse.length, 1);
assert.equal(settings.hooks.PreCompact, undefined);
assert.equal(settings.hooks.PostCompact, undefined);
});
test("install-hooks preserves existing hooks and adds PreToolUse idempotently", () => {
const fixture = tempFixture("install-hooks", { hookInstalled: false });
const skillsParent = join(fixture.home, ".claude", "skills");
mkdirSync(skillsParent, { recursive: true });
symlinkSync(repoRoot, join(skillsParent, "opc"));
const settingsPath = join(fixture.home, ".claude", "settings.json");
const custom = { type: "command", command: "custom-hook" };
const scopedOpc = {
type: "command",
command: `node "${join(skillsParent, "opc", "bin", "hooks", "opc-pre-tool-budget.mjs")}"`,
async: true,
};
writeFileSync(settingsPath, JSON.stringify({
hooks: {
PreToolUse: [{ matcher: "Bash", hooks: [custom, scopedOpc] }],
Notification: [{ hooks: [custom] }],
},
}, null, 2));
const path = installFakeJq(fixture.root);
const first = run(opcCli, ["install-hooks"], { ...fixture, path });
const second = run(opcCli, ["install-hooks"], { ...fixture, path });
assert.equal(first.status, 0, first.stderr);
assert.equal(second.status, 0, second.stderr);
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
assert.equal(settings.hooks.Notification.length, 1);
assert.equal(settings.hooks.PreCompact.length, 1);
assert.equal(settings.hooks.PostCompact.length, 1);
assert.equal(settings.hooks.PreToolUse.length, 2);
const opcHooks = settings.hooks.PreToolUse.flatMap(entry => entry.hooks || [])
.filter(hook => hook.command?.includes("opc-pre-tool-budget.mjs"));
assert.equal(opcHooks.length, 2);
const globalOpcHooks = settings.hooks.PreToolUse
.filter(entry => entry.matcher == null || entry.matcher === "")
.flatMap(entry => entry.hooks || [])
.filter(hook => hook.command === scopedOpc.command);
assert.equal(globalOpcHooks.length, 1);
assert.equal(globalOpcHooks[0].type, "command");
});
test("uninstall removes only OPC-owned hooks before deleting the skill", () => {
const fixture = tempFixture("uninstall-hooks", { hookInstalled: false });
const skill = join(fixture.home, ".claude", "skills", "opc");
mkdirSync(dirname(skill), { recursive: true });
symlinkSync(repoRoot, skill);
const settingsPath = join(fixture.home, ".claude", "settings.json");
const custom = { type: "command", command: "custom-hook" };
writeFileSync(settingsPath, JSON.stringify({
hooks: {
PreCompact: [{ hooks: [
{ type: "command", command: `bash "${join(skill, "bin", "hooks", "opc-pre-compact.sh")}"` },
custom,
] }],
PostCompact: [{ hooks: [
{ type: "command", command: `bash "${join(skill, "bin", "hooks", "opc-post-compact.sh")}"` },
] }],
PreToolUse: [{ hooks: [
{ type: "command", command: `node "${join(skill, "bin", "hooks", "opc-pre-tool-budget.mjs")}"` },
custom,
] }],
Notification: [{ hooks: [custom] }],
},
}, null, 2));
const result = run(opcCli, ["uninstall"], fixture);
assert.equal(result.status, 0, result.stderr);
assert.equal(existsSync(skill), false);
const settings = JSON.parse(readFileSync(settingsPath, "utf8"));
assert.deepEqual(settings.hooks.PreCompact, [{ hooks: [custom] }]);
assert.equal(settings.hooks.PostCompact, undefined);
assert.deepEqual(settings.hooks.PreToolUse, [{ hooks: [custom] }]);
assert.deepEqual(settings.hooks.Notification, [{ hooks: [custom] }]);
});
test("uninstall aborts before deletion when settings are malformed", () => {
const fixture = tempFixture("uninstall-malformed-settings", { hookInstalled: false });
const skill = join(fixture.home, ".claude", "skills", "opc");
mkdirSync(dirname(skill), { recursive: true });
symlinkSync(repoRoot, skill);
const settingsPath = join(fixture.home, ".claude", "settings.json");
writeFileSync(settingsPath, "{");
const result = run(opcCli, ["uninstall"], fixture);
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Cannot safely uninstall OPC/);
assert.equal(existsSync(skill), true);
assert.equal(readFileSync(settingsPath, "utf8"), "{");
});
test("uninstall preserves unrelated settings when the skill is already absent", () => {
const fixture = tempFixture("uninstall-absent-skill", { hookInstalled: false });
const settingsPath = join(fixture.home, ".claude", "settings.json");
mkdirSync(dirname(settingsPath), { recursive: true });
const settings = {
hooks: {
PreCompact: "custom-shape",
PostCompact: [{}],
PreToolUse: [{ hooks: [{ type: "command", command: "custom-hook" }] }],
},
};
const original = JSON.stringify(settings, null, 2);
writeFileSync(settingsPath, original);
const result = run(opcCli, ["uninstall"], fixture);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /Nothing else to remove/);
assert.equal(readFileSync(settingsPath, "utf8"), original);
});
test("uninstall succeeds when both settings and skill are absent", () => {
const fixture = tempFixture("uninstall-fully-absent", { hookInstalled: false });
const result = run(opcCli, ["uninstall"], fixture);
assert.equal(result.status, 0, result.stderr);
assert.match(result.stdout, /Nothing else to remove/);
});
test("missing PreToolUse script fails without modifying settings", () => {
const fixture = tempFixture("install-hooks-missing", { hookInstalled: false });
const hooks = join(fixture.home, ".claude", "skills", "opc", "bin", "hooks");
mkdirSync(hooks, { recursive: true });
writeFileSync(join(hooks, "opc-pre-compact.sh"), "#!/bin/sh\n");
writeFileSync(join(hooks, "opc-post-compact.sh"), "#!/bin/sh\n");
const settingsPath = join(fixture.home, ".claude", "settings.json");
const original = JSON.stringify({ hooks: { Notification: [] } }, null, 2);
writeFileSync(settingsPath, original);
const result = run(opcCli, ["install-hooks"], {
...fixture,
path: installFakeJq(fixture.root),
});
assert.notEqual(result.status, 0);
assert.match(result.stderr, /opc-pre-tool-budget\.mjs/);
assert.equal(readFileSync(settingsPath, "utf8"), original);
});
});
describe("bounded auto instructions", () => {
test("skill and runtime remove unconditional continuation", () => {
const skill = readFileSync(skillFile, "utf8");
const loopAdvance = readFileSync(join(repoRoot, "bin", "lib", "loop-advance.mjs"), "utf8");
assert.match(skill, /opc-harness init --auto --claude-session-id "\$\{CLAUDE_SESSION_ID\}"/);
assert.match(skill, /opc-harness init --flow \{TEMPLATE\} --entry \{ENTRY_NODE\} # interactive/);
assert.doesNotMatch(`${skill}\n${loopAdvance}`, /do not pause, do not ask user, keep executing|Anything else = keep executing/);
assert.match(skill, /when the circuit breaker trips, stop and report immediately/i);
});
});
const SEV_RE = /\*\*Severity\*\*:?\s*(🔴|🟡|🔵)/;
const STATUS_RE = /\*\*(?:R2\s+)?Status\*?\*?:?\s*(✅|⚠️|❌)/;
const LOCATION_RE = /\*\*Location\*\*:?\s*(.+)/;
const FINDING_HEADING_RE = /^#{2,3}\s+(?:Finding\s+)?(\d+)[\s.:—\-]+(.+)/i;
const FINDING_HEADING_ALT = /^#{2,3}\s+(\d+)\.\s+(.+)/;
export function parseStructuredFindings(content) {
const lines = content.split('\n');
const findings = [];
let current = null;
for (const line of lines) {
const m = line.match(FINDING_HEADING_RE) || line.match(FINDING_HEADING_ALT);
if (m) {
if (current) findings.push(current);
current = { num: m[1], title: m[2].trim(), severity: null, location: null, status: null };
continue;
}
if (/^#{2,3}\s+/.test(line) && !m) {
if (current) findings.push(current);
current = null;
continue;
}
if (!current) continue;
const sevM = line.match(SEV_RE);
if (sevM) { current.severity = sevM[1]; continue; }
const statM = line.match(STATUS_RE);
if (statM) { current.status = statM[1]; continue; }
const locM = line.match(LOCATION_RE);
if (locM) { current.location = locM[1].replace(/`/g, '').trim(); }
}
if (current) findings.push(current);
return findings;
}
export function structuredSeverityName(severity) {
return { '🔴': 'critical', '🟡': 'warning', '🔵': 'suggestion' }[severity] || 'finding';
}
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "fs";
import { spawnSync } from "child_process";
import { createHash } from "crypto";
import { join, resolve } from "path";
import { appendProvenanceEvent } from "./provenance-ledger.mjs";
const EXECUTION_ACTOR = "opc-harness:test-command";
function readJson(path) {
try {
return JSON.parse(readFileSync(path, "utf8"));
} catch {
return null;
}
}
function latestRunDir(nodeDir) {
if (!existsSync(nodeDir)) return null;
const runs = readdirSync(nodeDir, { withFileTypes: true })
.filter(entry => entry.isDirectory() && /^run_\d+$/.test(entry.name))
.sort((a, b) => Number(b.name.slice(4)) - Number(a.name.slice(4)));
return runs[0] ? join(nodeDir, runs[0].name) : null;
}
function commandSpecFrom(data) {
if (!data || typeof data.testCommand !== "string" || data.testCommand.trim() === "") return null;
return {
testCommand: data.testCommand.trim(),
prerequisites: Array.isArray(data.prerequisites) ? data.prerequisites : [],
allowVacuousChecks: Array.isArray(data.allowVacuousChecks) ? data.allowVacuousChecks : [],
cwd: typeof data.cwd === "string" && data.cwd ? data.cwd : null,
timeoutMs: Number.isInteger(data.timeoutMs) ? data.timeoutMs : 120000,
};
}
export function testCommandHash(command) {
return createHash("sha256").update(command).digest("hex");
}
function sha256(text) {
return createHash("sha256").update(text).digest("hex");
}
function readText(path) {
try {
return readFileSync(path, "utf8");
} catch {
return null;
}
}
function planPathFromHandshake(nodeDir, handshake) {
if (!Array.isArray(handshake?.artifacts)) return null;
const art = handshake.artifacts.find(a => a?.type === "test-plan" && typeof a.path === "string");
return art ? join(nodeDir, art.path) : null;
}
function sourcePlanHash(sessionDir, nodeId) {
const nodeDir = join(sessionDir, "nodes", nodeId);
const runDir = latestRunDir(nodeDir);
const handshake = readJson(join(nodeDir, "handshake.json"));
const candidates = [
planPathFromHandshake(nodeDir, handshake),
runDir ? join(runDir, "test-plan.md") : null,
join(nodeDir, "test-plan.md"),
].filter(Boolean);
for (const path of candidates) {
const text = readText(path);
if (text != null) return sha256(text);
}
return null;
}
export function loadTestCommandSpec(sessionDir, nodeId) {
const nodeDir = join(sessionDir, "nodes", nodeId);
const runDir = latestRunDir(nodeDir);
const candidates = [
join(nodeDir, "test-execution.json"),
join(nodeDir, "handshake.json"),
runDir ? join(runDir, "test-execution.json") : null,
runDir ? join(runDir, "handshake.json") : null,
].filter(Boolean);
for (const path of candidates) {
const spec = commandSpecFrom(readJson(path));
if (spec) return { ...spec, sourcePlanHash: sourcePlanHash(sessionDir, nodeId) };
}
return null;
}
function trimOutput(value) {
const text = String(value || "");
return text.length > 20000 ? text.slice(-20000) : text;
}
function commandNeedsJsProject(command) {
return /\b(npm|npx|pnpm|yarn|node|vitest|jest|playwright)\b/i.test(command);
}
function packageMentions(path, pattern) {
const data = readJson(path);
const sections = ["dependencies", "devDependencies", "optionalDependencies"];
return sections.some(section => Object.keys(data?.[section] || {}).some(name => pattern.test(name)));
}
function hasJsProjectSupport(dir, command) {
if (/playwright/i.test(command)) {
return existsSync(join(dir, "playwright.config.js"))
|| existsSync(join(dir, "playwright.config.ts"))
|| existsSync(join(dir, "node_modules", ".bin", "playwright"))
|| packageMentions(join(dir, "package.json"), /playwright/i);
}
return existsSync(join(dir, "package.json")) || existsSync(join(dir, "node_modules"));
}
function findJsProjectDirs(root, depth = 3) {
if (depth < 0 || !existsSync(root)) return [];
const found = hasJsProjectSupport(root, "") ? [root] : [];
let entries = [];
try { entries = readdirSync(root, { withFileTypes: true }); } catch { return found; }
for (const entry of entries) {
if (!entry.isDirectory() || /^(\.git|node_modules|\.opc|\.harness)/.test(entry.name)) continue;
found.push(...findJsProjectDirs(join(root, entry.name), depth - 1));
}
return found;
}
function commandCwd(spec) {
if (spec.cwd) {
const cwd = spec.cwd.startsWith("/") ? spec.cwd : resolve(process.cwd(), spec.cwd);
return { cwd, source: "explicit" };
}
const base = process.cwd();
if (!commandNeedsJsProject(spec.testCommand) || hasJsProjectSupport(base, spec.testCommand)) {
return { cwd: base, source: "process-cwd" };
}
const matches = findJsProjectDirs(base).filter(dir => hasJsProjectSupport(dir, spec.testCommand));
return matches.length === 1
? { cwd: matches[0], source: "auto-js-project" }
: { cwd: base, source: "process-cwd" };
}
function writeResultFiles(runDir, spec, result, cwdInfo) {
const stdout = trimOutput(result.stdout);
const stderrText = result.error?.message ? `${result.stderr || ""}\n${result.error.message}` : result.stderr;
const stderr = trimOutput(stderrText);
const exitCode = result.status == null ? 1 : result.status;
const commandHash = testCommandHash(spec.testCommand);
const json = {
testCommand: spec.testCommand,
prerequisites: spec.prerequisites,
cwd: cwdInfo.cwd,
cwdSource: cwdInfo.source,
provenance: {
kind: "opc-test-command",
commandHash,
sourcePlanHash: spec.sourcePlanHash,
executionActor: EXECUTION_ACTOR,
},
exitCode,
timedOut: Boolean(result.error && result.error.code === "ETIMEDOUT"),
stdout,
stderr,
test_fail_count: exitCode === 0 ? 0 : 1,
};
const resultText = JSON.stringify(json, null, 2) + "\n";
writeFileSync(join(runDir, "test-command-result.json"), resultText);
writeFileSync(join(runDir, "test-command-output.txt"),
[`$ ${spec.testCommand}`, `cwd: ${cwdInfo.cwd}`, `cwdSource: ${cwdInfo.source}`, `exitCode: ${exitCode}`, "", stdout, stderr].join("\n"));
return { exitCode, timedOut: json.timedOut, resultHash: sha256(resultText) };
}
function runTestCommand(spec, cwd) {
try {
return spawnSync("sh", ["-c", spec.testCommand], {
cwd, encoding: "utf8", timeout: spec.timeoutMs,
});
} catch (err) {
return {
status: 1,
stdout: "",
stderr: `testCommand spawn failed: ${err.message}`,
error: { code: err.code || "SPAWN_ERROR" },
};
}
}
export function executeTestCommand(sessionDir, targetNode, runId, sourceNode) {
const spec = loadTestCommandSpec(sessionDir, sourceNode);
if (!spec) return null;
const runDir = join(sessionDir, "nodes", targetNode, runId);
mkdirSync(runDir, { recursive: true });
const cwdInfo = commandCwd(spec);
const result = runTestCommand(spec, cwdInfo.cwd);
const summary = writeResultFiles(runDir, spec, result, cwdInfo);
const verdict = summary.exitCode === 0 ? "PASS" : "FAIL";
const commandHash = testCommandHash(spec.testCommand);
const ledger = appendProvenanceEvent(sessionDir, {
eventType: "test-command-result",
nodeId: targetNode,
runId,
sourceNode,
commandHash,
sourcePlanHash: spec.sourcePlanHash,
resultHash: summary.resultHash,
resultPath: `nodes/${targetNode}/${runId}/test-command-result.json`,
exitCode: summary.exitCode,
});
const testEvidenceProvenance = {
kind: "opc-test-command",
sourceNode,
commandHash,
sourcePlanHash: spec.sourcePlanHash,
resultHash: summary.resultHash,
executionActor: EXECUTION_ACTOR,
ledger,
};
const handshake = {
nodeId: targetNode,
nodeType: "execute",
runId,
status: "completed",
verdict,
summary: `testCommand exitCode=${summary.exitCode}`,
timestamp: new Date().toISOString(),
artifacts: [
{ type: "test-result", path: `${runId}/test-command-result.json` },
{ type: "cli-output", path: `${runId}/test-command-output.txt` },
],
testCommand: spec.testCommand,
testCommandCwd: cwdInfo.cwd,
testCommandCwdSource: cwdInfo.source,
prerequisites: spec.prerequisites,
testEvidenceProvenance,
testEvidencePolicy: {
allowVacuousChecks: spec.allowVacuousChecks,
},
};
writeFileSync(join(sessionDir, "nodes", targetNode, "handshake.json"), JSON.stringify(handshake, null, 2) + "\n");
return { executed: true, verdict, exitCode: summary.exitCode, cwd: cwdInfo.cwd, cwdSource: cwdInfo.source, resultPath: join(runDir, "test-command-result.json") };
}
import { existsSync, readFileSync, readdirSync } from "fs";
import { join } from "path";
import { TEST_LAYERS, TEST_LAYER_KEYWORDS } from "./tier-baselines.mjs";
const CMD_RE = /\b(npm\s+(test|run)|npx\s+\w|pytest|vitest|jest|playwright\s+test|curl\s+|bash\s+|sh\s+|node\s+|python[3]?\s+)/i;
function latestRunDir(nodeDir) {
try {
return readdirSync(nodeDir)
.filter(name => /^run_\d+$/.test(name))
.sort((a, b) => parseInt(b.slice(4), 10) - parseInt(a.slice(4), 10))
.map(name => join(nodeDir, name))[0] || null;
} catch {
return null;
}
}
function findPlanPath(dir, nodeId) {
const nodeDir = join(dir, "nodes", nodeId);
const runDir = latestRunDir(nodeDir);
const runPlan = runDir ? join(runDir, "test-plan.md") : null;
if (runPlan && existsSync(runPlan)) return runPlan;
const nodePlan = join(nodeDir, "test-plan.md");
return existsSync(nodePlan) ? nodePlan : null;
}
function layerCoverage(lines) {
const lower = lines.join("\n").toLowerCase();
const missing = [];
const shallow = [];
for (const layer of TEST_LAYERS) {
const keywords = TEST_LAYER_KEYWORDS[layer];
const found = keywords.some(kw => lower.includes(kw));
if (!found) {
missing.push(layer);
continue;
}
const start = lines.findIndex(line =>
/^#{1,3}\s/.test(line) && keywords.some(kw => line.toLowerCase().includes(kw))
);
if (start === -1) continue;
let contentLines = 0;
for (let i = start + 1; i < lines.length; i++) {
if (/^#{1,3}\s/.test(lines[i])) break;
if (lines[i].trim()) contentLines++;
}
if (contentLines < 3) shallow.push(layer);
}
return { missing, shallow };
}
export function caseBlocks(lines) {
const blocks = [];
let cur = null;
let start = -1;
for (let i = 0; i < lines.length; i++) {
const match = lines[i].match(/^#{2,4}\s+(TC-[\w-]+)/i)
|| lines[i].match(/^\s*[-*]\s+(?:\*\*)?(TC-[\w-]+)\b/i);
if (match) {
if (cur) blocks.push({ id: cur, lines: lines.slice(start, i) });
cur = match[1];
start = i;
} else if (cur && /^#{1,2}\s/.test(lines[i])) {
blocks.push({ id: cur, lines: lines.slice(start, i) });
cur = null;
}
}
if (cur) blocks.push({ id: cur, lines: lines.slice(start) });
return blocks;
}
function resolveAnchorRef(anchor, roots) {
const ref = anchor.split(/\s+[—–-]\s+/)[0].trim().match(/^(.+):(\d+)(?:-(\d+))?$/);
if (!ref) return { error: "invalid format" };
const file = ref[1].trim();
const startLine = Number(ref[2]);
const endLine = ref[3] ? Number(ref[3]) : startLine;
const resolved = file.startsWith("/")
? (existsSync(file) ? file : null)
: roots.map(root => join(root, file)).find(existsSync);
if (!resolved) return { error: "unresolved", file };
const lineCount = readFileSync(resolved, "utf8").split("\n").length;
if (startLine < 1 || startLine > lineCount) {
return { error: "line out of range", file, line: startLine, lineCount };
}
if (endLine < startLine || endLine > lineCount) {
return { error: "range out of range", file, line: endLine, lineCount };
}
return {};
}
export function anchorIssues(lines, roots) {
const issues = [];
for (const block of caseBlocks(lines)) {
if (/^TC-TIER/i.test(block.id)) continue;
const text = block.lines.join("\n");
const priority = text.match(/priority[^\n]*?\b(P[012])\b/i)?.[1]?.toUpperCase();
if (priority !== "P0" && priority !== "P1") continue;
const anchor = text.match(/\banchor\b[^\n]*?:\s*(.+)$/im)?.[1]?.trim().replace(/`/g, "");
if (!anchor) {
issues.push(`${block.id} (${priority}) missing Anchor`);
continue;
}
const result = resolveAnchorRef(anchor, roots);
if (result.error === "invalid format") {
issues.push(`${block.id} Anchor invalid format: ${anchor}`);
} else if (result.error === "unresolved") {
issues.push(`${block.id} Anchor ref unresolved: ${result.file}`);
} else if (result.error === "line out of range") {
issues.push(`${block.id} Anchor line out of range: ${result.file}:${result.line}`);
} else if (result.error === "range out of range") {
issues.push(`${block.id} Anchor range out of range: ${result.file}:${result.line}`);
}
}
return issues;
}
export function collectTestDesignPlanReasons(dir, nodeId) {
const planPath = findPlanPath(dir, nodeId);
if (!planPath) return [`${nodeId} test-plan.md missing`];
const text = readFileSync(planPath, "utf8");
const lines = text.split("\n");
const { missing, shallow } = layerCoverage(lines);
const noCommands = lines.filter(line => CMD_RE.test(line)).length === 0 && lines.length >= 10;
const anchors = anchorIssues(lines, [dir, process.cwd()]);
const reasons = [];
if (missing.length > 0) reasons.push(`missing layers: ${missing.join(", ")}`);
if (shallow.length > 0) reasons.push(`shallow sections: ${shallow.join(", ")}`);
if (noCommands) reasons.push("0 actionable commands in test plan");
if (anchors.length > 0) reasons.push(`anchor: ${anchors.join("; ")}`);
return reasons.map(reason => `${nodeId} test plan: ${reason}`);
}
// Mechanical gate for structured test-result artifacts.
import { findProvenanceEvent } from "./provenance-ledger.mjs";
function safeInt(value) {
const n = parseInt(value, 10);
return Number.isFinite(n) ? n : 0;
}
function parseDetail(detail) {
if (!detail) return null;
if (typeof detail === "object") return detail;
if (typeof detail !== "string") return null;
const text = detail.trim();
if (!text.startsWith("{") && !text.startsWith("[")) return null;
try { return JSON.parse(text); } catch { return null; }
}
function allowVacuous(context, check) {
const allowed = context?.allowVacuousChecks;
return Array.isArray(allowed) && allowed.includes(check?.id);
}
function checkTotal(check) {
if (typeof check?.total === "number") return check.total;
const detail = parseDetail(check?.detail);
return typeof detail?.total === "number" ? detail.total : null;
}
function collectChecksReasons(data, context) {
if (!Array.isArray(data?.checks)) return [];
const reasons = [];
const failed = data.checks.filter(c => c && c.pass === false);
if (failed.length > 0) {
const ids = failed.slice(0, 5).map(c => c.id || "unnamed").join(", ");
reasons.push(`${failed.length} structured check(s) failed: ${ids}`);
}
const vacuous = data.checks.filter(c =>
c && c.pass === true && !allowVacuous(context, c) && checkTotal(c) === 0);
if (vacuous.length > 0) {
const ids = vacuous.slice(0, 5).map(c => c.id || "unnamed").join(", ");
reasons.push(`${vacuous.length} vacuous PASS check(s) matched total=0: ${ids}`);
}
return reasons;
}
function collectSummaryReasons(data) {
const reasons = [];
if (Array.isArray(data?.summary?.failed) && data.summary.failed.length > 0) {
reasons.push(`${data.summary.failed.length} summarized test failure(s) present`);
}
if (safeInt(data?.test_fail_count) > 0)
reasons.push(`${safeInt(data.test_fail_count)} test(s) failed`);
if (safeInt(data?.dead_test_count) > 0)
reasons.push(`${safeInt(data.dead_test_count)} dead test(s) detected`);
if (safeInt(data?.p0_count) > 0)
reasons.push(`${safeInt(data.p0_count)} P0 issue(s) unresolved`);
if (String(data?.sync_check_status || "").toUpperCase() === "FAIL")
reasons.push("sync-check failed");
return reasons;
}
function isTestExecuteNode(nodeId) {
return /^test[-_]execute$/.test(String(nodeId || ""));
}
function hasResultIntegrity(handshakeProv, context) {
return typeof handshakeProv?.resultHash === "string"
&& typeof context.artifactHash === "string"
&& handshakeProv.resultHash === context.artifactHash;
}
function ledgerReason(handshakeProv, context) {
const ledger = handshakeProv?.ledger;
if (ledger?.kind !== "opc-hmac-ledger") return "missing OPC signed provenance ledger";
const found = findProvenanceEvent(context.sessionDir, ledger.recordHash);
if (!found.ok) return found.error;
const event = found.event;
const resultPath = `nodes/${context.nodeId}/${context.artifact?.path}`;
if (event.eventType !== "test-command-result") return "ledger event type mismatch";
const expectedRunId = context.runId || context.handshake?.runId;
if (event.nodeId !== context.nodeId || event.runId !== expectedRunId) return "ledger node/run mismatch";
if (event.sourceNode !== handshakeProv.sourceNode) return "ledger source node mismatch";
if (event.commandHash !== handshakeProv.commandHash) return "ledger command hash mismatch";
if (event.sourcePlanHash !== handshakeProv.sourcePlanHash) return "ledger source plan hash mismatch";
if (event.resultHash !== handshakeProv.resultHash) return "ledger result hash mismatch";
if (event.resultPath !== resultPath) return "ledger result path mismatch";
return null;
}
function hasCommandProvenance(data, handshake, context) {
const resultProv = data?.provenance || data?.testEvidenceProvenance;
const handshakeProv = handshake?.testEvidenceProvenance;
return publicProvenanceReason(resultProv, handshakeProv, context) === null
&& ledgerReason(handshakeProv, context) === null;
}
function publicProvenanceReason(resultProv, handshakeProv, context) {
if (resultProv?.kind !== "opc-test-command") return "test-result is not OPC testCommand evidence";
if (handshakeProv?.kind !== "opc-test-command") return "handshake is not OPC testCommand evidence";
if (typeof resultProv.commandHash !== "string") return "test-result command hash missing";
if (typeof handshakeProv.commandHash !== "string") return "handshake command hash missing";
if (resultProv.commandHash !== handshakeProv.commandHash) return "test-result and handshake command hashes differ";
if (resultProv.executionActor !== "opc-harness:test-command") return "test-result execution actor mismatch";
if (handshakeProv.executionActor !== "opc-harness:test-command") return "handshake execution actor mismatch";
if (typeof context.expectedCommandHash !== "string" || resultProv.commandHash !== context.expectedCommandHash) return "source testCommand hash mismatch";
if (typeof handshakeProv.sourcePlanHash !== "string" || !handshakeProv.sourcePlanHash) return "handshake source test-plan hash missing";
if (typeof resultProv.sourcePlanHash !== "string" || !resultProv.sourcePlanHash) return "test-result source test-plan hash missing";
if (typeof context.expectedSourcePlanHash !== "string") return "source test-plan hash missing";
if (resultProv.sourcePlanHash !== context.expectedSourcePlanHash) return "test-result source test-plan hash mismatch";
if (handshakeProv.sourcePlanHash !== context.expectedSourcePlanHash) return "handshake source test-plan hash mismatch";
if (!hasResultIntegrity(handshakeProv, context)) return "result hash mismatch";
return null;
}
function collectProvenanceReasons(data, context) {
if (!isTestExecuteNode(context.nodeId) || context.artifact?.type !== "test-result") {
return [];
}
if (hasCommandProvenance(data, context.handshake, context)) return [];
const resultProv = data?.provenance || data?.testEvidenceProvenance;
const prov = context.handshake?.testEvidenceProvenance;
const publicReason = publicProvenanceReason(resultProv, prov, context);
if (publicReason) {
return [`test-execute test-result lacks matching OPC testCommand provenance, source test-plan hash, and result hash: ${publicReason}`];
}
const reason = ledgerReason(prov, context);
if (reason) return [`test-execute test-result lacks valid OPC signed provenance ledger: ${reason}`];
return ["test-execute test-result lacks matching OPC testCommand provenance, source test-plan hash, and result hash — self-authored or modified test evidence is weak"];
}
export function collectTestResultReasons(data, context = {}) {
return [
...collectSummaryReasons(data),
...collectChecksReasons(data, context),
...collectProvenanceReasons(data, context),
];
}

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

# OPC Runaway Guard 最小改动设计
## 目标
用最小机械 guard 封住两类 accidental runaway:
1. auto flow 内同一条 repair edge 持续执行 review → fix → review;
2. 单个 node 内持续调用 tool/subagent,迟迟不 transition。
本设计是 **accidental-runaway circuit breaker**:它约束 Claude Code 官方 tool execution path,不是防御同一 OS 用户下 malicious process 的 security boundary。
## 非目标
- 不实现 human authorization、nonce、TTY 判断、Touch ID、signed grant 或独立 broker。
- 不防御蓄意绕过、预先启动的 background process 或直接修改本地文件的同 UID process。
- 不重构 finding lifecycle、review/test/extension 协议或 flow template schema。
- 不新增 flow-level `blocked` lifecycle、`block`、`unblock` 或 budget reset command。
- 不改变现有 `maxLoopsPerEdge`、`maxTotalSteps`、`maxNodeReentry`。
- 不设置 Agent、model、flow 或 tool-specific budget。
- 不限制 interactive flow 的人工 repair 次数。
- 不顺带重构 multi-session ownership 或 takeover。
## 设计概览
增加两个 guard,共用一个 run-scoped stop marker:
1. **Flow guard:auto repair-edge budget**
- 只统计成功的 `FAIL` / `ITERATE` transition;
- 每条 exact repair edge 在一个 flow 中最多自动执行一次;
- 第二次执行时拒绝 transition,并在当前 node/run 写 `guard-stop.json`。
2. **Node guard:PreToolUse budget**
- 每个 node/run 最多 30 分钟、100 次 aggregate tool calls;
- 使用 `O_CREAT | O_EXCL` atomic slots,parallel calls 不丢计数;
- 超限时在当前 node/run 写同一个 `guard-stop.json`。
`PreToolUse` 发现当前 node/run 已有 stop marker 后,拒绝该 Claude session 的所有后续 tools,包括 Bash。外部 terminal 使用现有 `stop`、`goto`、`skip` 或 `pass` 进入其他状态后,旧 marker 因 runKey 不再匹配而自然失效。
不增加 recovery token,也不尝试证明调用者是人。
## Session Registry
### 目的
Hook 不能依赖 `latest` symlink:同 repo 的另一个窗口 init 会覆盖它。也不需要扫描所有 canonical、legacy 和 `.harness*` 目录。
默认 `/opc` auto init 写一个 session registry:
```text
~/.opc/runtime/<sha256(claudeSessionId)>.json
```
内容:
```json
{
"sessionId": "<Claude Code session_id>",
"sessionDir": "<absolute OPC session dir>",
"projectRoot": "<canonical absolute project root>",
"registeredAt": "<ISO timestamp>"
}
```
文件名使用 session ID 的 SHA-256,不把未经验证的 input 直接拼入路径。读取后仍必须精确比较文件内 `sessionId` 与 hook input。
### Init contract
默认 `/opc` 路径必须调用:
```text
opc-harness init --auto --claude-session-id "${CLAUDE_SESSION_ID}" ...
```
`${CLAUDE_SESSION_ID}` 是 Claude Code skill string substitution,不是 shell environment variable。
`/opc -i` interactive 路径不传 `--auto`,不创建 registry,也不受新 guard 影响。
`init --auto` 必须:
1. 验证 PreToolUse hook 已安装;
2. 要求非空 `--claude-session-id`;
3. 拒绝同一 session ID 同时绑定第二个 active auto flow;
4. 在新 flow state 中写入 `autoMode: true`、`_claudeSessionId`、`flowStartedAt` 和空 `autoRepairCounts`;
5. atomic write registry;registry 创建失败时回滚本次新建的 session dir/state,init 不得报告成功。
registry 不在 `stop` 或 `finalize` 时立即删除:hook 读取 state 后会对 stopped/completed flow allow;同一 session 再次 init 时可覆盖指向 stopped/completed flow 的旧 registry。Session GC 必须保留仍由 matching registry 指向的 active auto flow;清理 stopped/completed auto flow 时,必须先在同一个 registry lock 下验证并删除 matching registry,registry lock 或 cleanup 失败则保留 session dir。这样 GC 不会留下指向缺失 state 的 stale registry,也不会删除仍受 guard 约束的 active flow。process crash 留下的 active registry/session 由 external stop 后的后续 GC 清理。
本轮不实现跨 Claude session resume/claim。Claude process 重启后,用户可在 external terminal 停止旧 flow,再启动新 flow;compaction 不更换 session ID,继续正常工作。
## Current Run Identity
不改变现有 history semantics。current run 按以下规则解析:
```text
if history tail exists and tail.nodeId == currentNode:
runId = tail.runId
startedAt = tail.timestamp
runKey = "history:" + (history.length - 1) + ":" + runId + ":" + startedAt
else if totalSteps == 0
and history is empty
and currentNode == entryNode:
runId = "run_1" // display compatibility only
startedAt = flowStartedAt
runKey = "initial:" + entryNode + ":" + flowStartedAt
else:
invalid state → fail closed
```
initial execution 与首次 re-entry 都可能显示 `run_1`;`initial:` 和 `history:` prefix 保证 budget identity 不碰撞,而不改变 run numbering。
## Flow Guard:Auto Repair-Edge Budget
### Repair attempt
当且仅当以下条件全部成立时,transition 是 auto repair attempt:
```text
state.autoMode == true
AND verdict IN {FAIL, ITERATE}
AND to != null
```
budget key 是 exact edge:
```text
from + "→" + to
```
`flow-state.json` 增加独立 counter:
```json
{
"autoRepairCounts": {
"code-review→build": 1
}
}
```
不能复用 `edgeCounts`,因为 `goto`、`skip` 和普通 transition 也会递增它。旧 state 缺少 `autoRepairCounts` 时按空 object 处理。
### Transition contract
在 state lock 内完成以下顺序:
1. 校验 requested edge 和 current state;
2. 计算 exact repair edge count;
3. 若 count 已为 `1`,在任何 handshake、extension、history 或 target-directory side effect 前 atomic-create 当前 run 的 `guard-stop.json`,然后返回:
```json
{
"allowed": false,
"requiresHuman": true,
"reason": "auto repair budget reached for 'code-review→build'"
}
```
4. 首次 repair transition 继续执行现有 validation 和 graph limits;
5. 只有 transition 最终成功时,才在同一 state lock 内递增 `autoRepairCounts[edgeKey]`。
validation failure、PASS、`goto`、`skip` 和 `pass` 不消耗 repair budget。不同 repair edge 独立计数,因此多阶段 flow 的每个 stage 都有一次自动修复机会。
如果 stop marker 创建失败,transition fail closed,不得继续 transition。
`cmdAdvance` 必须检查 nested transition result。`transition.allowed == false` 时返回 `advanced:false`,并透传 `requiresHuman` 和 reason。
## Node Guard:PreToolUse Budget
### Hook registration
扩展现有 `opc install-hooks`:
- 保留 `PreCompact`、`PostCompact`;
- 新增同步 `PreToolUse` command hook;
- 保留用户已有 hooks;
- 重复安装幂等;
- 写 settings 前验证全部 hook 文件存在;
- 使用 atomic replacement,失败时原 settings 保持完整。
Hook 使用 Node 实现,不依赖 `jq` 或 `flock`。
### Activation
每次 PreToolUse:
1. 读取 hook input `session_id` 对应的 registry;
2. registry 不存在时 allow,零副作用;
3. 精确校验 registry 内 `sessionId`;
4. canonicalize `cwd` 和 `projectRoot`,要求 cwd 等于 project root 或位于其下;
5. 读取 registry 指向的 `flow-state.json`;
6. completed、stopped 或 interactive flow allow;
7. 要求 state `_claudeSessionId` 与 hook `session_id` 精确相等;
8. 解析 current node/runKey。
registry 已存在且损坏、指向缺失 state、identity 不匹配或 current run 无法解析时,对该 session fail closed。没有 registry 的普通 Claude session、interactive flow 和其他 session 不受影响。
### Budget storage
每个 node/run 使用独立目录:
```text
$SESSION_DIR/node-budget/<sha256(nodeId,runKey)>/
context.json
guard-stop.json
slots/
000001.json
000002.json
...
```
`context.json` 首次 atomic-create 后固定:
```json
{
"nodeId": "code-review",
"runId": "run_2",
"runKey": "history:4:run_2:<timestamp>",
"startedAt": "<resolved run timestamp>",
"maxWallTimeSeconds": 1800,
"maxToolCalls": 100
}
```
本轮不提供 environment override 或 runtime configuration,避免 takeover、restart 或配置变化扩大既有 budget。
`startedAt` 使用 run timestamp,不以第一次 tool call 起算。30 分钟按绝对 wall clock 计算,Claude Code 关闭或离线时间也计入。
### Atomic slots
每次 tool call 依次尝试用 `O_CREAT | O_EXCL` 创建 slot `1..100`:
1. 第一个成功创建的 slot 是本次调用的唯一 claim;
2. `EEXIST` 时继续尝试下一个;
3. 全部存在时超限;
4. 空或损坏 slot 仍视为已消耗;
5. slot 永不删除,不使用共享 JSON read-modify-write counter。
slot evidence 至少包含:
```json
{
"sessionId": "<PreToolUse session_id>",
"toolUseId": "<tool_use_id>",
"toolName": "Agent",
"agentId": "<optional agent_id>",
"claimedAt": "<ISO timestamp>"
}
```
Agent invocation 消耗一个 slot;Agent 内每个 child tool invocation 也触发 PreToolUse,并继续消耗同一 root session + node/run budget。`agent_id` 只用于 evidence,不进入 budget key。
### Decision order
1. 定位 registry 和 active auto flow;
2. 解析 current node/run 和 budget directory;
3. 若当前 run 的 `guard-stop.json` 已存在,deny;
4. atomic-create 或验证 frozen `context.json`;
5. 若 `now - startedAt >= 1800s`,atomic-create stop marker 并 deny;
6. 尝试 claim slot;成功则 allow;
7. 无 slot 可 claim时 atomic-create stop marker 并 deny。
第 100 次 aggregate tool call允许,第 101 次拒绝。Hook 只在下一次 tool call 前执行,不能中断已经运行中的 provider request、tool 或 subagent。
### Unified stop marker
Flow guard 和 node guard 都写同一格式:
```json
{
"sessionId": "<Claude session ID>",
"nodeId": "code-review",
"runKey": "history:4:run_2:<timestamp>",
"reason": "repair-edge-budget | wall-time-budget | tool-call-budget",
"edgeKey": "<optional exact repair edge>",
"createdAt": "<ISO timestamp>"
}
```
文件使用 `O_CREAT | O_EXCL`。多个并发 trigger 只保留第一个原因;已存在即表示当前 run 已 trip。
Hook deny 时 exit `0` 并输出 Claude Code 官方 decision JSON:
```json
{
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"permissionDecision": "deny",
"permissionDecisionReason": "OPC accidental-runaway circuit breaker tripped for the current node/run. Stop and report. Recovery requires an external terminal transition or stop."
}
}
```
不使用 exit `2` 携带 JSON。
## Recovery
Trip 后,同一 Claude session 的下一次 tool call会被 hook 拒绝,因此不能通过该 session 的 Bash 自动恢复。
用户在 external terminal 使用现有命令:
```text
opc-harness stop --dir <session>
opc-harness goto <node> --dir <session>
opc-harness skip --dir <session>
opc-harness pass --dir <session> // 仅 gate
```
`stop` 后 flow 不再 active;其他命令成功进入新的 node/run 后,runKey 改变,旧 stop marker 自动失效。不删除 marker、不增加 reset/unblock command。
这里的“external terminal”描述恢复路径,不是 human identity proof。设计不承诺阻止 malicious same-UID process 调用相同命令。
## Auto Mode 文案
删除现有无条件继续语义:
```text
do not pause, do not ask user, keep executing
```
替换为:
```text
auto mode — continue without confirmation only while node and repair-edge budgets remain;
when the circuit breaker trips, stop and report immediately;
do not retry or attempt recovery from the current Claude session
```
## Failure Handling
| 场景 | 行为 |
|---|---|
| session registry 不存在 | allow,零副作用 |
| interactive/completed/stopped flow | allow,零副作用 |
| registry 存在但损坏或 identity/run 无法验证 | 对该 session fail closed |
| context、slot 或 stop marker 无法创建 | 对该 session fail closed |
| slot 文件为空或损坏 | 按已占用处理 |
| wall time、slots 或 repair edge 耗尽 | 写 run-scoped stop marker并停止后续 tool execution |
| hook 脚本缺失 | `install-hooks` 失败;`init --auto` 拒绝启动 |
## 测试设计
### Flow guard
1. PASS 和 interactive transition 不受 repair guard 影响。
2. 首次 exact `FAIL` / `ITERATE` repair transition允许且只在成功后递增 counter。
3. 同一 edge 第二次 repair 在其他 side effect 前创建 stop marker并拒绝。
4. marker 创建失败时 transition fail closed。
5. validation failure、`goto`、`skip`、`pass` 不消耗 repair budget。
6. 不同 repair edge 独立计数。
7. `cmdAdvance` denial 返回 `advanced:false`。
8. 现有三个 graph limits 继续生效。
### Registry and activation
1. 默认 `/opc` 传 `--auto` 和 `${CLAUDE_SESSION_ID}`;`/opc -i` 不传。
2. `init --auto` 在 hook 缺失、session ID 缺失或 registry 写失败时不成功。
3. 同一 session ID 不能同时注册两个 active auto flow。
4. Hook 不使用 `latest`,只读取 session registry 指向的 flow。
5. 无 registry、interactive、completed、stopped 或 cwd 不匹配时 allow。
6. registry/state/identity/run 损坏时只对对应 session fail closed。
7. stopped/completed registry 静默 allow;同 session 的下一次 init 可覆盖旧 registry。
8. Session GC 保留 matching registry 指向的 active auto flow。
9. Session GC 只在持有 registry lock 且精确匹配 session dir 时删除 terminal flow 的 registry。
10. registry lock 或 cleanup 失败时,GC 保留 session dir 并报告 error。
### Hook budget
1. initial node 与首次 re-entry 使用不同 runKey。
2. context 首次创建后 budget 固定。
3. 第 100 次 aggregate call允许,第 101 次写 marker并拒绝。
4. Agent invocation 与所有 child tools 共享 aggregate slots。
5. 100+ parallel invocations 恰好最多 100 个 allow,无 lost update 或超发。
6. absolute wall time 超限写 marker并拒绝。
7. marker 存在后所有后续 tools,包括 Bash,持续拒绝。
8. 新 node/run 使用新 context,旧 marker 不生效。
9. deny JSON schema 和 exit code 符合 Claude Code hook contract。
### Recovery
1. repair-edge denial 返回前 marker 已 durable。
2. 同 session 不能用后续 Bash 调 recovery command。
3. external `goto` / `skip` / `pass` 进入新 run 后 hook恢复允许。
4. external `stop` 后 hook允许。
5. marker 和 slots 不被 recovery 删除。
所有新增 production code 必须 100% coverage;完整 test suite 无失败、无 skipped tests。
## 修改范围
预计涉及:
- `bin/lib/flow-core.mjs`:auto init、session registry、`flowStartedAt`、文案;
- `bin/lib/flow-transition.mjs`:repair-only counter、stop marker、`cmdAdvance` denial;
- `bin/lib/runaway-guard.mjs`:共享 run identity、registry path、budget path 和 atomic stop-marker helper;
- `bin/hooks/opc-pre-tool-budget.mjs`:新增 hook;
- `bin/opc.mjs`:安装 PreToolUse hook、atomic settings write;
- `SKILL.md`:默认 auto invocation、interactive invocation、文案;
- 对应 flow、registry、hook、install、recovery tests。
明确不修改:
- `bin/lib/flow-escape.mjs`;
- `bin/lib/driver-owner.mjs`;
- `bin/opc-harness.mjs` command surface;
- `bin/flow-templates.mjs`;
- evaluator、implementer、synthesize、finding parser 和 extension runtime。
## Acceptance Criteria
1. 默认 `/opc` auto flow 机械启用 guard;interactive flow不受影响。
2. 每条 exact `FAIL` / `ITERATE` repair edge 最多自动成功一次,多阶段 edge 互不影响。
3. 第二次 repair attempt 在其他 transition side effect 前 durable trip 当前 run。
4. 每个 node/run 最多 30 分钟和 100 aggregate tools;parallel calls 不丢计数、不超发。
5. Agent 与 child tools 共享同一个 aggregate budget,无独立 Agent cap。
6. repair、wall-time 或 tool-call guard trip 后,同 session 后续所有 tools均被拒绝。
7. Hook 通过 session registry 精确定位 flow,不依赖 `latest` 或全目录扫描。
8. external terminal 的现有 stop/transition 命令可恢复;不增加 reset、claim 或 authorization subsystem。
9. auto mode 不再包含无条件 `keep executing` 指令。
10. 文档明确本功能是 accidental-runaway circuit breaker,不是 malicious same-UID process 的 security boundary。
11. 所有新增代码 100% test coverage,现有 tests 全部通过且无 skipped tests。
# Brief Protocol
You are the **Brief Architect**. Your job is to transform a vague spec, discussion notes, or gate findings into an unambiguous, mechanically executable build brief. The builder who reads your output should make **zero design judgments** — every decision is already made.
## Context
- **Task:** {TASK_DESCRIPTION}
- **Upstream context:** {UPSTREAM_HANDSHAKE_SUMMARY}
- **Acceptance Criteria:** Read `{absolute path to $SESSION_DIR/acceptance-criteria.md}`
- **Quality Tier:** {TIER}
- **Design Artifacts:** (if DI extension active) design-brief.md, design-tokens.json in session dir
## Extension Context (mandatory)
Before starting work, run:
```
opc-harness prompt-context --node {NODE_ID} --role architect --dir {HARNESS_DIR}
```
Append the returned `append` string to your working context. Record `applied[]` in the handshake under `extensionsApplied`.
When Design Intelligence is active, node preflight may have already written
`design-mode.json`, `design-brief.md`, or `design-tokens.json` in the session
directory. Use those files to make design decisions concrete in
`build-brief.md`; do not merely mention that DI was applied.
## Brief Structure
Your output is `build-brief.md`. It MUST contain these sections. The mechanical linter (`opc-harness brief-lint`) gates your output — if it fails, the brief cannot proceed to build.
### 1. File Plan (mandatory — all tiers)
Every file: path + responsibility + estimated line count. No "etc.", "and more", "as needed".
```markdown
## File Plan
- index.html — main entry, ~200 lines
- styles.css — all styles, ~150 lines
- app.js — chart logic + event handlers, ~180 lines
```
### 2. Technology Decisions (mandatory — all tiers)
All choices resolved: library name + version + source URL (CDN, npm). The builder does not pick libraries — you do.
```markdown
## Technology Decisions
- Chart.js v4.4.0 via https://cdn.jsdelivr.net/npm/chart.js@4.4.0
- Tailwind CSS v3.4.1 via https://cdn.tailwindcss.com
```
### 3. Design Tokens (mandatory — polished/delightful tiers; optional — functional tier)
All colors = hex, all spacing = px/rem, all fonts = font-family stack. No "warm color", "appropriate spacing", "suitable font".
If DI extension provided `design-tokens.json`, reference those values directly. Otherwise resolve manually.
```markdown
## Design Tokens (resolved)
- Primary: #0EA5E9
- Background: #FFFFFF
- Text: #1E293B
- Font: Inter, system-ui, sans-serif
- Spacing unit: 8px
```
**Functional tier:** This section is optional. If present, it will be linted; if absent, no failure.
### 4. Component Inventory (mandatory — polished/delightful tiers; optional — functional tier)
For UI tasks: each page/component's structure, interaction behavior, and mock data with concrete values.
For backend/CLI tasks: replace with **API/Data Contract** — endpoint signatures, request/response shapes, error codes.
```markdown
## Component Inventory
- Dashboard: 4 cards showing KPI (¥126,560 revenue, 8,846 visits, 2.3% bounce rate, 4:32 avg session)
- Chart: line chart with 12 monthly data points (Jan: 45000, Feb: 52000, ...)
```
Or for functional tier:
```markdown
## API Contract
- POST /auth/login — body: {email, password} → 200: {token, user} | 401: {error}
- GET /users/:id — header: Authorization → 200: {user} | 404: {error}
```
**Functional tier:** This section is optional. If present, it will be linted; if absent, no failure.
### 5. Constraints (mandatory — all tiers)
Every constraint quantified with measurable values.
```markdown
## Constraints
- Contrast: 4.5:1 body text, 3:1 large text (WCAG AA)
- Responsive: breakpoints at 992px, 768px, 375px
- Animation: transition duration 200ms ease-out
- Performance: LCP < 2.5s on 3G
```
For functional tier:
```markdown
## Constraints
- Latency: p99 < 200ms
- Memory: RSS < 512MB under load
- Concurrency: handle 100 simultaneous connections
```
### 6. Iteration Delta (mandatory — only when gate returned ITERATE/FAIL)
Read the previous gate's findings. List each specific change. Not "fix the issues" — explicit file + what changes.
```markdown
## Iteration Delta
- styles.css: change chart accent from #FF0000 to #0EA5E9 per gate finding
- index.html: add aria-label to navigation links per a11y review
- app.js: fix data loading race condition (finding #3)
```
## Quality Gate
After writing `build-brief.md`, the orchestrator runs:
```bash
opc-harness brief-lint build-brief.md [--tier {TIER}] [--has-prior-findings]
```
If it fails, you get up to 3 auto-fix attempts. After 3 failures, the issue surfaces to the user.
**Tier affects which checks run:**
- `functional` — skips tokens-resolved, data-fixtures, no-vague-design
- `polished` / `delightful` — all checks enforced
## Handshake
Write to `{absolute path to $SESSION_DIR/nodes/{NODE_ID}/handshake.json}`:
```json
{
"nodeId": "{NODE_ID}",
"nodeType": "brief",
"runId": "run_{RUN}",
"status": "completed",
"summary": "<one-sentence brief summary>",
"timestamp": "<ISO8601>",
"artifacts": [
{ "type": "brief", "path": "build-brief.md" },
{ "type": "report", "path": "run_{RUN}/brief-lint-result.json" }
]
}
```
Do NOT commit changes — the orchestrator handles commits.
## Anti-Patterns
| Temptation | Why it's wrong | Do this instead |
|---|---|---|
| "Use appropriate colors" | Builder will pick wrong colors | Resolve to hex: `#0EA5E9` |
| "Add sample data" | Builder invents bad fixtures | Provide exact values: `¥126,560` |
| "Use a charting library" | Builder picks wrong version/CDN | Name it: `Chart.js v4.4.0 via CDN URL` |
| "Handle errors properly" | Builder's "proper" ≠ your "proper" | Specify: `show toast, retry after 3s, max 3 retries` |
| "etc.", "and more" | Builder skips what's not listed | Enumerate every item explicitly |
| "Make it responsive" | No breakpoints = no testing | List breakpoints: `992px, 768px, 375px` |
## Report
When done, report:
- What the brief covers (scope summary)
- Key design decisions made
- Any unresolved questions that need user input
# Hotfix Protocol
`hotfix` is the narrow repair node between `test-execute` and the terminal
gate. It exists so a one-line repair does not force a full build-review-test
loop, while preserving the rule that `test-execute` only runs tests and gathers
evidence.
## Allowed Scope
Allowed:
- Add or correct an accessibility attribute.
- Adjust a color token or contrast value.
- Fix a typo, label, heading level, or ARIA relationship.
- Repair a missing class or obvious selector mismatch.
- Make a small config/test-command correction needed to rerun evidence.
Forbidden:
- New feature behavior.
- Data model, API, persistence, routing, or auth changes.
- Large component rewrites.
- Test expectation changes that hide a product failure.
- Any change that needs a new design or architecture decision.
## Flow Rule
`test-execute` uses:
- `PASS -> gate`
- `ITERATE -> hotfix`
`hotfix` uses:
- `PASS -> test-execute`
- `ITERATE -> build`
- `FAIL -> brief`
After a hotfix, evidence must be recaptured by `test-execute`; hotfix output is
not final verification.
## Handshake
A completed hotfix node must write a normal handshake with `nodeType:"hotfix"`
and a structured `hotfix` object:
```json
{
"nodeId": "hotfix",
"nodeType": "hotfix",
"runId": "run_1",
"status": "completed",
"verdict": "PASS",
"summary": "Adjusted focus contrast token and aria-label.",
"timestamp": "2026-01-01T00:00:00.000Z",
"artifacts": [{ "type": "hotfix-report", "path": "run_1/hotfix-report.md" }],
"hotfix": {
"scope": "trivial",
"allowedOperations": ["contrast-token-adjustment", "aria-label"],
"forbiddenOperations": [],
"structuralChange": false
}
}
```
`opc-harness validate` rejects hotfix handshakes that omit the `hotfix` object,
claim a non-trivial scope, mark `structuralChange:true`, or list forbidden
operations.
# Tier Coverage Schema
`tierCoverage` is required on completed `execute` node handshakes when
`.harness/flow-state.json` has a quality tier with warning or critical baseline
items. It is the executor's explicit proof that every tier baseline item was
tested or consciously skipped.
Functional tier has no required keys. Polished and delightful tiers are enforced
by `opc-harness validate`.
## Shape
```json
{
"tierCoverage": {
"covered": ["typography", "color-scheme", "navigation"],
"skipped": [
{
"key": "code-blocks",
"reason": "Product has no code examples or developer documentation surfaces."
}
]
}
}
```
Rules:
- `covered` must be an array of baseline key strings.
- `skipped` must be an array of `{ "key": string, "reason": string }` objects.
- `reason` must be at least 10 characters and explain why the item is not applicable.
- Every required key for the tier must appear in either `covered` or `skipped`.
- Unknown keys are rejected.
## Baseline Keys
### `polished`
Required keys:
```text
typography
color-scheme
navigation
responsive
code-blocks
tables
testing-md
loading-states
error-states
favicon-meta
focus-styles
```
Valid but optional at this tier:
```text
page-transitions
```
### `delightful`
Required keys:
```text
typography
color-scheme
navigation
responsive
code-blocks
tables
testing-md
loading-states
error-states
favicon-meta
focus-styles
page-transitions
micro-interactions
```
There are no optional baseline keys at this tier.
## Validation
Run:
```bash
opc-harness validate .harness/nodes/test-execute/handshake.json
```
On malformed `tierCoverage`, the error includes this schema path plus the valid
and required key lists for the active tier.
To see executable tier test cases:
```bash
opc-harness tier-baseline --tier polished
opc-harness tier-baseline --tier delightful
```
---
name: opc
version: 0.10.2
description: "OPC — One Person Company. Digraph-based task pipeline with independent multi-role evaluation. Builds, reviews, analyzes, and brainstorms with specialist agents. Every path ends with evaluation. /opc <task>, /opc -i <task>, /opc <role> [role...]"
---
# OPC — One Person Company
One principle: **the agent that does the work never evaluates it.**
A full team in a single skill. The digraph engine handles any task — building code, reviewing code, analyzing problems, brainstorming designs. It infers which flow and entry point to use from the task itself, and every path ends with independent evaluation.
## Invocation
**Harness path:** The `opc-harness` binary lives at `bin/opc-harness.mjs` relative to this skill's install directory. Resolve it once at session start:
```bash
OPC_HARNESS="$HOME/.claude/skills/opc/bin/opc-harness.mjs"
```
All `opc-harness` references below mean `node "$OPC_HARNESS"`. Set this as a shell variable and reuse it throughout the session.
```
/opc <task> # auto mode — infer flow and roles from the task
/opc -i <task> # interactive mode — ask questions before dispatch
/opc <role> [role...] # explicit roles — skip role selection, dispatch directly
/opc loop <task> # autonomous loop — decompose, schedule cron, run 24h unattended
/opc skip # skip current node, advance via PASS edge
/opc pass # force-pass current gate
/opc stop # terminate flow, preserve session state
/opc goto <nodeId> # manual jump to a node (cycle limits still enforced)
```
## Task Inference + Flow Selection
The orchestrator reads the task, selects a flow template, and determines the entry point.
| Task says... | Flow template | Default entry |
|---|---|---|
| "review", "audit", "check", "before we merge", "找问题", "开源前看看" | review | review |
| "analyze", "diagnose", "what's wrong with", "分析" | review | review |
| "build", "implement", "create", "fix bug", "帮我实现", "重构成..." | build-verify | brief |
| "quick fix", "small change", "one-liner", "patch", "trivial fix", "快速修复", "小改动" | quick | build |
| "brainstorm", "explore options", "what are the approaches", "有什么方案" | build-verify | brief |
| "plan", "decompose", "break this down", "scope", "estimate", "拆一下" | build-verify | brief |
| "verify", "test", "QA", "check before release", "发布前验收" | pre-release | acceptance |
| "post-release", "user test", "onboarding check", "用户验收" | pre-release | acceptance |
| Complex, vague, or multi-keyword request | full-stack | discuss |
| `/opc loop` or multi-unit feature backlog | **loop-protocol** | plan decomposition |
**Entry override** — user context can shift the entry point (only if target ∈ template nodes):
| User has... | Entry override |
|---|---|
| A vague idea or brief | First node in template |
| A spec or design doc | brief (if ∈ template), else build |
| An implementation plan | brief (if ∈ template), else build |
| A qualified build-brief.md from prior run | build (skip brief if lint passes) |
| Code/artifact that needs evaluation | review, code-review, or test-design (if ∈ template) |
| Everything done, needs acceptance | acceptance (if ∈ template) |
**Priority rules:**
- `/opc loop <task>` = enter autonomous loop mode. Follow `./pipeline/loop-protocol.md`: first check `.opc/runbooks/` for a matching runbook, otherwise decompose task into units. Initialize loop state, start cron, execute ticks. Each tick runs the appropriate OPC flow for that unit type.
- `/opc <role> [role...]` without a task = review of current codebase using review flow with named roles.
- `/opc` with no arguments = prompt user to describe their task.
- If task matches multiple rows, prefer the flow that includes build — code changes must precede review.
Show triage result:
```
📌 Flow: {flow template name}
📍 Entry: {entry node}
⚡ Interaction: auto / interactive
Rationale: {1 sentence}
```
**Override:** If user explicitly names a task type, respect that. Users can adjust after seeing triage.
## Flow Templates
Flow graph structures (nodes, edges, limits) are defined in `opc-harness` code. The orchestrator uses `opc-harness route` to determine next nodes — **do not look up edges yourself**.
Each template below describes which agents to dispatch at each node and which protocol to use.
### legacy-linear
Equivalent to v0.4.x behavior. Used as internal fallback only.
| Node | Type | Agents | Protocol |
|------|------|--------|----------|
| design | discussion | [planner] | design exploration |
| plan | build | [planner] | task decomposition |
| build | build | [implementer] | implementer-prompt.md |
| evaluate | review | [selected roles] | role-evaluator-prompt.md |
| deliver | build | — | commit + report |
### review
| Node | Type | Agents | Protocol |
|------|------|--------|----------|
| review | review | [selected roles] | role-evaluator-prompt.md |
| gate | gate | — | gate-protocol.md |
Gate loopback: FAIL/ITERATE → review (multi-round with prior findings as context). Review is not limited to code — it evaluates any artifact: architecture proposals, documents, strategies, products.
### build-verify
| Node | Type | Agents | Protocol |
|------|------|--------|----------|
| brief | brief | [architect] | brief-protocol.md |
| build | build | [implementer] | implementer-prompt.md |
| code-review | review | [selected roles] | role-evaluator-prompt.md |
| test-design | review | [tester, + user/domain roles] | test-design-protocol.md |
| test-execute | execute | [orchestrator] | executor-protocol.md |
| gate | gate | — | gate-protocol.md |
**test-design** is a review node where multiple roles design test cases (API tests, E2E UI tests, edge cases) without executing them. **test-execute** runs the designed test plan and captures evidence. Principle: *the person who decides what to test must not be the person who runs the tests.*
### quick
| Node | Type | Agents | Protocol |
|------|------|--------|----------|
| build | build | [implementer] | implementer-prompt.md |
| review | review | [selected roles] | role-evaluator-prompt.md |
| test-design | review | [tester, + user/domain roles] | test-design-protocol.md |
| test-execute | execute | [orchestrator] | executor-protocol.md |
| gate | gate | — | gate-protocol.md |
**Scope**: Non-UI, single-file or ≤3 file changes, low risk. If task involves UI/design, multi-module refactoring, or security-related changes → use build-verify instead. Gate loops back to build (no brief node), and quick still requires OPC-generated testCommand evidence before final PASS.
### full-stack
The complete flow with discussion, multi-stage gates, and E2E verification.
| Node | Type | Agents | Protocol |
|------|------|--------|----------|
| discuss | discussion | [architect, engineer, tester] | discussion-protocol.md |
| brief | brief | [architect] | brief-protocol.md |
| build | build | [implementer] | implementer-prompt.md |
| code-review | review | [frontend, backend] | role-evaluator-prompt.md |
| test-design | review | [tester, + user/domain roles] | test-design-protocol.md |
| test-execute | execute | [orchestrator] | executor-protocol.md |
| gate-test | gate | — | gate-protocol.md |
| acceptance | review | [pm, designer] | role-evaluator-prompt.md |
| gate-acceptance | gate | — | gate-protocol.md |
| audit | review | [security, compliance, a11y] | role-evaluator-prompt.md |
| gate-audit | gate | — | gate-protocol.md |
| e2e-user | execute | [new-user, active-user, churned-user] | executor-protocol.md |
| gate-e2e | gate | — | gate-protocol.md |
| ux-simulation | execute | [new-user, active-user, churned-user] | ux-simulation-protocol.md + ux-observer-protocol.md |
| gate-final | gate | — | gate-protocol.md |
### pre-release
| Node | Type | Agents | Protocol |
|------|------|--------|----------|
| acceptance | review | [pm, designer] | role-evaluator-prompt.md |
| gate-acceptance | gate | — | gate-protocol.md |
| audit | review | [security, compliance, a11y] | role-evaluator-prompt.md |
| gate-audit | gate | — | gate-protocol.md |
| e2e-user | execute | [new-user, active-user, churned-user] | executor-protocol.md |
| gate-e2e | gate | — | gate-protocol.md |
---
## Getting Started
**Before task inference**, check for existing state:
1. Run `opc-harness ls` to discover active flows. If any exist for the current project, show them and ask whether to resume or start fresh.
2. If `.harness/` has `wave-*` files but no `flow-state.json` → **legacy v0.4.x format detected**. Print: "Detected v0.4.x .harness/ format. Please delete .harness/ and re-run, or manually migrate." Do not proceed.
3. Otherwise → fresh start.
After flow selection, initialize with the matching interaction mode:
```bash
opc-harness init --auto --claude-session-id "${CLAUDE_SESSION_ID}" --flow {TEMPLATE} --entry {ENTRY_NODE}
opc-harness init --flow {TEMPLATE} --entry {ENTRY_NODE} # interactive (`/opc -i`) only
```
Auto init requires the installed OPC `PreToolUse` hook. Interactive init does not create a Claude session registry and is not subject to the node or repair-edge circuit breaker.
Init auto-creates `~/.opc/sessions/{project-hash}/{session-id}/` and updates the `latest` symlink. **All subsequent harness commands automatically resolve to the latest session dir** — you do NOT need to pass `--dir` or capture the output. Just run commands normally:
```bash
opc-harness route --node review --verdict PASS --flow {TEMPLATE}
opc-harness transition --from review --to gate --verdict PASS --flow {TEMPLATE}
opc-harness viz --flow {TEMPLATE}
```
**Multi-window safety:** Each `init` creates a new session dir. If multiple OPC windows run on the same project, the last one to `init` becomes `latest`. To pin a specific session, pass `--dir <path>` explicitly.
**Backward compat:** Pass `--dir .harness` to init for a project-local harness dir.
**Show flow graph** — immediately after init, run `opc-harness viz --flow {TEMPLATE}` and display the ASCII output to the user. This gives them a visual map of the entire flow before execution begins.
Before starting, extract **acceptance criteria** — 3-7 concrete, testable bullet points. Evaluators grade against these.
### Quality Tier Selection — Mandatory Pre-Flight
Before the Definition of Done questions, the orchestrator MUST select a **quality tier**. See `./pipeline/quality-tiers.md` for full definitions.
| Tier | When | Baseline |
|------|------|----------|
| `functional` | CLI, API, backend, library, infra | No UI craft requirements |
| `polished` | UI, frontend, website, dashboard, docs | Dark/light, responsive, loading/error/empty states, favicon, focus styles |
| `delightful` | Showcase, demo, pitch, consumer product | All of polished + transitions, animations, micro-interactions, onboarding |
**Selection rules:**
1. User explicitly specifies tier → use it
2. Task involves UI/frontend → default `polished`
3. Task is CLI/API/backend → default `functional`
4. Task includes "showcase", "demo", "pitch", "delightful", "beautiful" → `delightful`
5. Interactive mode → ask the user
Show tier selection:
```
🎯 Quality Tier: {tier}
Baseline: {N items from tier checklist}
```
The tier's baseline checklist items are **automatically appended** to acceptance criteria under a "## Quality Baseline ({tier})" section in `acceptance-criteria.md` (in the session dir). The implementer and evaluator both receive the tier as context.
### Definition of Done — Mandatory Pre-Flight (all modes)
Before dispatching ANY work, the orchestrator MUST establish a clear definition of done. This applies to **both auto and interactive modes** — the only difference is how the answers are obtained (inferred vs asked).
**Three questions that must have answers before the first node executes:**
1. **What does "done" look like?** — Concrete, observable outcomes. Not "implement auth" but "user can log in with email/password, session persists across refresh, logout clears session."
2. **How will we verify it?** — Map each outcome to a verification method:
- Code change → which tests? (`npm test`, specific test file, new test to write?)
- UI change → which page/component to screenshot? What should be visible?
- API change → which endpoint to curl? What response shape?
- Refactor → which existing tests must still pass?
3. **How will we evaluate quality?** — What should reviewers look for beyond "it works"?
- Performance constraints? ("page load < 2s")
- Security concerns? ("no PII in logs")
- Compatibility? ("works in Safari")
- Edge cases? ("handles empty input, 10k items, unicode")
**In auto mode**: infer answers from the task description + codebase context (package.json scripts, existing tests, CLAUDE.md rules). Show inferred answers to user for confirmation. If task is too vague to infer concrete verification methods → **ask, even in auto mode.** A vague task is worse than a 30-second clarification.
**In interactive mode (`-i`)**: ask directly, grouped with role-specific questions.
**In loop mode (`/opc loop`)**: these answers go into `plan.md` per unit, so every tick knows how to verify itself even after context compaction.
Write the finalized acceptance criteria to `acceptance-criteria.md` (in the session dir) and include them in every subagent prompt.
**Design Reproduction Pre-Flight:** When the task involves reproducing/replicating a visual design from a reference image (keywords: 复刻, replicate, reproduce, reference image, 参考图, design reproduction), the orchestrator MUST run these additional init steps:
1. **Detect reference image** — user provides a path (e.g., `/Users/.../ref.jpg`). Confirm the file exists.
2. **Extract design spec** — run `analyze_reference.py` to generate a structured spec:
```bash
python3 ~/.claude/skills/image-x/scripts/analyze_reference.py <ref_image> --output <session_dir>/spec.json
```
3. **Write `## Reference` section** in `acceptance-criteria.md`:
```markdown
## Reference
- reference_image: /absolute/path/to/ref.jpg
- design_spec: /absolute/path/to/session/spec.json
```
4. **Set quality baseline** for design reproduction:
```markdown
## Quality Baseline (polished)
- design-diff overall ≥ 4.0
- zero major diffs
```
This enables the full automated loop: build reads spec.json → implementer produces HTML → test-execute screenshots + VLM design-diff → gate reads diffs → ITERATE feeds diffs back to build. See `./pipeline/executor-protocol.md` § "Design Reproduction Mode" for test-execute details.
**Criteria Lint — Mandatory Gate:** After writing `acceptance-criteria.md`, run `opc-harness criteria-lint acceptance-criteria.md` (use the session dir path). If it fails, revise and re-run (max 3 auto-fix attempts in auto mode, user-driven in interactive mode). See `./pipeline/criteria-lint.md` for the mechanical checks. Init is gated — `opc-harness init` refuses to start if criteria-lint hasn't passed.
### Task Scope — Mandatory for Loop Mode
In loop mode, every `plan.md` MUST include a `## Task Scope` section listing the user's original requirements:
```markdown
## Task Scope
- SCOPE-1: Backend API for user auth
- SCOPE-2: Frontend login page with form validation
- SCOPE-3: Browser E2E tests covering login flow
- SCOPE-4: Unit tests with 100% coverage on new code
```
The harness enforces this mechanically:
- **init-loop** refuses to start if `## Task Scope` is missing (bypass: `--skip-scope`)
- **complete-tick** on the final tick checks that every SCOPE-N item was covered by at least one completed unit (keyword overlap or explicit reference). Uncovered items = hard error, pipeline cannot complete (bypass: `--skip-scope-check`)
- **next-tick** termination output includes `uncovered_scope` if any items lack coverage
This prevents the #1 failure mode: LLM decomposition misses part of the original task, pipeline declares "complete" while major scope items are untouched.
### Interactive Mode Details (with `-i`)
Ask targeted questions derived from selected roles — what does each role need that can't be inferred from the codebase? Aim for 3-5 grouped questions, merged with the Definition of Done questions above.
- Engineering roles usually read code directly — no extra context needed.
- Product and user roles benefit most: "Who are your target users?", "What's the product stage?"
- Security and Compliance may need: "Do you handle PII?", "Target markets?"
**Persona construction** for user roles: In auto mode, infer from project context. In interactive mode, ask directly.
### Project Context
Subagents don't inherit CLAUDE.md or project instructions automatically. When dispatching any subagent, **forward relevant project context**: dev workflow rules, precommit checks, coding conventions, test commands. Include this in every subagent prompt.
### Superpowers Integration
If `superpowers` skills are available, use them: brainstorming for design, plan writing, subagent-driven development for build, and branch delivery.
---
## Built-in Roles
```
Product: pm, designer
User Lens: new-user, active-user, churned-user
Engineering: frontend, backend, devops, architect, engineer
Quality: security, tester, compliance, a11y
Specialist: planner, user-simulator, devil-advocate
```
Role definitions live in `roles/<name>.md`. Add a `.md` file to `roles/` to create a custom role.
### Role Discovery
The orchestrator searches for role definitions in this order (later sources override earlier ones with the same filename):
1. **Built-in roles** — `roles/<name>.md` in OPC's install directory
2. **Flow template roles** — if the active flow template specifies `rolesDir`, scan `_resolvedRolesDir/<name>.md`. Custom roles with the same name as a built-in one take precedence for this flow.
3. **Dynamic roles** — created on-the-fly during execution (see below)
**How to check for custom roles:** After `opc-harness init`, if the flow template was loaded from `~/.claude/flows/`, check `FLOW_TEMPLATES[template]._resolvedRolesDir`. If it exists and is a directory, scan it for `.md` files and merge into the role pool.
**Protocol discovery** works the same way: if the flow template specifies `protocolDir`, protocols in `_resolvedProtocolDir/<name>.md` supplement or override built-in protocols in `pipeline/`.
### Role Selection
1. **Tag filter** — from the flow template, you know the node type. Map to stage tags:
| Node type | Stage tags |
|-----------|-----------|
| review | review |
| build | build |
| execute | execute, post-release, verification |
| discussion | brainstorm, plan, discussion |
| gate | (no roles dispatched) |
Read the `tags:` front matter from each `roles/<name>.md`. Keep only roles whose tags include at least one matching stage tag.
2. **Select from filtered pool** — pick 2-5 roles with distinct angles. Read each candidate's "When to Include" section to decide relevance.
- **Mandatory roles always included** — roles with `mandatory: true` in front matter are auto-included in every review node. The orchestrator cannot remove them. Currently: `skeptic-owner`.
- Each dispatched agent must have a DISTINCT angle. If two would produce 80%+ overlapping output, pick one.
- Not every task needs every role. A CSS fix doesn't need Security.
- **Devil's Advocate auto-inclusion:** When a discussion node reaches Round 2 with near-unanimous agreement (all agents converge on the same approach), the orchestrator SHOULD include devil-advocate in a subsequent review pass. Consensus is a signal to challenge, not to proceed. For irreversible decisions (data deletion, public API contracts, destructive migrations), devil-advocate is MANDATORY.
- If user specified roles explicitly, use those — skip tag filtering entirely.
**Dynamic Role Creation:** If the task requires expertise not covered by any candidate, create a role on-the-fly following the same format (Identity + Expertise + When to Include + Anti-Patterns). Write to `$SESSION_DIR/nodes/{nodeId}/dynamic-role-{name}.md`. Max 5 dynamic roles per flow run.
Show role selection:
```
📋 Agents:
- frontend — <specific scope>
- security — <specific scope>
...
Launching {N} agents...
```
---
## Node Execution
**Auto mode is bounded.** Continue without confirmation only while node and repair-edge budgets remain. Normal graph limits and validation failures still apply.
When the circuit breaker trips, stop and report immediately. Do not retry or attempt recovery from the current Claude session. Recovery requires the user to run an existing `opc-harness stop`, `goto`, `skip`, or `pass` command from an external terminal.
The orchestrator uses **cursor-based execution** — `flow-state.json.currentNode` is the single pointer. No topological sort.
### Execution Loop
```
1. Read flow-state.json → currentNode
2. Look up currentNode in the flow template table above → get type, agents, protocol
3. Execute based on node type (see below)
4. After execution:
- opc-harness validate → check handshake.json
- Update progress.md with narrative line
- opc-harness route --node {current} --verdict PASS --flow {template} → get next
- opc-harness transition --from {current} --to {next} --verdict PASS --flow {template}
- **Show flow viz**: run `opc-harness viz --flow {template}` and display to user
- Loop back to step 1
5. When route returns next=null → flow complete → Deliver → **Prompt replay** (see below)
```
### Node Type: discussion
Follow `./pipeline/discussion-protocol.md`.
1. Dispatch agents for 3 rounds. **Round 1: parallel** (agents are independent — no reason to serialize). Round 2: serial with context injection (each agent sees Round 1 outputs, writes diffs only). Round 3: facilitator convergence.
2. **Orchestrator writes handshake.json** after collecting all artifacts (agents don't write it).
3. Discussion nodes produce no verdict — the decision artifact feeds downstream.
### Node Type: build
Follow `./pipeline/implementer-prompt.md` in Build/Fix/Polish mode.
1. Dispatch implementer subagent.
2. **Single agent** → agent writes its own handshake.json.
3. **Multiple agents** (parallel, with `isolation: "worktree"`) → orchestrator merges artifacts and writes handshake.json.
4. With superpowers: invoke `superpowers:subagent-driven-development`.
5. **After committing delivered code**, run `opc-harness record-commit --sha <sha>` (or bare, defaulting to HEAD) so the terminal gate's changeScopeCoverage layer scopes to what this flow produced instead of a blind `HEAD~1` diff. Skip only if the build committed nothing.
### Node Type: review
Follow `./pipeline/role-evaluator-prompt.md`.
1. Select roles per Role Selection rules.
2. Dispatch evaluators — parallel if no dependencies, serial with context injection if dependencies exist.
3. Each agent writes `eval-{role}.md` to `$SESSION_DIR/nodes/{NODE_ID}/run_{RUN}/`.
4. **Orchestrator writes handshake.json** after all agents return, merging all eval files into artifacts[].
5. Before dispatching, build context brief using `./pipeline/context-brief.md` (for review/analysis tasks).
**Critical — Review Independence:**
- Review MUST use independent subagents (Agent tool), never the orchestrator reviewing its own build output.
- In loop mode, review MUST be a separate tick/unit from implementation. Never combine build + review in one tick.
- The orchestrator MUST NOT filter, downgrade, or dismiss findings before writing the handshake. All findings pass through to the gate.
### Node Type: execute
Follow `./pipeline/executor-protocol.md`.
**Executor nodes are executed by the orchestrator directly — not as a subagent.** This is because executors need full tool access (Bash, Playwright, Skills).
1. Smoke test tool availability.
2. Execute acceptance criteria scenarios.
3. Capture evidence (CLI output, screenshots).
4. **Orchestrator writes handshake.json** with evidence artifacts.
5. Handshake validation enforces: execute nodes must have evidence artifacts.
### Node Type: gate
Follow `./pipeline/gate-protocol.md`.
**Gate nodes are executed by the orchestrator directly — no subagent dispatch.**
1. `opc-harness synthesize --node {upstream}` → get verdict.
2. Mechanical validation (severity emojis, file refs, fix suggestions).
3. `opc-harness route --node {gate} --verdict {V} --flow {template}` → get next node.
4. `opc-harness transition --from {gate} --to {next} --verdict {V} --flow {template}` → validates edge, writes gate handshake, updates state.
5. Notify user: pass/loopback/done/blocked.
---
## Verdict & Loopback
Gate nodes produce verdicts via `opc-harness synthesize` (code, not LLM judgment):
- Any 🔴 → FAIL
- Any 🟡 → ITERATE
- All 🔵/LGTM → PASS
- Any BLOCKED → BLOCKED
**Code enforces all limits:**
- `maxLoopsPerEdge` = 3 (same edge can't be traversed more than 3 times)
- `maxTotalSteps` = 20-30 (depending on flow template)
- `maxNodeReentry` = 5 (same node can't be entered more than 5 times)
**Oscillation detection:** After a loopback, run `opc-harness diff` on consecutive evaluations. If `oscillation: true`, surface to user.
**Escape hatches:**
- `/opc skip` — skip current node, advance via PASS edge
- `/opc pass` — force gate to PASS
- `/opc stop` — terminate flow, preserve state
- `/opc goto <nodeId>` — manual jump (cycle limits still enforced via `transition`)
When transition returns `allowed: false` → show the user why (which limit hit) and offer escape options. Never continue without user consent.
---
## File-Based State
```
$SESSION_DIR/ # ~/.opc/sessions/{hash}/{id}/ or .harness/ if --dir used
├── flow-state.json # Current node, execution history, edge counts, limits
├── progress.md # Human-readable narrative log
└── nodes/
└── {nodeId}/
├── handshake.json # Machine-readable envelope (summary + verdict + artifact paths)
└── run_{N}/
├── eval.md # Single evaluator output (detailed findings)
├── eval-{role}.md # Per-role evaluator output (multi-role)
├── round-1-{role}.md # Discussion round 1
├── round-2-{role}.md # Discussion round 2 (diffs only)
├── decision.md # Discussion facilitator decision
├── screenshot-{N}.png # Executor GUI evidence
└── command-output-{N}.txt # Executor CLI evidence
```
**Relationships:**
- `handshake.json` = envelope. Its `artifacts[]` points to detailed files (eval.md, screenshots, etc.)
- `flow-state.json` = sole source of truth for execution position and history
- `eval.md` / `eval-{role}.md` = human-readable findings (read by `synthesize` to compute verdict)
- `progress.md` = narrative projection of flow execution (for humans)
---
## Prompt Templates
All templates live in `./pipeline/`:
- `evaluator-prompt.md` — Single generic evaluator
- `role-evaluator-prompt.md` — Role-specific evaluator (review, analysis, brainstorm outputs)
- `implementer-prompt.md` — Implementer (Build / Fix / Polish modes)
- `discussion-protocol.md` — Multi-agent discussion (round-robin, 3 rounds, facilitator)
- `gate-protocol.md` — Verdict aggregation + code-based routing + transition + **findings disposition**
- `executor-protocol.md` — CLI/GUI execution with evidence requirements
- `test-design-protocol.md` — **Test case design** (review node, multi-role test planning before execution)
- `loop-protocol.md` — **Autonomous multi-unit execution** (plan decomposition → cron loop → auto-terminate)
- `handoff-template.md` — Handshake.json specification
- `context-brief.md` — Design context brief procedure
- `report-format.md` — Presentation templates + JSON schema + replay
- `quality-tiers.md` — Tier definitions + baseline checklists + severity calibration
- `ux-simulation-protocol.md` — **UX simulation gate** (red flag detection, delta comparison, ordinal tier fit)
- `ux-observer-protocol.md` — **UX observer dispatch** (persona-based pattern observation, closed enum red flags)
- `criteria-lint.md` — **DoD mechanical lint** (single-pass structure + content checks, pre-init gate)
---
## External Flow Templates
Custom flows can be defined as JSON files in `~/.claude/flows/`. The harness loads them at startup and merges them into the template registry. Built-in templates take precedence (external cannot override).
**JSON schema:**
```json
{
"nodes": ["discover", "build", "review", "gate"],
"edges": {
"discover": { "PASS": "build" },
"build": { "PASS": "review" },
"review": { "PASS": "gate" },
"gate": { "PASS": null, "FAIL": "build", "ITERATE": "build" }
},
"limits": { "maxLoopsPerEdge": 3, "maxTotalSteps": 20, "maxNodeReentry": 5 },
"nodeTypes": {
"discover": "discussion", "build": "build",
"review": "review", "gate": "gate"
},
"softEvidence": true,
"opc_compat": ">=0.10",
"contextSchema": {
"build": {
"required": ["task"],
"rules": { "task": "non-empty-string" }
}
}
}
```
**Validation rules:**
- `nodes`, `edges`, `limits` are required
- All edge sources and targets must be in `nodes`
- `nodeTypes` values must be: `discussion`, `build`, `review`, `execute`, `gate`
- `opc_compat` uses `>=X.Y` semver range (current harness compatibility: 0.10.0)
- Prototype pollution names (`__proto__`, `constructor`, `prototype`) are rejected
**Optional fields:**
- `softEvidence: true` — downgrades missing-evidence errors to warnings for execute nodes
- `contextSchema` — per-node validation rules for `flow-context.json`
- `opc_compat` — minimum harness version required
**contextSchema rules:**
- `non-empty-string` — must be a non-empty string
- `non-empty-array` — must be a non-empty array
- `non-empty-object` — must be a non-empty plain object (not array)
- `positive-integer` — must be a positive integer > 0
---
## Harness Command Reference
All commands output JSON to stdout. Errors go to stderr. All output is machine-parseable.
### Flow Commands
| Command | Usage | Description |
|---------|-------|-------------|
| `init` | `--flow <tpl> [--entry <node>] [--dir <p>]` | Initialize flow state. Creates `flow-state.json` and node directories. Seeds `baseSha` (git floor) and empty `producedCommits`. |
| `record-commit` | `[--sha <sha>] [--dir <p>]` | Record a commit the flow produced into `flow-state.producedCommits`. Defaults to HEAD; dedups; fail-closed on invalid sha. The gate's changeScopeCoverage layer scopes to these commits. |
| `route` | `--node <id> --verdict <V> --flow <tpl>` | Get next node from graph edges. Returns `{next, allowed}`. |
| `transition` | `--from <n> --to <n> --verdict <V> --flow <tpl> --dir <p>` | Execute state transition. Validates edge, checks limits, writes gate handshake, enforces backlog. |
| `validate` | `<handshake.json>` | Validate handshake schema (required fields, evidence check for execute nodes). |
| `validate-chain` | `[--dir <p>]` | Validate entire execution path — checks all handshakes match history. |
| `validate-context` | `--flow <tpl> --node <id> [--dir <p>]` | Validate `flow-context.json` against contextSchema rules. |
| `finalize` | `[--dir <p>] [--strict]` | Finalize terminal node. Marks flow as completed. |
| `viz` | `--flow <tpl> [--dir <p>] [--json]` | Visualize flow graph (ASCII or JSON). Shows ▶ current, ✅ visited, ○ pending. |
| `replay` | `[--dir <p>]` | Export full replay data as JSON (flow state + handshakes + run artifacts). |
### Escape Hatches
| Command | Usage | Description |
|---------|-------|-------------|
| `skip` | `[--dir <p>] [--flow <tpl>]` | Skip current node, advance via PASS edge. Writes skip handshake. |
| `pass` | `[--dir <p>]` | Force-pass current gate node. Only works on gate-type nodes. |
| `stop` | `[--dir <p>]` | Terminate flow, preserve state. Sets status to "stopped". |
| `goto` | `<nodeId> [--dir <p>]` | Manual jump to any node. Cycle limits still enforced. |
| `ls` | `[--base <p>]` | List all active flows (scans `~/.opc/sessions/` and project-local `.harness*` directories). |
### Eval Commands
| Command | Usage | Description |
|---------|-------|-------------|
| `verify` | `<file>` | Parse evaluation markdown → JSON (severity counts, verdict, findings). |
| `synthesize` | `<dir> --node <id> [--run N] [--base <dir>] [--no-strict] [--iteration N]` | Merge all evaluations for a node → aggregate verdict. D2 compound gate enforced by default (≥3 layers → FAIL); `--no-strict` for shadow mode. `--base` validates file:line refs. |
| `report` | `<dir> --mode <m> --task <t>` | Generate full report JSON with presentation data. |
| `diff` | `<file1> <file2>` | Compare two evaluation rounds. Detects oscillation. |
### Loop Commands (Layer 2 — Zero Trust)
| Command | Usage | Description |
|---------|-------|-------------|
| `init-loop` | `[--plan <file>] [--dir <p>]` | Initialize loop state from plan.md. Validates plan structure, detects test/lint scripts. |
| `complete-tick` | `--unit <id> --artifacts <a,b> [--description <text>] [--dir <p>]` | Complete tick with evidence. Validates artifacts per unit type, checks plan hash, overlap detection. |
| `next-tick` | `[--dir <p>]` | Get next unit. Checks stall/oscillation, returns `{ready, unit, terminate}`. |
### Transition Details
The `transition` command enforces:
- **Edge validation** — only declared edges are allowed
- **Cycle limits** — `maxLoopsPerEdge`, `maxTotalSteps`, `maxNodeReentry`
- **Idempotency** — repeated identical transitions are silently accepted
- **Gate detection** — uses `nodeTypes[from] === "gate"` (not name prefix)
- **Pre-transition validation** — upstream handshake must exist and be valid
- **Backlog enforcement** — if upstream has warnings, `backlog.md` must exist for FAIL/ITERATE transitions
---
## Resilience
**Agent spawn failures:** Retry once. If it fails again, surface to user.
**Context compaction resilience:** `opc install-hooks` always registers the Node-based `PreToolUse` guard required by auto flows. When `jq` is available, it also registers optional PreCompact/PostCompact shell hooks that snapshot state and inject resume context after compaction. When auto-compact fires:
1. **PreCompact** writes a resume brief to `$SESSION_DIR/resume-brief.md`
2. **PostCompact** injects the brief as `additionalContext` into the new context
3. The orchestrator sees the injection and resumes the flow automatically
If the optional compaction hooks are unavailable, flow-state.json still persists on disk, but the orchestrator must be manually re-invoked via `/opc` (which runs `opc-harness ls` to discover active flows).
**State recovery:** On resume, run `opc-harness validate-chain`. If inconsistent → surface to user, do not auto-repair.
**Legacy detection:** If `.harness/` in project root has `wave-*` files but no `flow-state.json` → refuse to run. Print migration instructions.
**Fresh context per agent.** Always spawn new subagents. Files carry state; agents bring fresh capacity.
---
## Flow Completion & Replay
When the flow completes (route returns `next=null`):
1. Show final viz: `opc-harness viz --flow {template}`
2. Show summary: total steps, nodes visited, any loopbacks
3. **Generate HTML report** (use the session dir from init output, or find it via `opc-harness ls`):
```bash
node "$OPC_HARNESS/../opc-report.mjs" --dir <session-dir> --output <session-dir>/report.html --title "{task summary}"
```
This produces a self-contained dark-theme HTML report with mechanically parsed stats, pipeline visualization, findings tables, and R2 fix tracking. Open it for the user.
4. **Prompt the user:**
```
✅ Flow complete! Report: $SESSION_DIR/report.html
Want to see the replay? Run: /opc replay
```
#!/bin/bash
# Regression tests for brief-lint checks
set -e
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
setup_git
assert_pass() {
local desc="$1" file="$2" extra="${3:-}"
local OUT
OUT=$($HARNESS brief-lint "$file" $extra 2>/dev/null) || true
local ok
ok=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('pass',False))" 2>/dev/null)
if [ "$ok" = "True" ]; then
echo " ✅ $desc"
PASS=$((PASS + 1))
else
echo " ❌ $desc — expected pass, got: $OUT"
FAIL=$((FAIL + 1))
fi
}
assert_fail() {
local desc="$1" file="$2" check="$3" extra="${4:-}"
local OUT
OUT=$($HARNESS brief-lint "$file" $extra 2>/dev/null) || true
local ok
ok=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('pass',False))" 2>/dev/null)
if [ "$ok" = "False" ]; then
if echo "$OUT" | grep -q "$check"; then
echo " ✅ $desc"
PASS=$((PASS + 1))
else
echo " ❌ $desc — failed but wrong check: $OUT"
FAIL=$((FAIL + 1))
fi
else
echo " ❌ $desc — expected fail, got pass"
FAIL=$((FAIL + 1))
fi
}
# ═══════════════════════════════════════════════════════════════
echo ""
echo "=== TEST GROUP 1: Golden pass ==="
# ═══════════════════════════════════════════════════════════════
cat > golden.md << 'EOF'
## File Plan
- index.html — main entry, ~200 lines
- styles.css — all styles, ~150 lines
## Technology Decisions
- Chart.js v4.4.0 via https://cdn.jsdelivr.net/npm/chart.js@4.4.0
- Tailwind CSS v3.4.1 via CDN
## Design Tokens (resolved)
- Primary: #0EA5E9
- Background: #FFFFFF
- Text: #1E293B
## Component Inventory
- Dashboard: 4 cards showing KPI (¥126,560 revenue, 8,846 visits)
- Chart: line chart with 12 monthly data points
## Constraints
- Contrast: 4.5:1 body, 3:1 large text
- Responsive: 992px, 768px, 375px breakpoints
- Animation: 200ms ease-out transitions
EOF
echo "--- 1.1: Golden brief passes ---"
assert_pass "golden brief" golden.md
# ═══════════════════════════════════════════════════════════════
echo ""
echo "=== TEST GROUP 2: tech-decisions checks ==="
# ═══════════════════════════════════════════════════════════════
echo "--- 2.1: No version number ---"
cat > no-version.md << 'EOF'
## File Plan
- index.html — entry, ~200 lines
## Technology Decisions
- Chart.js via CDN
- Tailwind CSS via CDN
## Design Tokens (resolved)
- Primary: #0EA5E9
- Background: #FFFFFF
- Text: #1E293B
## Component Inventory
- Dashboard: 4 cards showing KPI (¥126,560 revenue)
## Constraints
- Contrast: 4.5:1 body text
- Responsive: 992px breakpoint
EOF
assert_fail "no version" no-version.md "tech-decisions-resolved"
echo ""
echo "--- 2.2: Open-ended tech choice ---"
cat > open-ended.md << 'EOF'
## File Plan
- index.html — entry, ~200 lines
## Technology Decisions
- Use a charting library v1.0 via CDN
## Design Tokens (resolved)
- Primary: #0EA5E9
- Background: #FFFFFF
- Text: #1E293B
## Component Inventory
- Dashboard: 4 cards showing KPI (¥126,560 revenue)
## Constraints
- Contrast: 4.5:1 body text
- Responsive: 992px breakpoint
EOF
assert_fail "open-ended" open-ended.md "tech-decisions-resolved"
echo ""
echo "--- 2.3: Bare language name without library ---"
cat > bare-lang.md << 'EOF'
## File Plan
- index.html — entry, ~200 lines
## Technology Decisions
- Use JavaScript v1.0 via CDN
## Design Tokens (resolved)
- Primary: #0EA5E9
- Background: #FFFFFF
- Text: #1E293B
## Component Inventory
- Dashboard: 4 cards showing KPI (¥126,560 revenue)
## Constraints
- Contrast: 4.5:1 body text
- Responsive: 992px breakpoint
EOF
assert_fail "bare language" bare-lang.md "tech-decisions-resolved"
echo ""
echo "--- 2.4: Language + specific library is OK ---"
cat > lang-with-lib.md << 'EOF'
## File Plan
- index.html — entry, ~200 lines
## Technology Decisions
- Use JavaScript with Chart.js v4.4.0 via https://cdn.jsdelivr.net/npm/chart.js@4.4.0
## Design Tokens (resolved)
- Primary: #0EA5E9
- Background: #FFFFFF
- Text: #1E293B
## Component Inventory
- Dashboard: 4 cards showing KPI (¥126,560 revenue)
## Constraints
- Contrast: 4.5:1 body text
- Responsive: 992px breakpoint
EOF
assert_pass "language + library OK" lang-with-lib.md
# ═══════════════════════════════════════════════════════════════
echo ""
echo "=== TEST GROUP 3: file-plan checks ==="
# ═══════════════════════════════════════════════════════════════
echo "--- 3.1: Missing line estimates ---"
cat > no-estimates.md << 'EOF'
## File Plan
- index.html — main entry
- styles.css — all styles
## Technology Decisions
- Chart.js v4.4.0 via https://cdn.jsdelivr.net/npm/chart.js@4.4.0
## Design Tokens (resolved)
- Primary: #0EA5E9
- Background: #FFFFFF
- Text: #1E293B
## Component Inventory
- Dashboard: 4 cards showing KPI (¥126,560 revenue)
## Constraints
- Contrast: 4.5:1 body text
- Responsive: 992px breakpoint
EOF
assert_fail "no line estimates" no-estimates.md "file-plan-estimates"
echo ""
echo "--- 3.2: Incomplete markers ---"
cat > incomplete.md << 'EOF'
## File Plan
- index.html — main entry, ~200 lines
- styles.css — all styles etc.
## Technology Decisions
- Chart.js v4.4.0 via https://cdn.jsdelivr.net/npm/chart.js@4.4.0
## Design Tokens (resolved)
- Primary: #0EA5E9
- Background: #FFFFFF
- Text: #1E293B
## Component Inventory
- Dashboard: 4 cards showing KPI (¥126,560 revenue)
## Constraints
- Contrast: 4.5:1 body text
- Responsive: 992px breakpoint
EOF
assert_fail "etc marker" incomplete.md "file-plan-complete"
# ═══════════════════════════════════════════════════════════════
echo ""
echo "=== TEST GROUP 4: vague design checks ==="
# ═══════════════════════════════════════════════════════════════
echo "--- 4.1: Vague design term ---"
cat > vague.md << 'EOF'
## File Plan
- index.html — entry, ~200 lines
## Technology Decisions
- Chart.js v4.4.0 via https://cdn.jsdelivr.net/npm/chart.js@4.4.0
## Design Tokens (resolved)
- Primary: #0EA5E9
- Background: #FFFFFF
- Text: use an appropriate color
## Component Inventory
- Dashboard: 4 cards showing KPI (¥126,560 revenue)
## Constraints
- Contrast: 4.5:1 body text
- Responsive: 992px breakpoint
EOF
assert_fail "vague design" vague.md "no-vague-design"
# ═══════════════════════════════════════════════════════════════
echo ""
echo "=== TEST GROUP 5: iteration-delta ==="
# ═══════════════════════════════════════════════════════════════
echo "--- 5.1: Missing delta when prior findings exist ---"
assert_fail "missing delta" golden.md "iteration-delta" "--has-prior-findings"
echo ""
echo "--- 5.2: With delta section ---"
cat > with-delta.md << 'EOF'
## File Plan
- index.html — entry, ~200 lines
## Technology Decisions
- Chart.js v4.4.0 via https://cdn.jsdelivr.net/npm/chart.js@4.4.0
## Design Tokens (resolved)
- Primary: #0EA5E9
- Background: #FFFFFF
- Text: #1E293B
## Component Inventory
- Dashboard: 4 cards showing KPI (¥126,560 revenue)
## Constraints
- Contrast: 4.5:1 body text
- Responsive: 992px breakpoint
## Iteration Delta
- Fix chart color from #FF0000 to #0EA5E9 per gate finding
EOF
assert_pass "with delta" with-delta.md "--has-prior-findings"
# ═══════════════════════════════════════════════════════════════
echo ""
echo "=== TEST GROUP 6: forgery resistance ==="
# ═══════════════════════════════════════════════════════════════
echo "--- 6.1: CLI pass field is boolean ---"
cat > forge-attempt.md << 'EOF'
## File Plan
- index.html — entry, ~200 lines
## Technology Decisions
- Chart.js v4.4.0 via https://cdn.jsdelivr.net/npm/chart.js@4.4.0
## Design Tokens (resolved)
- Primary: #0EA5E9
- Background: #FFFFFF
- Text: #1E293B
## Component Inventory
- Dashboard: 4 cards showing KPI (¥126,560 revenue)
## Constraints
- Contrast: 4.5:1 body text
- Responsive: 992px breakpoint
EOF
OUT=$($HARNESS brief-lint forge-attempt.md 2>/dev/null) || true
PASS_VAL=$(echo "$OUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(type(d.get('pass')).__name__, d.get('pass'))" 2>/dev/null)
if echo "$PASS_VAL" | grep -q "bool"; then
echo " ✅ pass field is boolean"
PASS=$((PASS + 1))
else
echo " ❌ pass field type: $PASS_VAL"
FAIL=$((FAIL + 1))
fi
echo ""
echo "--- 6.2: Validate catches forged report (bad brief + fake pass:true) ---"
# Anti-forgery: a vague brief with a hand-crafted {"pass":true} report must be rejected
# by opc-harness validate, which re-runs brief-lint on the actual content.
rm -rf .h-forge && $HARNESS init --flow build-verify --dir .h-forge >/dev/null 2>/dev/null
mkdir -p .h-forge/nodes/brief/run_1
# Write a BAD brief — vague, missing sections
echo "# Vague brief with no structure" > .h-forge/nodes/brief/build-brief.md
# Write a FORGED report claiming pass
echo '{"pass":true,"checksRun":8,"checksPassed":8,"failures":[],"warnings":[]}' > .h-forge/nodes/brief/run_1/brief-lint-result.json
# Write handshake referencing both artifacts
cat > .h-forge/nodes/brief/handshake.json << 'HS'
{
"nodeId": "brief", "nodeType": "brief", "runId": "run_1",
"status": "completed", "summary": "forged", "timestamp": "2024-01-01T00:00:00Z",
"artifacts": [{"type":"brief","path":"build-brief.md"},{"type":"report","path":"run_1/brief-lint-result.json"}]
}
HS
OUT=$($HARNESS validate .h-forge/nodes/brief/handshake.json 2>/dev/null)
VALID=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('valid'))" 2>/dev/null)
if [ "$VALID" = "False" ]; then
echo " ✅ validate rejects forged report (re-runs lint on actual brief)"
PASS=$((PASS + 1))
else
echo " ❌ validate accepted forged report: $OUT"
FAIL=$((FAIL + 1))
fi
echo ""
echo "--- 6.3: Validate accepts real brief with real passing lint ---"
rm -rf .h-real && $HARNESS init --flow build-verify --dir .h-real >/dev/null 2>/dev/null
mkdir -p .h-real/nodes/brief/run_1
write_golden_brief .h-real/nodes/brief/build-brief.md
echo '{"pass":true}' > .h-real/nodes/brief/run_1/brief-lint-result.json
cat > .h-real/nodes/brief/handshake.json << 'HS'
{
"nodeId": "brief", "nodeType": "brief", "runId": "run_1",
"status": "completed", "summary": "real", "timestamp": "2024-01-01T00:00:00Z",
"artifacts": [{"type":"brief","path":"build-brief.md"},{"type":"report","path":"run_1/brief-lint-result.json"}]
}
HS
OUT=$($HARNESS validate .h-real/nodes/brief/handshake.json 2>/dev/null)
VALID=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('valid'))" 2>/dev/null)
if [ "$VALID" = "True" ]; then
echo " ✅ validate accepts legitimate brief"
PASS=$((PASS + 1))
else
echo " ❌ validate rejected legitimate brief: $OUT"
FAIL=$((FAIL + 1))
fi
echo ""
echo "--- 6.4: Forged report error mentions brief-lint ---"
OUT=$($HARNESS validate .h-forge/nodes/brief/handshake.json 2>/dev/null)
ERRORS=$(echo "$OUT" | python3 -c "import sys,json; print(' '.join(json.load(sys.stdin).get('errors',[])))" 2>/dev/null)
if echo "$ERRORS" | grep -q "brief-lint"; then
echo " ✅ error message mentions brief-lint re-run"
PASS=$((PASS + 1))
else
echo " ❌ error: $ERRORS"
FAIL=$((FAIL + 1))
fi
echo ""
echo "--- 6.5: Validate enforces Iteration Delta on gate loopback (run_2, no delta) ---"
# A brief re-entered as run_2 (gate sent it back) MUST have an Iteration Delta
# section. The golden brief has none → validate must reject it at run_2.
rm -rf .h-loop && $HARNESS init --flow build-verify --dir .h-loop >/dev/null 2>/dev/null
mkdir -p .h-loop/nodes/brief/run_2
write_golden_brief .h-loop/nodes/brief/build-brief.md
echo '{"pass":true}' > .h-loop/nodes/brief/run_2/brief-lint-result.json
cat > .h-loop/nodes/brief/handshake.json << 'HS'
{
"nodeId": "brief", "nodeType": "brief", "runId": "run_2",
"status": "completed", "summary": "loopback no delta", "timestamp": "2024-01-01T00:00:00Z",
"artifacts": [{"type":"brief","path":"build-brief.md"},{"type":"report","path":"run_2/brief-lint-result.json"}]
}
HS
OUT=$($HARNESS validate .h-loop/nodes/brief/handshake.json 2>/dev/null)
VALID=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('valid'))" 2>/dev/null)
ERRORS=$(echo "$OUT" | python3 -c "import sys,json; print(' '.join(json.load(sys.stdin).get('errors',[])))" 2>/dev/null)
if [ "$VALID" = "False" ] && echo "$ERRORS" | grep -q "Iteration Delta"; then
echo " ✅ validate rejects loopback brief missing Iteration Delta"
PASS=$((PASS + 1))
else
echo " ❌ validate did not enforce Iteration Delta on run_2: $OUT"
FAIL=$((FAIL + 1))
fi
echo ""
echo "--- 6.6: Validate accepts loopback brief WITH Iteration Delta (run_2) ---"
rm -rf .h-loop2 && $HARNESS init --flow build-verify --dir .h-loop2 >/dev/null 2>/dev/null
mkdir -p .h-loop2/nodes/brief/run_2
{ write_golden_brief /dev/stdout; printf '\n## Iteration Delta\n- Fixed contrast on KPI cards to 4.5:1 per prior finding\n- Added 200ms transition to chart hover\n'; } > .h-loop2/nodes/brief/build-brief.md
echo '{"pass":true}' > .h-loop2/nodes/brief/run_2/brief-lint-result.json
cat > .h-loop2/nodes/brief/handshake.json << 'HS'
{
"nodeId": "brief", "nodeType": "brief", "runId": "run_2",
"status": "completed", "summary": "loopback with delta", "timestamp": "2024-01-01T00:00:00Z",
"artifacts": [{"type":"brief","path":"build-brief.md"},{"type":"report","path":"run_2/brief-lint-result.json"}]
}
HS
OUT=$($HARNESS validate .h-loop2/nodes/brief/handshake.json 2>/dev/null)
VALID=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('valid'))" 2>/dev/null)
if [ "$VALID" = "True" ]; then
echo " ✅ validate accepts loopback brief with Iteration Delta"
PASS=$((PASS + 1))
else
echo " ❌ validate rejected valid loopback brief: $OUT"
FAIL=$((FAIL + 1))
fi
echo ""
echo "--- 6.7: run_1 brief does NOT require Iteration Delta (first pass) ---"
# Regression guard: first-pass brief (run_1) must still pass without a delta section.
OUT=$($HARNESS validate .h-real/nodes/brief/handshake.json 2>/dev/null)
VALID=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('valid'))" 2>/dev/null)
if [ "$VALID" = "True" ]; then
echo " ✅ run_1 brief passes without Iteration Delta"
PASS=$((PASS + 1))
else
echo " ❌ run_1 brief wrongly required Iteration Delta: $OUT"
FAIL=$((FAIL + 1))
fi
print_results
#!/bin/bash
# Tests for text-based severity parsing + formatErrors
set -e
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
setup_git
parse_eval() {
local file="$1"
# Use synthesize with a single file to exercise parseEvaluation
echo "$($HARNESS verify "$file" 2>/dev/null)"
}
echo "=== TEST GROUP 1: Text-based severity parsing ==="
echo ""
# ── 1.1: Emoji severity still works (regression) ──
echo "--- 1.1: Emoji severity (regression) ---"
cat > emoji.md << 'EOF'
# Review
## Security
🔴 src/app.ts:10 — XSS vulnerability in user input
Reasoning: User input rendered without escaping.
→ Use textContent instead of innerHTML.
## Summary
VERDICT: FINDINGS[1]
EOF
OUT=$(parse_eval emoji.md)
CRIT=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('critical',0))" 2>/dev/null)
if [ "$CRIT" = "1" ]; then
echo " ✅ emoji 🔴 parsed as critical"
PASS=$((PASS + 1))
else
echo " ❌ expected critical=1, got: $CRIT"
FAIL=$((FAIL + 1))
fi
# ── 1.2: [CRITICAL] text severity ──
echo "--- 1.2: [CRITICAL] text severity ---"
cat > text-crit.md << 'EOF'
# Review
## Security
[CRITICAL] src/app.ts:10 — XSS vulnerability in user input
Reasoning: User input rendered without escaping.
→ Use textContent instead of innerHTML.
## Summary
VERDICT: FINDINGS[1]
EOF
OUT=$(parse_eval text-crit.md)
CRIT=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('critical',0))" 2>/dev/null)
if [ "$CRIT" = "1" ]; then
echo " ✅ [CRITICAL] parsed as critical"
PASS=$((PASS + 1))
else
echo " ❌ expected critical=1, got: $CRIT"
FAIL=$((FAIL + 1))
fi
# ── 1.3: [WARNING] text severity ──
echo "--- 1.3: [WARNING] text severity ---"
cat > text-warn.md << 'EOF'
# Review
## Performance
[WARNING] src/db.ts:42 — Missing connection pooling
Reasoning: Each request creates a new database connection.
→ Use connection pool with max 10 connections.
## Summary
VERDICT: FINDINGS[1]
EOF
OUT=$(parse_eval text-warn.md)
WARN=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('warning',0))" 2>/dev/null)
if [ "$WARN" = "1" ]; then
echo " ✅ [WARNING] parsed as warning"
PASS=$((PASS + 1))
else
echo " ❌ expected warning=1, got: $WARN"
FAIL=$((FAIL + 1))
fi
# ── 1.4: [SUGGESTION] text severity ──
echo "--- 1.4: [SUGGESTION] text severity ---"
cat > text-sug.md << 'EOF'
# Review
## Code Quality
[SUGGESTION] src/utils.ts:30 — Unused helper function
Reasoning: Function is never imported in any other module.
→ Remove dead code.
## Summary
VERDICT: FINDINGS[1]
EOF
OUT=$(parse_eval text-sug.md)
SUG=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('suggestion',0))" 2>/dev/null)
if [ "$SUG" = "1" ]; then
echo " ✅ [SUGGESTION] parsed as suggestion"
PASS=$((PASS + 1))
else
echo " ❌ expected suggestion=1, got: $SUG"
FAIL=$((FAIL + 1))
fi
# ── 1.5: Lowercase [critical] text severity ──
echo "--- 1.5: Lowercase [critical] ---"
cat > text-lower.md << 'EOF'
# Review
## Security
[critical] src/app.ts:10 — SQL injection in query builder
Reasoning: String concatenation used for SQL queries.
→ Use parameterized queries.
## Summary
VERDICT: FINDINGS[1]
EOF
OUT=$(parse_eval text-lower.md)
CRIT=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('critical',0))" 2>/dev/null)
if [ "$CRIT" = "1" ]; then
echo " ✅ [critical] lowercase parsed"
PASS=$((PASS + 1))
else
echo " ❌ expected critical=1, got: $CRIT"
FAIL=$((FAIL + 1))
fi
# ── 1.6: Mixed emoji + text in same eval ──
echo "--- 1.6: Mixed emoji + text severity ---"
cat > mixed.md << 'EOF'
# Review
## Security
🔴 src/auth.ts:10 — Session fixation
Reasoning: Session not regenerated after login.
→ Call session.regenerate() after auth.
## Performance
[WARNING] src/db.ts:42 — N+1 query pattern
Reasoning: Loading related records in a loop.
→ Use JOIN or batch loading.
## Code Quality
🔵 src/utils.ts:30 — Unused import
Reasoning: Dead import clutters readability.
→ Remove unused import.
## Summary
VERDICT: FINDINGS[3]
EOF
OUT=$(parse_eval mixed.md)
TOTAL=$(echo "$OUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('critical',0)+d.get('warning',0)+d.get('suggestion',0))" 2>/dev/null)
if [ "$TOTAL" = "3" ]; then
echo " ✅ mixed: 3 findings total"
PASS=$((PASS + 1))
else
echo " ❌ expected 3 findings, got: $TOTAL"
FAIL=$((FAIL + 1))
fi
# ── 1.7: Bare CRITICAL without brackets → NOT parsed ──
echo "--- 1.7: Bare CRITICAL without brackets (no false positive) ---"
cat > bare.md << 'EOF'
# Review
## Notes
This is a critical component of the system.
The warning about memory leaks is important.
No suggestion for improvement needed.
## Summary
VERDICT: LGTM
EOF
OUT=$(parse_eval bare.md)
TOTAL=$(echo "$OUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('critical',0)+d.get('warning',0)+d.get('suggestion',0))" 2>/dev/null)
if [ "$TOTAL" = "0" ]; then
echo " ✅ bare words not parsed as severity"
PASS=$((PASS + 1))
else
echo " ❌ expected 0, got: $TOTAL (false positive)"
FAIL=$((FAIL + 1))
fi
# ── 1.8: Legacy metadata severity line → NOT parsed ──
echo "--- 1.8: Legacy metadata line (no false positive) ---"
cat > legacy-meta.md << 'EOF'
# Review
## Finding 1 — Real structured issue title
**Severity**: 🔴
**Location**: src/legacy.js:7
VERDICT: FINDINGS[1]
EOF
OUT=$(parse_eval legacy-meta.md)
TOTAL=$(echo "$OUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('critical',0)+d.get('warning',0)+d.get('suggestion',0))" 2>/dev/null)
if [ "$TOTAL" = "0" ]; then
echo " ✅ metadata severity is not parsed as a finding"
PASS=$((PASS + 1))
else
echo " ❌ expected 0 metadata findings, got: $TOTAL"
FAIL=$((FAIL + 1))
fi
echo ""
echo "=== TEST GROUP 2: formatErrors ==="
echo ""
# ── 2.1: Unstructured severity line → formatError ──
echo "--- 2.1: Unstructured severity → formatError ---"
cat > unstructured.md << 'EOF'
# Review
## Issues
🔴 This is bad code
🟡 Performance could be better
🔵 Consider refactoring
## Summary
VERDICT: FINDINGS[3]
EOF
OUT=$(parse_eval unstructured.md)
# verify returns formatErrors array — check it has entries
FE_COUNT=$(echo "$OUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('formatErrors',[])))" 2>/dev/null)
if [ "$FE_COUNT" = "3" ]; then
echo " ✅ 3 formatErrors collected for unstructured findings"
PASS=$((PASS + 1))
else
echo " ❌ expected 3 formatErrors, got: $FE_COUNT"
FAIL=$((FAIL + 1))
fi
# ── 2.2: formatErrors with 0 structured → synthesize produces warning ──
echo "--- 2.2: formatErrors + 0 structured → synthesize warns ---"
# Set up a proper harness dir for synthesize
rm -rf .h-fe
$HARNESS init --flow review --entry review --dir .h-fe 2>/dev/null
mkdir -p .h-fe/nodes/review/run_1
# Analyst: all unstructured (no em-dash, no file:line)
cat > .h-fe/nodes/review/run_1/eval-analyst.md << 'EVALEOF'
# Analyst Review
## Issues
🔴 This is bad
🟡 That is bad
🔵 Everything is bad
## Summary
VERDICT: FINDINGS[3]
EVALEOF
# Checker: properly structured
cat > .h-fe/nodes/review/run_1/eval-checker.md << 'EVALEOF'
# Checker Review
## Security
🔴 src/auth.ts:10 — Session fixation vulnerability
Reasoning: Session ID not regenerated after login.
→ Call session.regenerate() after authentication.
## Performance
🔵 src/db.ts:42 — Consider adding connection pooling
Reasoning: Creates new connection per request.
→ Use connection pool.
## Summary
VERDICT: FINDINGS[2]
EVALEOF
OUT=$($HARNESS synthesize .h-fe --node review 2>/dev/null)
WARNINGS=$(echo "$OUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(' '.join(d.get('thinEvalWarnings',[])))" 2>/dev/null)
if echo "$WARNINGS" | grep -q "format"; then
echo " ✅ synthesize warns about format errors"
PASS=$((PASS + 1))
else
echo " ❌ no format warning in: $WARNINGS"
FAIL=$((FAIL + 1))
fi
# ── 2.3: formatErrors increment warning totals ──
echo "--- 2.3: formatErrors move synthesize verdict ---"
rm -rf .h-fe-count
$HARNESS init --flow review --entry review --dir .h-fe-count 2>/dev/null
mkdir -p .h-fe-count/nodes/review/run_1
cat > .h-fe-count/nodes/review/run_1/eval-skeptic-owner.md << 'EVALEOF'
# Skeptic Owner Review
## Findings
🟡 This severity marker is intentionally unstructured
Reasoning: the parser must not silently drop a severity marker.
→ Rewrite the finding with file:line and an em dash.
## Scope
EVALEOF
for i in $(seq 1 55); do echo "Format error regression line $i has varied review context for the parser." >> .h-fe-count/nodes/review/run_1/eval-skeptic-owner.md; done
cat >> .h-fe-count/nodes/review/run_1/eval-skeptic-owner.md << 'EVALEOF'
VERDICT: FINDINGS[1]
EVALEOF
OUT=$($HARNESS synthesize .h-fe-count --node review 2>/dev/null)
SYNTH_VERDICT=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('verdict'))" 2>/dev/null)
WARN_TOTAL=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('totals',{}).get('warning',0))" 2>/dev/null)
if [ "$SYNTH_VERDICT" = "ITERATE" ] && [ "$WARN_TOTAL" -ge 2 ]; then
echo " ✅ formatErrors contribute warning totals and ITERATE"
PASS=$((PASS + 1))
else
echo " ❌ expected ITERATE with warning total >=2, got verdict=$SYNTH_VERDICT warning=$WARN_TOTAL"
FAIL=$((FAIL + 1))
fi
print_results
#!/bin/bash
set -e
# F10 DX gate: the `ctx.nodeCapabilities not set` WARN must fire ONLY when caps could
# not be resolved from a flow template (genuine misconfig / raw-library misuse). When the
# CLI successfully resolves a flow template and the node simply declares no capabilities
# (e.g. `quick`'s review node, or build-verify's test-design/gate nodes), an empty caps
# list is LEGITIMATE — emitting the WARN there is pure noise.
#
# - quick:review (template has no nodeCapabilities map) → NO WARN
# - build-verify:test-design (map exists, node absent from it) → NO WARN
# - build-verify:gate (map exists, node absent from it) → NO WARN
# - build-verify:code-review (node HAS caps) → NO WARN (control)
# - prompt-context with no flow-state + no --flow (template unresolvable) → WARN STILL fires
#
# The last case is the preservation guard: we suppress the false positive without blanket-
# silencing the WARN. Template-unresolvable-yet-extensions-loaded is a real, actionable
# misconfig and must stay loud. The F2 raw-library WARN-once contract (extensions.test.mjs)
# covers the programmatic "forgot to pass caps" path and must remain green independently.
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
WARN_PATTERN="nodeCapabilities not set"
# A minimal well-formed extension so the registry has >0 extensions (the WARN short-circuits
# to silence when nobody is listening).
mkdir -p exts/ok-ext
cat > exts/ok-ext/hook.mjs << 'EOF'
export const meta = { name: "ok-ext", provides: ["verification@1"] };
export function promptAppend() { return ""; }
export function verdictAppend() { return []; }
EOF
assert_warn_count() {
local desc="$1" expected="$2" stderr="$3" actual
actual=$(echo "$stderr" | grep -c "$WARN_PATTERN" || true)
if [ "$actual" = "$expected" ]; then
echo " ✅ $desc"; PASS=$((PASS + 1))
else
echo " ❌ $desc — expected $expected WARN line(s), got $actual"; FAIL=$((FAIL + 1))
fi
}
# init <flow> into a fresh subdir, fire prompt-context on <node>, capture stderr only.
warn_stderr() {
local flow="$1" node="$2" sub="sess-${flow}-${node}"
rm -rf "$sub"; mkdir -p "$sub"
( cd "$sub" && $HARNESS init --flow "$flow" --dir . >/dev/null 2>&1 )
OPC_EXTENSIONS_DIR="$TMPDIR/exts" $HARNESS prompt-context --node "$node" --role tester --dir "$sub" 2>&1 1>/dev/null
}
echo "=== F10: nodeCapabilities WARN fires only on unresolved-template path ==="
# ───────────────────────────────────────────────────────────────
echo ""
echo "--- N1: quick:review (template has no nodeCapabilities map) → NO WARN ---"
OUT=$(warn_stderr quick review)
assert_warn_count "quick review → silent" 0 "$OUT"
# ───────────────────────────────────────────────────────────────
echo ""
echo "--- N2: build-verify:test-design (capless node) → NO WARN ---"
OUT=$(warn_stderr build-verify test-design)
assert_warn_count "build-verify test-design → silent" 0 "$OUT"
# ───────────────────────────────────────────────────────────────
echo ""
echo "--- N3: build-verify:gate (capless node) → NO WARN ---"
OUT=$(warn_stderr build-verify gate)
assert_warn_count "build-verify gate → silent" 0 "$OUT"
# ───────────────────────────────────────────────────────────────
echo ""
echo "--- N4 (control): build-verify:code-review (HAS caps) → NO WARN ---"
OUT=$(warn_stderr build-verify code-review)
assert_warn_count "build-verify code-review → silent" 0 "$OUT"
# ───────────────────────────────────────────────────────────────
# Preservation guard: no flow-state.json and no --flow → template cannot resolve →
# caps genuinely unknown while extensions are loaded → the WARN MUST still fire.
echo ""
echo "--- N5 (guard): unresolved template + extensions loaded → WARN STILL fires ---"
rm -rf bare; mkdir -p bare # no init: no flow-state.json
OUT=$(OPC_EXTENSIONS_DIR="$TMPDIR/exts" $HARNESS prompt-context --node review --role tester --dir bare 2>&1 1>/dev/null)
assert_warn_count "unresolved template → warns" 1 "$OUT"
# ───────────────────────────────────────────────────────────────
# Node-typo guard: template resolves, but the requested node is NOT in template.nodes.
# An empty caps list here is NOT a legitimate "capless node" — it's a misconfig (typo /
# wrong --node). Suppressing the WARN would hide a loaded-but-no-match extension with zero
# diagnostics. So an unknown node must be treated as unresolved → WARN STILL fires.
echo ""
echo "--- N6 (guard): resolved template + unknown node → WARN STILL fires ---"
OUT=$(warn_stderr build-verify typo-node)
assert_warn_count "unknown node → warns" 1 "$OUT"
print_results
#!/bin/bash
set -e
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
HARNESS="$REPO_DIR/bin/opc-harness.mjs"
PASS=0
FAIL=0
ok() { echo " ✅ $1"; PASS=$((PASS+1)); }
bad() { echo " ❌ $1"; FAIL=$((FAIL+1)); }
DIR="$REPO_DIR/.tmp-extension-version-state-$$"
EXT_DIR="$DIR/exts"
rm -rf "$DIR"
mkdir -p "$EXT_DIR/versioned-ext"
cat > "$EXT_DIR/versioned-ext/ext.json" <<'JSON'
{
"name": "versioned-ext",
"version": "4.5.6",
"meta": {
"provides": ["design-system-injection@1"]
}
}
JSON
cat > "$EXT_DIR/versioned-ext/hook.mjs" <<'JS'
export const meta = { provides: ["design-system-injection@1"] };
JS
OPC_EXTENSIONS_DIR="$EXT_DIR" "$HARNESS" init --flow build-verify --entry brief --dir "$DIR/.harness" >/dev/null
VERSION=$(python3 - "$DIR/.harness/flow-state.json" <<'PY'
import json, sys
state = json.load(open(sys.argv[1]))
versions = {e["name"]: e["version"] for e in state.get("extensionVersions", [])}
print(versions.get("versioned-ext", "missing"))
PY
)
if [ "$VERSION" = "4.5.6" ]; then
ok "init records ext.json version in flow-state"
else
bad "expected 4.5.6, got $VERSION"
fi
rm -rf "$DIR"
echo ""
echo "Extension version state tests: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1
#!/bin/bash
set -e
# F9: fact-check ref resolution must also look in the session dir, not only --base.
# An eval legitimately citing a session artifact (e.g. test-plan.md) must NOT
# be flagged invalidRef just because that artifact lives outside the project base.
# F2: a non-git --base must not (a) leak git's "fatal: not a git repository" to the
# terminal, nor (b) silently skip change-scope verification. It must emit an
# explicit verificationWarnings entry so "verification didn't run" is visible.
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
jq_field() {
echo "$1" | python3 -c "import sys,json; d=json.load(sys.stdin); v=d.get('$2'); print('__NULL__' if v is None else json.dumps(v))" 2>/dev/null
}
assert_contains() {
local desc="$1" text="$2" pattern="$3"
if echo "$text" | grep -q "$pattern"; then
echo " ✅ $desc"; PASS=$((PASS + 1))
else
echo " ❌ $desc — pattern '$pattern' not found"; FAIL=$((FAIL + 1))
fi
}
assert_not_contains() {
local desc="$1" text="$2" pattern="$3"
if echo "$text" | grep -q "$pattern"; then
echo " ❌ $desc — pattern '$pattern' found (should not be)"; FAIL=$((FAIL + 1))
else
echo " ✅ $desc"; PASS=$((PASS + 1))
fi
}
assert_field_eq() {
local desc="$1" json="$2" field="$3" expected="$4" actual
actual=$(jq_field "$json" "$field")
if [ "$actual" = "$expected" ]; then
echo " ✅ $desc"; PASS=$((PASS + 1))
else
echo " ❌ $desc — $field: expected $expected, got $actual"; FAIL=$((FAIL + 1))
fi
}
# Build a git project base with N committed files. Echoes the base path.
make_git_base() {
local b
b=$(mktemp -d)
( cd "$b" \
&& git init -q . \
&& git config user.email t@t.t \
&& git config user.name t \
&& echo "export function realThing() { return 1; }" > real.ts \
&& echo "export const other = 2;" > other.ts \
&& git add . \
&& git commit -q -m init ) >/dev/null 2>&1
echo "$b"
}
# Build a NON-git base dir with one source file. Echoes the base path.
make_nongit_base() {
local b
b=$(mktemp -d)
echo "export function realThing() { return 1; }" > "$b/real.ts"
echo "$b"
}
setup_session() {
rm -rf .harness
mkdir -p .harness/nodes/code-review/run_1
cat > .harness/flow-state.json << 'EOF'
{"currentNode":"code-review","history":[{"node":"code-review","run":1}],"edgeCounts":{},"stepCount":1}
EOF
}
echo "=== Fact-check base/session ref resolution (F2 + F9) ==="
# ───────────────────────────────────────────────────────────────
echo ""
echo "--- F9.1: eval cites a SESSION artifact not in --base → NOT invalidRef ---"
setup_session
# session-relative artifact lives next to the flow-state, outside project base
cat > .harness/test-plan.md << 'EOF'
# Test Plan
L1 unit coverage
L2 integration coverage
L3 pagination coverage scenario — empty page and last page
L4 e2e coverage
L5 a11y coverage
EOF
cat > .harness/nodes/code-review/run_1/eval-tester.md << 'EOF'
# Test Design Review
🔵 test-plan.md:4 — pagination coverage scenario is under-specified
**Reasoning:** the plan omits pagination boundary tests for empty and last page.
**Fix:** add explicit pagination coverage cases.
EOF
BASE_F9=$(make_git_base)
OUT=$($HARNESS synthesize .harness --node code-review --base "$BASE_F9" 2>/dev/null)
assert_not_contains "session ref not flagged as fabricated" "$OUT" "fabricated refs detected"
# ───────────────────────────────────────────────────────────────
echo ""
echo "--- F9.2 (guard): a GENUINELY missing ref is still invalidRef ---"
setup_session
cat > .harness/nodes/code-review/run_1/eval-tester.md << 'EOF'
# Review
🔵 src/ghost-nonexistent-9999.ts:5 — references a file that exists nowhere
**Reasoning:** this file does not exist in base or session.
**Fix:** n/a.
EOF
BASE_F9B=$(make_git_base)
OUT=$($HARNESS synthesize .harness --node code-review --base "$BASE_F9B" 2>/dev/null)
assert_contains "genuine fake ref still flagged fabricated" "$OUT" "fabricated refs detected"
# ───────────────────────────────────────────────────────────────
echo ""
echo "--- F2.1: non-git --base emits explicit verificationWarnings ---"
setup_session
cat > .harness/nodes/code-review/run_1/eval-tester.md << 'EOF'
# Review
🟡 real.ts:1 — realThing returns a magic number
**Reasoning:** realThing hardcodes a return value.
**Fix:** make realThing configurable.
EOF
BASE_NG=$(make_nongit_base)
OUT=$($HARNESS synthesize .harness --node code-review --base "$BASE_NG" 2>/dev/null)
assert_contains "non-git base surfaces verificationWarnings" "$OUT" "verificationWarnings"
assert_contains "verificationWarnings explains git repo requirement" "$OUT" "not a git repository"
# ───────────────────────────────────────────────────────────────
echo ""
echo "--- F2.2: non-git --base does NOT leak git stderr to terminal ---"
setup_session
cat > .harness/nodes/code-review/run_1/eval-tester.md << 'EOF'
# Review
🟡 real.ts:1 — realThing returns a magic number
**Reasoning:** realThing hardcodes a return value.
**Fix:** make realThing configurable.
EOF
BASE_NG2=$(make_nongit_base)
ERR=$($HARNESS synthesize .harness --node code-review --base "$BASE_NG2" 2>&1 1>/dev/null)
assert_not_contains "no git 'fatal:' leaked to stderr" "$ERR" "fatal:"
assert_not_contains "no git 'usage:' leaked to stderr" "$ERR" "usage: git"
# ───────────────────────────────────────────────────────────────
echo ""
echo "--- F2.3 (guard): git --base happy path emits no false verificationWarnings ---"
setup_session
cat > .harness/nodes/code-review/run_1/eval-tester.md << 'EOF'
# Review
🟡 real.ts:1 — realThing returns a magic number
**Reasoning:** realThing hardcodes a return value.
**Fix:** make realThing configurable.
EOF
BASE_G=$(make_git_base)
OUT=$($HARNESS synthesize .harness --node code-review --base "$BASE_G" 2>/dev/null)
assert_field_eq "git base → verificationWarnings absent" "$OUT" "verificationWarnings" "__NULL__"
# ───────────────────────────────────────────────────────────────
# F2.4: non-git --base is an infrastructure evidence gap, not a product defect.
# A 🔵-suggestion-only eval should keep its PASS verdict while surfacing
# verificationWarnings loudly for the operator/report layer.
# NOTE: the eval is written as the mandatory 'skeptic-owner' role so the
# mandatory-role check does NOT add its own warning — that would mask the
# policy by making the verdict ITERATE for an unrelated reason.
echo ""
echo "--- F2.4: 🔵-only eval + non-git --base keeps PASS with warning ---"
setup_session
cat > .harness/nodes/code-review/run_1/eval-skeptic-owner.md << 'EOF'
# Skeptic Owner Review
🔵 real.ts:1 — realThing could expose a named constant
**Reasoning:** a named constant would read better than a literal.
**Fix:** extract the magic number to a const.
EOF
BASE_NG3=$(make_nongit_base)
OUT=$($HARNESS synthesize .harness --node code-review --base "$BASE_NG3" 2>/dev/null)
assert_field_eq "non-git base does not false-red verdict" "$OUT" "verdict" '"PASS"'
assert_contains "non-git base still surfaces warning" "$OUT" "verificationWarnings"
# ───────────────────────────────────────────────────────────────
echo ""
echo "--- F13: absence/meta finding does NOT trigger weakRef hallucination ---"
setup_session
cat > .harness/nodes/code-review/run_1/eval-skeptic-owner.md << 'EOF'
# Skeptic Owner Review
## Contract Evidence
🔵 real.ts:1 — test runner is not wired to this module
**Reasoning:** this is an absence claim about execution wiring, not a positive claim about tokens on line 1.
**Fix:** connect the runner or record the missing wiring in the plan.
## Grounding Notes
The cited line anchors the module under review.
The claim is about what is absent from the execution path.
The review keeps this as a suggestion so it should not hard-fail.
## Additional Review Detail
EOF
for i in $(seq 1 45); do echo "Absence meta review line $i records wiring context with varied evidence text." >> .harness/nodes/code-review/run_1/eval-skeptic-owner.md; done
cat >> .harness/nodes/code-review/run_1/eval-skeptic-owner.md << 'EOF'
VERDICT: PASS FINDINGS[1]
EOF
BASE_ABSENCE=$(make_git_base)
OUT=$($HARNESS synthesize .harness --node code-review --base "$BASE_ABSENCE" 2>/dev/null)
assert_not_contains "absence/meta finding is not marked possible hallucination" "$OUT" "possible hallucination"
print_results
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/test-helpers.sh"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
setup_tmpdir
export HOME="$(pwd -P)/home"
mkdir -p "$HOME"
check() {
local label="$1" cond="$2"
if eval "$cond"; then
echo " ✅ $label"
PASS=$((PASS + 1))
else
echo " ❌ $label"
FAIL=$((FAIL + 1))
fi
}
check_json() {
local label="$1" expr="$2" input="$3"
local result
result=$(printf "%s" "$input" | python3 -c "import json,sys; d=json.load(sys.stdin); print($expr)" 2>/dev/null)
check "$label" '[ "$result" = "True" ] || [ "$result" = "true" ]'
}
echo "=== FE1: extension-test --dev-server shorthand ==="
mkdir -p ext
cat > ext/hook.mjs <<'HOOK'
export default {
hooks: {
"execute.run": async (ctx) => {
if (ctx.devServerUrl !== "http://localhost:8787") {
throw new Error(`bad devServerUrl: ${ctx.devServerUrl || ""}`);
}
return { devServerUrl: ctx.devServerUrl };
}
}
};
HOOK
OUT=$($HARNESS extension-test --ext ext --hook execute.run --context '{"devServerUrl":"http://wrong"}' --dev-server http://localhost:8787 2>/dev/null)
check "dev-server shorthand overrides context" 'echo "$OUT" | grep -q "localhost:8787"'
OUT=$($HARNESS extension-test --ext ext --hook execute.run --context '{"devServerUrl":"http://wrong"}' --dev-server=http://localhost:8787 2>/dev/null)
check "dev-server equals form works" 'echo "$OUT" | grep -q "localhost:8787"'
echo ""
echo "=== FE2: validate/finalize resolve latest session ==="
mkdir -p project && cd project
git init -q
git config user.email test@test.com
git config user.name Test
git commit --allow-empty -m init -q
$HARNESS init --flow review --entry review --no-extensions >/dev/null 2>/dev/null
SESSION=$(node --input-type=module -e "import { getLatestSessionDir } from '$ROOT/bin/lib/util.mjs'; console.log(getLatestSessionDir());")
mkdir -p "$SESSION/nodes/review/run_1"
printf '# A\nVERDICT: PASS FINDINGS[0]\n' > "$SESSION/nodes/review/run_1/eval-a.md"
printf '# B\nVERDICT: PASS FINDINGS[0]\n' > "$SESSION/nodes/review/run_1/eval-b.md"
cat > "$SESSION/nodes/review/run_1/handshake.json" <<'JSON'
{
"nodeId": "review",
"nodeType": "review",
"runId": "run_1",
"status": "completed",
"verdict": "PASS",
"summary": "ok",
"timestamp": "2026-01-01T00:00:00.000Z",
"artifacts": [
{ "type": "eval", "path": "eval-a.md" },
{ "type": "eval", "path": "eval-b.md" }
]
}
JSON
OUT=$($HARNESS validate 2>/dev/null)
check_json "validate without path uses latest current handshake" "d['valid']==True" "$OUT"
$HARNESS transition --from review --to gate --verdict PASS --flow review >/dev/null 2>/dev/null
OUT=$($HARNESS finalize 2>/dev/null)
check_json "finalize without --dir uses latest session" "d['finalized']==True" "$OUT"
cd "$TMPDIR"
echo ""
echo "=== FE3: hotfix node boundary ==="
mkdir -p hotfix && cd hotfix
$HARNESS init --flow build-verify --entry test-execute --dir .harness --no-extensions >/dev/null 2>/dev/null
mkdir -p .harness/nodes/test-execute/run_1
printf 'tests failed on trivial aria label\n' > .harness/nodes/test-execute/run_1/output.txt
cat > .harness/nodes/test-execute/handshake.json <<'JSON'
{
"nodeId": "test-execute",
"nodeType": "execute",
"runId": "run_1",
"status": "completed",
"verdict": "ITERATE",
"summary": "one trivial failure",
"timestamp": "2026-01-01T00:00:00.000Z",
"artifacts": [{ "type": "cli-output", "path": "run_1/output.txt" }]
}
JSON
OUT=$($HARNESS transition --from test-execute --to hotfix --verdict ITERATE --flow build-verify --dir .harness 2>/dev/null)
check_json "test-execute ITERATE routes to hotfix" "d['allowed']==True and d['next']=='hotfix'" "$OUT"
mkdir -p .harness/nodes/hotfix/run_1
mkdir -p .harness/nodes/test-design
cat > .harness/nodes/test-design/test-execution.json <<'JSON'
{ "testCommand": "printf retest > hotfix-retest.txt", "timeoutMs": 10000 }
JSON
printf 'Added aria-label only.\n' > .harness/nodes/hotfix/run_1/hotfix-report.md
cat > .harness/nodes/hotfix/handshake.json <<'JSON'
{
"nodeId": "hotfix",
"nodeType": "hotfix",
"runId": "run_1",
"status": "completed",
"verdict": "PASS",
"summary": "Added aria-label only.",
"timestamp": "2026-01-01T00:00:00.000Z",
"artifacts": [{ "type": "hotfix-report", "path": "run_1/hotfix-report.md" }],
"hotfix": {
"scope": "trivial",
"allowedOperations": ["aria-label"],
"forbiddenOperations": [],
"structuralChange": false
}
}
JSON
OUT=$($HARNESS transition --from hotfix --to test-execute --verdict PASS --flow build-verify --dir .harness 2>/dev/null)
check_json "hotfix PASS routes back to test-execute evidence node" "d['allowed']==True and d['next']=='test-execute' and d['testCommandExecution']['executed']==True" "$OUT"
cat > .harness/nodes/hotfix/handshake.json <<'JSON'
{
"nodeId": "hotfix",
"nodeType": "hotfix",
"runId": "run_1",
"status": "completed",
"verdict": "PASS",
"summary": "Reworked component.",
"timestamp": "2026-01-01T00:00:00.000Z",
"artifacts": [{ "type": "hotfix-report", "path": "run_1/hotfix-report.md" }],
"hotfix": {
"scope": "structural",
"allowedOperations": ["component-rewrite"],
"forbiddenOperations": ["component-rewrite"],
"structuralChange": true
}
}
JSON
OUT=$($HARNESS validate .harness/nodes/hotfix/handshake.json 2>/dev/null)
check_json "structural hotfix handshake is rejected" "d['valid']==False and any('hotfix.scope' in e for e in d['errors'])" "$OUT"
print_results
#!/bin/bash
set -e
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
setup_git
json_field() {
echo "$1" | python3 -c "import json,sys; print(json.load(sys.stdin).get('$2'))"
}
write_state_history() {
local dir="$1"
python3 - "$dir/flow-state.json" <<'PY'
import json, sys
path = sys.argv[1]
data = json.load(open(path))
data["history"] = [{"nodeId": "test-execute", "runId": "run_1", "timestamp": "2026-01-01T00:00:00.000Z"}]
data["currentNode"] = "gate"
json.dump(data, open(path, "w"), indent=2)
PY
}
write_state_history_two_runs() {
local dir="$1"
python3 - "$dir/flow-state.json" <<'PY'
import json, sys
path = sys.argv[1]
data = json.load(open(path))
data["history"] = [
{"nodeId": "test-execute", "runId": "run_1", "timestamp": "2026-01-01T00:00:00.000Z"},
{"nodeId": "test-execute", "runId": "run_2", "timestamp": "2026-01-01T00:01:00.000Z"},
]
data["currentNode"] = "gate"
json.dump(data, open(path, "w"), indent=2)
PY
}
write_test_execute_report_handshake() {
local dir="$1" run_id="$2"
python3 - "$dir/nodes/test-execute/handshake.json" "$run_id" <<'PY'
import json, sys
path, run_id = sys.argv[1], sys.argv[2]
data = {
"nodeId": "test-execute",
"nodeType": "execute",
"runId": run_id,
"status": "completed",
"verdict": "PASS",
"summary": "test execution report available",
"timestamp": "2026-01-01T00:00:00.000Z",
"artifacts": [{"type": "report", "path": f"{run_id}/report.json"}]
}
open(path, "w").write(json.dumps(data))
PY
}
echo "Test: gate-criteria.json"
echo "========================"
echo ""
$HARNESS init --flow build-verify --entry gate --dir .harness >/dev/null 2>/dev/null
cat > .harness/report.json <<'JSON'
{"summary":{"average_score":1.5}}
JSON
cat > .harness/gate-criteria.json <<'JSON'
{"checks":[{"id":"ai-smell-average","source":"report.json","path":"$.summary.average_score","operator":"<","threshold":2.0}]}
JSON
OUT=$($HARNESS transition --from gate --to null --verdict PASS --flow build-verify --dir .harness 2>/dev/null)
FINALIZED=$(json_field "$OUT" "finalized")
if [ "$FINALIZED" = "True" ]; then
echo " ✅ passing root gate criteria allows PASS"
PASS=$((PASS + 1))
else
echo " ❌ passing root gate criteria blocked: $OUT"
FAIL=$((FAIL + 1))
fi
$HARNESS init --flow build-verify --entry gate --dir .harness-fail >/dev/null 2>/dev/null
cat > .harness-fail/report.json <<'JSON'
{"summary":{"average_score":3.1}}
JSON
cat > .harness-fail/gate-criteria.json <<'JSON'
{"checks":[{"id":"ai-smell-average","source":"report.json","path":"$.summary.average_score","operator":"<","threshold":2.0}]}
JSON
OUT=$($HARNESS transition --from gate --to null --verdict PASS --flow build-verify --dir .harness-fail 2>/dev/null)
ALLOWED=$(json_field "$OUT" "allowed")
if [ "$ALLOWED" = "False" ] && grep -q "does not satisfy" <<< "$OUT"; then
echo " ✅ failing root gate criteria blocks PASS"
PASS=$((PASS + 1))
else
echo " ❌ failing root gate criteria did not block: $OUT"
FAIL=$((FAIL + 1))
fi
$HARNESS init --flow build-verify --entry gate --dir .harness-run >/dev/null 2>/dev/null
mkdir -p .harness-run/nodes/test-execute/run_1
write_state_history .harness-run
cat > .harness-run/nodes/test-execute/run_1/report.json <<'JSON'
{"summary":{"average_score":4.2}}
JSON
write_test_execute_report_handshake .harness-run run_1
cat > .harness-run/nodes/test-execute/run_1/gate-criteria.json <<'JSON'
{"checks":[{"id":"run-score","source":"report.json","path":"$.summary.average_score","operator":">=","threshold":4.0}]}
JSON
OUT=$($HARNESS transition --from gate --to null --verdict PASS --flow build-verify --dir .harness-run 2>/dev/null)
FINALIZED=$(json_field "$OUT" "finalized")
if [ "$FINALIZED" = "True" ]; then
echo " ✅ run-level gate criteria resolves source relative to run dir"
PASS=$((PASS + 1))
else
echo " ❌ run-level criteria blocked unexpectedly: $OUT"
FAIL=$((FAIL + 1))
fi
$HARNESS init --flow build-verify --entry gate --dir .harness-retry >/dev/null 2>/dev/null
mkdir -p .harness-retry/nodes/test-execute/run_1 .harness-retry/nodes/test-execute/run_2
write_state_history_two_runs .harness-retry
cat > .harness-retry/nodes/test-execute/run_1/report.json <<'JSON'
{"summary":{"average_score":1.0}}
JSON
cat > .harness-retry/nodes/test-execute/run_1/gate-criteria.json <<'JSON'
{"checks":[{"id":"score-old","source":"report.json","path":"$.summary.average_score","operator":">=","threshold":4.0}]}
JSON
cat > .harness-retry/nodes/test-execute/run_2/report.json <<'JSON'
{"summary":{"average_score":4.5}}
JSON
write_test_execute_report_handshake .harness-retry run_2
cat > .harness-retry/nodes/test-execute/run_2/gate-criteria.json <<'JSON'
{"checks":[{"id":"score-new","source":"report.json","path":"$.summary.average_score","operator":">=","threshold":4.0}]}
JSON
OUT=$($HARNESS transition --from gate --to null --verdict PASS --flow build-verify --dir .harness-retry 2>/dev/null)
FINALIZED=$(json_field "$OUT" "finalized")
if [ "$FINALIZED" = "True" ] && ! grep -q "score-old" <<< "$OUT"; then
echo " ✅ stale run-level criteria ignored after retry pass"
PASS=$((PASS + 1))
else
echo " ❌ stale run criteria blocked retry: $OUT"
FAIL=$((FAIL + 1))
fi
$HARNESS init --flow build-verify --entry gate --dir .harness-missing >/dev/null 2>/dev/null
cat > .harness-missing/gate-criteria.json <<'JSON'
{"checks":[{"id":"missing-source","source":"missing.json","path":"$.x","operator":"==","threshold":1}]}
JSON
OUT=$($HARNESS transition --from gate --to null --verdict PASS --flow build-verify --dir .harness-missing 2>/dev/null)
ALLOWED=$(json_field "$OUT" "allowed")
if [ "$ALLOWED" = "False" ] && grep -q "source missing" <<< "$OUT"; then
echo " ✅ missing source fails closed"
PASS=$((PASS + 1))
else
echo " ❌ missing source did not fail closed: $OUT"
FAIL=$((FAIL + 1))
fi
print_results
#!/bin/bash
# test-node-preflight-empty-task.sh - direct node-preflight skips empty AC
set -u
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT" || exit 1
PASS=0
FAIL=0
TMP=$(mktemp -d -t opc-node-preflight-empty-XXXXXX)
trap 'rm -rf "$TMP"' EXIT INT TERM HUP
ok() { PASS=$((PASS + 1)); echo " [ok] $1"; }
fail() { FAIL=$((FAIL + 1)); echo " [fail] $1"; }
EXT_DIR="$TMP/extensions"
mkdir -p "$EXT_DIR/design-ext"
cat > "$EXT_DIR/design-ext/ext.json" <<'JSON'
{
"name": "design-ext",
"version": "1.0.0",
"meta": { "provides": ["design-preflight@1"], "compatibleCapabilities": [] }
}
JSON
cat > "$EXT_DIR/design-ext/hook.mjs" <<'JS'
export const meta = { provides: ["design-preflight@1"] };
export function preflight() {
return { type: "design", confidence: 0.1, reason: "should not run" };
}
JS
FLOW_FILE="$TMP/flow.json"
cat > "$FLOW_FILE" <<'JSON'
{
"opc_compat": ">=0.0",
"nodes": ["build"],
"edges": { "build": { "PASS": null } },
"limits": { "maxLoopsPerEdge": 3, "maxTotalSteps": 10, "maxNodeReentry": 5 },
"nodeTypes": { "build": "build" },
"nodeCapabilities": { "build": ["design-preflight@1"] }
}
JSON
HARNESS="$TMP/harness"
mkdir -p "$HARNESS/.opc"
cat > "$HARNESS/.opc/config.json" <<JSON
{ "extensionsDir": "$EXT_DIR" }
JSON
cat > "$HARNESS/acceptance-criteria.md" <<'MD'
# Acceptance Criteria
MD
HARNESS_BIN="node $REPO_ROOT/bin/opc-harness.mjs"
OUT=$(OPC_BREAKER_STATE=disabled $HARNESS_BIN node-preflight --node build --dir "$HARNESS" --flow-file "$FLOW_FILE" 2>/dev/null)
echo "=== Direct Node Preflight Empty Task Test ==="
if echo "$OUT" | grep -q '"skipped":true'; then
ok "node-preflight reports skipped"
else
fail "expected skipped output: $OUT"
fi
if echo "$OUT" | grep -q '"preflightResults":0'; then
ok "node-preflight produced no preflight results"
else
fail "expected zero preflight results: $OUT"
fi
if [ ! -f "$HARNESS/design-mode.json" ] && [ ! -f "$HARNESS/di-state.json" ]; then
ok "node-preflight wrote no design artifacts"
else
fail "expected no design artifacts"
fi
echo
echo "=== Results: $PASS passed, $FAIL failed ==="
if [ "$FAIL" -gt 0 ]; then
exit 1
fi
#!/bin/bash
# Tests for the quick flow template
set -e
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
setup_git
echo "=== Quick flow template tests ==="
echo ""
write_clean_eval() {
local target="$1"
local role="$2"
{
echo "# $role Review"
echo "Role: $role"
echo "## Scope"
for i in $(seq 1 18); do echo "$role scope $i records a concrete review pass across the changed fixture."; done
echo "## Evidence"
for i in $(seq 1 18); do echo "$role evidence $i: command routing, artifacts, state history, and gate inputs were inspected."; done
echo "## Decision"
for i in $(seq 1 18); do echo "$role decision $i is PASS after checking the relevant harness contract and provenance path."; done
echo "VERDICT: PASS"
} > "$target"
}
write_quick_build() {
mkdir -p .harness/nodes/build
cat > .harness/nodes/build/handshake.json <<'EOF'
{"nodeId":"build","nodeType":"build","runId":"run_1","status":"completed","verdict":"PASS","summary":"built","timestamp":"2026-01-01T00:01:00.000Z","artifacts":[{"type":"code","path":"x"}]}
EOF
touch .harness/nodes/build/x
}
write_quick_review() {
mkdir -p .harness/nodes/review/run_1
write_clean_eval .harness/nodes/review/run_1/eval-skeptic-owner.md "skeptic-owner"
write_clean_eval .harness/nodes/review/run_1/eval-quick-reviewer.md "quick-reviewer"
cat > .harness/nodes/review/handshake.json <<'EOF'
{"nodeId":"review","nodeType":"review","runId":"run_1","status":"completed","verdict":"PASS","summary":"ok","timestamp":"2026-01-01T00:02:00.000Z","artifacts":[{"type":"eval","path":"run_1/eval-skeptic-owner.md"},{"type":"eval","path":"run_1/eval-quick-reviewer.md"}]}
EOF
}
write_quick_test_design() {
mkdir -p .harness/nodes/test-design/run_1
write_clean_eval .harness/nodes/test-design/run_1/eval-skeptic-owner.md "skeptic-owner"
write_clean_eval .harness/nodes/test-design/run_1/eval-quick-tester.md "quick-tester"
write_complete_test_plan .harness/nodes/test-design/run_1/test-plan.md
cat > .harness/nodes/test-design/handshake.json <<'EOF'
{"nodeId":"test-design","nodeType":"review","runId":"run_1","status":"completed","verdict":"PASS","summary":"tests designed","timestamp":"2026-01-01T00:03:00.000Z","artifacts":[{"type":"eval","path":"run_1/eval-skeptic-owner.md"},{"type":"eval","path":"run_1/eval-quick-tester.md"},{"type":"test-plan","path":"run_1/test-plan.md"}],"testCommand":"printf quick-ok > quick-test.txt"}
EOF
}
advance_quick() {
write_quick_build
$HARNESS transition --from build --to review --verdict PASS --flow quick --dir .harness 2>/dev/null >/dev/null
write_quick_review
$HARNESS transition --from review --to test-design --verdict PASS --flow quick --dir .harness 2>/dev/null >/dev/null
write_quick_test_design
$HARNESS transition --from test-design --to test-execute --verdict PASS --flow quick --dir .harness 2>/dev/null >/dev/null
$HARNESS transition --from test-execute --to gate --verdict PASS --flow quick --dir .harness 2>/dev/null >/dev/null
}
# ── 1: init --flow quick creates evidence-backed flow ──
echo "--- 1: init --flow quick ---"
$HARNESS init --flow quick --entry build --dir .harness 2>/dev/null
STATE=$(cat .harness/flow-state.json)
NODE=$(echo "$STATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('currentNode',''))" 2>/dev/null)
if [ "$NODE" = "build" ]; then
echo " ✅ init quick → currentNode=build"
PASS=$((PASS + 1))
else
echo " ❌ expected build, got: $NODE"
FAIL=$((FAIL + 1))
fi
# ── 2: route gate FAIL → build ──
echo "--- 2: route gate FAIL → build ---"
OUT=$($HARNESS route --node gate --verdict FAIL --flow quick 2>/dev/null)
NEXT=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('next',''))" 2>/dev/null)
if [ "$NEXT" = "build" ]; then
echo " ✅ gate FAIL → build"
PASS=$((PASS + 1))
else
echo " ❌ expected build, got: $NEXT"
FAIL=$((FAIL + 1))
fi
# ── 3: route gate ITERATE → build ──
echo "--- 3: route gate ITERATE → build ---"
OUT=$($HARNESS route --node gate --verdict ITERATE --flow quick 2>/dev/null)
NEXT=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('next',''))" 2>/dev/null)
if [ "$NEXT" = "build" ]; then
echo " ✅ gate ITERATE → build"
PASS=$((PASS + 1))
else
echo " ❌ expected build, got: $NEXT"
FAIL=$((FAIL + 1))
fi
# ── 4: route gate PASS → null (complete) ──
echo "--- 4: route gate PASS → null ---"
OUT=$($HARNESS route --node gate --verdict PASS --flow quick 2>/dev/null)
NEXT=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('next'))" 2>/dev/null)
if [ "$NEXT" = "None" ]; then
echo " ✅ gate PASS → null (flow complete)"
PASS=$((PASS + 1))
else
echo " ❌ expected None, got: $NEXT"
FAIL=$((FAIL + 1))
fi
# ── 5: viz --flow quick shows evidence-backed nodes ──
echo "--- 5: viz --flow quick ---"
VIZ=$($HARNESS viz --flow quick 2>/dev/null)
if echo "$VIZ" | grep -q "build" &&
echo "$VIZ" | grep -q "review" &&
echo "$VIZ" | grep -q "test-design" &&
echo "$VIZ" | grep -q "test-execute" &&
echo "$VIZ" | grep -q "gate"; then
echo " ✅ viz shows build, review, test-design, test-execute, gate"
PASS=$((PASS + 1))
else
echo " ❌ viz output: $VIZ"
FAIL=$((FAIL + 1))
fi
# ── 6: Full path build → review → test-design → test-execute → gate PASS ──
echo "--- 6: Full path build → review → test-design → test-execute → gate PASS ---"
rm -rf .harness
$HARNESS init --flow quick --entry build --dir .harness 2>/dev/null
advance_quick
STATE=$(cat .harness/flow-state.json)
CUR=$(echo "$STATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('currentNode',''))" 2>/dev/null)
if [ "$CUR" = "gate" ]; then
echo " ✅ full path: reached gate"
PASS=$((PASS + 1))
else
echo " ❌ expected gate, got: $CUR"
FAIL=$((FAIL + 1))
fi
OUT=$($HARNESS transition --from gate --to null --verdict PASS --flow quick --dir .harness 2>/dev/null)
FINALIZED=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('finalized', False))" 2>/dev/null)
if [ "$FINALIZED" = "True" ]; then
echo " ✅ gate PASS finalizes with OPC testCommand evidence"
PASS=$((PASS + 1))
else
echo " ❌ expected finalized true, got: $OUT"
FAIL=$((FAIL + 1))
fi
# ── 7: quick gate blocks missing testCommand evidence ──
echo "--- 7: quick gate blocks missing testCommand evidence ---"
rm -rf .harness
$HARNESS init --flow quick --entry gate --dir .harness 2>/dev/null
OUT=$($HARNESS transition --from gate --to null --verdict PASS --flow quick --dir .harness 2>/dev/null || true)
ALLOWED=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('allowed', True))" 2>/dev/null)
if [ "$ALLOWED" = "False" ] && echo "$OUT" | grep -q "required OPC testCommand evidence missing before gate"; then
echo " ✅ missing testCommand evidence blocks quick gate PASS"
PASS=$((PASS + 1))
else
echo " ❌ quick gate allowed missing testCommand evidence: $OUT"
FAIL=$((FAIL + 1))
fi
# ── 8: maxLoopsPerEdge=2 enforced ──
echo "--- 8: maxLoopsPerEdge=2 enforced ---"
rm -rf .harness
$HARNESS init --flow quick --entry build --dir .harness 2>/dev/null
# Loop 1: build → review → gate → FAIL → build
loopback_quick() {
mkdir -p .harness/nodes/gate
cat > .harness/nodes/gate/handshake.json <<'GEOF'
{"nodeId":"gate","nodeType":"gate","runId":"run_1","status":"completed","verdict":"FAIL","summary":"fail","timestamp":"2026-01-01T00:03:00.000Z","artifacts":[]}
GEOF
$HARNESS transition --from gate --to build --verdict FAIL --flow quick --dir .harness 2>/dev/null >/dev/null
}
advance_quick
loopback_quick
advance_quick
loopback_quick
# 3rd attempt should be blocked (maxLoopsPerEdge=2)
advance_quick
mkdir -p .harness/nodes/gate
cat > .harness/nodes/gate/handshake.json <<'GEOF'
{"nodeId":"gate","nodeType":"gate","runId":"run_1","status":"completed","verdict":"FAIL","summary":"fail","timestamp":"2026-01-01T00:03:00.000Z","artifacts":[]}
GEOF
TRANS=$($HARNESS transition --from gate --to build --verdict FAIL --flow quick --dir .harness 2>/dev/null || true)
ALLOWED=$(echo "$TRANS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('allowed', True))" 2>/dev/null)
if [ "$ALLOWED" = "False" ]; then
echo " ✅ 3rd gate→build blocked (maxLoopsPerEdge=2)"
PASS=$((PASS + 1))
else
echo " ❌ was allowed: $TRANS"
FAIL=$((FAIL + 1))
fi
print_results
#!/bin/bash
set -e
source "$(dirname "$0")/test-helpers.sh"
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
setup_tmpdir
CASE_DIR="$PWD"
echo "Test: readTaskFromAC"
echo "===================="
echo ""
cat > acceptance-criteria.md <<'EOF'
# Acceptance Criteria
Build an Ant Design Pro analytics dashboard with KPI cards and filters.
EOF
OUT=$(cd "$REPO" && node --input-type=module - "$CASE_DIR" <<'NODE'
import { readTaskFromAC } from "./bin/lib/ext-commands.mjs";
console.log(readTaskFromAC(process.argv[2]));
NODE
)
if grep -q "Ant Design Pro analytics dashboard" <<< "$OUT"; then
echo " ✅ skips boilerplate heading and reads task line"
PASS=$((PASS + 1))
else
echo " ❌ wrong task: $OUT"
FAIL=$((FAIL + 1))
fi
cat > acceptance-criteria.md <<'EOF'
# Build a fintech risk dashboard
- It must show live exposure and failed checks.
EOF
OUT=$(cd "$REPO" && node --input-type=module - "$CASE_DIR" <<'NODE'
import { readTaskFromAC } from "./bin/lib/ext-commands.mjs";
console.log(readTaskFromAC(process.argv[2]));
NODE
)
if [ "$OUT" = "Build a fintech risk dashboard" ]; then
echo " ✅ preserves meaningful first heading"
PASS=$((PASS + 1))
else
echo " ❌ meaningful heading lost: $OUT"
FAIL=$((FAIL + 1))
fi
print_results
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
HARNESS="node $ROOT/bin/opc-harness.mjs"
PASS=0
FAIL=0
check() {
local label="$1"
local cond="$2"
if eval "$cond"; then
echo "PASS: $label"
PASS=$((PASS + 1))
else
echo "FAIL: $label"
FAIL=$((FAIL + 1))
fi
}
write_review() {
local dir="$1"
mkdir -p "$dir/nodes/review/run_1"
printf '# Reviewer A\n\n[WARNING] src/app.js:12 — Report hides warning finding\n→ Render it in the final report\nReasoning: The warning must stay visible after recovery.\nVERDICT: ITERATE FINDINGS[1]\n' > "$dir/nodes/review/run_1/eval-a.md"
printf '# Reviewer B\n\n[SUGGESTION] src/app.js:18 — Add recovery context\n→ Include cumulative findings in prompt context\nReasoning: Compaction needs prior findings.\nVERDICT: PASS FINDINGS[1]\n' > "$dir/nodes/review/run_1/eval-b.md"
printf '# Legacy Review\n\n## Finding 1 — Real structured issue title\n**Severity**: 🔴\n**Location**: src/legacy.js:7\n**R2 Status**: ⚠️\n\nVERDICT: FAIL FINDINGS[1]\n' > "$dir/nodes/review/run_1/eval-legacy.md"
mkdir -p "$dir/nodes/review/run_2"
printf '# Reviewer C\n\n[WARNING] src/retry.js:33 — Retry run finding stays visible\n→ Preserve loopback findings per run\nReasoning: Retry runs must not be hidden by node-level de-duplication.\nVERDICT: ITERATE FINDINGS[1]\n' > "$dir/nodes/review/run_2/eval-c.md"
printf '{"nodeId":"review","nodeType":"review","runId":"run_1","status":"completed","summary":"done","timestamp":"2026-06-20T00:00:00Z","artifacts":[{"type":"eval","path":"run_1/eval-a.md"},{"type":"eval","path":"run_1/eval-b.md"}],"verdict":"PASS"}\n' > "$dir/nodes/review/handshake.json"
printf '{"nodeId":"review","runId":"run_1","status":"completed","fixes_applied":["Bound report parser to canonical eval severity parsing"]}\n' > "$dir/nodes/review/run_1/handshake.json"
}
TMPD="$(mktemp -d)"
trap 'rm -rf "$TMPD"' EXIT
cd "$TMPD"
$HARNESS init --flow review --entry review --dir .harness > /dev/null 2>&1
write_review ".harness"
$HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness > /dev/null 2>&1
check "transition writes cumulative findings" 'test -f .harness/cumulative-findings.md'
check "cumulative findings include warning" 'grep -q "Report hides warning finding" .harness/cumulative-findings.md'
check "cumulative findings include legacy structured title" 'grep -q "Real structured issue title" .harness/cumulative-findings.md'
check "cumulative findings include retry run" 'grep -q "Retry run finding stays visible" .harness/cumulative-findings.md'
check "cumulative findings include execution fix" 'grep -q "Bound report parser" .harness/cumulative-findings.md'
PROMPT_JSON=$(OPC_DISABLE_EXTENSIONS=1 $HARNESS prompt-context --node gate --role resume --dir .harness 2>/dev/null)
PROMPT_APPEND=$(printf '%s' "$PROMPT_JSON" | python3 -c 'import json,sys; print(json.load(sys.stdin)["append"])')
check "prompt-context injects recovery context" 'printf "%s" "$PROMPT_APPEND" | grep -q "OPC Recovery Context"'
check "prompt-context includes prior warning" 'printf "%s" "$PROMPT_APPEND" | grep -q "Report hides warning finding"'
check "prompt-context includes legacy structured title" 'printf "%s" "$PROMPT_APPEND" | grep -q "Real structured issue title"'
$HARNESS transition --from gate --to null --verdict PASS --flow review --dir .harness > /dev/null 2>&1
VIZ=$($HARNESS viz --flow review --dir .harness 2>/dev/null)
VIZ_JSON=$($HARNESS viz --flow review --dir .harness --json 2>/dev/null)
check "viz shows completed terminal state" 'printf "%s" "$VIZ" | grep -q "FLOW COMPLETED at gate"'
check "viz no longer marks terminal as current" '! printf "%s" "$VIZ" | grep -q "▶ gate"'
check "viz json exposes completion" 'printf "%s" "$VIZ_JSON" | grep -q "\"completed\": true"'
check "viz json exposes terminal node" 'printf "%s" "$VIZ_JSON" | grep -q "\"terminalNode\": \"gate\""'
node "$ROOT/bin/opc-report.mjs" --dir .harness --output report.html --title "Recovery Report" > /dev/null
check "report includes parser warning finding" 'grep -q "Report hides warning finding" report.html'
check "report preserves legacy structured title" 'grep -q "Real structured issue title" report.html'
check "report does not render severity metadata as title" '! grep -q "<h4>\\*\\*Severity\\*\\*" report.html'
check "report includes execution fixes section" 'grep -q "Fixes Applied During Execution" report.html'
check "report includes execution fix text" 'grep -q "Bound report parser" report.html'
SESSION_ROOT="$TMPD/session-root"
mkdir -p "$SESSION_ROOT/nodes/code-review/run_1"
printf '{"currentNode":"code-review"}\n' > "$SESSION_ROOT/flow-state.json"
printf '# Frontend Review\n\n[WARNING] src/docs.js:9 — Session-root eval is visible\n→ Include node eval files in report JSON\nReasoning: Session layouts do not use a nested .harness directory.\nVERDICT: ITERATE FINDINGS[1]\n' > "$SESSION_ROOT/nodes/code-review/run_1/eval-frontend.md"
REPORT_JSON=$($HARNESS report "$SESSION_ROOT" --mode review --task "session root report" 2>/dev/null)
check "report command accepts session root dir" 'printf "%s" "$REPORT_JSON" | grep -q "Session-root eval is visible"'
check "report command labels node eval role" 'printf "%s" "$REPORT_JSON" | grep -q "code-review/frontend"'
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ]
#!/bin/bash
# Regression: resolveFlowTemplate must fall back to state.flowTemplate when
# neither --flow nor --flow-file is given. Built-in flows persist the template
# NAME in flow-state.json (flowTemplate), not a _flow_file path. The real /opc
# skill calls `prompt-context --node X --role Y --dir DIR` WITHOUT --flow, so
# nodeCapabilities silently went empty → all capability-routed extensions
# no-matched. This guards that path.
set -e
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
setup_git
echo "=== resolveFlowTemplate state.flowTemplate fallback ==="
echo ""
# ── 1: init built-in flow persists flowTemplate (name), no _flow_file ──
echo "--- 1: built-in flow state has flowTemplate, no _flow_file ---"
$HARNESS init --flow build-verify --entry brief --dir .harness 2>/dev/null
STATE=$(cat .harness/flow-state.json)
TPL=$(echo "$STATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('flowTemplate',''))" 2>/dev/null)
FF=$(echo "$STATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('_flow_file',''))" 2>/dev/null)
if [ "$TPL" = "build-verify" ] && [ -z "$FF" ]; then
echo " ✅ flowTemplate=build-verify, _flow_file empty"
PASS=$((PASS + 1))
else
echo " ❌ flowTemplate=$TPL _flow_file=$FF"
FAIL=$((FAIL + 1))
fi
# ── 2: prompt-context WITHOUT --flow resolves nodeCapabilities ──
echo "--- 2: prompt-context (no --flow) → nodeCapabilities non-empty ---"
OUT=$($HARNESS prompt-context --node brief --role architect --dir .harness 2>/dev/null)
CAPS=$(echo "$OUT" | python3 -c "import sys,json; print(','.join(json.load(sys.stdin).get('nodeCapabilities',[])))" 2>/dev/null)
if echo "$CAPS" | grep -q "design-system-injection@1"; then
echo " ✅ nodeCapabilities resolved from state.flowTemplate: $CAPS"
PASS=$((PASS + 1))
else
echo " ❌ nodeCapabilities empty/wrong (no --flow, no _flow_file): '$CAPS'"
FAIL=$((FAIL + 1))
fi
# ── 3: explicit --flow still works (no regression) ──
echo "--- 3: prompt-context WITH --flow still resolves ---"
OUT=$($HARNESS prompt-context --node brief --role architect --dir .harness --flow build-verify 2>/dev/null)
CAPS=$(echo "$OUT" | python3 -c "import sys,json; print(','.join(json.load(sys.stdin).get('nodeCapabilities',[])))" 2>/dev/null)
if echo "$CAPS" | grep -q "design-system-injection@1"; then
echo " ✅ --flow path unaffected: $CAPS"
PASS=$((PASS + 1))
else
echo " ❌ --flow path broke: '$CAPS'"
FAIL=$((FAIL + 1))
fi
# ── 4: build node caps differ from brief node caps (per-node routing intact) ──
echo "--- 4: per-node capabilities (build ≠ brief) ---"
OUT=$($HARNESS prompt-context --node build --role implementer --dir .harness 2>/dev/null)
BUILD_CAPS=$(echo "$OUT" | python3 -c "import sys,json; print(','.join(json.load(sys.stdin).get('nodeCapabilities',[])))" 2>/dev/null)
# build has design-system-injection@1 but NOT design-spec-conformance@1 (that's brief-only)
if echo "$BUILD_CAPS" | grep -q "design-system-injection@1" && ! echo "$BUILD_CAPS" | grep -q "design-spec-conformance@1"; then
echo " ✅ build caps correct (has injection, not spec-conformance): $BUILD_CAPS"
PASS=$((PASS + 1))
else
echo " ❌ build caps wrong: '$BUILD_CAPS'"
FAIL=$((FAIL + 1))
fi
# ── 4b: node-preflight WITHOUT --flow resolves caps and task from state ──
echo "--- 4b: node-preflight (no --flow) → design-preflight fires with task ---"
EXT_DIR="$TMPDIR/state-exts"
mkdir -p "$EXT_DIR/state-design-ext" ".harness/.opc"
cat > "$EXT_DIR/state-design-ext/ext.json" <<'EOF'
{
"name": "state-design-ext",
"version": "0.1.0",
"meta": { "provides": ["design-preflight@1"], "compatibleCapabilities": [] }
}
EOF
cat > "$EXT_DIR/state-design-ext/hook.mjs" <<'EOF'
export const meta = {
provides: ["design-preflight@1"],
compatibleCapabilities: [],
};
export function preflight(ctx) {
return {
type: "design",
selection: { industry: "state-flow", taskSeen: ctx.task || "", taskDescriptionSeen: ctx.taskDescription || "" },
brief: "# Design Brief\n\nState flow preflight brief.",
tokens: { colors: { bg: "#ffffff", text: "#111111" } },
confidence: ctx.task ? 0.82 : 0.1,
reason: ctx.task ? "task propagated" : "task missing",
};
}
EOF
cat > ".harness/.opc/config.json" <<EOF
{ "extensionsDir": "$EXT_DIR" }
EOF
cat > ".harness/acceptance-criteria.md" <<'EOF'
# Acceptance Criteria
Build an operations analytics dashboard with compact KPI cards and ranking insight.
EOF
OUT=$(OPC_BREAKER_STATE=disabled $HARNESS node-preflight --node brief --dir .harness 2>/dev/null)
if echo "$OUT" | grep -q '"ok":true' && grep -q '"confidence": 0.82' .harness/design-mode.json && grep -q "operations analytics dashboard" .harness/design-selection.json && grep -q "taskDescriptionSeen" .harness/design-selection.json; then
echo " ✅ node-preflight resolved state.flowTemplate and propagated task"
PASS=$((PASS + 1))
else
echo " ❌ node-preflight state fallback failed: $OUT"
FAIL=$((FAIL + 1))
fi
# ── 5: unknown node → empty caps (no crash) ──
echo "--- 5: unknown node → empty caps ---"
OUT=$($HARNESS prompt-context --node nonexistent --role implementer --dir .harness 2>/dev/null)
CAPS=$(echo "$OUT" | python3 -c "import sys,json; print(','.join(json.load(sys.stdin).get('nodeCapabilities',[])))" 2>/dev/null)
if [ -z "$CAPS" ]; then
echo " ✅ unknown node → empty caps (no crash)"
PASS=$((PASS + 1))
else
echo " ❌ unexpected caps: '$CAPS'"
FAIL=$((FAIL + 1))
fi
# ── 6: route WITHOUT --flow resolves next from state.flowTemplate (F7) ──
# The real /opc skill calls `route --node X --verdict Y --dir DIR` without
# --flow. Before the F7 fix, cmdRoute called resolveFlowTemplate(args) without
# state → "no --flow or --flow-file specified". Assert the CONCRETE next node
# (brief PASS → build in build-verify) so this proves the correct template
# resolved via fallback, not merely that route returned valid.
echo "--- 6: route (no --flow) resolves concrete next from state.flowTemplate ---"
OUT=$($HARNESS route --node brief --verdict PASS --dir .harness 2>/dev/null)
NEXT=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('next',''))" 2>/dev/null)
if [ "$NEXT" = "build" ]; then
echo " ✅ route resolved build-verify via fallback (brief→build): $OUT"
PASS=$((PASS + 1))
else
echo " ❌ route did not resolve correct template (expected next=build): $OUT"
FAIL=$((FAIL + 1))
fi
# ── 7: route WITH --flow still works (no regression) ──
echo "--- 7: route WITH --flow still resolves ---"
OUT=$($HARNESS route --node brief --verdict PASS --dir .harness --flow build-verify 2>/dev/null)
NEXT=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('next',''))" 2>/dev/null)
if [ "$NEXT" = "build" ]; then
echo " ✅ route --flow path unaffected: $OUT"
PASS=$((PASS + 1))
else
echo " ❌ route --flow path broke: $OUT"
FAIL=$((FAIL + 1))
fi
# ── 8: route WITHOUT --flow AND no state file → graceful valid:false ──
# Guards the try/catch degradation path: missing flow-state.json must not crash;
# route should return valid:false with the "no --flow" error, exit 0.
echo "--- 8: route, no --flow, no state file → graceful error (no crash) ---"
mkdir -p .empty-harness
OUT=$($HARNESS route --node gate --verdict PASS --dir .empty-harness 2>/dev/null)
RC=$?
VALID=$(echo "$OUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print('valid' if d.get('valid') else 'invalid')" 2>/dev/null)
if [ "$VALID" = "invalid" ] && [ "$RC" = "0" ]; then
echo " ✅ graceful: valid:false, no crash (rc=0): $OUT"
PASS=$((PASS + 1))
else
echo " ❌ expected graceful valid:false rc=0, got rc=$RC: $OUT"
FAIL=$((FAIL + 1))
fi
# ── 9: autoMode state surfaces reminder in route output ──
# The F7 refactor moved the autoMode read into the shared state load; guard that
# `--auto` init still produces the reminder field on a route call.
echo "--- 9: autoMode init → route emits reminder ---"
rm -rf .auto-harness
AUTO_HOME="$TMPDIR/auto-home"
AUTO_HOOK="$AUTO_HOME/.claude/skills/opc/bin/hooks/opc-pre-tool-budget.mjs"
mkdir -p "$(dirname "$AUTO_HOOK")"
printf '#!/usr/bin/env node\n' > "$AUTO_HOOK"
mkdir -p "$AUTO_HOME/.claude"
cat > "$AUTO_HOME/.claude/settings.json" <<EOF
{
"hooks": {
"PreToolUse": [{
"hooks": [{
"type": "command",
"command": "node \"$AUTO_HOOK\"",
"timeout": 10
}]
}]
}
}
EOF
HOME="$AUTO_HOME" $HARNESS init --flow build-verify --entry brief --dir .auto-harness --auto --claude-session-id test-resolve-flowtemplate >/dev/null 2>&1
OUT=$($HARNESS route --node brief --verdict PASS --dir .auto-harness 2>/dev/null)
if echo "$OUT" | grep -q "auto mode"; then
echo " ✅ reminder present under autoMode: $OUT"
PASS=$((PASS + 1))
else
echo " ❌ reminder missing under autoMode: $OUT"
FAIL=$((FAIL + 1))
fi
# ── 10: non-auto init → route omits reminder ──
echo "--- 10: non-auto init → route has no reminder ---"
OUT=$($HARNESS route --node brief --verdict PASS --dir .harness 2>/dev/null)
if echo "$OUT" | grep -q "reminder"; then
echo " ❌ unexpected reminder without autoMode: $OUT"
FAIL=$((FAIL + 1))
else
echo " ✅ no reminder without autoMode: $OUT"
PASS=$((PASS + 1))
fi
print_results
#!/bin/bash
set -e
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
setup_git
json_field() {
echo "$1" | python3 -c "import json,sys; print(json.load(sys.stdin).get('$2'))"
}
assert_route_next() {
local desc="$1" flow="$2" node="$3" verdict="$4" expected="$5"
local out next valid
out=$($HARNESS route --node "$node" --verdict "$verdict" --flow "$flow" 2>/dev/null)
next=$(json_field "$out" "next")
valid=$(json_field "$out" "valid")
if [ "$valid" = "True" ] && [ "$next" = "$expected" ]; then
echo " ✅ $desc"
PASS=$((PASS + 1))
else
echo " ❌ $desc — valid=$valid next=$next output=$out"
FAIL=$((FAIL + 1))
fi
}
write_review_handshake() {
local dir="$1" verdict="$2"
mkdir -p "$dir/nodes/code-review"
cat > "$dir/nodes/code-review/handshake.json" <<EOF
{"nodeId":"code-review","nodeType":"review","runId":"run_1","status":"completed","verdict":"$verdict","summary":"review found work for producer","timestamp":"2026-01-01T00:00:00.000Z","artifacts":[{"type":"eval","path":"eval-a.md"},{"type":"eval","path":"eval-b.md"}]}
EOF
echo "# Eval A" > "$dir/nodes/code-review/eval-a.md"
echo "# Eval B" > "$dir/nodes/code-review/eval-b.md"
}
assert_transition_back_to_build() {
local desc="$1" dir="$2" verdict="$3"
local out allowed node
$HARNESS init --flow build-verify --entry code-review --dir "$dir" 2>/dev/null >/dev/null
write_review_handshake "$dir" "$verdict"
out=$($HARNESS transition --from code-review --to build --verdict "$verdict" --flow build-verify --dir "$dir" 2>/dev/null)
allowed=$(json_field "$out" "allowed")
node=$(python3 -c "import json; print(json.load(open('$dir/flow-state.json'))['currentNode'])")
if [ "$allowed" = "True" ] && [ "$node" = "build" ]; then
echo " ✅ $desc"
PASS=$((PASS + 1))
else
echo " ❌ $desc — allowed=$allowed currentNode=$node output=$out"
FAIL=$((FAIL + 1))
fi
}
echo "Test: Review failure routes"
echo "================================================"
echo ""
echo "1. code-review negative verdicts route to build"
assert_route_next "build-verify code-review PASS unchanged" build-verify code-review PASS test-design
assert_route_next "build-verify code-review FAIL → build" build-verify code-review FAIL build
assert_route_next "build-verify code-review ITERATE → build" build-verify code-review ITERATE build
assert_route_next "full-stack code-review FAIL → build" full-stack code-review FAIL build
assert_route_next "full-stack code-review ITERATE → build" full-stack code-review ITERATE build
echo ""
echo "2. quick review negative verdicts route to build"
assert_route_next "quick review FAIL → build" quick review FAIL build
assert_route_next "quick review ITERATE → build" quick review ITERATE build
echo ""
echo "3. transition accepts code-review negative routes"
assert_transition_back_to_build "code-review FAIL transition returns to build" .harness-fail FAIL
assert_transition_back_to_build "code-review ITERATE transition returns to build" .harness-iterate ITERATE
print_results
#!/bin/bash
set -e
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
setup_git
PASS=0
FAIL=0
ok() { echo " ✅ $1"; PASS=$((PASS+1)); }
bad() { echo " ❌ $1"; FAIL=$((FAIL+1)); }
write_run_handshake() {
local dir="$1"
mkdir -p "$dir/nodes/build/run_1"
echo "output" > "$dir/nodes/build/run_1/output.md"
cat > "$dir/nodes/build/run_1/handshake.json" <<'JSON'
{
"nodeId": "build",
"nodeType": "build",
"runId": "run_1",
"status": "completed",
"verdict": null,
"summary": "built",
"timestamp": "2026-01-01T00:00:00.000Z",
"artifacts": [{ "type": "source", "path": "output.md" }]
}
JSON
}
write_run_handshake_with_failing_report() {
local dir="$1"
mkdir -p "$dir/nodes/test-execute/run_1"
cat > "$dir/nodes/test-execute/run_1/test-command-result.json" <<'JSON'
{
"test_fail_count": 1
}
JSON
cat > "$dir/nodes/test-execute/run_1/handshake.json" <<'JSON'
{
"nodeId": "test-execute",
"nodeType": "execute",
"runId": "run_1",
"status": "completed",
"verdict": null,
"summary": "tests failed",
"timestamp": "2026-01-01T00:00:00.000Z",
"artifacts": [{ "type": "test-result", "path": "test-command-result.json" }]
}
JSON
}
echo "--- run-level handshake validates through node-level path ---"
DIR="$PWD/fallback-validate"
rm -rf "$DIR"
write_run_handshake "$DIR/.harness"
OUT=$($HARNESS validate "$DIR/.harness/nodes/build/handshake.json" 2>/dev/null)
if echo "$OUT" | grep -q '"valid":true'; then
ok "validate falls back to latest run_N/handshake.json"
else
bad "validate did not use run fallback: $OUT"
fi
echo "--- transition accepts latest run-level handshake ---"
DIR="$PWD/fallback-transition"
rm -rf "$DIR"
$HARNESS init --flow build-verify --entry build --dir fallback-transition/.harness --no-extensions >/dev/null 2>/dev/null
write_run_handshake "$DIR/.harness"
OUT=$($HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir fallback-transition/.harness 2>/dev/null)
if echo "$OUT" | grep -q '"allowed":true'; then
ok "transition falls back to latest run_N/handshake.json"
else
bad "transition did not use run fallback: $OUT"
fi
OUT=$($HARNESS validate-chain --dir fallback-transition/.harness 2>/dev/null)
if echo "$OUT" | grep -q '"valid":true'; then
ok "validate-chain accepts latest run_N/handshake.json"
else
bad "validate-chain did not use run fallback: $OUT"
fi
echo "--- pre-transition gate consumes run-level structured results ---"
DIR="$PWD/fallback-gate"
rm -rf "$DIR"
$HARNESS init --flow build-verify --entry test-execute --dir fallback-gate/.harness --no-extensions >/dev/null 2>/dev/null
write_run_handshake_with_failing_report "$DIR/.harness"
node - "$DIR/.harness/flow-state.json" <<'JS'
const fs = require("fs");
const path = process.argv[2];
const state = JSON.parse(fs.readFileSync(path, "utf8"));
state.history = [{ nodeId: "test-execute", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" }];
state.currentNode = "test-execute";
fs.writeFileSync(path, JSON.stringify(state, null, 2) + "\n");
JS
OUT=$($HARNESS transition --from test-execute --to gate --verdict PASS --flow build-verify --dir fallback-gate/.harness 2>/dev/null)
if echo "$OUT" | grep -q '"allowed":false' && echo "$OUT" | grep -q "1 test(s) failed"; then
ok "test-execute blocks failing structured result from run_N/handshake.json"
else
bad "test-execute did not consume run-level structured result: $OUT"
fi
echo ""
echo "Run handshake fallback tests: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1
#!/bin/bash
set -e
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
setup_git
json_field() {
echo "$1" | python3 -c "import json,sys; print(json.load(sys.stdin).get('$2'))"
}
write_test_design_handshake() {
local dir="$1" command="$2"
mkdir -p "$dir/nodes/test-design"
echo "# Eval A" > "$dir/nodes/test-design/eval-a.md"
echo "# Eval B" > "$dir/nodes/test-design/eval-b.md"
write_complete_test_plan "$dir/nodes/test-design/test-plan.md"
python3 - "$dir/nodes/test-design/handshake.json" "$command" <<'PY'
import json, sys
path, command = sys.argv[1], sys.argv[2]
data = {
"nodeId": "test-design",
"nodeType": "review",
"runId": "run_1",
"status": "completed",
"verdict": "PASS",
"summary": "tests designed",
"timestamp": "2026-01-01T00:00:00.000Z",
"artifacts": [
{"type": "eval", "path": "eval-a.md"},
{"type": "eval", "path": "eval-b.md"}
],
"testCommand": command,
"prerequisites": ["local fixture command"]
}
open(path, "w").write(json.dumps(data))
PY
}
write_test_execution_spec() {
local dir="$1" command="$2" cwd="$3"
mkdir -p "$dir/nodes/test-design"
python3 - "$dir/nodes/test-design/test-execution.json" "$command" "$cwd" <<'PY'
import json, sys
path, command, cwd = sys.argv[1], sys.argv[2], sys.argv[3]
data = {
"testCommand": command,
"cwd": cwd,
"prerequisites": ["cwd must exist"]
}
open(path, "w").write(json.dumps(data))
PY
}
echo "Test: test-design testCommand executes in test-execute"
echo "====================================================="
echo ""
$HARNESS init --flow build-verify --entry test-design --dir .harness >/dev/null 2>/dev/null
write_test_design_handshake .harness "node -e \"process.exit(0)\""
OUT=$($HARNESS transition --from test-design --to test-execute --verdict PASS --flow build-verify --dir .harness 2>/dev/null)
EXECUTED=$(python3 -c "import json,sys; print(json.load(sys.stdin)['testCommandExecution']['executed'])" <<< "$OUT")
EXIT_CODE=$(python3 -c "import json; print(json.load(open('.harness/nodes/test-execute/run_1/test-command-result.json'))['exitCode'])")
if [ "$EXECUTED" = "True" ] && [ "$EXIT_CODE" = "0" ]; then
echo " ✅ testCommand executed and wrote result evidence"
PASS=$((PASS + 1))
else
echo " ❌ testCommand evidence missing: executed=$EXECUTED exit=$EXIT_CODE"
FAIL=$((FAIL + 1))
fi
if [ -f .harness/nodes/test-execute/handshake.json ] &&
grep -q '"type": "test-result"' .harness/nodes/test-execute/handshake.json &&
grep -q '"type": "cli-output"' .harness/nodes/test-execute/handshake.json; then
echo " ✅ test-execute handshake records test-result and cli-output"
PASS=$((PASS + 1))
else
echo " ❌ test-execute handshake missing evidence artifacts"
FAIL=$((FAIL + 1))
fi
if grep -q '"kind": "opc-test-command"' .harness/nodes/test-execute/handshake.json &&
grep -q '"sourcePlanHash":' .harness/nodes/test-execute/handshake.json &&
grep -q '"resultHash":' .harness/nodes/test-execute/handshake.json &&
grep -q '"ledger":' .harness/nodes/test-execute/handshake.json &&
[ -f .harness/.opc-provenance.jsonl ] &&
grep -q '"executionActor": "opc-harness:test-command"' .harness/nodes/test-execute/handshake.json &&
grep -q '"kind": "opc-test-command"' .harness/nodes/test-execute/run_1/test-command-result.json &&
grep -q '"sourcePlanHash":' .harness/nodes/test-execute/run_1/test-command-result.json &&
grep -q '"executionActor": "opc-harness:test-command"' .harness/nodes/test-execute/run_1/test-command-result.json; then
echo " ✅ testCommand evidence records OPC provenance"
PASS=$((PASS + 1))
else
echo " ❌ testCommand evidence missing OPC provenance"
FAIL=$((FAIL + 1))
fi
SEAL_OUT=$($HARNESS seal --node test-execute --dir .harness 2>/dev/null)
SEAL_ERRORS=$(json_field "$SEAL_OUT" "validationErrors")
if grep -q '"kind": "opc-test-command"' .harness/nodes/test-execute/handshake.json &&
grep -q '"resultHash":' .harness/nodes/test-execute/handshake.json &&
[ "$SEAL_ERRORS" = "[]" ]; then
echo " ✅ seal preserves harness testCommand provenance"
PASS=$((PASS + 1))
else
echo " ❌ seal clobbered testCommand provenance or reported errors: $SEAL_OUT"
FAIL=$((FAIL + 1))
fi
python3 - <<'PY'
import json
path = ".harness/nodes/test-execute/run_1/test-command-result.json"
data = json.load(open(path))
data["tampered"] = True
open(path, "w").write(json.dumps(data, indent=2) + "\n")
PY
OUT=$($HARNESS transition --from test-execute --to gate --verdict PASS --flow build-verify --dir .harness 2>/dev/null)
ALLOWED=$(json_field "$OUT" "allowed")
if [ "$ALLOWED" = "False" ] && grep -q "result hash" <<< "$OUT"; then
echo " ✅ modified testCommand result blocks gate"
PASS=$((PASS + 1))
else
echo " ❌ modified testCommand result passed gate: $OUT"
FAIL=$((FAIL + 1))
fi
$HARNESS init --flow build-verify --entry test-design --dir .harness-auto-cwd >/dev/null 2>/dev/null
mkdir -p app/node_modules/fixture-pkg
printf '{"name":"app","private":true}\n' > app/package.json
printf 'module.exports = 42;\n' > app/node_modules/fixture-pkg/index.js
write_test_design_handshake .harness-auto-cwd "node -e \"require.resolve('fixture-pkg')\""
$HARNESS transition --from test-design --to test-execute --verdict PASS --flow build-verify --dir .harness-auto-cwd >/dev/null 2>/dev/null
EXIT_CODE=$(python3 -c "import json; print(json.load(open('.harness-auto-cwd/nodes/test-execute/run_1/test-command-result.json'))['exitCode'])")
CWD_SOURCE=$(python3 -c "import json; print(json.load(open('.harness-auto-cwd/nodes/test-execute/run_1/test-command-result.json'))['cwdSource'])")
if [ "$EXIT_CODE" = "0" ] && [ "$CWD_SOURCE" = "auto-js-project" ]; then
echo " ✅ testCommand auto-resolves unique JS package cwd"
PASS=$((PASS + 1))
else
echo " ❌ testCommand cwd auto-resolution failed: exit=$EXIT_CODE cwdSource=$CWD_SOURCE"
FAIL=$((FAIL + 1))
fi
$HARNESS init --flow build-verify --entry test-design --dir .harness-fail >/dev/null 2>/dev/null
write_test_design_handshake .harness-fail "node -e \"process.exit(7)\""
$HARNESS transition --from test-design --to test-execute --verdict PASS --flow build-verify --dir .harness-fail >/dev/null 2>/dev/null
OUT=$($HARNESS transition --from test-execute --to gate --verdict PASS --flow build-verify --dir .harness-fail 2>/dev/null)
ALLOWED=$(json_field "$OUT" "allowed")
if [ "$ALLOWED" = "False" ] && { grep -q "test(s) failed" <<< "$OUT" || grep -q "sealed verdict is.*FAIL" <<< "$OUT"; }; then
echo " ✅ failed testCommand blocks test-execute → gate through structured result"
PASS=$((PASS + 1))
else
echo " ❌ failed testCommand did not block test-execute → gate PASS: $OUT"
FAIL=$((FAIL + 1))
fi
$HARNESS init --flow build-verify --entry test-execute --dir .harness-forged >/dev/null 2>/dev/null
mkdir -p .harness-forged/nodes/test-execute/run_1
cat > .harness-forged/nodes/test-execute/run_1/test-execution.json <<'JSON'
{"summary":{"passed":["fake"]},"checks":[{"id":"fake-pass","pass":true,"total":1}]}
JSON
cat > .harness-forged/nodes/test-execute/handshake.json <<'JSON'
{
"nodeId": "test-execute",
"nodeType": "execute",
"runId": "run_1",
"status": "completed",
"verdict": "PASS",
"summary": "self-authored pass",
"timestamp": "2026-01-01T00:00:00.000Z",
"artifacts": [{"type": "test-result", "path": "run_1/test-execution.json"}]
}
JSON
OUT=$($HARNESS transition --from test-execute --to gate --verdict PASS --flow build-verify --dir .harness-forged 2>/dev/null)
ALLOWED=$(json_field "$OUT" "allowed")
if [ "$ALLOWED" = "False" ] && grep -q "lacks matching OPC testCommand provenance" <<< "$OUT"; then
echo " ✅ self-authored structured test evidence blocks before gate"
PASS=$((PASS + 1))
else
echo " ❌ self-authored structured test evidence passed early gate: $OUT"
FAIL=$((FAIL + 1))
fi
$HARNESS init --flow build-verify --entry test-execute --dir .harness-consistent-forge >/dev/null 2>/dev/null
mkdir -p .harness-consistent-forge/nodes/test-design/run_1 .harness-consistent-forge/nodes/test-execute/run_1
write_complete_test_plan .harness-consistent-forge/nodes/test-design/run_1/test-plan.md
COMMAND='node -e "process.exit(0)"'
python3 - <<'PY'
import hashlib, json
command = 'node -e "process.exit(0)"'
plan = open('.harness-consistent-forge/nodes/test-design/run_1/test-plan.md').read()
command_hash = hashlib.sha256(command.encode()).hexdigest()
plan_hash = hashlib.sha256(plan.encode()).hexdigest()
result = {
"testCommand": command,
"provenance": {
"kind": "opc-test-command",
"commandHash": command_hash,
"sourcePlanHash": plan_hash,
"executionActor": "opc-harness:test-command"
},
"checks": [{"id": "fake-pass", "pass": True, "total": 1}],
"test_fail_count": 0
}
result_text = json.dumps(result, indent=2) + "\n"
open('.harness-consistent-forge/nodes/test-execute/run_1/test-command-result.json', 'w').write(result_text)
result_hash = hashlib.sha256(result_text.encode()).hexdigest()
design_hs = {
"nodeId": "test-design", "nodeType": "review", "runId": "run_1",
"status": "completed", "verdict": "PASS", "summary": "tests designed",
"timestamp": "2026-01-01T00:00:00.000Z",
"artifacts": [{"type": "test-plan", "path": "run_1/test-plan.md"}],
"testCommand": command
}
exec_hs = {
"nodeId": "test-execute", "nodeType": "execute", "runId": "run_1",
"status": "completed", "verdict": "PASS", "summary": "forged public hashes",
"timestamp": "2026-01-01T00:00:00.000Z",
"artifacts": [{"type": "test-result", "path": "run_1/test-command-result.json"}],
"testEvidenceProvenance": {
"kind": "opc-test-command",
"sourceNode": "test-design",
"commandHash": command_hash,
"sourcePlanHash": plan_hash,
"resultHash": result_hash,
"executionActor": "opc-harness:test-command"
}
}
open('.harness-consistent-forge/nodes/test-design/handshake.json', 'w').write(json.dumps(design_hs))
open('.harness-consistent-forge/nodes/test-execute/handshake.json', 'w').write(json.dumps(exec_hs))
PY
OUT=$($HARNESS transition --from test-execute --to gate --verdict PASS --flow build-verify --dir .harness-consistent-forge 2>/dev/null)
ALLOWED=$(json_field "$OUT" "allowed")
if [ "$ALLOWED" = "False" ] && grep -q "signed provenance ledger" <<< "$OUT"; then
echo " ✅ consistent forged public hashes block without signed ledger"
PASS=$((PASS + 1))
else
echo " ❌ consistent forged public hashes passed gate: $OUT"
FAIL=$((FAIL + 1))
fi
$HARNESS init --flow build-verify --entry test-design --dir .harness-bad-cwd >/dev/null 2>/dev/null
write_test_design_handshake .harness-bad-cwd "node -e \"process.exit(0)\""
write_test_execution_spec .harness-bad-cwd "node -e \"process.exit(0)\"" "/tmp/opc-missing-test-cwd"
$HARNESS transition --from test-design --to test-execute --verdict PASS --flow build-verify --dir .harness-bad-cwd >/dev/null 2>/dev/null
EXIT_CODE=$(python3 -c "import json; print(json.load(open('.harness-bad-cwd/nodes/test-execute/run_1/test-command-result.json'))['exitCode'])")
if [ "$EXIT_CODE" = "1" ] && grep -q "ENOENT" .harness-bad-cwd/nodes/test-execute/run_1/test-command-output.txt; then
echo " ✅ invalid cwd writes failing test evidence"
PASS=$((PASS + 1))
else
echo " ❌ invalid cwd failed open"
FAIL=$((FAIL + 1))
fi
print_results
#!/bin/bash
set -e
# P1-1 mechanical gate: test-design P0/P1 cases must carry an Anchor (a build-artifact
# citation) proving the asserted behavior exists in the built code. This turns the
# test-design-protocol "Anchor (P0/P1 mandatory)" prompt rule into an enforced verdict:
# - P0/P1 case missing Anchor → totals.warning += 1 → verdict ITERATE
# - P0/P1 case Anchor is file:line but → totals.warning += 1 → verdict ITERATE
# the path does not resolve
# - P2 case missing Anchor → NOT flagged
# - P0/P1 case with valid file:line/range → NOT flagged
# - P0/P1 case with non-file anchor → totals.warning += 1 → verdict ITERATE
# - TC-TIER-* cases (mechanically injected baselines, not role-authored build
# assertions) are EXEMPT even at P0 with no Anchor
#
# Anti-fake-green: every trigger assertion also checks that `reason` names the anchor
# (not just verdict=ITERATE — which could come from an unrelated warning). The session
# always includes eval-skeptic-owner.md so the mandatory-role warning never masks the gate.
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
jq_field() {
echo "$1" | python3 -c "import sys,json; d=json.load(sys.stdin); v=d.get('$2'); print('__NULL__' if v is None else json.dumps(v))" 2>/dev/null
}
assert_contains() {
local desc="$1" text="$2" pattern="$3"
if echo "$text" | grep -q "$pattern"; then
echo " ✅ $desc"; PASS=$((PASS + 1))
else
echo " ❌ $desc — pattern '$pattern' not found"; FAIL=$((FAIL + 1))
fi
}
assert_not_contains() {
local desc="$1" text="$2" pattern="$3"
if echo "$text" | grep -q "$pattern"; then
echo " ❌ $desc — pattern '$pattern' found (should not be)"; FAIL=$((FAIL + 1))
else
echo " ✅ $desc"; PASS=$((PASS + 1))
fi
}
assert_field_eq() {
local desc="$1" json="$2" field="$3" expected="$4" actual
actual=$(jq_field "$json" "$field")
if [ "$actual" = "$expected" ]; then
echo " ✅ $desc"; PASS=$((PASS + 1))
else
echo " ❌ $desc — $field: expected $expected, got $actual"; FAIL=$((FAIL + 1))
fi
}
# A substantive 🔵-only eval (no critical/warning findings, real file refs, multi-section)
# so the compound quality gate does not trip and the baseline can reach PASS.
write_eval() {
local path="$1" title="$2"
{
echo "# $title"
echo ""
echo "## Analysis"
echo "Reviewed the implementation against the test plan in detail."
echo "Coverage of the public surface looks adequate for this feature."
echo "Edge cases around pagination and empty input are addressed."
echo ""
echo "## Findings"
echo "🔵 src/app.ts:12 — the helper could expose a named constant"
echo "→ Fix: extract the literal to a documented const for readability"
echo "Reasoning: a named constant reads better than a bare literal here."
echo ""
echo "## Coverage Analysis"
echo "Unit tests exercise the core parsing path thoroughly."
echo "Integration tests verify the end-to-end submit flow."
echo "Negative tests reject malformed input with explicit error codes."
echo "Accessibility checks cover keyboard navigation and contrast."
echo ""
echo "## Quality Assessment"
echo "Assertions are binary and steps are mechanically followable."
echo "Expected results are concrete, not aspirational."
echo "Failure impacts are documented for each priority-zero case."
echo ""
echo "VERDICT: PASS FINDINGS[1]"
} > "$path"
}
# Write a test-design session whose test-plan.md satisfies the existing layer/depth/command
# gate. The caller passes the TC-case block markdown to splice under L1, so the ONLY variable
# across tests is the anchor situation of those cases.
setup_plan() {
local tc_blocks="$1"
rm -rf .harness
mkdir -p .harness/nodes/test-design/run_1
cat > .harness/flow-state.json << 'EOF'
{"currentNode":"test-design","history":[{"node":"test-design","run":1}],"edgeCounts":{},"stepCount":1}
EOF
write_eval .harness/nodes/test-design/run_1/eval-skeptic-owner.md "Skeptic Owner Review"
write_eval .harness/nodes/test-design/run_1/eval-tester.md "Test Design — tester"
# A real build artifact in the session so file:line anchors can resolve.
cat > .harness/nodes/test-design/run_1/build.ts << 'EOF'
export function paginate(items, page) {
if (page < 1) return [];
return items.slice((page - 1) * 10, page * 10);
}
EOF
cat > .harness/nodes/test-design/run_1/test-plan.md << EOF
# Test Plan
## L1: Unit / Smoke
- Run \`npm test\` for unit coverage of the paginate helper
- Vitest coverage must exceed 80 percent on changed files
- Every changed module has a corresponding unit test file
$tc_blocks
## L2: Contract / Edge Cases
- Validate schema compliance and reject invalid input with error codes
- Test boundary values: empty string, max length, unicode payloads
- Confirm error code mapping matches the documented contract
## L3: Integration / E2E Flows
- Test integration end-to-end flow: load to submit to verify
- Run a multi-step workflow against a real test database container
- Verify webhook delivery on each state transition
## L4: UI / Visual / A11y
- Playwright screenshot at 1440px and 375px viewport widths
- Verify responsive layout breakpoints render correctly
- Run axe-core accessibility scan with zero serious violations
## L5: Tier Baseline / Polish
- Verify dark mode toggle preserves the user preference
- Check typography hierarchy across heading and body fonts
- Confirm navigation active states and favicon are present
EOF
}
echo "=== Test-plan Anchor mechanical gate (P1-1) ==="
# ───────────────────────────────────────────────────────────────
echo ""
echo "--- A0 (baseline): all P0/P1 cases carry valid file:line anchors → reason has no anchor issue ---"
setup_plan '### TC-TESTER-01: empty page boundary
- **Category**: edge-case
- **Priority**: P0
- **Anchor**: `nodes/test-design/run_1/build.ts:2` — `if (page < 1) return []`
- **Expected**: returns empty array for page 0
### TC-TESTER-02: slice window
- **Category**: unit
- **Priority**: P1
- **Anchor**: `nodes/test-design/run_1/build.ts:3` — slice math
- **Expected**: returns 10 items for a full page'
OUT=$($HARNESS synthesize .harness --node test-design 2>/dev/null)
assert_not_contains "valid anchors → no missing anchor issue" "$OUT" "missing Anchor"
assert_not_contains "valid anchors → no unresolved anchor issue" "$OUT" "Anchor ref unresolved"
assert_not_contains "valid anchors → no invalid anchor issue" "$OUT" "Anchor invalid format"
assert_field_eq "valid anchors → verdict PASS" "$OUT" "verdict" '"PASS"'
# ───────────────────────────────────────────────────────────────
echo ""
echo "--- A1: P0 case missing Anchor → ITERATE, reason names the case ---"
setup_plan '### TC-TESTER-01: empty page boundary
- **Category**: edge-case
- **Priority**: P0
- **Expected**: returns empty array for page 0'
OUT=$($HARNESS synthesize .harness --node test-design 2>/dev/null)
assert_field_eq "P0 no anchor → verdict ITERATE" "$OUT" "verdict" '"ITERATE"'
assert_contains "reason names missing Anchor" "$OUT" "missing Anchor"
assert_contains "reason names the offending case id" "$OUT" "TC-TESTER-01"
# ───────────────────────────────────────────────────────────────
echo ""
echo "--- A2: P1 case missing Anchor → ITERATE ---"
setup_plan '### TC-TESTER-01: slice window
- **Category**: unit
- **Priority**: P1
- **Expected**: returns 10 items for a full page'
OUT=$($HARNESS synthesize .harness --node test-design 2>/dev/null)
assert_field_eq "P1 no anchor → verdict ITERATE" "$OUT" "verdict" '"ITERATE"'
assert_contains "reason names missing Anchor (P1)" "$OUT" "missing Anchor"
# ───────────────────────────────────────────────────────────────
echo ""
echo "--- A3 (guard): P2 case missing Anchor → NOT flagged ---"
setup_plan '### TC-TESTER-01: nice-to-have polish check
- **Category**: e2e-ui
- **Priority**: P2
- **Anchor**: `nodes/test-design/run_1/build.ts:2`
- **Expected**: page renders
### TC-TESTER-02: cosmetic spacing
- **Category**: e2e-ui
- **Priority**: P2
- **Expected**: spacing is consistent'
OUT=$($HARNESS synthesize .harness --node test-design 2>/dev/null)
assert_not_contains "P2 missing anchor → no anchor issue" "$OUT" "missing Anchor"
assert_field_eq "P2 missing anchor → verdict PASS" "$OUT" "verdict" '"PASS"'
# ───────────────────────────────────────────────────────────────
echo ""
echo "--- A4 (guard): P0 with valid file:line Anchor → NOT flagged ---"
setup_plan '### TC-TESTER-01: empty page boundary
- **Category**: edge-case
- **Priority**: P0
- **Anchor**: `nodes/test-design/run_1/build.ts:2`
- **Expected**: returns empty array for page 0'
OUT=$($HARNESS synthesize .harness --node test-design 2>/dev/null)
assert_not_contains "valid file:line anchor → no anchor issue" "$OUT" "Anchor ref unresolved"
assert_not_contains "valid file:line anchor → no missing-anchor issue" "$OUT" "missing Anchor"
assert_field_eq "valid file:line anchor → verdict PASS" "$OUT" "verdict" '"PASS"'
# ───────────────────────────────────────────────────────────────
echo ""
echo "--- A5: P0 with invalid file:line Anchor → ITERATE ---"
setup_plan '### TC-TESTER-01: empty page boundary
- **Category**: edge-case
- **Priority**: P0
- **Anchor**: `nodes/test-design/run_1/ghost-nonexistent-9999.ts:5`
- **Expected**: returns empty array for page 0'
OUT=$($HARNESS synthesize .harness --node test-design 2>/dev/null)
assert_field_eq "invalid file:line anchor → verdict ITERATE" "$OUT" "verdict" '"ITERATE"'
assert_contains "reason names unresolved anchor ref" "$OUT" "Anchor ref unresolved"
# ───────────────────────────────────────────────────────────────
echo ""
echo "--- A6 (guard): TC-TIER P0 case with no Anchor → EXEMPT (not flagged) ---"
setup_plan '### TC-TIER-01: responsive layout baseline
- **Category**: e2e-ui
- **Priority**: P0
- **Expected**: layout reflows at mobile breakpoint'
OUT=$($HARNESS synthesize .harness --node test-design 2>/dev/null)
assert_not_contains "TC-TIER P0 no anchor → exempt, no anchor issue" "$OUT" "missing Anchor"
assert_field_eq "TC-TIER P0 no anchor → verdict PASS" "$OUT" "verdict" '"PASS"'
# ───────────────────────────────────────────────────────────────
echo ""
echo "--- A7: grep-token Anchor (not file:line) on P0 → ITERATE ---"
setup_plan '### TC-TESTER-01: empty page boundary
- **Category**: edge-case
- **Priority**: P0
- **Anchor**: grep `function paginate` — proves the helper exists
- **Expected**: returns empty array for page 0'
OUT=$($HARNESS synthesize .harness --node test-design 2>/dev/null)
assert_field_eq "grep-token anchor → verdict ITERATE" "$OUT" "verdict" '"ITERATE"'
assert_contains "grep-token anchor → invalid format issue" "$OUT" "Anchor invalid format"
assert_not_contains "grep-token anchor → no missing-anchor issue" "$OUT" "missing Anchor"
# ───────────────────────────────────────────────────────────────
# A8: file exists but the cited LINE is out of range. Existence alone is NOT
# enough — a phantom test can hang a fabricated line off a real file. The
# line number must be within the file's bounds or the Anchor is invalid.
echo ""
echo "--- A8: P0 Anchor file exists but line out of range → ITERATE ---"
setup_plan '### TC-TESTER-01: empty page boundary
- **Category**: edge-case
- **Priority**: P0
- **Anchor**: `nodes/test-design/run_1/build.ts:9999`
- **Expected**: returns empty array for page 0'
OUT=$($HARNESS synthesize .harness --node test-design 2>/dev/null)
assert_field_eq "out-of-range anchor line → verdict ITERATE" "$OUT" "verdict" '"ITERATE"'
assert_contains "reason names out-of-range anchor" "$OUT" "Anchor line out of range"
# A8b (guard): line 0 is also out of range (1-indexed)
echo ""
echo "--- A8b: P0 Anchor line 0 (below 1-indexed floor) → ITERATE ---"
setup_plan '### TC-TESTER-01: empty page boundary
- **Category**: edge-case
- **Priority**: P0
- **Anchor**: `nodes/test-design/run_1/build.ts:0`
- **Expected**: returns empty array for page 0'
OUT=$($HARNESS synthesize .harness --node test-design 2>/dev/null)
assert_field_eq "anchor line 0 → verdict ITERATE" "$OUT" "verdict" '"ITERATE"'
assert_contains "reason names out-of-range anchor (line 0)" "$OUT" "Anchor line out of range"
print_results
+44
-10

@@ -12,8 +12,36 @@ #!/usr/bin/env bash

# Read the PostCompact event payload from stdin (Claude Code provides cwd here).
INPUT="$(cat 2>/dev/null || true)"
CWD="$(echo "$INPUT" | jq -r '.cwd // empty' 2>/dev/null || true)"
# Resume only flows that were touched recently. A flow that has not been
# advanced in this window is treated as abandoned, not "interrupted by
# compaction" — resuming it injects an unrelated stale task. Override with
# OPC_RESUME_MAX_AGE_HOURS (0 disables the age gate).
MAX_AGE_HOURS="${OPC_RESUME_MAX_AGE_HOURS:-12}"
# Find in-progress flows
FLOW_JSON=$(node "$OPC_HARNESS" ls 2>/dev/null) || exit 0
LATEST=$(echo "$FLOW_JSON" | jq -r '
[.flows[] | select(.status == "in_progress")]
| sort_by(.lastModified) | last // empty
# Select the most recent in-progress flow that (a) belongs to the current
# working directory when known, and (b) is fresh enough to be worth mentioning.
# Freshness is judged by lastAdvanced (time of the last real node transition),
# falling back to file mtime only when a flow has not advanced yet. This is a
# noise gate, NOT a safety gate — the safety gate is the user-confirmation
# wording in the injected message below.
NOW="$(date +%s)"
LATEST=$(echo "$FLOW_JSON" | jq -r \
--arg cwd "$CWD" \
--argjson now "$NOW" \
--argjson maxage "$MAX_AGE_HOURS" '
[.flows[]
| select(.status == "in_progress")
| select($cwd == "" or .projectRoot == null or .projectRoot == $cwd)
| . + { _liveness: ((.lastAdvanced // .lastModified)) }
| select(
$maxage == 0
or (($now - (._liveness | sub("\\.[0-9]+Z$"; "Z") | fromdateiso8601)) < ($maxage * 3600))
)
]
| sort_by(._liveness) | last // empty
| @json

@@ -28,8 +56,14 @@ ' 2>/dev/null)

STEPS=$(echo "$LATEST" | jq -r '.totalSteps')
LAST_ADVANCED=$(echo "$LATEST" | jq -r '.lastAdvanced // .lastModified // "unknown"')
[ -d "$DIR" ] || exit 0
# Build resume context message
CONTEXT="[OPC RESUME] You have an in-progress OPC flow that was interrupted by context compaction.
# Build a NON-IMPERATIVE notice. flow-state.json is a mechanical record, not a
# statement of the user's current intent. After compaction the conversation may
# have moved on entirely, so this hook must NOT command a resume — it surfaces
# evidence and asks for confirmation. The user/operator decides; the default is
# to do nothing.
CONTEXT="[OPC NOTICE] There MAY be an unfinished OPC flow on disk. This is evidence, NOT an instruction — do NOT resume automatically.
Evidence:
- Session dir: $DIR

@@ -39,8 +73,8 @@ - Flow: $FLOW

- Steps completed: $STEPS
- Last real advance: $LAST_ADVANCED
Action required:
1. Run \`opc-harness ls\` to confirm flow state
2. Read \`$DIR/acceptance-criteria.md\` for the definition of done
3. Resume executing node **$NODE** in the **$FLOW** flow
4. Re-read skill.md and the relevant protocol for this node type — do NOT rely on pre-compaction memory"
Before doing anything with this flow, confirm with the user whether it is still the active task.
- If the user confirms it is current: run \`opc-harness ls\`, read \`$DIR/acceptance-criteria.md\`, then resume node **$NODE** — re-read SKILL.md and the node protocol; do NOT rely on pre-compaction memory.
- If it is unrelated to the current conversation: treat it as stale, ignore it, and suggest \`/opc stop\` to close it out.
- If unsure: ask the user. Do not act on this notice alone."

@@ -47,0 +81,0 @@ # If resume-brief.md exists (written by PreCompact), append it

+7
-6

@@ -57,2 +57,3 @@ // Criteria lint — mechanical DoD quality check for acceptance-criteria.md.

const E2E_TRIGGER_PHRASES = /\b(live trigger|end-to-end trigger|e2e trigger|live verification|live-trigger|e2e-trigger|upstream.*trigger|trigger.*downstream)\b/i;
const FORMAT_HINT = "Expected sections: ## Outcomes with - OUT-N bullets, ## Verification mapping each OUT-N, ## Quality Constraints, ## Out of Scope.";

@@ -76,3 +77,3 @@ // ── Run all checks ─────────────────────────────────────────────

if (outcomesSection === undefined) {
fail("outcomes-exist", "No outcomes section or no OUT-N bullets found");
fail("outcomes-exist", `No outcomes section or no OUT-N bullets found. ${FORMAT_HINT}`);
}

@@ -84,4 +85,4 @@

checksRun++;
if (outcomesSection && (outcomes.length < 3 || outcomes.length > 7)) {
fail("outcomes-count", `Found ${outcomes.length} outcomes — must be 3-7`);
if (outcomesSection && (outcomes.length < 3 || outcomes.length > 10)) {
fail("outcomes-count", `Found ${outcomes.length} outcomes — must be 3-10. ${FORMAT_HINT}`);
}

@@ -93,3 +94,3 @@

if (verificationSection === undefined) {
fail("verification-exists", "No verification section");
fail("verification-exists", `No verification section. ${FORMAT_HINT}`);
}

@@ -110,3 +111,3 @@

if (sections["Quality Constraints"] === undefined) {
fail("quality-section", "No quality constraints section");
fail("quality-section", `No quality constraints section. Use exact heading: ## Quality Constraints. ${FORMAT_HINT}`);
}

@@ -117,3 +118,3 @@

if (sections["Out of Scope"] === undefined) {
fail("scope-section", "No out-of-scope section");
fail("scope-section", `No out-of-scope section. ${FORMAT_HINT}`);
}

@@ -120,0 +121,0 @@

@@ -39,4 +39,4 @@ // criteria-lint.test.mjs — structural checks

test("outcomes-count fails with 8 outcomes", () => {
const outs = Array.from({ length: 8 }, (_, i) =>
test("outcomes-count fails with 11 outcomes", () => {
const outs = Array.from({ length: 11 }, (_, i) =>
`- OUT-${i + 1}: Outcome number ${i + 1} returns status code ${200 + i}`

@@ -50,5 +50,6 @@ );

assert.ok(failChecks(r).includes("outcomes-count"));
assert.ok(r.failures.find((f) => f.check === "outcomes-count").message.includes("3-10"));
});
test("outcomes-count passes with 3-7 outcomes", () => {
test("outcomes-count passes with 3-10 outcomes", () => {
const r = runLint(validDoc());

@@ -58,2 +59,10 @@ assert.ok(!failChecks(r).includes("outcomes-count"));

test("structural failures include expected format hint", () => {
const text = "## Outcomes\n- OUT-1: a\n- OUT-2: b\n- OUT-3: c\n## Verification\nOUT-1 OUT-2 OUT-3\n## Out of Scope\n- x";
const r = runLint(text);
const f = r.failures.find((failure) => failure.check === "quality-section");
assert.ok(f.message.includes("## Quality Constraints"));
assert.ok(f.message.includes("Expected sections"));
});
test("verification-exists fails when missing", () => {

@@ -60,0 +69,0 @@ const text = "## Outcomes\n- OUT-1: a\n- OUT-2: b\n- OUT-3: c\n## Quality Constraints\nq\n## Out of Scope\n- x";

@@ -5,3 +5,3 @@ // Evaluation analysis commands: verify, synthesize, tier-baseline

import { readFileSync, readdirSync, existsSync } from "fs";
import { join } from "path";
import { join, dirname } from "path";
import { execSync } from "child_process";

@@ -11,3 +11,62 @@ import { parseEvaluation } from "./eval-parser.mjs";

import { checkBaselineCoverage, generateTierTestCases, VALID_TIERS, TEST_LAYERS, TEST_LAYER_KEYWORDS, TEST_LAYER_LABELS } from "./tier-baselines.mjs";
import { anchorIssues as collectAnchorIssues } from "./test-plan-gate.mjs";
/**
* Resolve the set of changed files that a review's changeScope layer must cover.
*
* The change scope is defined by the commits the OPC flow actually PRODUCED —
* not by a blind `git diff HEAD~1`, which mis-attributes unrelated parallel
* commits and cannot see session-local artifacts the flow never committed.
*
* @param {string} baseDir git working tree to inspect (the --base directory)
* @param {string[]|null} changeCommits
* - null → flag absent (standalone/legacy caller): fall back to HEAD~1..HEAD
* - [] → flow explicitly produced NO commits: nothing to cover → skip clean
* - [sha] → diff exactly these commits (union of their name-only file lists)
* @returns {{ files: string[], skip: boolean, reason: string|null }}
* reason is non-null only when the skip is worth surfacing (non-git base).
*/
export function changeScopeDiffFiles(baseDir, changeCommits) {
const git = (cmd) =>
execSync(cmd, { cwd: baseDir, encoding: "utf8", timeout: 15000, stdio: ["ignore", "pipe", "ignore"] });
let baseIsGit = false;
try { git("git rev-parse --is-inside-work-tree"); baseIsGit = true; } catch { baseIsGit = false; }
if (!baseIsGit) {
return { files: [], skip: true, reason: `--base (${baseDir}) is not a git repository — cannot verify the review covers the change scope` };
}
// Flow-scoped mode: caller passed the commits this flow actually produced.
if (Array.isArray(changeCommits)) {
if (changeCommits.length === 0) {
// The flow committed nothing (reviewed a session-local artifact, or HEAD
// moved only via unrelated parallel commits). There is no flow-authored
// change scope to cover → skip cleanly, no warning, no false ITERATE.
return { files: [], skip: true, reason: null };
}
const files = new Set();
for (const sha of changeCommits) {
try {
const out = git(`git show --name-only --format= ${sha}`);
for (const ff of out.trim().split("\n")) if (ff.length > 0) files.add(ff);
} catch { /* unknown/invalid sha — skip this commit, keep the rest */ }
}
return { files: [...files], skip: false, reason: null };
}
// Legacy/standalone mode (flag absent): diff the last commit.
try {
let diffOut = "";
try { diffOut = git("git diff --name-only HEAD~1"); }
catch {
// Initial commit shows all files; git available but no HEAD~1.
try { diffOut = git("git show --name-only --format='' HEAD"); }
catch { /* git available but no commits yet */ }
}
return { files: diffOut.trim().split("\n").filter(ff => ff.length > 0), skip: false, reason: null };
} catch {
return { files: [], skip: true, reason: null };
}
}
export function cmdVerify(args) {

@@ -123,2 +182,7 @@ const file = args[0];

function isAbsenceOrMetaFinding(issue) {
return /\b(no|not|missing|absent|never|without|unwired|nothing|isn'?t|aren'?t|unreachable|dead code|not wired|not connected|not run|not executed)\b/i
.test(String(issue || ""));
}
// Note: synthesize assumes findings are bugs/issues (review use case).

@@ -221,5 +285,9 @@ export function cmdSynthesize(args) {

const isTestDesignNode = nodeId && /test[-_]design/.test(nodeId);
const requiresCodeGrounding = !isTestDesignNode;
const roles = [];
const totals = { critical: 0, warning: 0, suggestion: 0 };
const thinEvalWarnings = [];
const verificationWarnings = [];

@@ -229,2 +297,10 @@ // --base <dir> — project root for validating file:line references in findings

// --change-commits <sha,sha,...> — the commits this flow actually produced,
// defining the changeScope layer's true scope. Absent → null (legacy HEAD~1
// fallback); present-but-empty → [] (flow committed nothing → skip cleanly).
const changeCommitsRaw = getFlag(args, "change-commits", null);
const changeCommits = changeCommitsRaw === null
? null
: changeCommitsRaw.split(",").map(s => s.trim()).filter(s => s.length > 0);
// D1: --base deprecation warning — next version makes this a hard error

@@ -255,2 +331,13 @@ if (!baseDir) {

// ── Format error detection ───────────────────────────────
// Lines with severity markers that failed to parse as structured findings.
// If ALL severity markers failed → protocol violation, eval cannot produce PASS.
if (parsed.formatErrors && parsed.formatErrors.length > 0) {
const dropped = parsed.formatErrors.length;
totals.warning += dropped;
thinEvalWarnings.push(
`${roleName}: ${dropped} line(s) with severity markers dropped due to format errors`
);
}
const blocked = /BLOCKED/i.test(parsed.verdict);

@@ -271,3 +358,3 @@

// Review evals with zero file:line references → no grounding in code.
if (parsed.noCodeRefs && parsed.findings_count > 0) {
if (requiresCodeGrounding && parsed.noCodeRefs && parsed.findings_count > 0) {
totals.warning += 1;

@@ -321,24 +408,46 @@ thinEvalWarnings.push(`${roleName}: eval has 0 file:line references — findings not grounded in code`);

let weakRefCount = 0;
let testDesignAnchorIssueCount = 0;
if (baseDir && parsed.findings.length > 0) {
for (const f of parsed.findings) {
if (f.file) {
const resolved = f.file.startsWith("/") ? f.file : join(baseDir, f.file);
if (!existsSync(resolved)) {
// F9: resolve refs against the project base AND the session dir(s). Evals
// legitimately cite session artifacts (test-plan.md, brief.md, sibling evals)
// that live outside the project base — those are valid, not fabricated.
const refRoots = [baseDir, dir, dirname(f.path)];
for (const finding of parsed.findings) {
if (finding.file) {
let resolved = null;
if (finding.file.startsWith("/")) {
if (existsSync(finding.file)) resolved = finding.file;
} else {
for (const root of refRoots) {
const cand = join(root, finding.file);
if (existsSync(cand)) { resolved = cand; break; }
}
}
if (!resolved) {
invalidRefCount++;
} else if (f.line != null) {
} else if (finding.line != null) {
try {
const content = readFileSync(resolved, "utf8");
const srcLines = content.split("\n");
if (f.line < 1 || f.line > srcLines.length) {
if (finding.line < 1 || finding.line > srcLines.length) {
invalidRefCount++;
} else {
// Content relevance: extract source line, check token overlap with finding issue
const srcLine = srcLines[f.line - 1].toLowerCase();
const issueTokens = (f.issue || "").toLowerCase()
// Content relevance: ±3 line window, check token overlap with finding issue
const lo = Math.max(0, finding.line - 4); // finding.line is 1-indexed
const hi = Math.min(srcLines.length, finding.line + 2);
const windowText = srcLines.slice(lo, hi).join(" ").toLowerCase();
const CODE_STOPWORDS = new Set([
"const", "let", "var", "function", "return", "import", "export",
"from", "this", "that", "the", "and", "for", "with", "not",
"has", "are", "was", "but", "can", "will", "new", "class",
"true", "false", "null", "undefined", "async", "await",
]);
const issueTokens = (finding.issue || "").toLowerCase()
.replace(/[^a-z0-9_]/g, " ").split(/\s+/)
.filter(t => t.length >= 3); // skip noise words
const srcTokens = srcLine.replace(/[^a-z0-9_]/g, " ").split(/\s+/)
.filter(t => t.length >= 3);
if (issueTokens.length >= 2 && srcTokens.length >= 1) {
const shared = issueTokens.filter(t => srcTokens.some(s => s.includes(t) || t.includes(s)));
.filter(t => t.length >= 3 && !CODE_STOPWORDS.has(t));
const windowTokens = windowText
.replace(/[^a-z0-9_]/g, " ").split(/\s+/)
.filter(t => t.length >= 3 && !CODE_STOPWORDS.has(t));
if (!isAbsenceOrMetaFinding(finding.issue) && issueTokens.length >= 2 && windowTokens.length >= 1) {
const shared = issueTokens.filter(t => windowTokens.some(s => s.includes(t) || t.includes(s)));
if (shared.length === 0) {

@@ -358,3 +467,4 @@ weakRefCount++;

if (weakRefCount > 0) {
thinEvalWarnings.push(`${roleName}: ${weakRefCount} finding(s) reference valid file:line but issue text shares no tokens with actual source — possible mismatch`);
totals.warning += weakRefCount;
thinEvalWarnings.push(`${roleName}: ${weakRefCount} finding(s) reference valid file:line but issue text shares no tokens with source (±3 lines) — possible hallucination`);
}

@@ -366,19 +476,14 @@ }

let changeScopeUncovered = false;
if (baseDir && parsed.findings_count > 0) {
if (requiresCodeGrounding && baseDir && parsed.findings_count > 0) {
if (_diffFilesCache === null) {
try {
// Try HEAD~1 first (normal case), then HEAD (initial commit shows all files)
let diffOut = "";
try {
diffOut = execSync("git diff --name-only HEAD~1", { cwd: baseDir, encoding: "utf8", timeout: 15000 });
} catch {
try {
diffOut = execSync("git show --name-only --format='' HEAD", { cwd: baseDir, encoding: "utf8", timeout: 15000 });
} catch { /* git not available or no commits */ }
}
_diffFilesCache = diffOut.trim().split("\n").filter(f => f.length > 0);
} catch {
console.error("⚠️ git diff timed out or failed — changeScopeCoverage skipped");
_diffFilesCache = [];
// Scope the "changed files" to what the flow actually produced (via
// --change-commits), not a blind HEAD~1 diff. This avoids the structural
// false-positive where an unrelated parallel commit or a session-local
// artifact review gets compared against noise. A non-git base is surfaced
// as an explicit warning; an empty flow-produced set skips cleanly.
const scope = changeScopeDiffFiles(baseDir, changeCommits);
if (scope.reason) {
verificationWarnings.push(`changeScopeCoverage skipped: ${scope.reason}`);
}
_diffFilesCache = scope.files;
}

@@ -408,2 +513,12 @@ if (_diffFilesCache.length > 0) {

if (isTestDesignNode) {
const anchorRoots = [baseDir, dir, dirname(f.path)].filter(Boolean);
const issues = collectAnchorIssues(text.split("\n"), anchorRoots);
if (issues.length > 0) {
testDesignAnchorIssueCount = issues.length;
totals.warning += issues.length;
thinEvalWarnings.push(`${roleName}: test-design anchor issue(s): ${issues.join("; ")}`);
}
}
roles.push({

@@ -417,3 +532,3 @@ role: roleName,

thinEvalExempt: thinEvalExempt || false,
noCodeRefs: parsed.noCodeRefs || false,
noCodeRefs: (requiresCodeGrounding && parsed.noCodeRefs) || false,
lineCount: parsed.lineCount,

@@ -430,2 +545,3 @@ findingsCount: parsed.findings_count || 0,

invalidRefCount,
testDesignAnchorIssueCount,
});

@@ -679,2 +795,7 @@

const anchorRoots = [baseDir, dir].filter(Boolean);
const anchorIssues = collectAnchorIssues(planLines, anchorRoots);
totals.warning += anchorIssues.length;
if (anchorIssues.length > 0) issues.push(`anchor: ${anchorIssues.join("; ")}`);
if (issues.length > 0) {

@@ -768,2 +889,3 @@ if (verdict === "PASS") {

thinEvalWarnings: thinEvalWarnings.length > 0 ? thinEvalWarnings : undefined,
verificationWarnings: verificationWarnings.length > 0 ? verificationWarnings : undefined,
evalQualityGate: qualityFailRoles.length > 0

@@ -770,0 +892,0 @@ ? { triggered: true, mode: strict ? "enforce" : "shadow", roles: qfDetail }

@@ -8,5 +8,10 @@ // Evaluation markdown parser — regex constants + pure parsing function.

"🔵": "suggestion",
"CRITICAL": "critical",
"WARNING": "warning",
"SUGGESTION": "suggestion",
};
export const SEVERITY_RE = /(?:\[?)(🔴|🟡|🔵)(?:\]?)/;
// Emoji: optional brackets. Text: MUST use brackets to avoid false positives.
export const SEVERITY_RE = /(?:\[?)(🔴|🟡|🔵)(?:\]?)|\[(CRITICAL|WARNING|SUGGESTION)\]/i;
const FINDING_SEVERITY_RE = /^(?:[-*]\s*)?(?:\*{0,2})?(?:(?:\[?)(🔴|🟡|🔵)(?:\]?)|\[(CRITICAL|WARNING|SUGGESTION)\])/i;
export const FILE_REF_RE = /[\w./-]+\.\w+:\d+/;

@@ -90,2 +95,3 @@ export const HEDGING_RE = /\bmight\b|\bcould potentially\b|\bconsider\b/i;

const findings = [];
const formatErrors = [];

@@ -138,4 +144,7 @@ let currentFinding = null;

// Severity / finding detection (skip markdown headings, tables, and section labels)
const sevMatch = trimmed.match(SEVERITY_RE);
const sevMatch = trimmed.match(FINDING_SEVERITY_RE);
if (sevMatch && !trimmed.startsWith("#") && !trimmed.startsWith("|") && !VERDICT_RE.test(trimmed)) {
if (/^\*{0,2}(severity|location|status|r2\s+status)\*{0,2}:/i.test(trimmed)) {
continue;
}
const fileMatch = trimmed.match(FILE_REF_RE);

@@ -164,2 +173,3 @@ const dashIdx = trimmed.indexOf("—");

.replace(/[🔴🟡🔵]/g, "")
.replace(/\[(CRITICAL|WARNING|SUGGESTION)\]/gi, "")
.replace(/[*_`\[\]()]/g, "")

@@ -171,3 +181,4 @@ .trim();

const severity = SEVERITY_MAP[sevMatch[1]];
const severityKey = sevMatch[1] || sevMatch[2];
const severity = SEVERITY_MAP[severityKey.toUpperCase()] || SEVERITY_MAP[severityKey];
severityCounts[severity]++;

@@ -184,2 +195,7 @@ const issue = dashIdx !== -1 ? trimmed.slice(dashIdx + 1).trim() : trimmed;

// Track unstructured findings: severity marker without em-dash AND without file:line
if (dashIdx === -1 && !fileMatch) {
formatErrors.push({ line: lineNum, text: trimmed, reason: "severity marker found but no em-dash or file:line — unstructured finding" });
}
if (currentFinding) findings.push(currentFinding);

@@ -312,3 +328,5 @@

aspirationalLineCount: aspirationalLines.length,
// Format errors (severity markers that failed to parse as findings)
formatErrors,
};
}
// Evaluation reporting commands: report, diff
// Depends on: eval-parser.mjs, util.mjs
import { readFileSync, readdirSync } from "fs";
import { existsSync, readFileSync, readdirSync, statSync } from "fs";
import { join } from "path";

@@ -9,2 +9,6 @@ import { parseEvaluation } from "./eval-parser.mjs";

const ROLE_FILE_RE = /^evaluation-wave-\d+-(?!round\d)(.+)\.md$/;
const SINGLE_EVAL_RE = /^evaluation-wave-(\d+)\.md$/;
const NODE_EVAL_RE = /^eval-(.+)\.md$/;
function processEvalFile(filepath, roleName) {

@@ -34,2 +38,45 @@ const text = readFileSync(filepath, "utf8");

function resolveReportDir(dir) {
if (existsSync(join(dir, "flow-state.json"))) return dir;
return join(dir, ".harness");
}
function collectRootEvalEntries(harnessDir) {
const files = readdirSync(harnessDir);
const roleFiles = files.filter((f) => ROLE_FILE_RE.test(f));
if (roleFiles.length > 0) {
return roleFiles.map((f) => ({
path: join(harnessDir, f),
role: f.match(ROLE_FILE_RE)[1],
}));
}
return files.filter((f) => SINGLE_EVAL_RE.test(f)).map((f) => ({
path: join(harnessDir, f),
role: "evaluator",
}));
}
function collectNodeEvalEntries(harnessDir) {
const nodesDir = join(harnessDir, "nodes");
if (!existsSync(nodesDir)) return [];
const entries = [];
for (const nodeId of readdirSync(nodesDir).sort()) {
const nodeDir = join(nodesDir, nodeId);
if (!statSync(nodeDir).isDirectory()) continue;
const dirs = [nodeDir];
for (const child of readdirSync(nodeDir).sort()) {
const childDir = join(nodeDir, child);
if (statSync(childDir).isDirectory()) dirs.push(childDir);
}
for (const dir of dirs) {
for (const file of readdirSync(dir).sort()) {
const match = file.match(NODE_EVAL_RE);
if (!match) continue;
entries.push({ path: join(dir, file), role: `${nodeId}/${match[1]}` });
}
}
}
return entries;
}
export function cmdReport(args) {

@@ -55,8 +102,9 @@ const dir = args[0];

const harnessDir = join(dir, ".harness");
const ROLE_FILE_RE = /^evaluation-wave-\d+-(?!round\d)(.+)\.md$/;
const SINGLE_EVAL_RE = /^evaluation-wave-(\d+)\.md$/;
let roleFiles;
const harnessDir = resolveReportDir(dir);
let evalEntries;
try {
roleFiles = readdirSync(harnessDir).filter((f) => ROLE_FILE_RE.test(f));
evalEntries = [
...collectRootEvalEntries(harnessDir),
...collectNodeEvalEntries(harnessDir),
];
} catch (err) {

@@ -67,16 +115,7 @@ console.error(`Cannot read ${harnessDir}: ${err.message}`);

let singleEvalFiles = [];
if (roleFiles.length === 0) {
try {
singleEvalFiles = readdirSync(harnessDir).filter((f) => SINGLE_EVAL_RE.test(f));
} catch { /* already handled */ }
}
const agents = [];
const summary = { critical: 0, warning: 0, suggestion: 0 };
for (const f of roleFiles) {
const roleMatch = f.match(/^evaluation-wave-\d+-(.+)\.md$/);
if (!roleMatch) continue;
const { agent, accepted } = processEvalFile(join(harnessDir, f), roleMatch[1]);
for (const entry of evalEntries) {
const { agent, accepted } = processEvalFile(entry.path, entry.role);
agents.push(agent);

@@ -86,8 +125,2 @@ for (const fd of accepted) summary[fd.severity]++;

for (const f of singleEvalFiles) {
const { agent, accepted } = processEvalFile(join(harnessDir, f), "evaluator");
agents.push(agent);
for (const fd of accepted) summary[fd.severity]++;
}
const report = {

@@ -94,0 +127,0 @@ version: "1.0",

@@ -13,2 +13,3 @@ // ext-commands.mjs — CLI commands for extension system

import { loadLayeredOpcConfig, stripProvenance } from "./config-layering.mjs";
import { readCumulativeFindingsAppend } from "./cumulative-findings.mjs";

@@ -30,4 +31,9 @@ // ─── Shared helpers ──────────────────────────────────────────────

try {
const firstLine = readFileSync(acPath, "utf8").split("\n")[0];
return firstLine.replace(/^#+\s*/, "").trim();
const lines = readFileSync(acPath, "utf8").split("\n");
for (const raw of lines) {
const line = raw.replace(/^#+\s*/, "").replace(/^[-*]\s+/, "").trim();
if (!line || /^acceptance criteria:?$/i.test(line)) continue;
return line;
}
return "";
} catch { return ""; }

@@ -50,3 +56,14 @@ }

* Read flow-state.json + resolved flow template, return the current node's
* required capabilities. Missing state or missing nodeCapabilities → [].
* required capabilities along with whether a flow template actually resolved.
*
* Returns `{ caps, templateResolved }`:
* - caps: the node's required capabilities, or [] when the template has no
* nodeCapabilities map or the node is present in template.nodes but absent
* from the map (a LEGITIMATE empty — the node simply declares no requirements).
* - templateResolved: true ONLY when a flow template resolved AND the requested
* node actually exists in template.nodes. An empty caps list is legitimate only
* for a real node; a node typo (--node not in template.nodes) is a misconfig, so
* we report templateResolved: false there to keep the missing-caps WARN loud.
* This lets callers distinguish "resolved, real node, no caps" (legitimate) from
* "couldn't resolve a template / unknown node" (genuine misconfig).
*/

@@ -61,7 +78,10 @@ function readNodeCapabilities(dir, node, args) {

const { template } = resolveFlowTemplate(args, state);
if (!template || !template.nodeCapabilities) return [];
const templateResolved = !!template;
const nodeResolved = templateResolved && Array.isArray(template.nodes) && template.nodes.includes(node);
if (!nodeResolved) return { caps: [], templateResolved: false };
if (!template.nodeCapabilities) return { caps: [], templateResolved: true };
const caps = template.nodeCapabilities[node];
return Array.isArray(caps) ? caps : [];
return { caps: Array.isArray(caps) ? caps : [], templateResolved: true };
} catch {
return [];
return { caps: [], templateResolved: false };
}

@@ -101,3 +121,3 @@ }

const devServerUrl = getFlag(args, "dev-server") || process.env.DEV_SERVER_URL || config.devServerUrl || "";
const nodeCapabilities = readNodeCapabilities(dir, node, args);
const { caps: nodeCapabilities, templateResolved } = readNodeCapabilities(dir, node, args);

@@ -125,9 +145,16 @@ // Resolve nodeType from flow-state.json + template

task,
taskDescription: task,
flowDir: resolve(dir),
runDir: resolve(dir),
cwd: process.cwd(),
devServerUrl,
nodeCapabilities,
nodeCapabilitiesResolved: templateResolved,
};
const append = await firePromptAppend(registry, context);
const extensionAppend = await firePromptAppend(registry, context);
const append = [
readCumulativeFindingsAppend(resolve(dir)),
extensionAppend,
].filter(Boolean).join("\n\n");

@@ -164,3 +191,5 @@ // Stamp extensionsApplied into this node's latest run handshake (if run dir exists)

if (args.includes("--help")) {
console.error("Usage: opc-harness extension-test --ext <path> [--hook <hookname>] [--context <json>] [--all-hooks] [--fixture-dir <path>] [--lint] [--lint-strict]");
console.error("Usage: opc-harness extension-test --ext <path> [--hook <hookname>] [--context <json>] [--dev-server <url>] [--all-hooks] [--fixture-dir <path>] [--lint] [--lint-strict]");
console.error(" --dev-server <url> Shorthand for --context '{\"devServerUrl\":\"<url>\"}'.");
console.error(" Overrides context.devServerUrl when both are set.");
console.error(" --fixture-dir <path> Copy fixture dir to a fresh tmpdir and set ctx.flowDir/ctx.runDir to it.");

@@ -182,3 +211,3 @@ console.error(" Symlinks are dereferenced to prevent sandbox escape. The tmpdir is");

const KNOWN_FLAGS = new Set([
"--ext", "--hook", "--context", "--all-hooks", "--fixture-dir",
"--ext", "--hook", "--context", "--dev-server", "--all-hooks", "--fixture-dir",
"--lint", "--lint-strict", "--help",

@@ -199,2 +228,3 @@ ]);

const contextJson = getFlag(args, "context", "{}");
const devServerUrl = getFlag(args, "dev-server");
const allHooks = args.includes("--all-hooks");

@@ -232,2 +262,3 @@ const fixtureDir = getFlag(args, "fixture-dir");

}
if (devServerUrl) context.devServerUrl = devServerUrl;

@@ -457,3 +488,3 @@ // F3: --fixture-dir copies the given dir into a fresh mkdtemp() dir and

const devServerUrl = getFlag(args, "dev-server") || process.env.DEV_SERVER_URL || config.devServerUrl || "";
const nodeCapabilities = readNodeCapabilities(dir, node, args);
const { caps: nodeCapabilities, templateResolved } = readNodeCapabilities(dir, node, args);

@@ -468,2 +499,3 @@ const context = {

nodeCapabilities,
nodeCapabilitiesResolved: templateResolved,
};

@@ -533,3 +565,3 @@

const devServerUrl = getFlag(args, "dev-server") || process.env.DEV_SERVER_URL || config.devServerUrl || "";
const nodeCapabilities = readNodeCapabilities(dir, node, args);
const { caps: nodeCapabilities, templateResolved } = readNodeCapabilities(dir, node, args);

@@ -544,2 +576,3 @@ const context = {

nodeCapabilities,
nodeCapabilitiesResolved: templateResolved,
};

@@ -610,3 +643,18 @@

const task = readTaskFromAC(dir);
const { caps: nodeCapabilities, templateResolved } = readNodeCapabilities(dir, node, args);
if (nodeCapabilities.length > 0 && !task.trim()) {
console.log(JSON.stringify({
ok: true,
node,
preflightResults: 0,
skipped: true,
reason: "empty acceptance criteria",
artifactTypes: [],
extensionsApplied: [],
nodeCapabilities,
}));
return;
}
let registry;

@@ -621,11 +669,14 @@ try {

const devServerUrl = getFlag(args, "dev-server") || process.env.DEV_SERVER_URL || config.devServerUrl || "";
const nodeCapabilities = readNodeCapabilities(dir, node, args);
const context = {
node,
nodeId: node,
role: "preflight",
task,
taskDescription: task,
flowDir: resolve(dir),
cwd: process.cwd(),
devServerUrl,
nodeCapabilities,
nodeCapabilitiesResolved: templateResolved,
};

@@ -632,0 +683,0 @@

@@ -73,2 +73,8 @@ // extensions.mjs — OPC Extension System

function startupCheckReason(result) {
if (!result || typeof result !== "object") return "";
const value = result.reason ?? result.msg ?? result.message;
return value ? `: ${String(value).slice(0, 200)}` : "";
}
// ─── Failure record helpers ──────────────────────────────────────

@@ -120,2 +126,14 @@ //

function startupFailureEntry(name, kind, message, meta = {}) {
return {
ext: name,
hook: "startup.check",
kind,
message: String(message || "").slice(0, 500),
at: new Date().toISOString(),
provides: Array.isArray(meta.provides) ? meta.provides : [],
disabledCapabilities: Array.isArray(meta._disabledCapabilities) ? meta._disabledCapabilities : [],
};
}
/**

@@ -502,2 +520,8 @@ * Manually re-enable a disabled extension and clear its failure streak so it

if (!Array.isArray(registry.extensions) || registry.extensions.length === 0) return;
// F10: when caps were deliberately resolved from a flow template, an empty/absent
// caps list is LEGITIMATE (the node simply declares no requirements — e.g. gate or
// test-design nodes, or a template with no nodeCapabilities map at all). The CLI sets
// this flag once it has resolved a template; raw library callers (the F2 "forgot to
// pass caps" path) do not set it, so they still warn.
if (context?.nodeCapabilitiesResolved) return;
const caps = context?.nodeCapabilities;

@@ -514,2 +538,27 @@ if (Array.isArray(caps) && caps.length > 0) return;

function readExtensionManifest(extDir) {
const manifestPath = join(extDir, "ext.json");
if (!existsSync(manifestPath)) return {};
try {
const parsed = JSON.parse(readFileSync(manifestPath, "utf8"));
return parsed && typeof parsed === "object" ? parsed : {};
} catch (err) {
console.error(`WARN: could not read extension manifest ${manifestPath}: ${err.message}`);
return {};
}
}
function mergeManifestMeta(hookMeta, manifest) {
const manifestMeta = manifest.meta && typeof manifest.meta === "object" && !Array.isArray(manifest.meta) ? manifest.meta : {};
const runtimeMeta = hookMeta && typeof hookMeta === "object" && !Array.isArray(hookMeta) ? hookMeta : {};
const meta = { ...manifestMeta, ...runtimeMeta };
if (meta.version === undefined && manifest.version !== undefined) {
meta.version = manifest.version;
}
if (meta.description === undefined && manifest.description !== undefined) {
meta.description = manifest.description;
}
return meta;
}
// ─── loadExtensions ──────────────────────────────────────────────

@@ -583,2 +632,3 @@

const applied = [];
const startupFailures = [];

@@ -599,2 +649,3 @@ for (const name of ordered) {

console.error(`WARN: optional extension ${name} failed to load:`, err.message);
startupFailures.push(startupFailureEntry(name, "load-error", err.message));
continue;

@@ -605,5 +656,7 @@ }

const hook = normalizeHook(raw, mod);
const manifest = readExtensionManifest(extDir);
// Read meta — supports named `export const meta` or `default.meta`
const meta = mod.meta || (mod.default && mod.default.meta) || {};
const hookMeta = mod.meta || (mod.default && mod.default.meta) || {};
const meta = mergeManifestMeta(hookMeta, manifest);

@@ -635,3 +688,3 @@ // Validate meta.provides shape (capability contract)

try {
await withTimeout(
const checkResult = await withTimeout(
Promise.resolve(hook.hooks["startup.check"]({})),

@@ -641,7 +694,27 @@ HOOK_TIMEOUT_MS,

);
// Capture disabledCapabilities from startupCheck result
if (checkResult && Array.isArray(checkResult.disabledCapabilities)) {
meta._disabledCapabilities = checkResult.disabledCapabilities;
}
if (checkResult && checkResult.ok === false) {
const reason = startupCheckReason(checkResult);
if (isRequired) {
throw new Error(`startup.check returned ok:false${reason}`);
}
console.error(`WARN: optional extension ${name} startup.check returned ok:false${reason}`);
startupFailures.push(startupFailureEntry(name, "ok-false", `startup.check returned ok:false${reason}`, meta));
continue;
}
} catch (err) {
if (isRequired) {
throw new Error(`FATAL: required extension '${name}' missing or failed startup.check`);
const detail = err?.message ? `: ${err.message}` : "";
throw new Error(`FATAL: required extension '${name}' missing or failed startup.check${detail}`);
}
console.error(`WARN: optional extension ${name} startup.check failed:`, err.message);
startupFailures.push(startupFailureEntry(
name,
isHookTimeoutError(err) ? "timeout" : "throw",
err.message,
meta
));
continue;

@@ -651,2 +724,15 @@ }

// Remove disabled capabilities from provides (e.g., VLM missing → visual-consistency-check@1 disabled)
if (Array.isArray(meta._disabledCapabilities) && meta._disabledCapabilities.length > 0) {
const disabled = new Set(meta._disabledCapabilities.map(normalizeCapability));
for (const cap of meta._disabledCapabilities) {
startupFailures.push(startupFailureEntry(name, "capability-disabled", `startup.check disabled ${cap}`, {
provides: [cap],
_disabledCapabilities: [cap],
}));
}
meta.provides = (meta.provides || []).filter(cap => !disabled.has(normalizeCapability(cap)));
console.error(`[extensions] ${name}: disabled capabilities: ${meta._disabledCapabilities.join(", ")}`);
}
extensions.push({ name, promptMd, hook, meta, enabled: true });

@@ -662,3 +748,3 @@ applied.push(name);

const registry = { extensions, applied, failures: [] };
const registry = { extensions, applied, failures: [], startupFailures };

@@ -1080,2 +1166,8 @@ // F5 / U5.7: apply persisted circuit-breaker state from <flowDir>/.extension-state.json.

const diState = preflightResult.diState && typeof preflightResult.diState === "object"
? preflightResult.diState
: { version: 1, preflight: { confidence: preflightResult.confidence || 0, reason: preflightResult.reason || "" } };
writeDiStateArtifact(sessionDir, diState);
if (diState.preflight?.status === "no-task") return;
// design-mode.json — always written (contains the activation decision)

@@ -1120,2 +1212,9 @@ // Mode semantics:

function writeDiStateArtifact(sessionDir, diState) {
atomicWriteSync(
join(sessionDir, "di-state.json"),
JSON.stringify({ version: 1, ...diState, updatedAt: new Date().toISOString() }, null, 2) + "\n"
);
}
// ─── Failure report ──────────────────────────────────────────────

@@ -1248,2 +1347,3 @@

applied: registry.applied,
startupFailures: Array.isArray(registry.startupFailures) ? registry.startupFailures : [],
timestamp: new Date().toISOString(),

@@ -1250,0 +1350,0 @@ bypass: registry.bypass || null,

// Advisory file locking using .lock files with PID + timestamp.
// Uses O_EXCL for atomic creation; stale lock detection via dead PID.
// Publishes complete lock records atomically via same-directory hard links;
// stale lock detection uses the recorded PID.
// Depends on: (none — self-contained)
import { readFileSync, writeFileSync, unlinkSync, existsSync } from "fs";
import {
existsSync,
linkSync,
readFileSync,
statSync,
unlinkSync,
writeFileSync,
} from "fs";
import { randomBytes } from "crypto";
const UNREADABLE_LOCK_GRACE_MS = 1000;
// Synchronous sleep without spawning a shell process.

@@ -50,5 +60,17 @@ // Uses SharedArrayBuffer + Atomics.wait for zero-dependency sync delay.

} catch {
// Corrupt lock file — treat as stale
// A live writer may have created the path before publishing complete JSON.
// Never delete a freshly unreadable lock; fail closed until it ages out.
try {
if (Date.now() - statSync(lockPath).mtimeMs < UNREADABLE_LOCK_GRACE_MS) {
if (Date.now() >= deadline) {
return { acquired: false, holder: { pid: -1, timestamp: null, command: "unknown" } };
}
sleepMs(50);
continue;
}
} catch {
// The lock disappeared between read and stat; retry acquisition.
continue;
}
try { unlinkSync(lockPath); } catch { /* race — ok */ }
// Retry atomic acquire
continue;

@@ -84,9 +106,20 @@ }

const tempPath = `${lockPath}.tmp.${process.pid}.${nonce}`;
let publishError = null;
try {
writeFileSync(lockPath, JSON.stringify(lockData, null, 2) + "\n", { flag: "wx" });
} catch (err) {
if (err.code === "EEXIST") {
// Another process created the lock between our check and write — retry
writeFileSync(tempPath, JSON.stringify(lockData, null, 2) + "\n", {
flag: "wx",
mode: 0o600,
});
linkSync(tempPath, lockPath);
} catch (error) {
publishError = error;
} finally {
try { unlinkSync(tempPath); } catch { /* temp may not have been created */ }
}
if (publishError) {
if (publishError.code === "EEXIST") {
// Another process published the lock first.
if (Date.now() >= deadline) {
// Try to read who holds it
try {

@@ -102,3 +135,2 @@ const existing = JSON.parse(readFileSync(lockPath, "utf8"));

}
// Other write error (permissions, etc.)
if (Date.now() >= deadline) {

@@ -105,0 +137,0 @@ return { acquired: false, holder: { pid: -1, timestamp: null, command: "unknown" } };

@@ -87,2 +87,4 @@ // flow-core-consistency.test.mjs — consistency, validators, routing

assert.ok(errors.some((e) => e.includes("tierCoverage object")));
assert.ok(errors.some((e) => e.includes("pipeline/tier-coverage-schema.md")));
assert.ok(errors.some((e) => e.includes("typography")));
});

@@ -100,2 +102,3 @@

assert.ok(errors.some((e) => e.includes("tierCoverage.covered must be an array")));
assert.ok(errors.some((e) => e.includes("Expected tierCoverage")));
});

@@ -127,2 +130,16 @@

test("unknown tierCoverage key error includes valid key list", () => {
const { errors } = validateHandshakeData(validHandshake({
nodeType: "execute",
artifacts: [{ type: "test-result", path: "test.log" }],
tierCoverage: { covered: ["banana"], skipped: [] },
}), {
tier: "delightful",
});
assert.ok(errors.some((e) => e.includes("unknown baseline key: 'banana'")));
assert.ok(errors.some((e) => e.includes("Valid keys for delightful")));
assert.ok(errors.some((e) => e.includes("micro-interactions")));
assert.ok(!errors.some((e) => e.includes("dark-mode")));
});
test("functional tier (no required keys) → no tierCoverage needed", () => {

@@ -129,0 +146,0 @@ const { errors } = validateHandshakeData(validHandshake({

// Flow core commands: route, init, validate, validateHandshakeData, validate-context
// Depends on: flow-templates.mjs, viz-commands.mjs (getMarker), util.mjs
import { readFileSync, mkdirSync, existsSync, readdirSync } from "fs";
import { join, dirname, resolve } from "path";
import {
existsSync,
mkdirSync,
readFileSync,
readlinkSync,
readdirSync,
renameSync,
rmSync,
symlinkSync,
} from "fs";
import { join, dirname, resolve, basename } from "path";
import { createHash } from "crypto";
import { homedir } from "os";
import { execSync } from "child_process";
import { FLOW_TEMPLATES, resolveFlowTemplate, loadFlowFromFile } from "./flow-templates.mjs";
import { getMarker } from "./viz-commands.mjs";
import {
getFlag, resolveDir, atomicWriteSync, createSessionDir,
getFlag, resolveDir, atomicWriteSync, createSessionDir, getProjectRoot, getSessionsBaseDir,
VALID_NODE_TYPES, VALID_STATUSES, VALID_VERDICTS, EVIDENCE_TYPES,
WRITER_SIG,
} from "./util.mjs";
import { VALID_TIERS, getRequiredBaselineKeys, getAllBaselineKeys } from "./tier-baselines.mjs";
import { checkEvalDistinctness } from "./eval-parser.mjs";
import {
VALID_TIERS,
getRequiredBaselineKeys,
getAllBaselineKeys,
formatTierCoverageHint,
} from "./tier-baselines.mjs";
import { checkEvalDistinctness, parseEvaluation } from "./eval-parser.mjs";
import { runBriefLint } from "./brief-lint.mjs";
import { loadExtensions, saveRegistryCache, resolveBypass, clearBreakerState, fireNodePreflight } from "./extensions.mjs";
import { parseBypassArgs } from "./bypass-args.mjs";
import { readTaskFromAC } from "./ext-commands.mjs";
import { readTaskFromAC, findLatestRunDir } from "./ext-commands.mjs";
import { collectTestResultReasons } from "./test-result-gate.mjs";
import { loadTestCommandSpec, testCommandHash } from "./test-command-execution.mjs";
import {
AUTO_MODE_REMINDER,
readSessionRegistry,
registryPath,
writeSessionRegistry,
} from "./runaway-guard.mjs";
import { lockFile } from "./file-lock.mjs";

@@ -31,3 +57,18 @@ // ─── route ──────────────────────────────────────────────────────

const resolved = resolveFlowTemplate(args);
// F7 fix: load flow-state.json BEFORE resolving the template so
// resolveFlowTemplate can fall back to state.flowTemplate when neither --flow
// nor --flow-file is given. The real /opc skill calls `route` without --flow,
// so without this it errored "no --flow or --flow-file specified". Mirrors
// viz-commands.mjs:34 / ext-commands.mjs:57.
const stateDir = resolveDir(args, { optional: true });
let state = null;
if (stateDir) {
const statePath = join(stateDir, "flow-state.json");
try {
state = JSON.parse(readFileSync(statePath, "utf8"));
if (state._flow_file) loadFlowFromFile(state._flow_file);
} catch { /* no/corrupt state file — resolve from args alone */ }
}
const resolved = resolveFlowTemplate(args, state);
if (resolved.error) {

@@ -50,11 +91,6 @@ console.log(JSON.stringify({ next: null, valid: false, error: resolved.error }));

// Read autoMode from state if available
const stateDir = resolveDir(args, { optional: true });
// Read autoMode from the state loaded above
let autoReminder;
if (stateDir) {
const statePath = join(stateDir, "flow-state.json");
try {
const st = JSON.parse(readFileSync(statePath, "utf8"));
if (st.autoMode) autoReminder = "auto mode — do not pause, do not ask user, keep executing";
} catch { /* no state file, skip */ }
if (state && state.autoMode) {
autoReminder = AUTO_MODE_REMINDER;
}

@@ -67,2 +103,124 @@

// Resolve the current git HEAD sha for a working tree, or null if not a repo.
function gitHeadSha(cwd) {
try {
return execSync("git rev-parse HEAD", {
cwd, encoding: "utf8", timeout: 15000, stdio: ["ignore", "pipe", "ignore"],
}).trim() || null;
} catch { return null; }
}
// ─── record-commit ──────────────────────────────────────────────
// Record a commit the flow produced into flow-state.producedCommits. The gate's
// changeScope layer diffs exactly these commits, so a delivered change is
// coverage-checked while a session-local / no-commit flow is left alone.
// Usage: opc-harness record-commit [--sha <sha>] [--dir <session>]
export function cmdRecordCommit(args) {
const dir = resolveDir(args);
const statePath = join(dir, "flow-state.json");
if (!existsSync(statePath)) {
console.log(JSON.stringify({ recorded: false, error: "flow-state.json not found" }));
return;
}
let state;
try {
state = JSON.parse(readFileSync(statePath, "utf8"));
} catch (err) {
console.log(JSON.stringify({ recorded: false, error: `corrupt flow-state.json: ${err.message}` }));
return;
}
const root = (typeof state.projectRoot === "string" && state.projectRoot) ? state.projectRoot : getProjectRoot();
let sha = getFlag(args, "sha", null);
if (!sha) {
sha = gitHeadSha(root);
if (!sha) {
console.log(JSON.stringify({ recorded: false, error: "cannot resolve HEAD — not a git repository" }));
return;
}
}
// Fail closed: only record a real, resolvable commit.
let full;
try {
full = execSync(`git rev-parse --verify ${sha}^{commit}`, {
cwd: root, encoding: "utf8", timeout: 15000, stdio: ["ignore", "pipe", "ignore"],
}).trim();
} catch {
console.log(JSON.stringify({ recorded: false, error: `not a valid commit: ${sha}` }));
return;
}
if (!Array.isArray(state.producedCommits)) state.producedCommits = [];
const already = state.producedCommits.includes(full);
if (!already) state.producedCommits.push(full);
state._last_modified = new Date().toISOString();
atomicWriteSync(statePath, JSON.stringify(state, null, 2) + "\n");
console.log(JSON.stringify({ recorded: true, sha: full, already, producedCommits: state.producedCommits }));
}
function validatePreToolHook(home) {
const hookPath = join(home, ".claude", "skills", "opc", "bin", "hooks", "opc-pre-tool-budget.mjs");
if (!existsSync(hookPath)) {
return `PreToolUse hook script is missing: ${hookPath}. Run 'opc install' and 'opc install-hooks'.`;
}
const settingsPath = join(home, ".claude", "settings.json");
let settings;
try {
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
} catch (error) {
return `PreToolUse hook is not installed: cannot read ${settingsPath}: ${error.message}`;
}
const entries = settings?.hooks?.PreToolUse;
const expectedCommand = `node "${hookPath}"`;
const installed = Array.isArray(entries) && entries.some(entry =>
(entry?.matcher == null || entry.matcher === "") &&
entry?.hooks?.some(hook =>
hook?.type === "command" && hook.async !== true && hook.command === expectedCommand
)
);
return installed ? null : `PreToolUse hook is not installed in ${settingsPath}. Run 'opc install-hooks'.`;
}
function activeRegistryConflict(sessionId, home) {
let registry;
try {
registry = readSessionRegistry(sessionId, home);
} catch (error) {
return `cannot verify existing session registry: ${error.message}`;
}
if (!registry) return null;
let state;
try {
state = JSON.parse(readFileSync(join(registry.sessionDir, "flow-state.json"), "utf8"));
} catch (error) {
return `cannot verify existing registered flow: ${error.message}`;
}
if (state?.status === "completed" || state?.status === "stopped") return null;
if (state?.autoMode === true && state?._claudeSessionId === sessionId) {
return `Claude session '${sessionId}' is already bound to an active auto flow at ${registry.sessionDir}`;
}
return `existing session registry for '${sessionId}' is not safely replaceable`;
}
function restoreLatestAfterFailedInit(latestLink, failedDir, previousTarget) {
if (!latestLink) return;
try {
const currentTarget = readlinkSync(latestLink);
if (resolve(dirname(latestLink), currentTarget) !== resolve(failedDir)) return;
if (previousTarget === null) {
rmSync(latestLink, { force: true });
return;
}
const tempLink = `${latestLink}.rollback.${process.pid}`;
rmSync(tempLink, { force: true });
symlinkSync(previousTarget, tempLink);
renameSync(tempLink, latestLink);
} catch {
// Best effort: registry failure remains the primary error.
}
}
export async function cmdInit(args) {

@@ -72,4 +230,4 @@ const entry = getFlag(args, "entry");

const autoMode = args.includes("--auto");
const claudeSessionId = getFlag(args, "claude-session-id");
const hasExplicitDir = args.includes("--dir");
const dir = hasExplicitDir ? resolveDir(args) : createSessionDir();

@@ -81,2 +239,15 @@ if (tier && !VALID_TIERS.has(tier)) {

if (autoMode) {
if (!claudeSessionId) {
console.log(JSON.stringify({ created: false, error: "init --auto requires non-empty --claude-session-id" }));
return;
}
const home = homedir();
const hookError = validatePreToolHook(home);
if (hookError) {
console.log(JSON.stringify({ created: false, error: hookError }));
return;
}
}
const resolved = resolveFlowTemplate(args);

@@ -95,10 +266,45 @@ if (resolved.error) {

let registryLock = null;
if (autoMode) {
const home = homedir();
const path = registryPath(claudeSessionId, home);
try {
mkdirSync(dirname(path), { recursive: true });
registryLock = lockFile(path, { command: "init-auto" });
} catch (error) {
console.log(JSON.stringify({ created: false, error: `cannot prepare session registry: ${error.message}` }));
return;
}
if (!registryLock.acquired) {
console.log(JSON.stringify({ created: false, error: "cannot acquire session registry lock" }));
return;
}
const conflict = activeRegistryConflict(claudeSessionId, home);
if (conflict) {
registryLock.release();
console.log(JSON.stringify({ created: false, error: conflict }));
return;
}
}
const explicitDir = hasExplicitDir ? resolveDir(args) : null;
const removeDirOnRegistryFailure = !hasExplicitDir || !existsSync(explicitDir);
const latestLink = hasExplicitDir ? null : join(getSessionsBaseDir(), "latest");
let previousLatestTarget = null;
if (latestLink) {
try { previousLatestTarget = readlinkSync(latestLink); } catch { /* no previous session */ }
}
const dir = explicitDir || createSessionDir();
const nodesPath = join(dir, "nodes");
const nodesExistedBefore = existsSync(nodesPath);
const statePath = join(dir, "flow-state.json");
const force = args.includes("--force");
if (existsSync(statePath) && !force) {
registryLock?.release();
console.log(JSON.stringify({ created: false, error: "flow-state.json already exists (use --force to overwrite)" }));
return;
}
const priorStateText = existsSync(statePath) ? readFileSync(statePath, "utf8") : null;
mkdirSync(join(dir, "nodes"), { recursive: true });
mkdirSync(nodesPath, { recursive: true });

@@ -119,2 +325,4 @@ // ─── Resolve bypass state BEFORE writing flow-state.json ────────

const projectRoot = getProjectRoot();
const flowStartedAt = new Date().toISOString();
const state = {

@@ -132,6 +340,16 @@ version: "1.0",

edgeCounts: {},
projectRoot,
// Git floor at flow start + commits the flow produces. changeScope diffs
// producedCommits (recorded via `record-commit`), never a blind HEAD~1.
baseSha: gitHeadSha(projectRoot),
producedCommits: [],
bypassMode: bypassRecord,
autoMode: autoMode || undefined,
...(autoMode ? {
_claudeSessionId: claudeSessionId,
flowStartedAt,
autoRepairCounts: {},
} : {}),
_written_by: WRITER_SIG,
_last_modified: new Date().toISOString(),
_last_modified: flowStartedAt,
_flow_file: template._source_file || undefined,

@@ -145,2 +363,26 @@ _write_nonce: createHash("sha256")

if (autoMode) {
try {
writeSessionRegistry({
sessionId: claudeSessionId,
sessionDir: resolve(dir),
projectRoot,
registeredAt: flowStartedAt,
}, homedir());
} catch (error) {
if (removeDirOnRegistryFailure) {
rmSync(dir, { recursive: true, force: true });
restoreLatestAfterFailedInit(latestLink, dir, previousLatestTarget);
} else {
if (priorStateText === null) rmSync(statePath, { force: true });
else atomicWriteSync(statePath, priorStateText);
if (!nodesExistedBefore) rmSync(nodesPath, { recursive: true, force: true });
}
registryLock.release();
console.log(JSON.stringify({ created: false, error: `cannot write session registry: ${error.message}` }));
return;
}
registryLock.release();
}
// ─── Persist .ext-registry.json (which extensions this flow will use) ────

@@ -208,11 +450,13 @@ // This is also the observable surface for the benchmark bypass: running

let preflightResult = null;
let preflightStatus = null;
if (bypassCfg.noExtensions !== true) {
try {
const firstBuildNode = template.nodes.find(n =>
template.nodeTypes?.[n] === "build" || n === "build"
const firstBriefOrBuild = template.nodes.find(n =>
template.nodeTypes?.[n] === "brief" || template.nodeTypes?.[n] === "build" || n === "brief" || n === "build"
);
preflightNode = firstBuildNode || entryNode;
preflightNode = firstBriefOrBuild || entryNode;
const preflightCaps = template.nodeCapabilities?.[preflightNode] || [];
const preflightTask = readTaskFromAC(dir);
if (preflightCaps.length > 0) {
if (preflightCaps.length > 0 && preflightTask.trim()) {
const preflightRegistry = await loadExtensions(bypassCfg);

@@ -224,5 +468,6 @@ const preflightCtx = {

role: "preflight",
task: readTaskFromAC(dir),
taskDescription: readTaskFromAC(dir),
task: preflightTask,
taskDescription: preflightTask,
flowDir: resolve(dir),
cwd: process.cwd(),
devServerUrl: process.env.DEV_SERVER_URL || "",

@@ -232,3 +477,7 @@ nodeCapabilities: preflightCaps,

preflightResult = await fireNodePreflight(preflightRegistry, preflightCtx);
if (preflightResult?.length) preflightStatus = { node: preflightNode, status: "ok" };
console.error(`[init] auto-preflight for '${preflightNode}': ${preflightResult?.length ? 'artifacts generated' : 'no output'}`);
} else if (preflightCaps.length > 0) {
preflightStatus = { node: preflightNode, status: "skipped", reason: "empty acceptance criteria" };
console.error(`[init] auto-preflight for '${preflightNode}': skipped (empty acceptance criteria)`);
}

@@ -242,3 +491,3 @@ } catch (err) {

created: true, flow, entry: entryNode, tier: tier || null, dir,
...(preflightResult?.length ? { preflight: { node: preflightNode, status: "ok" } } : {}),
...(preflightStatus ? { preflight: preflightStatus } : {}),
}));

@@ -315,2 +564,74 @@ }

if (data.nodeType === "hotfix" && data.status === "completed") {
const h = data.hotfix;
if (h == null || typeof h !== "object" || Array.isArray(h)) {
errors.push("hotfix node requires hotfix object describing the trivial repair");
} else {
if (h.scope !== "trivial") {
errors.push("hotfix.scope must be 'trivial'");
}
if (!Array.isArray(h.allowedOperations) || h.allowedOperations.length === 0) {
errors.push("hotfix.allowedOperations must list the trivial operation(s) performed");
}
if (h.structuralChange === true) {
errors.push("hotfix.structuralChange must not be true");
}
if (Array.isArray(h.forbiddenOperations) && h.forbiddenOperations.length > 0) {
errors.push("hotfix.forbiddenOperations must be empty");
}
}
}
// ─── Brief node must have build-brief.md + passing lint result ───
if (data.nodeType === "brief" && data.status === "completed" && !data.skipped && Array.isArray(data.artifacts)) {
const briefArt = data.artifacts.find(a => a.type === "brief");
const hasReport = data.artifacts.some(a => a.type === "report");
if (!briefArt) {
errors.push("brief node requires artifact with type: 'brief' (build-brief.md)");
}
if (!hasReport) {
errors.push("brief node requires artifact with type: 'report' (brief-lint-result.json)");
}
// Anti-forgery: re-run brief-lint on the actual brief content instead of trusting report JSON
if (briefArt && opts.baseDir) {
const briefPath = existsSync(join(opts.baseDir, briefArt.path))
? join(opts.baseDir, briefArt.path) : briefArt.path;
try {
const briefText = readFileSync(briefPath, "utf8");
// Resolve tier: explicit opts.tier → flow-state.json → undefined (= all checks)
let lintTier = opts.tier;
if (!lintTier) {
try {
// baseDir is typically nodes/{nodeId}/, session root is two levels up
const sessionRoot = resolve(opts.baseDir, "..", "..");
const fsPath = join(sessionRoot, "flow-state.json");
if (existsSync(fsPath)) {
const fs = JSON.parse(readFileSync(fsPath, "utf8"));
lintTier = fs.tier || undefined;
}
} catch { /* best-effort tier resolution */ }
}
const lintResult = runBriefLint(briefText, { tier: lintTier });
if (lintResult.failures.length > 0) {
const failNames = lintResult.failures.map(f => f.check).join(", ");
errors.push(`brief-lint re-run failed on actual brief content: ${failNames}`);
}
// Iteration Delta enforcement on gate loopback: a brief re-entry (run_2+)
// only happens when the gate sent the flow back with prior findings, so the
// '## Iteration Delta' section becomes mandatory. We re-run with
// hasPriorFindings so this is hard-enforced at validate stage — the brief
// cannot pass validation on a loopback without listing what changed.
const runNum = parseInt(String(data.runId).replace(/^run_/, ""), 10);
if (Number.isFinite(runNum) && runNum > 1) {
const deltaResult = runBriefLint(briefText, { tier: lintTier, hasPriorFindings: true });
if (deltaResult.failures.some(f => f.check === "iteration-delta")) {
errors.push("brief re-entered after gate loopback (run_" + runNum + ") but has no '## Iteration Delta' section — list specific changes from prior findings");
}
}
} catch {
errors.push(`brief artifact unreadable: ${briefArt.path}`);
}
}
}
// ─── Review independence check (zero trust: ≥2 distinct eval artifacts) ───

@@ -352,9 +673,10 @@ if (data.nodeType === "review" && data.status === "completed" && Array.isArray(data.artifacts)) {

const tc = data.tierCoverage;
const tierHint = formatTierCoverageHint(opts.tier);
if (tc == null || typeof tc !== "object") {
errors.push(`execute node must have tierCoverage object when flow tier is '${opts.tier}'`);
errors.push(`execute node must have tierCoverage object when flow tier is '${opts.tier}'. ${tierHint}`);
} else {
const covered = Array.isArray(tc.covered) ? tc.covered : null;
const skipped = Array.isArray(tc.skipped) ? tc.skipped : null;
if (covered == null) errors.push("tierCoverage.covered must be an array");
if (skipped == null) errors.push("tierCoverage.skipped must be an array");
if (covered == null) errors.push(`tierCoverage.covered must be an array. ${tierHint}`);
if (skipped == null) errors.push(`tierCoverage.skipped must be an array. ${tierHint}`);

@@ -366,10 +688,10 @@ if (covered && skipped) {

if (s == null || typeof s !== "object") {
errors.push(`tierCoverage.skipped[${i}] must be an object`);
errors.push(`tierCoverage.skipped[${i}] must be an object. ${tierHint}`);
continue;
}
if (!s.key || typeof s.key !== "string") {
errors.push(`tierCoverage.skipped[${i}] missing 'key'`);
errors.push(`tierCoverage.skipped[${i}] missing 'key'. ${tierHint}`);
}
if (!s.reason || typeof s.reason !== "string" || s.reason.length < 10) {
errors.push(`tierCoverage.skipped[${i}] missing 'reason' (min 10 chars — explain why the item is not applicable)`);
errors.push(`tierCoverage.skipped[${i}] missing 'reason' (min 10 chars — explain why the item is not applicable). ${tierHint}`);
}

@@ -381,3 +703,3 @@ }

if (!allKeys.has(k)) {
errors.push(`tierCoverage.covered contains unknown baseline key: '${k}'`);
errors.push(`tierCoverage.covered contains unknown baseline key: '${k}'. ${tierHint}`);
}

@@ -387,3 +709,3 @@ }

if (s && s.key && !allKeys.has(s.key)) {
errors.push(`tierCoverage.skipped contains unknown baseline key: '${s.key}'`);
errors.push(`tierCoverage.skipped contains unknown baseline key: '${s.key}'. ${tierHint}`);
}

@@ -396,3 +718,3 @@ }

if (!declared.has(k)) {
errors.push(`tierCoverage missing required baseline item: '${k}' (must be in covered or skipped)`);
errors.push(`tierCoverage missing required baseline item: '${k}' (must be in covered or skipped). ${tierHint}`);
}

@@ -424,7 +746,61 @@ }

function resolveHandshakeForValidate(file) {
const direct = resolve(file);
if (existsSync(direct)) return direct;
if (basename(direct) !== "handshake.json") return direct;
const latestRun = findLatestRunDir(dirname(direct));
const fallback = latestRun ? join(latestRun, "handshake.json") : null;
return fallback && existsSync(fallback) ? fallback : direct;
}
function harnessDirForHandshake(file) {
const dir = dirname(resolve(file));
if (/^run_\d+$/.test(basename(dir))) return dirname(dirname(dirname(dir)));
return dirname(dirname(dir));
}
function firstPositionalArg(args) {
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a.startsWith("--")) {
if (!a.includes("=") && args[i + 1] && !args[i + 1].startsWith("--")) i++;
continue;
}
return a;
}
return null;
}
function resolveDefaultHandshakeForValidate(args) {
const dir = resolveDir(args);
const statePath = join(dir, "flow-state.json");
if (!existsSync(statePath)) {
return { error: "flow-state.json not found" };
}
let state;
try {
state = JSON.parse(readFileSync(statePath, "utf8"));
} catch (err) {
return { error: `cannot parse flow-state.json: ${err.message}` };
}
if (!state.currentNode) {
return { error: "flow-state.json has no currentNode" };
}
return {
file: resolveHandshakeForValidate(join(dir, "nodes", state.currentNode, "handshake.json")),
};
}
export function cmdValidate(args) {
const file = args[0];
if (!file) {
console.error("Usage: opc-harness validate <handshake.json>");
process.exit(1);
const inputFile = firstPositionalArg(args);
let file;
if (!inputFile) {
const resolved = resolveDefaultHandshakeForValidate(args);
if (resolved.error) {
console.log(JSON.stringify({ valid: false, errors: [resolved.error] }));
return;
}
file = resolved.file;
} else {
file = resolveHandshakeForValidate(inputFile);
}

@@ -443,3 +819,3 @@

try {
const harnessDir = dirname(dirname(dirname(file)));
const harnessDir = harnessDirForHandshake(file);
const statePath = join(harnessDir, "flow-state.json");

@@ -477,2 +853,96 @@ if (existsSync(statePath)) {

function testEvidenceContext(dir, handshake) {
const sourceNode = handshake?.testEvidenceProvenance?.sourceNode;
if (!sourceNode) return {};
const spec = loadTestCommandSpec(dir, sourceNode);
if (!spec) return {};
return {
expectedCommandHash: testCommandHash(spec.testCommand),
expectedSourcePlanHash: spec.sourcePlanHash,
allowVacuousChecks: spec.allowVacuousChecks,
};
}
function collectFilesRecursive(root, prefix = "") {
const out = [];
let entries = [];
try { entries = readdirSync(root, { withFileTypes: true }); } catch { return out; }
for (const entry of entries) {
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
const full = join(root, entry.name);
if (entry.isDirectory()) {
out.push(...collectFilesRecursive(full, rel));
} else if (entry.isFile()) {
out.push(rel);
}
}
return out;
}
function classifyArtifact(relPath, nodeType) {
const name = basename(relPath);
const lower = name.toLowerCase();
if (lower === "build-brief.md") return "brief";
if (lower === "test-plan.md") return "test-plan";
if (lower === "test-execution.json") return "test-plan";
if (/^eval-.*\.md$/i.test(name) || lower === "eval.md") return "eval";
if (/^screenshot.*\.(png|jpg|jpeg|gif|webp)$/i.test(name)) return "screenshot";
if (/^(command-output|cli-output|test-command-output).*\.(txt|log)$/i.test(name) || /\.log$/i.test(name)) return "cli-output";
if ((nodeType === "execute" && /^test-.*\.json$/i.test(name)) || lower === "test-command-result.json") return "test-result";
if (/^test-.*\.json$/i.test(name) && /execute/i.test(relPath)) return "test-result";
if (/^(.*-)?lint-result\.json$/i.test(name) || /^(.*-)?report\.json$/i.test(name) || /^(.*-)?result\.json$/i.test(name)) return "report";
if (/\.(ts|tsx|js|jsx|css|html|mjs|cjs)$/i.test(name)) return "source";
if (lower.endsWith(".md") || lower.endsWith(".txt")) return "source";
return null;
}
function normalizeEvalVerdict(raw) {
const text = String(raw || "").toUpperCase();
if (/\bBLOCKED\b/.test(text)) return "BLOCKED";
if (/\bFAIL\b/.test(text)) return "FAIL";
if (/\bITERATE\b/.test(text)) return "ITERATE";
if (/\b(PASS|APPROVE|LGTM|TEST-CASES)\b/.test(text)) return "PASS";
return null;
}
function inferEvalVerdict(evalArtifacts, nodeDir) {
const findings = { critical: 0, warning: 0, suggestion: 0 };
const parsedVerdicts = [];
for (const a of evalArtifacts) {
try {
const parsed = parseEvaluation(readFileSync(join(nodeDir, a.path), "utf8"));
findings.critical += parsed.critical || 0;
findings.warning += parsed.warning || 0;
findings.suggestion += parsed.suggestion || 0;
const v = normalizeEvalVerdict(parsed.verdict);
if (v) parsedVerdicts.push(v);
} catch { /* skip unreadable eval; artifact validation catches missing files */ }
}
let verdict = null;
if (parsedVerdicts.includes("BLOCKED")) verdict = "BLOCKED";
else if (parsedVerdicts.includes("FAIL") || findings.critical > 0) verdict = "FAIL";
else if (parsedVerdicts.includes("ITERATE") || findings.warning > 0) verdict = "ITERATE";
else if (parsedVerdicts.includes("PASS") || findings.suggestion > 0) verdict = "PASS";
return { verdict, findings };
}
function readJsonFile(path) {
try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
}
function preserveHarnessTestEvidence(target, existing) {
const prov = existing?.testEvidenceProvenance;
if (prov?.kind !== "opc-test-command" || prov?.executionActor !== "opc-harness:test-command") return;
for (const key of [
"testCommand",
"testCommandCwd",
"testCommandCwdSource",
"prerequisites",
"testEvidenceProvenance",
"testEvidencePolicy",
]) {
if (Object.hasOwn(existing, key)) target[key] = existing[key];
}
}
export function cmdSeal(args) {

@@ -545,36 +1015,23 @@ const nodeId = getFlag(args, "node");

const runId = runDir.split("/").pop();
const handshakePath = join(nodeDir, "handshake.json");
const existingHandshake = readJsonFile(handshakePath);
// Scan files and classify artifacts
const files = readdirSync(runDir);
const files = collectFilesRecursive(runDir).map(f => `${runId}/${f}`);
for (const nodeLevel of ["build-brief.md", "test-plan.md", "test-execution.json"]) {
if (existsSync(join(nodeDir, nodeLevel))) files.push(nodeLevel);
}
const artifacts = [];
const warnings = [];
for (const f of files) {
const lower = f.toLowerCase();
let type = null;
if (/^eval-.*\.md$/i.test(f)) type = "eval";
else if (/^screenshot.*\.(png|jpg|jpeg|gif|webp)$/i.test(f)) type = "screenshot";
else if (/^(command-output|cli-output).*\.(txt|log)$/i.test(f) || /\.log$/i.test(f)) type = "cli-output";
else if (/^test-.*\.json$/i.test(f)) type = "test-result";
else if (lower.endsWith(".md")) type = "source";
else if (lower.endsWith(".txt")) type = "source";
else continue; // skip unknown files
artifacts.push({ type, path: `${runId}/${f}` });
for (const f of files.sort()) {
const type = classifyArtifact(f, nodeType);
if (!type) continue;
artifacts.push({ type, path: f });
}
// Infer verdict from eval files
let verdict = null;
const evalFiles = artifacts.filter(a => a.type === "eval");
if (evalFiles.length > 0) {
// Read last eval file, look for VERDICT line
const lastEval = evalFiles[evalFiles.length - 1];
try {
const content = readFileSync(join(nodeDir, lastEval.path), "utf8");
const verdictMatch = content.match(/\*\*(?:ITERATE|PASS|FAIL|BLOCKED)\*\*/);
if (verdictMatch) {
verdict = verdictMatch[0].replace(/\*\*/g, "");
}
} catch { /* ignore */ }
}
const inferred = inferEvalVerdict(evalFiles, nodeDir);
let verdict = inferred.verdict;

@@ -599,25 +1056,10 @@ // Review node: warn if < 2 eval files

// Count findings from eval content
let critical = 0, warning = 0, suggestion = 0;
for (const a of evalFiles) {
try {
const content = readFileSync(join(nodeDir, a.path), "utf8");
critical += (content.match(/🔴/g) || []).length;
warning += (content.match(/🟡/g) || []).length;
suggestion += (content.match(/🔵/g) || []).length;
} catch { /* skip */ }
}
if (nodeType === "execute") preserveHarnessTestEvidence(handshake, existingHandshake);
const { critical, warning, suggestion } = inferred.findings;
if (critical + warning + suggestion > 0) {
handshake.findings = { critical, warning, suggestion };
// Auto-set verdict if not found from text
if (!verdict) {
if (critical > 0) verdict = "FAIL";
else if (warning > 0) verdict = "ITERATE";
else verdict = "PASS";
handshake.verdict = verdict;
}
}
// Write handshake
const handshakePath = join(nodeDir, "handshake.json");
atomicWriteSync(handshakePath, JSON.stringify(handshake, null, 2) + "\n");

@@ -630,2 +1072,23 @@

});
if (nodeType === "execute") {
const evidenceContext = testEvidenceContext(dir, handshake);
for (const art of artifacts) {
if (art.type !== "test-result" || !/\.json$/i.test(art.path)) continue;
try {
const text = readFileSync(join(nodeDir, art.path), "utf8");
const data = JSON.parse(text);
errors.push(...collectTestResultReasons(data, {
handshake,
nodeId,
runId: handshake.runId,
artifact: art,
artifactHash: createHash("sha256").update(text).digest("hex"),
sessionDir: dir,
...evidenceContext,
}));
} catch {
errors.push(`artifact ${art.path} unreadable — fail-closed`);
}
}
}

@@ -662,3 +1125,11 @@ for (const w of warnings) console.error(`⚠️ ${w}`);

const resolved = resolveFlowTemplate(args);
// F7-sibling: load flow-state.json so resolveFlowTemplate can fall back to
// state.flowTemplate / restore state._flow_file when called without --flow.
let vcState = null;
try {
vcState = JSON.parse(readFileSync(join(dir, "flow-state.json"), "utf8"));
if (vcState._flow_file) loadFlowFromFile(vcState._flow_file);
} catch { /* no/corrupt state file — resolve from args alone */ }
const resolved = resolveFlowTemplate(args, vcState);
if (resolved.error) {

@@ -665,0 +1136,0 @@ console.log(JSON.stringify({ valid: false, errors: [resolved.error] }));

@@ -16,2 +16,3 @@ // Flow escape hatches + listing: skip, pass, stop, goto, ls

import { lockFile } from "./file-lock.mjs";
import { resolveCallerIdentity, checkOwnership } from "./driver-owner.mjs";

@@ -338,2 +339,3 @@ // ── Shared state loader ──

const recursive = args.includes("--recursive");
const showAll = args.includes("--all");
const results = [];

@@ -344,2 +346,19 @@

// Caller identity is resolved once — loops owned by another live Claude
// session are hidden (a loop is bound to exactly one session). Pass --all
// to bypass the filter (e.g. for debugging / operator overview).
const caller = resolveCallerIdentity();
function isForeignLoop(dir) {
if (showAll) return false;
const lp = join(dir, "loop-state.json");
if (!existsSync(lp)) return false; // not a loop — never hidden
try {
const loopState = JSON.parse(readFileSync(lp, "utf8"));
return checkOwnership(loopState, caller).decision === "BLOCKED";
} catch {
return false; // corrupt — surface it rather than hide
}
}
function addCandidate(dir) {

@@ -349,5 +368,13 @@ const sp = join(dir, "flow-state.json");

seen.add(dir);
if (isForeignLoop(dir)) return; // owned by another live session — hide
try {
const state = JSON.parse(readFileSync(sp, "utf8"));
const st = statSync(sp);
// lastAdvanced = time of the last *real* transition (history tail
// timestamp). Unlike file mtime, this is only written by an actual
// node advance, so it is not polluted by unrelated writes. Liveness
// checks should prefer it; lastModified stays for compatibility.
const lastAdvanced = Array.isArray(state.history) && state.history.length
? (state.history.at(-1)?.timestamp ?? null)
: null;
results.push({

@@ -359,4 +386,6 @@ dir,

status: state.status || "in_progress",
projectRoot: state.projectRoot || null,
totalSteps: state.totalSteps,
lastModified: st.mtime.toISOString(),
lastAdvanced,
});

@@ -363,0 +392,0 @@ } catch { /* corrupt — skip */ }

@@ -35,16 +35,19 @@ // Flow graph definitions — nodes, edges, limits per template

"build-verify": {
nodes: ["build", "code-review", "test-design", "test-execute", "gate"],
nodes: ["brief", "build", "code-review", "test-design", "test-execute", "hotfix", "gate"],
edges: {
brief: { PASS: "build" },
build: { PASS: "code-review" },
"code-review": { PASS: "test-design" },
"code-review": { PASS: "test-design", FAIL: "build", ITERATE: "build" },
"test-design": { PASS: "test-execute" },
"test-execute": { PASS: "gate" },
gate: { PASS: null, FAIL: "build", ITERATE: "build" },
"test-execute": { PASS: "gate", ITERATE: "hotfix" },
hotfix: { PASS: "test-execute", FAIL: "brief", ITERATE: "build" },
gate: { PASS: null, FAIL: "brief", ITERATE: "brief" },
},
limits: { maxLoopsPerEdge: 3, maxTotalSteps: 25, maxNodeReentry: 5 },
nodeTypes: { build: "build", "code-review": "review", "test-design": "review", "test-execute": "execute", gate: "gate" },
nodeTypes: { brief: "brief", build: "build", "code-review": "review", "test-design": "review", "test-execute": "execute", hotfix: "hotfix", gate: "gate" },
// Capability contract: what specialist expertise each node requests.
// Extensions with matching `provides` are auto-activated.
nodeCapabilities: {
build: ["design-system-injection@1", "design-spec-conformance@1", "design-preflight@1"],
brief: ["design-system-injection@1", "design-spec-conformance@1", "design-preflight@1"],
build: ["design-system-injection@1"],
"code-review": ["code-quality-check@1", "visual-consistency-check@1"],

@@ -54,5 +57,18 @@ "test-execute": ["visual-consistency-check@1"],

},
"quick": {
nodes: ["build", "review", "test-design", "test-execute", "gate"],
edges: {
build: { PASS: "review" },
review: { PASS: "test-design", FAIL: "build", ITERATE: "build" },
"test-design": { PASS: "test-execute" },
"test-execute": { PASS: "gate", FAIL: "build", ITERATE: "build" },
gate: { PASS: null, FAIL: "build", ITERATE: "build" },
},
limits: { maxLoopsPerEdge: 2, maxTotalSteps: 15, maxNodeReentry: 3 },
nodeTypes: { build: "build", review: "review", "test-design": "review", "test-execute": "execute", gate: "gate" },
requiredTestCommandEvidence: true,
},
"full-stack": {
nodes: [
"discuss", "build", "code-review", "test-design", "test-execute", "gate-test",
"discuss", "brief", "build", "code-review", "test-design", "test-execute", "hotfix", "gate-test",
"acceptance", "gate-acceptance",

@@ -64,21 +80,23 @@ "audit", "gate-audit",

edges: {
discuss: { PASS: "build" },
discuss: { PASS: "brief" },
brief: { PASS: "build" },
build: { PASS: "code-review" },
"code-review": { PASS: "test-design" },
"code-review": { PASS: "test-design", FAIL: "build", ITERATE: "build" },
"test-design": { PASS: "test-execute" },
"test-execute": { PASS: "gate-test" },
"gate-test": { PASS: "acceptance", FAIL: "build", ITERATE: "build" },
"test-execute": { PASS: "gate-test", ITERATE: "hotfix" },
hotfix: { PASS: "test-execute", FAIL: "brief", ITERATE: "build" },
"gate-test": { PASS: "acceptance", FAIL: "brief", ITERATE: "brief" },
acceptance: { PASS: "gate-acceptance" },
"gate-acceptance": { PASS: "audit", FAIL: "build", ITERATE: "acceptance" },
"gate-acceptance": { PASS: "audit", FAIL: "brief", ITERATE: "acceptance" },
audit: { PASS: "gate-audit" },
"gate-audit": { PASS: "e2e-user", FAIL: "build", ITERATE: "audit" },
"gate-audit": { PASS: "e2e-user", FAIL: "brief", ITERATE: "audit" },
"e2e-user": { PASS: "gate-e2e" },
"gate-e2e": { PASS: "post-launch-sim", FAIL: "build", ITERATE: "e2e-user" },
"gate-e2e": { PASS: "post-launch-sim", FAIL: "brief", ITERATE: "e2e-user" },
"post-launch-sim": { PASS: "gate-final" },
"gate-final": { PASS: null, FAIL: "build", ITERATE: "discuss" },
"gate-final": { PASS: null, FAIL: "brief", ITERATE: "discuss" },
},
limits: { maxLoopsPerEdge: 3, maxTotalSteps: 35, maxNodeReentry: 5 },
nodeTypes: {
discuss: "discussion", build: "build", "code-review": "review",
"test-design": "review", "test-execute": "execute",
discuss: "discussion", brief: "brief", build: "build", "code-review": "review",
"test-design": "review", "test-execute": "execute", hotfix: "hotfix",
"gate-test": "gate", acceptance: "review", "gate-acceptance": "gate",

@@ -90,3 +108,4 @@ audit: "review", "gate-audit": "gate", "e2e-user": "execute", "gate-e2e": "gate",

nodeCapabilities: {
build: ["design-system-injection@1", "design-spec-conformance@1", "design-preflight@1"],
brief: ["design-system-injection@1", "design-spec-conformance@1", "design-preflight@1"],
build: ["design-system-injection@1"],
"code-review": ["code-quality-check@1", "visual-consistency-check@1"],

@@ -393,3 +412,3 @@ "test-execute": ["visual-consistency-check@1"],

// Guard: --flow-file cannot override built-in template names
const BUILTIN_NAMES = new Set(["review", "build-verify", "full-stack", "pre-release", "legacy-linear"]);
const BUILTIN_NAMES = new Set(["review", "build-verify", "full-stack", "pre-release", "legacy-linear", "quick"]);
if (BUILTIN_NAMES.has(name)) {

@@ -496,7 +515,8 @@ return { error: `cannot override built-in template '${name}' via --flow-file — use a different name` };

// Priority 3: lookup by name in FLOW_TEMPLATES
if (!flowName) return { error: "no --flow or --flow-file specified" };
const template = Object.hasOwn(FLOW_TEMPLATES, flowName) ? FLOW_TEMPLATES[flowName] : null;
if (!template) return { error: `unknown flow template: ${flowName}` };
return { template, name: flowName };
// Priority 3: lookup by name — explicit --flow, else persisted state.flowTemplate
const resolvedName = flowName || (state && state.flowTemplate);
if (!resolvedName) return { error: "no --flow or --flow-file specified" };
const template = Object.hasOwn(FLOW_TEMPLATES, resolvedName) ? FLOW_TEMPLATES[resolvedName] : null;
if (!template) return { error: `unknown flow template: ${resolvedName}` };
return { template, name: resolvedName };
}

@@ -5,5 +5,6 @@ // Flow transition commands: transition, validate-chain, finalize

import { readFileSync, readdirSync, mkdirSync, existsSync, writeFileSync } from "fs";
import { join, dirname, resolve } from "path";
import { join, dirname, resolve, basename } from "path";
import { fileURLToPath } from "url";
import os from "os";
import { createHash } from "crypto";
import { execFileSync } from "child_process";

@@ -14,23 +15,81 @@ import { FLOW_TEMPLATES, resolveFlowTemplate, loadFlowFromFile } from "./flow-templates.mjs";

import {
getFlag, resolveDir, atomicWriteSync, gcSessions,
getFlag, resolveDir, atomicWriteSync, gcSessions, getProjectRoot,
WRITER_SIG, IDEMPOTENCY_WINDOW_MS,
} from "./util.mjs";
import { lockFile } from "./file-lock.mjs";
import { AUTO_MODE_REMINDER, createStopMarker } from "./runaway-guard.mjs";
import { resolveBypass, loadExtensions, firePromptAppend, fireVerdictAppend, survivingExtensions, saveRegistryCache } from "./extensions.mjs";
import { parseBypassArgs } from "./bypass-args.mjs";
import { loadOpcConfig, readTaskFromAC, findLatestRunDir } from "./ext-commands.mjs";
import { collectGateCriteriaReasons } from "./gate-criteria.mjs";
import { collectDiVerdictReasons } from "./di-verdict-gate.mjs";
import { executeTestCommand, loadTestCommandSpec, testCommandHash } from "./test-command-execution.mjs";
import { collectExtensionStartupReasons } from "./extension-startup-gate.mjs";
import { collectTestDesignPlanReasons } from "./test-plan-gate.mjs";
import { readCumulativeFindingsAppend, writeCumulativeFindings } from "./cumulative-findings.mjs";
import { collectTestResultReasons } from "./test-result-gate.mjs";
// ─── Step 1.5: Structured result check (extracted for testability) ───
function nodeHandshakePath(dir, nodeId) {
const nodeDir = join(dir, "nodes", nodeId);
const direct = join(nodeDir, "handshake.json");
if (existsSync(direct)) return direct;
const latestRun = findLatestRunDir(nodeDir);
const fallback = latestRun ? join(latestRun, "handshake.json") : null;
return fallback && existsSync(fallback) ? fallback : direct;
}
/**
* Scan upstream nodes (since last gate) for artifacts with type "report" or
* "test-result". Returns an array of fail reasons. Empty array = PASS.
* Fail-closed: unreadable artifacts produce a fail reason.
*/
export function checkStructuredResults(dir, state, template, currentNode) {
const structuredFailReasons = [];
const histNoGates = state.history.filter(h => {
const nt = template.nodeTypes?.[h.nodeId];
return nt && nt !== "gate";
});
function mandatoryRoleHint(nodeId) {
if (/^test[-_]design$/.test(nodeId)) {
return " For test-design, skeptic-owner reviews test plan completeness, not code quality.";
}
return "";
}
function testEvidenceContext(dir, handshake) {
const sourceNode = handshake?.testEvidenceProvenance?.sourceNode;
if (!sourceNode) return {};
const spec = loadTestCommandSpec(dir, sourceNode);
if (!spec) return {};
return {
expectedCommandHash: testCommandHash(spec.testCommand),
expectedSourcePlanHash: spec.sourcePlanHash,
allowVacuousChecks: spec.allowVacuousChecks,
};
}
function synthesizeBaseForState(state) {
if (typeof state?.projectRoot === "string" && state.projectRoot) return state.projectRoot;
return getProjectRoot();
}
// Scope the gate's changeScope layer to the commits this flow actually produced.
// Always emit the flag (empty when nothing was recorded) so finalize/advance get
// the flow-scoped behavior instead of a blind HEAD~1 diff. Empty → skip cleanly.
function changeCommitsArgs(state) {
const commits = Array.isArray(state?.producedCommits) ? state.producedCommits : [];
return ["--change-commits", commits.join(",")];
}
function harnessPath() {
return join(dirname(fileURLToPath(import.meta.url)), "..", "opc-harness.mjs");
}
function parseJsonLastLine(text) {
try {
return JSON.parse(String(text || "").trim());
} catch {
// Some commands may include a leading log line before a compact JSON object.
}
try {
return JSON.parse(String(text || "").trim().split("\n").pop());
} catch {
return null;
}
}
function sha256(text) {
return createHash("sha256").update(text).digest("hex");
}
function entriesSinceLastGate(state, template, currentNode) {
let lastGateHistIdx = -1;

@@ -45,25 +104,198 @@ for (let i = state.history.length - 1; i >= 0; i--) {

}
const upstreamNodes = lastGateHistIdx === -1
? histNoGates
: state.history.slice(lastGateHistIdx + 1).filter(h => {
const nt = template.nodeTypes?.[h.nodeId];
return nt && nt !== "gate";
});
return lastGateHistIdx === -1 ? state.history : state.history.slice(lastGateHistIdx + 1);
}
function collectReviewEvalArtifactReasons(hsPath, nodeId, handshake) {
const artifacts = Array.isArray(handshake?.artifacts) ? handshake.artifacts : [];
const evalArtifacts = artifacts.filter(a => a?.type === "eval" || a?.type === "evaluation");
if (evalArtifacts.length === 0) {
return [`review node ${nodeId} has no eval artifacts, cannot prove PASS`];
}
const reasons = [];
for (const art of evalArtifacts) {
if (typeof art.path !== "string" || art.path.length === 0) {
reasons.push(`review eval artifact for ${nodeId} has no path — fail-closed`);
continue;
}
try {
readFileSync(resolve(dirname(hsPath), art.path), "utf8");
} catch (err) {
reasons.push(`review eval artifact for ${nodeId} unreadable: ${art.path} — fail-closed: ${err.message}`);
}
}
return reasons;
}
function collectGateSynthesizeReasons(dir, state, template, currentNode, verdict) {
if (verdict !== "PASS") return [];
const reasons = [];
const seen = new Set();
for (const entry of entriesSinceLastGate(state, template, currentNode)) {
const nodeId = entry.nodeId;
if (seen.has(nodeId) || template.nodeTypes?.[nodeId] !== "review") continue;
seen.add(nodeId);
const hsPath = nodeHandshakePath(dir, nodeId);
if (!existsSync(hsPath)) continue;
let handshake;
try {
handshake = JSON.parse(readFileSync(hsPath, "utf8"));
} catch {
continue;
}
const artifactReasons = collectReviewEvalArtifactReasons(hsPath, nodeId, handshake);
if (artifactReasons.length > 0) {
reasons.push(...artifactReasons);
continue;
}
let output;
try {
output = execFileSync(
"node",
[harnessPath(), "synthesize", "--node", nodeId, "--dir", dir, "--base", synthesizeBaseForState(state), ...changeCommitsArgs(state)],
{ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }
);
} catch (err) {
reasons.push(`synthesize failed for ${nodeId}: ${err.stderr || err.message}`);
continue;
}
const synth = parseJsonLastLine(output);
if (!synth) {
reasons.push(`synthesize output for ${nodeId} was not valid JSON`);
} else if (synth.verdict && synth.verdict !== "PASS") {
reasons.push(`synthesize verdict for ${nodeId} is ${synth.verdict}, not PASS`);
}
}
return reasons;
}
function normalizeHandshakeVerdict(value) {
const verdict = String(value || "").toUpperCase();
return ["PASS", "FAIL", "ITERATE", "BLOCKED"].includes(verdict) ? verdict : null;
}
function collectGateHandshakeVerdictReasons(dir, state, template, currentNode, verdict) {
if (verdict !== "PASS") return [];
const reasons = [];
const seen = new Set();
for (const entry of entriesSinceLastGate(state, template, currentNode)) {
const nodeId = entry.nodeId;
const nodeType = template.nodeTypes?.[nodeId];
if (seen.has(nodeId) || !nodeType || nodeType === "gate") continue;
seen.add(nodeId);
const hsPath = nodeHandshakePath(dir, nodeId);
if (!existsSync(hsPath)) {
reasons.push(`handshake for ${nodeId} is missing, cannot prove PASS`);
continue;
}
let handshake;
try {
handshake = JSON.parse(readFileSync(hsPath, "utf8"));
} catch (err) {
reasons.push(`handshake for ${nodeId} is corrupt, cannot prove PASS: ${err.message}`);
continue;
}
const sealedVerdict = normalizeHandshakeVerdict(handshake?.verdict);
if (sealedVerdict && sealedVerdict !== "PASS") {
reasons.push(`sealed verdict for ${nodeId} is ${sealedVerdict}, not PASS`);
}
}
return reasons;
}
function collectGateVerdictReasons(dir, state, template, currentNode, verdict) {
return [
...collectGateHandshakeVerdictReasons(dir, state, template, currentNode, verdict),
...collectGateSynthesizeReasons(dir, state, template, currentNode, verdict),
];
}
function hasOpcTestCommandEvidence(handshake) {
const prov = handshake?.testEvidenceProvenance;
return handshake?.nodeType === "execute"
&& /^test[-_]execute$/.test(String(handshake?.nodeId || ""))
&& prov?.kind === "opc-test-command"
&& prov?.executionActor === "opc-harness:test-command"
&& typeof prov?.commandHash === "string"
&& typeof prov?.sourcePlanHash === "string"
&& Array.isArray(handshake?.artifacts)
&& handshake.artifacts.some(a => a?.type === "test-result" && /\.json$/i.test(a?.path || ""));
}
function collectHandshakeStructuredReasons(dir, nodeId, hsPath, handshake) {
const reasons = [];
if (!Array.isArray(handshake?.artifacts)) return reasons;
const evidenceContext = testEvidenceContext(dir, handshake);
for (const art of handshake.artifacts) {
if (art.type !== "test-result" || !/\.json$/i.test(art.path || "")) continue;
const artPath = resolve(dirname(hsPath), art.path);
let text;
let data;
try {
text = readFileSync(artPath, "utf8");
data = JSON.parse(text);
} catch {
reasons.push(`artifact ${art.path} unreadable — fail-closed`);
continue;
}
reasons.push(...collectTestResultReasons(data, {
handshake,
nodeId,
runId: handshake?.runId,
artifact: art,
artifactHash: sha256(text),
sessionDir: dir,
...evidenceContext,
}));
}
return reasons;
}
// ─── Step 1.5: Structured result check (extracted for testability) ───
/**
* Scan upstream nodes (since last gate) for artifacts with type "report" or
* "test-result". Returns an array of fail reasons. Empty array = PASS.
* Fail-closed: unreadable artifacts produce a fail reason.
*/
export function checkStructuredResults(dir, state, template, currentNode) {
const structuredFailReasons = [];
structuredFailReasons.push(...collectGateCriteriaReasons(dir, state, template, currentNode));
structuredFailReasons.push(...collectDiVerdictReasons(dir, state, template, currentNode));
structuredFailReasons.push(...collectExtensionStartupReasons(dir, state, template, currentNode));
const upstreamNodes = entriesSinceLastGate(state, template, currentNode)
.filter(h => {
const nt = template.nodeTypes?.[h.nodeId];
return nt && nt !== "gate";
});
const seen = new Set();
let requiredTestCommandEvidenceFound = false;
for (const entry of upstreamNodes) {
if (seen.has(entry.nodeId)) continue;
seen.add(entry.nodeId);
const hsPath = join(dir, "nodes", entry.nodeId, "handshake.json");
if (!existsSync(hsPath)) continue;
const hsPath = nodeHandshakePath(dir, entry.nodeId);
if (!existsSync(hsPath)) {
structuredFailReasons.push(`handshake for ${entry.nodeId} is missing — fail-closed`);
continue;
}
let hs;
try { hs = JSON.parse(readFileSync(hsPath, "utf8")); } catch { continue; }
try {
hs = JSON.parse(readFileSync(hsPath, "utf8"));
} catch (err) {
structuredFailReasons.push(`handshake for ${entry.nodeId} is corrupt — fail-closed: ${err.message}`);
continue;
}
if (!Array.isArray(hs.artifacts)) continue;
if (template.requiredTestCommandEvidence && hasOpcTestCommandEvidence(hs)) {
requiredTestCommandEvidenceFound = true;
}
for (const art of hs.artifacts) {
if (art.type !== "report" && art.type !== "test-result") continue;
const artPath = resolve(join(dir, "nodes", entry.nodeId), art.path);
const artPath = resolve(dirname(hsPath), art.path);
let text;
let data;
try {
data = JSON.parse(readFileSync(artPath, "utf8"));
text = readFileSync(artPath, "utf8");
data = JSON.parse(text);
} catch (e) {

@@ -73,13 +305,23 @@ structuredFailReasons.push(`artifact ${art.path} unreadable — fail-closed`);

}
const safeInt = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : 0; };
if (safeInt(data.test_fail_count) > 0)
structuredFailReasons.push(`${safeInt(data.test_fail_count)} test(s) failed`);
if (safeInt(data.dead_test_count) > 0)
structuredFailReasons.push(`${safeInt(data.dead_test_count)} dead test(s) detected`);
if (safeInt(data.p0_count) > 0)
structuredFailReasons.push(`${safeInt(data.p0_count)} P0 issue(s) unresolved`);
if (String(data.sync_check_status || "").toUpperCase() === "FAIL")
structuredFailReasons.push("sync-check failed");
const evidenceContext = testEvidenceContext(dir, hs);
structuredFailReasons.push(...collectTestResultReasons(data, {
handshake: hs,
nodeId: entry.nodeId,
// The handshake read at hsPath is always the node's LATEST run. When a
// node was re-run (e.g. via goto), history holds an earlier entry for
// the same nodeId; the dedup above keeps that stale entry, so
// entry.runId can lag the handshake on disk. Validate the signed
// provenance against the handshake's own runId, which matches the
// artifact and ledger event actually present.
runId: hs.runId || entry.runId,
artifact: art,
artifactHash: sha256(text),
sessionDir: dir,
...evidenceContext,
}));
}
}
if (template.requiredTestCommandEvidence && !requiredTestCommandEvidenceFound) {
structuredFailReasons.push("required OPC testCommand evidence missing before gate");
}
return structuredFailReasons;

@@ -126,2 +368,11 @@ }

if (st) {
const synthReasons = collectGateVerdictReasons(dir, st, resolvedTpl.template, from, verdict);
if (synthReasons.length > 0) {
console.log(JSON.stringify({
allowed: false,
reason: `gate synthesize check failed: ${synthReasons.join("; ")}`,
synthesizeFailReasons: synthReasons,
}));
return;
}
const failReasons = checkStructuredResults(dir, st, resolvedTpl.template, from);

@@ -213,2 +464,52 @@ if (failReasons.length > 0) {

const edgeKey = `${from}\u2192${to}`;
const isAutoRepairAttempt = state.autoMode === true
&& (verdict === "FAIL" || verdict === "ITERATE");
let autoRepairCount = 0;
if (isAutoRepairAttempt) {
const counts = state.autoRepairCounts;
if (counts !== undefined && (!counts || typeof counts !== "object" || Array.isArray(counts))) {
console.log(JSON.stringify({
allowed: false,
requiresHuman: true,
reason: "autoRepairCounts is invalid",
}));
return;
}
const rawCount = counts?.[edgeKey];
if (rawCount !== undefined && (!Number.isInteger(rawCount) || rawCount < 0)) {
console.log(JSON.stringify({
allowed: false,
requiresHuman: true,
reason: `auto repair count is invalid for '${edgeKey}'`,
}));
return;
}
autoRepairCount = rawCount ?? 0;
if (autoRepairCount >= 1) {
try {
createStopMarker(dir, state, {
reason: "repair-edge-budget",
edgeKey,
});
} catch (error) {
console.log(JSON.stringify({
allowed: false,
requiresHuman: true,
reason: `auto repair budget reached for '${edgeKey}', but stop marker creation failed: ${error.message}`,
}));
return;
}
console.log(JSON.stringify({
allowed: false,
requiresHuman: true,
reason: `auto repair budget reached for '${edgeKey}'`,
}));
return;
}
}
const limits = {

@@ -225,3 +526,2 @@ maxTotalSteps: state.maxTotalSteps ?? template.limits.maxTotalSteps,

const edgeKey = `${from}\u2192${to}`;
const edgeCount = state.edgeCounts[edgeKey] || 0;

@@ -246,3 +546,3 @@ if (edgeCount >= limits.maxLoopsPerEdge) {

if (!isGate) {
const fromHandshakePath = join(dir, "nodes", from, "handshake.json");
const fromHandshakePath = nodeHandshakePath(dir, from);
if (!existsSync(fromHandshakePath)) {

@@ -274,2 +574,6 @@ console.log(JSON.stringify({

}
const sealedVerdict = normalizeHandshakeVerdict(hsData.verdict);
if (sealedVerdict && sealedVerdict !== verdict) {
hsErrors.push(`sealed verdict is '${sealedVerdict}', but requested transition verdict is '${verdict}'`);
}
for (const w of hsWarnings) {

@@ -286,4 +590,26 @@ console.error(`\u26a0\ufe0f ${w}`);

}
const structuredReasons = collectHandshakeStructuredReasons(dir, from, fromHandshakePath, hsData);
if (structuredReasons.length > 0 && verdict !== "FAIL") {
console.log(JSON.stringify({
allowed: false,
reason: `pre-transition structured result check failed: ${structuredReasons.join("; ")} — verdict must be FAIL, not ${verdict}`,
structuredFailReasons: structuredReasons,
}));
return;
}
}
// ── Test-design plan gate ──────────────────────────────────────
if (!isGate && /^test[-_]design$/.test(from) && /^test[-_]execute$/.test(to) && verdict === "PASS") {
const testPlanReasons = collectTestDesignPlanReasons(dir, from);
if (testPlanReasons.length > 0) {
console.log(JSON.stringify({
allowed: false,
reason: `test-design gate failed: ${testPlanReasons.join("; ")}`,
testPlanReasons,
}));
return;
}
}
// ── OUT-2: Mandatory role enforcement when transitioning from review nodes ──

@@ -316,3 +642,3 @@ if (!isGate && fromNodeType === "review") {

if (mandatoryRoles.length > 0) {
const fromHandshakePath = join(dir, "nodes", from, "handshake.json");
const fromHandshakePath = nodeHandshakePath(dir, from);
if (existsSync(fromHandshakePath)) {

@@ -343,3 +669,3 @@ const hsData = JSON.parse(readFileSync(fromHandshakePath, "utf8"));

allowed: false,
error: `Missing mandatory role evaluations: [${missingRoles.join(", ")}]. Review node must include all mandatory roles.`,
error: `Missing mandatory role evaluations: [${missingRoles.join(", ")}]. Review node must include all mandatory roles.${mandatoryRoleHint(from)}`,
missingRoles,

@@ -386,8 +712,15 @@ }));

saveRegistryCache(resolve(dir), vRegistry);
// Write extensionsApplied to handshake (same as cmdExtensionVerdict)
const handshakePath = join(latestRunDir, "handshake.json");
let handshake = {};
try { handshake = JSON.parse(readFileSync(handshakePath, "utf8")); } catch { /* start fresh */ }
handshake.extensionsApplied = survivingExtensions(vRegistry);
writeFileSync(handshakePath, JSON.stringify(handshake, null, 2));
const appliedExts = survivingExtensions(vRegistry);
// Write extensionsApplied to run-level handshake
const runHandshakePath = join(latestRunDir, "handshake.json");
let runHandshake = {};
try { runHandshake = JSON.parse(readFileSync(runHandshakePath, "utf8")); } catch { /* start fresh */ }
runHandshake.extensionsApplied = appliedExts;
writeFileSync(runHandshakePath, JSON.stringify(runHandshake, null, 2));
// Also stamp node-level handshake (validate-chain checks this level)
const nodeHandshakePath = join(fromNodeDir, "handshake.json");
let nodeHandshake = {};
try { nodeHandshake = JSON.parse(readFileSync(nodeHandshakePath, "utf8")); } catch { /* start fresh */ }
nodeHandshake.extensionsApplied = appliedExts;
writeFileSync(nodeHandshakePath, JSON.stringify(nodeHandshake, null, 2));
}

@@ -476,2 +809,12 @@ }

if (isGate) {
const synthReasons = collectGateVerdictReasons(dir, state, template, from, verdict);
if (synthReasons.length > 0) {
console.log(JSON.stringify({
allowed: false,
reason: `gate synthesize check failed: ${synthReasons.join("; ")}`,
synthesizeFailReasons: synthReasons,
}));
return;
}
// ── Step 1.5: Structured result check (universal enforcement) ──

@@ -510,2 +853,6 @@ // This runs on EVERY gate transition, regardless of entry path

state.edgeCounts[edgeKey] = edgeCount + 1;
if (isAutoRepairAttempt) {
state.autoRepairCounts ??= {};
state.autoRepairCounts[edgeKey] = autoRepairCount + 1;
}
state._written_by = WRITER_SIG;

@@ -516,3 +863,11 @@ state._last_modified = new Date().toISOString();

mkdirSync(join(dir, "nodes", to, runId), { recursive: true });
try { writeCumulativeFindings(dir, state); } catch { /* best effort */ }
let testCommandExecution = null;
const toNodeType = template.nodeTypes?.[to] || null;
if (toNodeType === "execute" && /^test[-_]execute$/.test(to) && (/^test[-_]design$/.test(from) || /^hotfix$/.test(from))) {
const testSpecNode = /^hotfix$/.test(from) ? "test-design" : from;
testCommandExecution = executeTestCommand(dir, to, runId, testSpecNode);
}
// Print live flow viz to stderr

@@ -531,3 +886,3 @@ console.error("");

const autoReminder = state.autoMode ? "auto mode — do not pause, do not ask user, keep executing" : undefined;
const autoReminder = state.autoMode ? AUTO_MODE_REMINDER : undefined;

@@ -556,3 +911,6 @@ // ── Extension context for next node ────────────────────────────

};
const append = await firePromptAppend(registry, context);
const append = [
readCumulativeFindingsAppend(resolve(dir)),
await firePromptAppend(registry, context),
].filter(Boolean).join("\n\n");
extensionContext = {

@@ -580,2 +938,3 @@ append,

...(autoReminder ? { reminder: autoReminder } : {}),
...(testCommandExecution ? { testCommandExecution } : {}),
...(extensionContext?.append ? { extensionContextPath: resolve(join(dir, "nodes", to, "extension-context.md")) } : {}),

@@ -607,12 +966,8 @@ }));

// Load requiredExtensions from explicit config only.
// Load requiredExtensions from layered config (user → repo → cli).
// validate-chain is post-hoc — it verifies claims, not environment state.
// Auto-discover (filesystem scan) is a runtime concern (init/transition).
let requiredExtensions = [];
try {
const configPath = join(os.homedir(), ".opc", "config.json");
if (existsSync(configPath)) {
const cfg = JSON.parse(readFileSync(configPath, "utf8"));
if (Array.isArray(cfg.requiredExtensions)) requiredExtensions = cfg.requiredExtensions;
}
const cfg = loadOpcConfig(dir);
if (Array.isArray(cfg.requiredExtensions)) requiredExtensions = cfg.requiredExtensions;
} catch { /* best effort */ }

@@ -655,3 +1010,3 @@

const nd = entry.node || entry.nodeId;
const handshakePath = join(dir, "nodes", nd, "handshake.json");
const handshakePath = nodeHandshakePath(dir, nd);
executedPath.push(nd);

@@ -672,3 +1027,3 @@

for (const nd of nodeDirs) {
const hp = join(nodesDir, nd, "handshake.json");
const hp = nodeHandshakePath(dir, nd);
if (existsSync(hp)) {

@@ -681,2 +1036,4 @@ try {

const isGateNode = nd.startsWith("gate") || data.node === "gate" || data.nodeId === "gate";
const nodeType = data.nodeType || chainTemplate?.nodeTypes?.[nd] || "";
const isPromptPhase = nodeType === "brief" || nodeType === "build";
const nodeCaps = chainTemplate?.nodeCapabilities?.[nd] || [];

@@ -693,2 +1050,17 @@ if (requiredExtensions.length > 0 && !isGateNode && nodeCaps.length > 0) {

}
// Verify eval-extensions.json actually exists in the latest run dir
// Prompt-phase nodes (brief, build) produce code, not evaluations —
// they have extensionsApplied from prompt-context but no eval-extensions.json.
// Only verdict-phase nodes (review, execute) produce eval artifacts.
if (applied.length > 0 && !isPromptPhase) {
const latestRun = findLatestRunDir(join(nodesDir, nd));
if (!latestRun) {
errors.push(`${nd}: extensionsApplied claims [${applied.join(",")}] but no run directory exists`);
} else {
const evalExtPath = join(latestRun, "eval-extensions.json");
if (!existsSync(evalExtPath)) {
errors.push(`${nd}: extensionsApplied claims [${applied.join(",")}] but eval-extensions.json not found in ${basename(latestRun)}`);
}
}
}
}

@@ -767,5 +1139,2 @@ }

// Find the harness binary path (same dir as this module)
const harnessPath = join(dirname(fileURLToPath(import.meta.url)), "..", "opc-harness.mjs");
// Step 1: synthesize

@@ -777,3 +1146,3 @@ console.error(`[advance] synthesizing ${upstreamNode}...`);

"node",
[harnessPath, "synthesize", "--node", upstreamNode, "--dir", dir],
[harnessPath(), "synthesize", "--node", upstreamNode, "--dir", dir, "--base", synthesizeBaseForState(state), ...changeCommitsArgs(state)],
{ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }

@@ -791,7 +1160,3 @@ );

let synthResult;
try {
synthResult = JSON.parse(synthOutput.trim().split("\n").pop());
} catch {
synthResult = {};
}
synthResult = parseJsonLastLine(synthOutput) || {};
let verdict = synthResult.verdict || "PASS";

@@ -811,3 +1176,3 @@ console.error(`[advance] synthesize verdict: ${verdict}`);

try {
const routeArgs = [harnessPath, "route", "--node", currentNode, "--verdict", verdict, "--flow", state.flowTemplate];
const routeArgs = [harnessPath(), "route", "--node", currentNode, "--verdict", verdict, "--flow", state.flowTemplate];
if (state._flow_file) routeArgs.push("--flow-file", state._flow_file);

@@ -849,3 +1214,3 @@ routeArgs.push("--dir", dir);

try {
const transArgs = [harnessPath, "transition", "--from", currentNode, "--to", toArg, "--verdict", verdict, "--flow", state.flowTemplate];
const transArgs = [harnessPath(), "transition", "--from", currentNode, "--to", toArg, "--verdict", verdict, "--flow", state.flowTemplate];
if (state._flow_file) transArgs.push("--flow-file", state._flow_file);

@@ -861,2 +1226,15 @@ transArgs.push("--dir", dir);

if (transResult.allowed === false) {
console.log(JSON.stringify({
advanced: false,
verdict,
upstream: upstreamNode,
next,
transition: transResult,
...(transResult.requiresHuman ? { requiresHuman: true } : {}),
reason: transResult.reason || "transition denied",
}));
return;
}
console.log(JSON.stringify({

@@ -926,2 +1304,21 @@ advanced: true,

const currentNodeType = template.nodeTypes?.[currentNode];
const currentIsGate = currentNodeType === "gate" || currentNode === "gate" || currentNode.startsWith("gate-");
if (currentIsGate) {
const synthReasons = collectGateVerdictReasons(dir, state, template, currentNode, "PASS");
const structuredReasons = checkStructuredResults(dir, state, template, currentNode);
if (synthReasons.length > 0 || structuredReasons.length > 0) {
console.log(JSON.stringify({
finalized: false,
error: [
...synthReasons.map(r => `gate verdict check failed: ${r}`),
...structuredReasons.map(r => `Step 1.5 structural check failed: ${r}`),
].join("; "),
synthesizeFailReasons: synthReasons,
structuredFailReasons: structuredReasons,
}));
return;
}
}
// --strict: validate ALL nodes have valid handshakes before finalizing

@@ -1006,2 +1403,13 @@ if (strict) {

if (currentIsGate) {
const terminalVerdict = normalizeHandshakeVerdict(hsData.verdict);
if (terminalVerdict && terminalVerdict !== "PASS") {
console.log(JSON.stringify({
finalized: false,
error: `terminal gate verdict is '${terminalVerdict}', expected PASS`,
}));
return;
}
}
if (state.status === "completed") {

@@ -1035,2 +1443,3 @@ console.log(JSON.stringify({

atomicWriteSync(statePath, JSON.stringify(freshState, null, 2) + "\n");
try { writeCumulativeFindings(dir, freshState); } catch { /* best effort */ }

@@ -1037,0 +1446,0 @@ // Post-finalize: GC old sessions (best-effort)

@@ -5,3 +5,3 @@ // flow-transition.test.mjs — Step 1.5 structured result check

import assert from "node:assert/strict";
import { mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";

@@ -11,3 +11,6 @@ import os from "node:os";

import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { checkStructuredResults } from "./flow-transition.mjs";
import { budgetPaths, resolveCurrentRun } from "./runaway-guard.mjs";
import { appendProvenanceEvent } from "./provenance-ledger.mjs";

@@ -26,2 +29,9 @@ const TMPBASE = join(os.homedir(), ".opc", "sessions", `ft-test-${Date.now()}`);

const EXEC_TEMPLATE = {
nodeTypes: {
"test-execute": "execute",
gate: "gate",
},
};
// Minimal flow state: build → code-review → gate

@@ -40,2 +50,13 @@ function makeState() {

function makeExecState() {
return {
flowTemplate: "build-verify",
currentNode: "gate",
history: [
{ nodeId: "test-execute", runId: "run_1" },
{ nodeId: "gate", runId: "run_1" },
],
};
}
function setupDir(name, handshakes) {

@@ -62,2 +83,81 @@ const dir = join(TMPBASE, name);

function sha256(text) {
return createHash("sha256").update(text).digest("hex");
}
function artifactHash(content) {
return sha256(typeof content === "string" ? content : JSON.stringify(content));
}
function addTestLedger(dir, { nodeId = "test-execute", runId = "run_1", sourceNode = "test-design", commandHash, sourcePlanHash, resultHash, resultFile = "test-results.json" }) {
const ledger = appendProvenanceEvent(dir, {
eventType: "test-command-result",
nodeId,
runId,
sourceNode,
commandHash,
sourcePlanHash,
resultHash,
resultPath: `nodes/${nodeId}/${runId}/${resultFile}`,
exitCode: 0,
});
const hsPath = join(dir, "nodes", nodeId, "handshake.json");
const hs = JSON.parse(readFileSync(hsPath, "utf8"));
hs.testEvidenceProvenance.ledger = ledger;
writeFileSync(hsPath, JSON.stringify(hs));
}
const TEST_PLAN = "# Test Plan\n\n### TC-TESTER-01\n- **Priority**: P0\n- **Steps**: run command\n";
const COMPLETE_TEST_PLAN = `
# Test Plan
## Unit smoke
Run npm test for unit coverage.
Cover module smoke behavior.
Assert basic render success.
## Contract edge case
Validate schema boundaries.
Cover invalid input.
Assert error code stability.
## Integration e2e flow
Run playwright test through the workflow.
Cover multi-step happy path.
Assert persisted state.
## UI visual accessibility
Capture screenshot at desktop and mobile viewport.
Check responsive layout.
Run a11y smoke checks.
## Tier baseline polish
Check typography hierarchy.
Check navigation affordance.
Check dark mode baseline.
`;
function cleanPassEval(title, focus) {
const lines = [`# ${title}`, "", "## Scope Review"];
for (let i = 1; i <= 18; i++) {
lines.push(`${focus} scope item ${i}: reviewed without blocking findings.`);
}
lines.push("", "## Evidence Review");
for (let i = 1; i <= 18; i++) {
lines.push(`${focus} evidence item ${i}: handshake and artifact context are consistent.`);
}
lines.push("", "## Quality Review");
for (let i = 1; i <= 18; i++) {
lines.push(`${focus} quality item ${i}: no critical or warning issue was found.`);
}
lines.push("", "## Summary", "LGTM. No findings. Ready for gate PASS.", "VERDICT: PASS FINDINGS[0]", "");
return lines.join("\n");
}
function writeDiVerdict(dir, nodeId, runId, verdict) {
const verdictDir = join(dir, "nodes", nodeId, runId, "ext-design-intelligence");
mkdirSync(verdictDir, { recursive: true });
writeFileSync(join(verdictDir, "verdict.json"), JSON.stringify(verdict, null, 2));
}
// Cleanup after all tests

@@ -184,2 +284,319 @@ test.after(() => {

test("checks[].pass=false → FAIL", () => {
const dir = setupDir("t8b-checks-fail", {
build: {
artifacts: [{
type: "test-result",
path: "run_1/test-results.json",
_content: { checks: [{ id: "OUT-real", pass: false, detail: "broken" }] },
}],
},
"code-review": { artifacts: [] },
});
const reasons = checkStructuredResults(dir, makeState(), TEMPLATE, "gate");
assert.ok(reasons.some(r => r.includes("structured check(s) failed")));
});
test("checks[] total=0 pass is vacuous → FAIL", () => {
const dir = setupDir("t8c-vacuous-check", {
build: {
artifacts: [{
type: "test-result",
path: "run_1/test-results.json",
_content: { checks: [{ id: "OUT-star-aria", pass: true, detail: { total: 0, withal: 0 } }] },
}],
},
"code-review": { artifacts: [] },
});
const reasons = checkStructuredResults(dir, makeState(), TEMPLATE, "gate");
assert.ok(reasons.some(r => r.includes("vacuous PASS")));
assert.ok(reasons.some(r => r.includes("OUT-star-aria")));
});
test("checks[] result-level allowVacuous is ignored", () => {
const dir = setupDir("t8d-vacuous-result-allow-ignored", {
build: {
artifacts: [{
type: "test-result",
path: "run_1/test-results.json",
_content: { checks: [{ id: "OUT-empty-state", pass: true, allowVacuous: true, detail: { total: 0 } }] },
}],
},
"code-review": { artifacts: [] },
});
const reasons = checkStructuredResults(dir, makeState(), TEMPLATE, "gate");
assert.ok(reasons.some(r => r.includes("vacuous PASS")));
});
test("test-execute checks without testCommand provenance → FAIL", () => {
const dir = setupDir("t8e-self-authored-checks", {
"test-execute": {
artifacts: [{
type: "test-result",
path: "run_1/test-results.json",
_content: { checks: [{ id: "OUT-browser-render", pass: true, detail: { total: 1 } }] },
}],
},
});
const reasons = checkStructuredResults(dir, makeExecState(), EXEC_TEMPLATE, "gate");
assert.ok(reasons.some(r => r.includes("matching OPC testCommand provenance")));
});
test("test-execute checks with matching testCommand provenance pass", () => {
const command = "node -e \"process.exit(0)\"";
const commandHash = sha256(command);
const sourcePlanHash = sha256(TEST_PLAN);
const result = {
provenance: { kind: "opc-test-command", commandHash, sourcePlanHash, executionActor: "opc-harness:test-command" },
checks: [{ id: "OUT-browser-render", pass: true, detail: { total: 1 } }],
};
const dir = setupDir("t8f-command-provenance", {
"test-design": {
artifacts: [{ type: "test-plan", path: "run_1/test-plan.md", _content: TEST_PLAN }],
testCommand: command,
},
"test-execute": {
testEvidenceProvenance: {
kind: "opc-test-command", sourceNode: "test-design", commandHash,
sourcePlanHash, resultHash: artifactHash(result), executionActor: "opc-harness:test-command",
},
artifacts: [{
type: "test-result",
path: "run_1/test-results.json",
_content: result,
}],
},
});
addTestLedger(dir, { commandHash, sourcePlanHash, resultHash: artifactHash(result) });
const reasons = checkStructuredResults(dir, makeExecState(), EXEC_TEMPLATE, "gate");
assert.equal(reasons.some(r => r.includes("testCommand provenance")), false);
assert.equal(reasons.some(r => r.includes("provenance ledger")), false);
});
test("test-execute re-run via goto validates against handshake runId, not stale history entry", () => {
// Regression: a goto re-run leaves an earlier test-execute entry in history.
// The dedup keeps that stale (run_1) entry, but the handshake on disk is the
// latest run (run_2). Validation must use the handshake's own runId so the
// signed run_2 ledger event is not compared against the stale run_1.
const command = "node -e \"process.exit(0)\"";
const commandHash = sha256(command);
const sourcePlanHash = sha256(TEST_PLAN);
const result = {
provenance: { kind: "opc-test-command", commandHash, sourcePlanHash, executionActor: "opc-harness:test-command" },
checks: [{ id: "OUT-browser-render", pass: true, detail: { total: 1 } }],
};
const dir = setupDir("t8f-rerun-goto-runid", {
"test-design": {
artifacts: [{ type: "test-plan", path: "run_2/test-plan.md", _content: TEST_PLAN }],
testCommand: command,
},
"test-execute": {
runId: "run_2",
testEvidenceProvenance: {
kind: "opc-test-command", sourceNode: "test-design", commandHash,
sourcePlanHash, resultHash: artifactHash(result), executionActor: "opc-harness:test-command",
},
artifacts: [{
type: "test-result",
path: "run_2/test-results.json",
_content: result,
}],
},
});
addTestLedger(dir, { runId: "run_2", commandHash, sourcePlanHash, resultHash: artifactHash(result) });
const rerunState = {
flowTemplate: "build-verify",
currentNode: "gate",
history: [
{ nodeId: "test-execute", runId: "run_1" },
{ nodeId: "gate", runId: "run_1" },
{ nodeId: "test-execute", runId: "run_2" },
{ nodeId: "gate", runId: "run_2" },
],
};
const reasons = checkStructuredResults(dir, rerunState, EXEC_TEMPLATE, "gate");
assert.equal(reasons.some(r => r.includes("node/run mismatch")), false);
assert.equal(reasons.some(r => r.includes("provenance ledger")), false);
});
test("test-execute public-hash provenance without signed ledger → FAIL", () => {
const command = "node -e \"process.exit(0)\"";
const commandHash = sha256(command);
const sourcePlanHash = sha256(TEST_PLAN);
const result = {
provenance: { kind: "opc-test-command", commandHash, sourcePlanHash, executionActor: "opc-harness:test-command" },
checks: [{ id: "OUT-browser-render", pass: true, detail: { total: 1 } }],
};
const dir = setupDir("t8f1-command-provenance-no-ledger", {
"test-design": {
artifacts: [{ type: "test-plan", path: "run_1/test-plan.md", _content: TEST_PLAN }],
testCommand: command,
},
"test-execute": {
testEvidenceProvenance: {
kind: "opc-test-command", sourceNode: "test-design", commandHash,
sourcePlanHash, resultHash: artifactHash(result), executionActor: "opc-harness:test-command",
},
artifacts: [{
type: "test-result",
path: "run_1/test-results.json",
_content: result,
}],
},
});
const reasons = checkStructuredResults(dir, makeExecState(), EXEC_TEMPLATE, "gate");
assert.ok(reasons.some(r => r.includes("signed provenance ledger")));
});
test("test-execute command provenance without source test-plan hash → FAIL", () => {
const command = "node -e \"process.exit(0)\"";
const commandHash = sha256(command);
const dir = setupDir("t8f2-command-without-plan-provenance", {
"test-design": {
artifacts: [{ type: "test-plan", path: "run_1/test-plan.md", _content: TEST_PLAN }],
testCommand: command,
},
"test-execute": {
testEvidenceProvenance: {
kind: "opc-test-command", sourceNode: "test-design", commandHash,
executionActor: "opc-harness:test-command",
},
artifacts: [{
type: "test-result",
path: "run_1/test-results.json",
_content: {
provenance: { kind: "opc-test-command", commandHash, executionActor: "opc-harness:test-command" },
checks: [{ id: "OUT-browser-render", pass: true, detail: { total: 1 } }],
},
}],
},
});
const reasons = checkStructuredResults(dir, makeExecState(), EXEC_TEMPLATE, "gate");
assert.ok(reasons.some(r => r.includes("source test-plan hash")));
});
test("test-execute checks with forged result-only provenance → FAIL", () => {
const dir = setupDir("t8g-forged-result-provenance", {
"test-execute": {
artifacts: [{
type: "test-result",
path: "run_1/test-results.json",
_content: {
provenance: { kind: "opc-test-command", commandHash: "abc123" },
checks: [{ id: "OUT-browser-render", pass: true, detail: { total: 1 } }],
},
}],
},
});
const reasons = checkStructuredResults(dir, makeExecState(), EXEC_TEMPLATE, "gate");
assert.ok(reasons.some(r => r.includes("matching OPC testCommand provenance")));
});
test("test-execute result tamper after harness run → FAIL", () => {
const command = "node -e \"process.exit(0)\"";
const commandHash = sha256(command);
const sourcePlanHash = sha256(TEST_PLAN);
const original = {
provenance: { kind: "opc-test-command", commandHash, sourcePlanHash, executionActor: "opc-harness:test-command" },
test_fail_count: 1,
};
const tampered = {
provenance: { kind: "opc-test-command", commandHash, sourcePlanHash, executionActor: "opc-harness:test-command" },
test_fail_count: 0,
};
const dir = setupDir("t8g3-tampered-result-hash", {
"test-design": {
artifacts: [{ type: "test-plan", path: "run_1/test-plan.md", _content: TEST_PLAN }],
testCommand: command,
},
"test-execute": {
testEvidenceProvenance: {
kind: "opc-test-command", sourceNode: "test-design", commandHash,
sourcePlanHash, resultHash: artifactHash(original), executionActor: "opc-harness:test-command",
},
artifacts: [{
type: "test-result",
path: "run_1/test-results.json",
_content: tampered,
}],
},
});
addTestLedger(dir, { commandHash, sourcePlanHash, resultHash: artifactHash(original) });
const reasons = checkStructuredResults(dir, makeExecState(), EXEC_TEMPLATE, "gate");
assert.ok(reasons.some(r => r.includes("result hash")));
});
test("test-execute test-result without checks still needs command provenance", () => {
const dir = setupDir("t8g2-self-authored-zero-tests", {
"test-execute": {
artifacts: [{
type: "test-result",
path: "run_1/test-results.json",
_content: { test_fail_count: 0, dead_test_count: 0 },
}],
},
});
const reasons = checkStructuredResults(dir, makeExecState(), EXEC_TEMPLATE, "gate");
assert.ok(reasons.some(r => r.includes("matching OPC testCommand provenance")));
});
test("test-execute checks with mismatched testCommand hash → FAIL", () => {
const command = "node -e \"process.exit(0)\"";
const sourcePlanHash = sha256(TEST_PLAN);
const dir = setupDir("t8h-mismatched-command-hash", {
"test-design": {
artifacts: [{ type: "test-plan", path: "run_1/test-plan.md", _content: TEST_PLAN }],
testCommand: command,
},
"test-execute": {
testEvidenceProvenance: {
kind: "opc-test-command", sourceNode: "test-design", commandHash: "wrong",
sourcePlanHash, executionActor: "opc-harness:test-command",
},
artifacts: [{
type: "test-result",
path: "run_1/test-results.json",
_content: {
provenance: { kind: "opc-test-command", commandHash: "wrong", sourcePlanHash, executionActor: "opc-harness:test-command" },
checks: [{ id: "OUT-browser-render", pass: true, detail: { total: 1 } }],
},
}],
},
});
const reasons = checkStructuredResults(dir, makeExecState(), EXEC_TEMPLATE, "gate");
assert.ok(reasons.some(r => r.includes("matching OPC testCommand provenance")));
});
test("test-design allowVacuousChecks can authorize known empty check", () => {
const command = "node -e \"process.exit(0)\"";
const commandHash = sha256(command);
const sourcePlanHash = sha256(TEST_PLAN);
const result = {
provenance: { kind: "opc-test-command", commandHash, sourcePlanHash, executionActor: "opc-harness:test-command" },
checks: [{ id: "OUT-empty-state", pass: true, detail: { total: 0 } }],
};
const dir = setupDir("t8i-test-design-vacuous-policy", {
"test-design": {
artifacts: [{ type: "test-plan", path: "run_1/test-plan.md", _content: TEST_PLAN }],
testCommand: command,
allowVacuousChecks: ["OUT-empty-state"],
},
"test-execute": {
testEvidenceProvenance: {
kind: "opc-test-command", sourceNode: "test-design", commandHash,
sourcePlanHash, resultHash: artifactHash(result), executionActor: "opc-harness:test-command",
},
artifacts: [{
type: "test-result",
path: "run_1/test-results.json",
_content: result,
}],
},
});
addTestLedger(dir, { commandHash, sourcePlanHash, resultHash: artifactHash(result) });
const reasons = checkStructuredResults(dir, makeExecState(), EXEC_TEMPLATE, "gate");
assert.equal(reasons.some(r => r.includes("vacuous PASS")), false);
assert.equal(reasons.some(r => r.includes("testCommand provenance")), false);
});
test("artifact type=screenshot → ignored (PASS)", () => {

@@ -199,2 +616,63 @@ const dir = setupDir("t9-screenshot-ignored", {

});
test("hard DI AI smell verdict blocks PASS", () => {
const dir = setupDir("t10-di-ai-smell", {
build: { artifacts: [] },
"code-review": { artifacts: [] },
});
writeDiVerdict(dir, "build", "run_1", {
pass: false,
recommendation: "FAIL",
aiSmellErrors: 1,
});
const reasons = checkStructuredResults(dir, makeState(), TEMPLATE, "gate");
assert.ok(reasons.some(r => r.includes("DI AI smell verdict")));
});
test("DI ITERATE verdict blocks gate PASS even when pass=true", () => {
const dir = setupDir("t10b-di-iterate", {
build: { artifacts: [] },
"code-review": { artifacts: [] },
});
writeDiVerdict(dir, "build", "run_1", {
pass: true,
recommendation: "ITERATE",
aiSmellErrors: 0,
});
const reasons = checkStructuredResults(dir, makeState(), TEMPLATE, "gate");
assert.ok(reasons.some(r => r.includes("DI verdict failed")));
assert.ok(reasons.some(r => r.includes("ITERATE")));
});
test("DI verdict sidecar uses latest run per node", () => {
const dir = setupDir("t11-di-ai-smell-retry", {
build: { artifacts: [] },
"code-review": { artifacts: [] },
});
writeDiVerdict(dir, "build", "run_1", {
pass: false,
recommendation: "FAIL",
aiSmellErrors: 1,
});
writeDiVerdict(dir, "build", "run_2", {
pass: true,
recommendation: "PASS",
aiSmellErrors: 0,
});
const state = {
flowTemplate: "build-verify",
currentNode: "gate",
history: [
{ nodeId: "build", runId: "run_1" },
{ nodeId: "build", runId: "run_2" },
{ nodeId: "code-review", runId: "run_1" },
{ nodeId: "gate", runId: "run_1" },
],
};
const reasons = checkStructuredResults(dir, state, TEMPLATE, "gate");
assert.equal(reasons.some(r => r.includes("DI AI smell verdict")), false);
});
});

@@ -205,3 +683,9 @@

/** Create a full session dir that cmdTransition/cmdPass will accept. */
function createSession(name, { artifacts = [], failingReport = false } = {}) {
function createSession(name, {
artifacts = [],
failingReport = false,
diVerdict = null,
autoMode = false,
autoRepairCounts,
} = {}) {
const dir = join(TMPBASE, name);

@@ -215,4 +699,9 @@ mkdirSync(join(dir, "nodes", "build", "run_1"), { recursive: true });

// Write eval files so synthesize produces a verdict
writeFileSync(join(dir, "nodes", "test-execute", "run_1", "eval-engineer.md"),
"# Engineer Review\n**Verdict: ✅ APPROVE**\nNo issues.\n");
for (const nodeId of ["code-review", "test-design"]) {
writeFileSync(join(dir, "nodes", nodeId, "run_1", "eval-skeptic-owner.md"),
cleanPassEval("Skeptic-Owner Evaluation", nodeId));
writeFileSync(join(dir, "nodes", nodeId, "run_1", "eval-peer.md"),
cleanPassEval("Peer Evaluation", nodeId));
}
writeFileSync(join(dir, "nodes", "test-design", "run_1", "test-plan.md"), COMPLETE_TEST_PLAN);

@@ -224,5 +713,11 @@ // Write handshakes for upstream nodes

status: "completed", summary: "done", timestamp: new Date().toISOString(),
artifacts: nodeId === "build" ? artifacts : [],
artifacts: nodeId === "build" ? artifacts : [
{ type: "eval", path: "run_1/eval-skeptic-owner.md" },
{ type: "eval", path: "run_1/eval-peer.md" },
],
verdict: null,
};
if (nodeId === "test-design") {
hs.artifacts.push({ type: "test-plan", path: "run_1/test-plan.md" });
}
writeFileSync(join(dir, "nodes", nodeId, "handshake.json"), JSON.stringify(hs));

@@ -249,2 +744,3 @@ // test-execute needs evidence

}
if (diVerdict) writeDiVerdict(dir, "build", "run_1", diVerdict);

@@ -256,3 +752,3 @@ // flow-state.json: currentNode = gate

currentNode: "gate",
entryNode: "build",
entryNode: "brief",
totalSteps: 4,

@@ -270,2 +766,6 @@ maxTotalSteps: 25,

],
flowStartedAt: new Date().toISOString(),
autoMode: autoMode || undefined,
...(autoRepairCounts === undefined ? {} : { autoRepairCounts }),
_claudeSessionId: autoMode ? `session-${name}` : undefined,
_written_by: "opc-harness",

@@ -309,6 +809,43 @@ _write_nonce: `test-${Date.now()}`,

test("direct transition PASS with hard DI AI smell verdict → rejected", () => {
const dir = createSession("bypass-transition-di-smell", {
diVerdict: { pass: false, recommendation: "FAIL", aiSmellErrors: 1 },
});
const result = runHarness("transition", [
"--from", "gate", "--to", "null", "--verdict", "PASS",
"--flow", "build-verify", "--dir", dir,
]);
assert.equal(result.allowed, false, `should be rejected, got: ${JSON.stringify(result)}`);
assert.ok(
result.reason?.includes("DI AI smell verdict"),
`reason should mention DI AI smell verdict, got: ${result.reason}`
);
});
test("direct gate PASS with upstream synthesize ITERATE → rejected", () => {
const dir = createSession("bypass-transition-synthesize");
writeFileSync(join(dir, "nodes", "code-review", "run_1", "eval-skeptic-owner.md"), [
"# Skeptic Owner Review",
"",
"[WARNING] package.json:1 — Package metadata needs review",
"Reasoning: package metadata is part of the committed source and is being checked.",
"→ Keep package metadata aligned with the release contract.",
"",
"VERDICT: FINDINGS[1]",
].join("\n"));
const result = runHarness("transition", [
"--from", "gate", "--to", "null", "--verdict", "PASS",
"--flow", "build-verify", "--dir", dir,
]);
assert.equal(result.allowed, false, `should be rejected, got: ${JSON.stringify(result)}`);
assert.ok(
result.reason?.includes("gate synthesize check failed"),
`reason should mention synthesize gate, got: ${result.reason}`
);
});
test("direct transition FAIL with failing artifacts → allowed (correct verdict)", () => {
const dir = createSession("bypass-transition-fail", { failingReport: true });
const result = runHarness("transition", [
"--from", "gate", "--to", "build", "--verdict", "FAIL",
"--from", "gate", "--to", "brief", "--verdict", "FAIL",
"--flow", "build-verify", "--dir", dir,

@@ -340,1 +877,208 @@ ]);

});
function readState(dir) {
return JSON.parse(readFileSync(join(dir, "flow-state.json"), "utf8"));
}
function runGateRepair(dir) {
return runHarness("transition", [
"--from", "gate", "--to", "brief", "--verdict", "FAIL",
"--flow", "build-verify", "--dir", dir,
]);
}
describe("exact auto repair-edge budget", () => {
test("first successful auto repair consumes the exact edge", () => {
const dir = createSession("repair-first", { autoMode: true });
const result = runGateRepair(dir);
assert.equal(result.allowed, true, JSON.stringify(result));
assert.equal(readState(dir).autoRepairCounts["gate→brief"], 1);
});
test("second exact repair trips durably before graph limits or transition side effects", () => {
const dir = createSession("repair-second", {
autoMode: true,
autoRepairCounts: { "gate→brief": 1 },
});
const state = readState(dir);
state.maxTotalSteps = state.totalSteps;
writeFileSync(join(dir, "flow-state.json"), JSON.stringify(state, null, 2));
const before = readFileSync(join(dir, "flow-state.json"), "utf8");
const result = runGateRepair(dir);
assert.equal(result.allowed, false);
assert.equal(result.requiresHuman, true);
assert.match(result.reason, /auto repair budget reached.*gate→brief/);
assert.equal(readFileSync(join(dir, "flow-state.json"), "utf8"), before);
assert.equal(existsSync(join(dir, "nodes", "brief")), false);
const run = resolveCurrentRun(state);
const paths = budgetPaths(dir, "gate", run.runKey);
assert.deepEqual(JSON.parse(readFileSync(paths.stop, "utf8")), {
sessionId: "session-repair-second",
nodeId: "gate",
runKey: run.runKey,
reason: "repair-edge-budget",
edgeKey: "gate→brief",
createdAt: JSON.parse(readFileSync(paths.stop, "utf8")).createdAt,
});
});
test("different exact repair edges remain independent", () => {
const dir = createSession("repair-independent", {
autoMode: true,
autoRepairCounts: { "code-review→build": 1 },
});
const result = runGateRepair(dir);
assert.equal(result.allowed, true, JSON.stringify(result));
assert.deepEqual(readState(dir).autoRepairCounts, {
"code-review→build": 1,
"gate→brief": 1,
});
});
test("interactive transitions ignore auto repair counts", () => {
const dir = createSession("repair-interactive", {
autoRepairCounts: { "gate→brief": 1 },
});
const result = runGateRepair(dir);
assert.equal(result.allowed, true, JSON.stringify(result));
assert.equal(readState(dir).autoRepairCounts["gate→brief"], 1);
});
test("failed graph validation does not consume a repair", () => {
const dir = createSession("repair-validation", { autoMode: true });
const state = readState(dir);
state.maxNodeReentry = 0;
writeFileSync(join(dir, "flow-state.json"), JSON.stringify(state, null, 2));
const result = runGateRepair(dir);
assert.equal(result.allowed, false);
assert.match(result.reason, /maxNodeReentry/);
assert.equal(readState(dir).autoRepairCounts, undefined);
});
test("malformed repair counters and marker I/O failure fail closed", () => {
for (const [index, autoRepairCounts] of [null, "invalid", []].entries()) {
const malformedDir = createSession(`repair-malformed-${index}`, {
autoMode: true,
autoRepairCounts,
});
const malformed = runGateRepair(malformedDir);
assert.equal(malformed.allowed, false);
assert.equal(malformed.requiresHuman, true);
assert.match(malformed.reason, /autoRepairCounts is invalid/);
}
for (const [index, count] of [1.5, -1].entries()) {
const malformedDir = createSession(`repair-count-${index}`, {
autoMode: true,
autoRepairCounts: { "gate→brief": count },
});
const malformed = runGateRepair(malformedDir);
assert.equal(malformed.allowed, false);
assert.equal(malformed.requiresHuman, true);
assert.match(malformed.reason, /auto repair count is invalid/);
}
const blockedDir = createSession("repair-marker-failure", {
autoMode: true,
autoRepairCounts: { "gate→brief": 1 },
});
writeFileSync(join(blockedDir, "node-budget"), "not-a-directory");
const before = readFileSync(join(blockedDir, "flow-state.json"), "utf8");
const blocked = runGateRepair(blockedDir);
assert.equal(blocked.allowed, false);
assert.equal(blocked.requiresHuman, true);
assert.match(blocked.reason, /stop marker creation failed/);
assert.equal(readFileSync(join(blockedDir, "flow-state.json"), "utf8"), before);
});
test("auto PASS does not consume repair budget", () => {
const dir = createSession("repair-pass", {
autoMode: true,
autoRepairCounts: { "gate→brief": 0 },
});
const result = runHarness("transition", [
"--from", "gate", "--to", "null", "--verdict", "PASS",
"--flow", "build-verify", "--dir", dir,
]);
assert.equal(result.finalized, true, JSON.stringify(result));
assert.deepEqual(readState(dir).autoRepairCounts, { "gate→brief": 0 });
});
});
function createAdvanceRepairSession(name) {
const dir = join(TMPBASE, name);
const reviewRun = join(dir, "nodes", "review", "run_1");
mkdirSync(reviewRun, { recursive: true });
mkdirSync(join(dir, "nodes", "gate"), { recursive: true });
writeFileSync(join(reviewRun, "eval-skeptic-owner.md"), [
"# Skeptic Owner Review",
"",
"[WARNING] package.json:1 — metadata needs another review",
"Reasoning: the current metadata is incomplete.",
"→ Repair the metadata before delivery.",
"",
"VERDICT: FINDINGS[1]",
].join("\n"));
writeFileSync(join(reviewRun, "eval-peer.md"), cleanPassEval("Peer Evaluation", "review"));
writeFileSync(join(dir, "nodes", "review", "handshake.json"), JSON.stringify({
nodeId: "review",
nodeType: "review",
runId: "run_1",
status: "completed",
verdict: "ITERATE",
summary: "needs repair",
timestamp: new Date().toISOString(),
artifacts: [
{ type: "eval", path: "run_1/eval-skeptic-owner.md" },
{ type: "eval", path: "run_1/eval-peer.md" },
],
}));
const now = new Date().toISOString();
writeFileSync(join(dir, "flow-state.json"), JSON.stringify({
version: "1.0",
flowTemplate: "review",
currentNode: "gate",
entryNode: "review",
totalSteps: 1,
maxTotalSteps: 10,
maxLoopsPerEdge: 3,
maxNodeReentry: 5,
edgeCounts: { "review→gate": 1 },
history: [
{ nodeId: "review", runId: "run_1", timestamp: now },
{ nodeId: "gate", runId: "run_1", timestamp: now },
],
flowStartedAt: now,
autoMode: true,
autoRepairCounts: { "gate→review": 1 },
_claudeSessionId: `session-${name}`,
_written_by: "opc-harness",
_write_nonce: `test-${Date.now()}`,
_last_modified: now,
}, null, 2));
return dir;
}
describe("advance repair denial propagation", () => {
test("reports advanced=false when the nested transition requires a human", () => {
const dir = createAdvanceRepairSession("repair-advance");
const result = runHarness("advance", ["--dir", dir]);
assert.equal(result.advanced, false, JSON.stringify(result));
assert.equal(result.requiresHuman, true);
assert.equal(result.transition.allowed, false);
assert.match(result.reason, /auto repair budget reached.*gate→review/);
});
});

@@ -10,2 +10,3 @@ // Loop advance command: next-tick

import { lockFile } from "./file-lock.mjs";
import { resolveCallerIdentity, checkOwnership, makeOwner, ownershipEnforcementWarning } from "./driver-owner.mjs";
import { FLOW_TEMPLATES, loadFlowFromFile } from "./flow-templates.mjs";

@@ -131,2 +132,26 @@

// ── Session-ownership gate ───────────────────────────────────
// Refuse to advance a loop owned by a different, still-live Claude session.
const caller = resolveCallerIdentity();
const foWarn = ownershipEnforcementWarning(caller);
if (foWarn) warnings.push(foWarn);
const ownership = checkOwnership(state, caller, { force: args.includes("--force-takeover") });
if (ownership.decision === "BLOCKED") {
console.log(JSON.stringify({
ready: false,
terminate: false,
reason: `not the loop owner — ${ownership.reason}`,
owner_conflict: true,
hint: "this loop is being driven by another Claude session. If that session is gone, re-run with --force-takeover to reclaim.",
}));
return;
}
if (ownership.decision === "TAKEOVER") {
state._owner = makeOwner(caller, state._owner && state._owner.token);
state._written_by = WRITER_SIG;
state._last_modified = new Date().toISOString();
atomicWriteSync(statePath, JSON.stringify(state, null, 2) + "\n");
warnings.push(`reclaimed loop ownership — ${ownership.reason}`);
}
// Auto-restore flow template from _flow_file if persisted

@@ -409,3 +434,3 @@ if (state._flow_file) {

resumePrompt,
reminder: state.autoMode ? "auto mode — do not pause, do not ask user, keep executing" : undefined,
reminder: state.autoMode ? "auto mode — continue without confirmation only while configured loop limits remain; stop and report immediately when a limit trips" : undefined,
warnings: warnings.length > 0 ? warnings : undefined,

@@ -554,5 +579,5 @@ }));

`2. Read the checkpoint above to understand where we left off`,
`3. Execute unit ${state.next_unit} using /opc with the ${contextHints.recommended_flow} flow`,
`3. This unit (${state.next_unit}) is already claimed (next-tick marked it in_progress) — execute it now using /opc with the ${contextHints.recommended_flow} flow`,
`4. After completion, run: opc-harness complete-tick --unit ${state.next_unit} --artifacts <paths> --description "<summary>" --delta "<technical decisions made>"`,
`5. Then run: opc-harness next-tick to get the next unit`,
`5. Do NOT run next-tick here. The NEXT tick begins by running opc-harness next-tick FIRST — it claims the next unit and re-checks ownership before any work.`,
);

@@ -559,0 +584,0 @@

@@ -13,2 +13,3 @@ // Loop init command: init-loop

import { runLint } from "./criteria-lint.mjs";
import { resolveCallerIdentity, makeOwner, ownershipEnforcementWarning } from "./driver-owner.mjs";

@@ -252,2 +253,10 @@ // ─── init-loop ──────────────────────────────────────────────────

// Session-ownership stamp: bind this loop to the Claude session that started
// it. Drive commands (next-tick/complete-tick) refuse to run from a different
// live session, preventing the compaction double-drive bug.
const owner = makeOwner(resolveCallerIdentity());
state._owner = owner;
const foWarn = ownershipEnforcementWarning(owner);
if (foWarn) initWarnings.push(foWarn);
const hasHooks = detectPreCommitHooks(projectDir);

@@ -275,2 +284,4 @@ const testScripts = detectTestScript(projectDir);

total_units: units.length,
owner_token: owner.token,
owner_claude_pid: owner.claude_pid,
external_validators: validatorList.length > 0 ? validatorList : ["none detected — quality relies on in-process checks only"],

@@ -277,0 +288,0 @@ warnings: initWarnings.length > 0 ? initWarnings : undefined,

@@ -398,2 +398,3 @@ // loop-p1p3.test.mjs — Tests for P1 (projectDir) + P3 (structured stall errors)

],
autoMode: true,
};

@@ -404,4 +405,6 @@ writeFileSync(join(tmp, "loop-state.json"), JSON.stringify(state));

// Should NOT stall — last tick succeeded
assert.equal(result.terminate, undefined || false);
assert.equal(result.terminate, false);
assert.equal(result.ready, true);
assert.match(result.reminder, /configured loop limits remain/);
assert.doesNotMatch(result.reminder, /keep executing/);
} finally {

@@ -408,0 +411,0 @@ rmSync(tmp, { recursive: true });

@@ -10,2 +10,3 @@ // Loop tick completion command: complete-tick

import { lockFile } from "./file-lock.mjs";
import { resolveCallerIdentity, checkOwnership, makeOwner, ownershipEnforcementWarning } from "./driver-owner.mjs";
import { checkEvalDistinctness, parseEvaluation } from "./eval-parser.mjs";

@@ -63,2 +64,24 @@

// ── Session-ownership gate ───────────────────────────────────
// The compaction double-drive bug slips in here: a resumed agent that jumps
// straight to complete-tick bypasses next-tick's in_progress guard. Refuse
// when a different, still-live Claude session owns the loop.
const caller = resolveCallerIdentity();
const foWarn = ownershipEnforcementWarning(caller);
if (foWarn) warnings.push(foWarn);
const ownership = checkOwnership(state, caller, { force: args.includes("--force-takeover") });
if (ownership.decision === "BLOCKED") {
console.log(JSON.stringify({
completed: false,
errors: [`not the loop owner — ${ownership.reason}`],
owner_conflict: true,
hint: "this loop is being driven by another Claude session. If that session is gone, re-run with --force-takeover to reclaim.",
}));
return;
}
if (ownership.decision === "TAKEOVER") {
state._owner = makeOwner(caller, state._owner && state._owner.token);
warnings.push(`reclaimed loop ownership — ${ownership.reason}`);
}
// Rule 7: terminated pipeline

@@ -65,0 +88,0 @@ if (TERMINAL_LOOP_STATUSES.has(state.status)) {

@@ -394,2 +394,13 @@ // Quality tier baseline definitions — single source of truth.

export function formatTierCoverageHint(tier) {
const required = [...getRequiredBaselineKeys(tier)];
const valid = [...getAllBaselineKeys(tier)];
return [
"See pipeline/tier-coverage-schema.md.",
"Expected tierCoverage: { covered: string[], skipped: { key: string, reason: string }[] }.",
`Required keys for ${tier}: ${required.join(", ") || "(none)"}.`,
`Valid keys for ${tier}: ${valid.join(", ") || "(none)"}.`,
].join(" ");
}
/**

@@ -396,0 +407,0 @@ * Check evaluator text for coverage of tier baseline items.

@@ -24,2 +24,3 @@ // tier-baselines.test.mjs — unit tests for tier-baselines.mjs

getAllBaselineKeys,
formatTierCoverageHint,
checkBaselineCoverage,

@@ -351,2 +352,14 @@ } from "./tier-baselines.mjs";

describe("formatTierCoverageHint", () => {
test("names schema doc and valid tier keys", () => {
const hint = formatTierCoverageHint("delightful");
assert.ok(hint.includes("pipeline/tier-coverage-schema.md"));
assert.ok(hint.includes("Expected tierCoverage"));
assert.ok(hint.includes("typography"));
assert.ok(hint.includes("micro-interactions"));
assert.ok(!hint.includes("responsive-layout"));
assert.ok(!hint.includes("dark-mode"));
});
});
describe("checkBaselineCoverage", () => {

@@ -353,0 +366,0 @@ test("matches keywords case-insensitively", () => {

// Shared utilities used across all harness modules.
// Single source of truth for getFlag, resolveDir, atomicWriteSync, constants.
import { writeFileSync, renameSync, symlinkSync, unlinkSync, readlinkSync, existsSync, mkdirSync, readdirSync, statSync, rmSync, realpathSync } from "fs";
import { writeFileSync, renameSync, symlinkSync, unlinkSync, readlinkSync, readFileSync, existsSync, mkdirSync, readdirSync, statSync, rmSync, realpathSync } from "fs";
import { resolve, join, dirname } from "path";

@@ -9,5 +9,9 @@ import { createHash, randomBytes } from "crypto";

import { execSync } from "child_process";
import { lockFile } from "./file-lock.mjs";
// ── CLI flag parsing ────────────────────────────────────────────
export function getFlag(args, name, fallback = null) {
const eqPrefix = `--${name}=`;
const eq = args.find(a => typeof a === "string" && a.startsWith(eqPrefix));
if (eq) return eq.slice(eqPrefix.length);
const idx = args.indexOf(`--${name}`);

@@ -74,3 +78,3 @@ return idx !== -1 && args[idx + 1] != null ? args[idx + 1] : fallback;

// ── Shared constants ────────────────────────────────────────────
export const VALID_NODE_TYPES = new Set(["discussion", "build", "review", "execute", "gate"]);
export const VALID_NODE_TYPES = new Set(["discussion", "brief", "build", "review", "execute", "hotfix", "gate"]);
export const VALID_STATUSES = new Set(["completed", "failed", "blocked"]);

@@ -95,3 +99,3 @@ export const VALID_VERDICTS = new Set(["PASS", "ITERATE", "FAIL", "BLOCKED"]);

*/
function getProjectRoot(cwd = process.cwd()) {
export function getProjectRoot(cwd = process.cwd()) {
try {

@@ -181,2 +185,68 @@ const gitRoot = execSync("git rev-parse --show-toplevel", {

export function runtimeRegistryPath(sessionId, home = homedir()) {
const key = createHash("sha256").update(String(sessionId)).digest("hex");
return join(home, ".opc", "runtime", `${key}.json`);
}
function deleteRegisteredAutoSession(dir, initialState, cutoff, errors) {
if (typeof initialState._claudeSessionId !== "string" || initialState._claudeSessionId.length === 0) {
errors.push(`cannot GC auto session '${dir}': missing Claude session ID`);
return false;
}
const sessionId = initialState._claudeSessionId;
const path = runtimeRegistryPath(sessionId);
const statePath = join(dir, "flow-state.json");
let lock;
try {
mkdirSync(dirname(path), { recursive: true });
lock = lockFile(path, { command: "gc-session-registry", timeout: 0 });
if (!lock.acquired) {
errors.push(`cannot acquire registry lock for '${dir}'`);
return false;
}
// Re-check age and identity under the same lock used by auto init. A
// same-directory --force init may have replaced the stale state while GC
// was waiting for this lock.
if (statSync(statePath).mtimeMs >= cutoff) return false;
const state = JSON.parse(readFileSync(statePath, "utf8"));
if (state?.autoMode !== true || state._claudeSessionId !== sessionId) return false;
let registry;
try {
registry = JSON.parse(readFileSync(path, "utf8"));
} catch (error) {
if (error?.code === "ENOENT") {
rmSync(dir, { recursive: true, force: true });
return true;
}
throw error;
}
if (registry?.sessionId !== sessionId || typeof registry.sessionDir !== "string") {
errors.push(`cannot GC auto session '${dir}': registry identity is invalid`);
return false;
}
if (resolve(registry.sessionDir) !== resolve(dir)) {
rmSync(dir, { recursive: true, force: true });
return true;
}
if (state.status !== "completed" && state.status !== "stopped") return false;
// Keep the lock until both operations finish. Unlinking the registry first
// can leave an orphan directory on I/O failure, but never a registry that
// points at a deleted flow.
unlinkSync(path);
rmSync(dir, { recursive: true, force: true });
return true;
} catch (error) {
errors.push(`cannot clean registry for '${dir}': ${error.message}`);
return false;
} finally {
if (lock?.acquired) lock.release();
}
}
/**

@@ -199,5 +269,17 @@ * Delete session dirs older than maxAgeDays in the given project's sessions base.

try {
const st = statSync(join(dir, "flow-state.json"));
const statePath = join(dir, "flow-state.json");
const st = statSync(statePath);
if (st.mtimeMs < cutoff) {
rmSync(dir, { recursive: true, force: true });
let state;
try {
state = JSON.parse(readFileSync(statePath, "utf8"));
} catch (error) {
errors.push(`cannot read expired session '${dir}': ${error.message}`);
continue;
}
if (state?.autoMode === true) {
if (!deleteRegisteredAutoSession(dir, state, cutoff, errors)) continue;
} else {
rmSync(dir, { recursive: true, force: true });
}
deleted.push(e.name);

@@ -204,0 +286,0 @@ }

@@ -11,2 +11,6 @@ // Visualization and replay commands: getMarker, cmdViz, cmdReplayData

if (!state) return "○";
if (state.status === "completed") {
if (state.currentNode === nodeId) return "✅";
if (state.history?.some((h) => h.nodeId === nodeId)) return "✅";
}
if (state.currentNode === nodeId) return "▶";

@@ -54,3 +58,8 @@ if (state.history?.some((h) => h.nodeId === nodeId)) return "✅";

const nodes = template.nodes.map((id) => ({ id, status: getMarker(id, state) }));
console.log(JSON.stringify({ nodes, loopbacks }, null, 2));
console.log(JSON.stringify({
nodes,
loopbacks,
completed: state?.status === "completed",
terminalNode: state?.status === "completed" ? state.currentNode || null : null,
}, null, 2));
return;

@@ -73,2 +82,6 @@ }

}
if (state?.status === "completed") {
console.log("");
console.log(` ══ FLOW COMPLETED${state.currentNode ? ` at ${state.currentNode}` : ""} ══`);
}
}

@@ -75,0 +88,0 @@

@@ -8,3 +8,3 @@ #!/usr/bin/env node

import { cmdReport, cmdDiff } from "./lib/eval-report.mjs";
import { cmdRoute, cmdInit, cmdValidate, cmdValidateContext, cmdSeal } from "./lib/flow-core.mjs";
import { cmdRoute, cmdInit, cmdValidate, cmdValidateContext, cmdSeal, cmdRecordCommit } from "./lib/flow-core.mjs";
import { cmdTransition, cmdValidateChain, cmdFinalize, cmdAdvance } from "./lib/flow-transition.mjs";

@@ -22,2 +22,3 @@ import { cmdPromptContext, cmdExtensionTest, cmdExtensionVerdict, cmdExtensionArtifact, cmdNodePreflight } from "./lib/ext-commands.mjs";

import { cmdCriteriaLint } from "./lib/criteria-lint.mjs";
import { cmdBriefLint } from "./lib/brief-lint.mjs";
import { cmdRunbook } from "./lib/runbook-commands.mjs";

@@ -37,2 +38,3 @@ import { cmdClean } from "./lib/clean.mjs";

case "init": await cmdInit(args); break;
case "record-commit": cmdRecordCommit(args); break;
case "validate": cmdValidate(args); break;

@@ -60,2 +62,3 @@ case "transition": await cmdTransition(args); break;

case "criteria-lint": cmdCriteriaLint(args); break;
case "brief-lint": cmdBriefLint(args); break;
case "prompt-context": await cmdPromptContext(args); break;

@@ -81,3 +84,3 @@ case "extension-test": await cmdExtensionTest(args); break;

console.log(" Execute state transition");
console.log(" validate <handshake.json> Validate handshake schema");
console.log(" validate [handshake.json] [--dir <p>] Validate handshake schema; no path = current node in latest session");
console.log(" validate-chain [--dir <p>] Validate entire execution path");

@@ -113,2 +116,3 @@ console.log(" validate-context --flow <tpl> [--flow-file <p>] --node <id> [--dir <p>]");

console.log(" criteria-lint <file> [--tier <t>] Lint acceptance criteria DoD");
console.log(" brief-lint <file> [--has-prior-findings] Lint build brief quality");
console.log();

@@ -124,3 +128,3 @@ console.log("Config commands:");

console.log("Extension commands:");
console.log(" extension-test --ext <p> [--hook <name>] [--context <json>] [--all-hooks] [--fixture-dir <p>] [--lint]");
console.log(" extension-test --ext <p> [--hook <name>] [--context <json>] [--dev-server <url>] [--all-hooks] [--fixture-dir <p>] [--lint]");
console.log(" Dry-run extension hook(s); --fixture-dir seeds ctx.flowDir; --lint runs authoring checks only");

@@ -127,0 +131,0 @@ console.log(" extension-verdict --node <id> --dir <p> Fire verdict.append → writes eval-extensions.{md,json}");

@@ -8,2 +8,5 @@ #!/usr/bin/env node

import { join, basename, relative } from 'path';
import { parseEvaluation } from './lib/eval-parser.mjs';
import { collectExecutionFixes } from './lib/cumulative-findings.mjs';
import { parseStructuredFindings } from './lib/structured-findings.mjs';

@@ -57,37 +60,34 @@ // --- CLI args ---

// --- Parse findings from an eval file ---
const SEV_RE = /\*\*Severity\*\*:?\s*(🔴|🟡|🔵)/;
const STATUS_RE = /\*\*(?:R2\s+)?Status\*?\*?:?\s*(✅|⚠️|❌)/;
const LOCATION_RE = /\*\*Location\*\*:?\s*(.+)/;
const FINDING_HEADING_RE = /^#{2,3}\s+(?:Finding\s+)?(\d+)[\s.:—\-]+(.+)/i;
// Also match: ### N. Title or ## Finding N — Title
const FINDING_HEADING_ALT = /^#{2,3}\s+(\d+)\.\s+(.+)/;
function severityIcon(severity) {
return { critical: '🔴', warning: '🟡', suggestion: '🔵' }[severity] || severity;
}
function mergeStatus(f, structured) {
return structured.find(s => {
const a = String(s.title || '').toLowerCase();
const b = String(f.issue || '').toLowerCase();
return a && b && (a.includes(b) || b.includes(a));
}) || null;
}
function canonicalFinding(f, idx, structured) {
const statusSource = mergeStatus(f, structured);
const location = f.file && f.line ? `${f.file}:${f.line}` : statusSource?.location;
return {
num: String(idx + 1),
title: f.issue,
severity: severityIcon(f.severity),
location,
status: statusSource?.status || null,
};
}
function parseFindings(content) {
const lines = content.split('\n');
const findings = [];
let current = null;
for (const line of lines) {
let m = line.match(FINDING_HEADING_RE) || line.match(FINDING_HEADING_ALT);
if (m) {
if (current) findings.push(current);
current = { num: m[1], title: m[2].trim(), severity: null, location: null, status: null };
continue;
}
// Non-finding heading (## without numbered pattern) — reset current to avoid status leakage
if (/^#{2,3}\s+/.test(line) && !m) {
if (current) findings.push(current);
current = null;
continue;
}
if (!current) continue;
const sevM = line.match(SEV_RE);
if (sevM) { current.severity = sevM[1]; continue; }
const statM = line.match(STATUS_RE);
if (statM) { current.status = statM[1]; continue; }
const locM = line.match(LOCATION_RE);
if (locM) { current.location = locM[1].replace(/`/g, '').trim(); continue; }
}
if (current) findings.push(current);
return findings;
const structured = parseStructuredFindings(content);
const parsed = parseEvaluation(content);
if (!parsed.findings.length) return structured;
const canonical = parsed.findings.map((f, idx) => canonicalFinding(f, idx, structured));
if (!structured.length) return canonical;
const extras = canonical.filter(f => !mergeStatus({ issue: f.title }, structured));
return structured.concat(extras.map((f, idx) => ({ ...f, num: String(structured.length + idx + 1) })));
}

@@ -129,2 +129,3 @@

const evalsByNode = collectEvals(DIR);
const executionFixes = collectExecutionFixes(DIR);

@@ -323,2 +324,3 @@ // Determine R1 vs R2 nodes from loop-state if available

${renderR1Findings()}
${renderExecutionFixes()}
${hasR2 ? renderFixes() : ''}

@@ -432,2 +434,12 @@ ${hasR2 ? renderR2Verdicts() : ''}

function renderExecutionFixes() {
if (!executionFixes.length) return '';
return `<div class="section">
<div class="section-title">Fixes Applied During Execution</div>
<div class="card">
${executionFixes.map(f => `<div class="fix-item"><span class="fix-icon">✓</span><span class="fix-title"><code>${esc(f.nodeId)}${f.runId ? `/${esc(f.runId)}` : ''}</code> ${esc(f.text)}</span></div>`).join('')}
</div>
</div>`;
}
// Infer R2 verdict from fix counts when parseVerdict fails

@@ -485,3 +497,3 @@ function inferR2Verdict(n) {

return `<div style="text-align:center;padding:2rem 0;color:var(--text-dim);font-size:.8rem;border-top:1px solid var(--border);margin-top:2rem">
Generated by <strong>opc-report.mjs</strong> &middot; ${totalFindings} findings across ${r1Nodes.length} reviewers${hasR2 ? ` &middot; ${r2Fixed} fixed, ${r2Partial} partial, ${r2NotFixed} unresolved` : ''}${tickCount ? ` &middot; ${tickCount} ticks` : ''}
Generated by <strong>opc-report.mjs</strong> &middot; ${totalFindings} findings across ${r1Nodes.length} reviewers${executionFixes.length ? ` &middot; ${executionFixes.length} execution fixes` : ''}${hasR2 ? ` &middot; ${r2Fixed} fixed, ${r2Partial} partial, ${r2NotFixed} unresolved` : ''}${tickCount ? ` &middot; ${tickCount} ticks` : ''}
</div>`;

@@ -488,0 +500,0 @@ }

@@ -8,2 +8,3 @@ #!/usr/bin/env node

import { spawnSync } from "child_process";
import { atomicWriteSync } from "./lib/util.mjs";

@@ -17,3 +18,3 @@ const __dirname = dirname(fileURLToPath(import.meta.url));

// Only these files/dirs are managed by OPC — custom roles are left alone
const MANAGED_ENTRIES = ["skill.md", "replay.md", "roles", "pipeline", "bin", "package.json"];
const MANAGED_ENTRIES = ["SKILL.md", "replay.md", "roles", "pipeline", "bin", "package.json"];

@@ -28,4 +29,4 @@ // Files removed in newer versions — clean up from target on install

function validateHookPrereqs(hooksDir) {
for (const file of ["opc-pre-compact.sh", "opc-post-compact.sh"]) {
function validateHookScripts(hooksDir) {
for (const file of ["opc-pre-compact.sh", "opc-post-compact.sh", "opc-pre-tool-budget.mjs"]) {
if (!existsSync(join(hooksDir, file))) {

@@ -35,7 +36,53 @@ return `missing hook script: ${join(hooksDir, file)}. Run 'opc install' first.`;

}
return null;
}
function hasJq() {
const jq = spawnSync("jq", ["--version"], { encoding: "utf8" });
if (jq.error || jq.status !== 0) {
return "opc install-hooks requires 'jq'. Install jq, then rerun 'opc install-hooks'.";
return !jq.error && jq.status === 0;
}
function removeInstalledHooks(settingsPath) {
if (!existsSync(settingsPath)) return 0;
let settings;
try {
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
} catch (error) {
throw new Error(`cannot parse ${settingsPath}: ${error.message}`);
}
return null;
if (!settings.hooks || typeof settings.hooks !== "object") return 0;
const hooksDir = join(skillsDir, "bin", "hooks");
const owned = {
PreCompact: `bash "${join(hooksDir, "opc-pre-compact.sh")}"`,
PostCompact: `bash "${join(hooksDir, "opc-post-compact.sh")}"`,
PreToolUse: `node "${join(hooksDir, "opc-pre-tool-budget.mjs")}"`,
};
let removed = 0;
for (const [event, ownedCommand] of Object.entries(owned)) {
const entries = settings.hooks[event];
if (!Array.isArray(entries)) continue;
const keptEntries = [];
for (const entry of entries) {
if (!Array.isArray(entry?.hooks)) {
keptEntries.push(entry);
continue;
}
const hooks = entry.hooks.filter(hook => {
const isOwned = hook?.type === "command" && hook.command === ownedCommand;
if (isOwned) removed++;
return !isOwned;
});
if (hooks.length > 0) keptEntries.push({ ...entry, hooks });
}
if (keptEntries.length > 0) settings.hooks[event] = keptEntries;
else delete settings.hooks[event];
}
if (removed > 0) {
atomicWriteSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
}
return removed;
}

@@ -71,3 +118,3 @@

console.log(` Use /opc in Claude Code to get started.`);
console.log(` Run 'opc install-hooks' to enable compression resilience.`);
console.log(` Run 'opc install-hooks' to enable auto-flow guards and compression resilience.`);
break;

@@ -91,37 +138,58 @@ }

const hooksDir = join(skillsDir, "bin", "hooks");
const prereqError = validateHookPrereqs(hooksDir);
if (prereqError) {
console.error(`✗ ${prereqError}`);
const scriptError = validateHookScripts(hooksDir);
if (scriptError) {
console.error(`✗ ${scriptError}`);
process.exit(1);
}
const jqAvailable = hasJq();
const preCmd = `bash "${join(hooksDir, "opc-pre-compact.sh")}"`;
const postCmd = `bash "${join(hooksDir, "opc-post-compact.sh")}"`;
const preToolCmd = `node "${join(hooksDir, "opc-pre-tool-budget.mjs")}"`;
// Merge PreCompact — preserve existing hooks
if (!settings.hooks.PreCompact) settings.hooks.PreCompact = [];
const hasPreCompact = settings.hooks.PreCompact.some(
entry => entry.hooks?.some(h => h.command?.includes("opc-pre-compact"))
);
if (!hasPreCompact) {
settings.hooks.PreCompact.push({
hooks: [{ type: "command", command: preCmd, timeout: 10 }]
});
if (jqAvailable) {
// Merge PreCompact — preserve existing hooks
if (!settings.hooks.PreCompact) settings.hooks.PreCompact = [];
const hasPreCompact = settings.hooks.PreCompact.some(
entry => entry.hooks?.some(h => h.command?.includes("opc-pre-compact"))
);
if (!hasPreCompact) {
settings.hooks.PreCompact.push({
hooks: [{ type: "command", command: preCmd, timeout: 10 }]
});
}
// Merge PostCompact — preserve existing hooks
if (!settings.hooks.PostCompact) settings.hooks.PostCompact = [];
const hasPostCompact = settings.hooks.PostCompact.some(
entry => entry.hooks?.some(h => h.command?.includes("opc-post-compact"))
);
if (!hasPostCompact) {
settings.hooks.PostCompact.push({
hooks: [{ type: "command", command: postCmd, timeout: 10 }]
});
}
}
// Merge PostCompact — preserve existing hooks
if (!settings.hooks.PostCompact) settings.hooks.PostCompact = [];
const hasPostCompact = settings.hooks.PostCompact.some(
entry => entry.hooks?.some(h => h.command?.includes("opc-post-compact"))
// Merge PreToolUse — preserve existing hooks and use a synchronous command decision.
if (!settings.hooks.PreToolUse) settings.hooks.PreToolUse = [];
const hasPreToolUse = settings.hooks.PreToolUse.some(
entry => (entry.matcher == null || entry.matcher === "") &&
entry.hooks?.some(h => h.type === "command" && h.async !== true && h.command === preToolCmd)
);
if (!hasPostCompact) {
settings.hooks.PostCompact.push({
hooks: [{ type: "command", command: postCmd, timeout: 10 }]
if (!hasPreToolUse) {
settings.hooks.PreToolUse.push({
hooks: [{ type: "command", command: preToolCmd, timeout: 10 }]
});
}
writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
console.log(`✓ OPC compact hooks registered in ${settingsPath}`);
console.log(` Verified: hook scripts present and jq available.`);
console.log(` PreCompact: snapshots active flow state before compaction`);
console.log(` PostCompact: injects resume context after compaction`);
mkdirSync(dirname(settingsPath), { recursive: true });
atomicWriteSync(settingsPath, JSON.stringify(settings, null, 2) + "\n");
console.log(`✓ OPC hooks registered in ${settingsPath}`);
console.log(` PreToolUse: enforces active auto-flow node budgets`);
if (jqAvailable) {
console.log(` PreCompact: snapshots active flow state before compaction`);
console.log(` PostCompact: injects resume context after compaction`);
} else {
console.log(` WARN: jq not found; skipped optional PreCompact/PostCompact hooks.`);
}
break;

@@ -131,4 +199,16 @@ }

case "uninstall": {
const settingsPath = join(homedir(), ".claude", "settings.json");
let removedHooks;
try {
removedHooks = removeInstalledHooks(settingsPath);
} catch (error) {
console.error(`✗ Cannot safely uninstall OPC: ${error.message}`);
process.exit(1);
}
if (removedHooks > 0) {
console.log(` Removed ${removedHooks} OPC hook(s) from ${settingsPath}`);
}
if (!existsSync(skillsDir)) {
console.log(`Nothing to remove — ${skillsDir} does not exist.`);
console.log(`Nothing else to remove — ${skillsDir} does not exist.`);
break;

@@ -201,3 +281,3 @@ }

console.log(" opc install Install skill files to ~/.claude/skills/opc/");
console.log(" opc install-hooks Register PreCompact/PostCompact hooks for compression resilience");
console.log(" opc install-hooks Register PreCompact/PostCompact and PreToolUse budget hooks");
console.log(" opc uninstall Remove skill files (preserves custom roles)");

@@ -204,0 +284,0 @@ console.log(" opc version Show version");

@@ -166,5 +166,5 @@ # OPC Contracts — Stable Interfaces for External Callers

},
"tierCoverage": { // present on execute nodes with tier (optional)
"covered": ["responsive-layout", "dark-mode"],
"skipped": [{ "key": "loading-states", "reason": "not applicable" }]
"tierCoverage": { // required on completed execute nodes with polished/delightful tier
"covered": ["typography", "color-scheme", "navigation", "responsive"],
"skipped": [{ "key": "code-blocks", "reason": "product has no code examples" }]
},

@@ -178,2 +178,3 @@ "skipped": true // set by /opc skip (optional)

- `execute` nodes require `≥1 evidence artifact` (type: screenshot, test-result, or cli-output)
- `execute` nodes in `polished` / `delightful` flows require `tierCoverage`; see [Tier Coverage Schema](pipeline/tier-coverage-schema.md)
- `gate` nodes are auto-created by `transition` — external callers don't write them

@@ -310,4 +311,5 @@

|----------|-------|--------------|
| `quick` | build → review → gate | build |
| `review` | review → gate | review |
| `build-verify` | build → code-review → test-design → test-execute → gate | build |
| `build-verify` | brief → build → code-review → test-design → test-execute → gate | brief, build |
| `full-stack` | discuss → build → code-review → test-design → test-execute → gate-test → acceptance → gate-acceptance → audit → gate-audit → e2e-user → gate-e2e → post-launch-sim → gate-final | discuss, build |

@@ -364,4 +366,4 @@ | `pre-release` | acceptance → gate-acceptance → audit → gate-audit → e2e-user → gate-e2e | acceptance |

- **Stable:** CLI command names, flag names, JSON output field names, file schema fields listed above
- **Unstable:** Internal module exports (`bin/lib/*.mjs`), `skill.md` wording, `pipeline/*.md` content, role `.md` content
- **Unstable:** Internal module exports (`bin/lib/*.mjs`), `SKILL.md` wording, `pipeline/*.md` content, role `.md` content
- **Additive:** New fields may be added to JSON outputs and schemas. Consumers should ignore unknown fields.
- **Breaking changes:** Signaled by bumping the minor version in `HARNESS_VERSION`. External flows use `opc_compat` to declare minimum version.

@@ -30,3 +30,3 @@ # Mental Replay — `/opc loop add a dark-mode toggle`

The orchestrator reads `skill.md` + `pipeline/loop-protocol.md`. Per
The orchestrator reads `SKILL.md` + `pipeline/loop-protocol.md`. Per
the new Step 0, before decomposition it shells out to:

@@ -33,0 +33,0 @@

@@ -277,3 +277,3 @@ # OPC Integration Guide

dw skill.md (orchestrator):
dw SKILL.md (orchestrator):
1. Reads task → selects dw-flow.json

@@ -295,3 +295,3 @@ 2. Calls: opc-harness init --flow-file ./flows/dw-flow.json --entry discover --dir .harness

dw skill.md:
dw SKILL.md:
1. Decomposes into units in plan.md

@@ -298,0 +298,0 @@ 2. Calls: opc-harness init-loop --plan plan.md --flow-file ./flows/dw-flow.json --dir .harness

{
"name": "@touchskyer/opc",
"version": "0.10.5",
"version": "0.10.6",
"description": "OPC — One Person Company. Task pipeline with independent multi-role evaluation.",

@@ -16,3 +16,3 @@ "type": "module",

"scripts",
"skill.md",
"SKILL.md",
"replay.md",

@@ -44,5 +44,5 @@ "roles",

"type": "git",
"url": "https://github.com/iamtouchskyer/opc.git"
"url": "git+https://github.com/iamtouchskyer/opc.git"
},
"homepage": "https://github.com/iamtouchskyer/opc"
}

@@ -30,3 +30,3 @@ # Criteria Lint — Mechanical DoD Quality Check

| `outcomes-exist` | `## Outcomes` section exists with >=1 `OUT-N:` prefixed bullet | "No outcomes section or no OUT-N bullets found" |
| `outcomes-count` | 3-7 `OUT-N:` bullets | "Found {N} outcomes — must be 3-7" |
| `outcomes-count` | 3-10 `OUT-N:` bullets | "Found {N} outcomes — must be 3-10" |
| `verification-exists` | `## Verification` section exists | "No verification section" |

@@ -33,0 +33,0 @@ | `verification-mapped` | Every `OUT-N` referenced in Verification section | "OUT-{N} has no verification method" |

@@ -49,3 +49,3 @@ # Discussion Protocol

4. **Final decision** — the concrete plan going forward
5. **Acceptance criteria** — 3-7 testable bullet points for downstream nodes
5. **Acceptance criteria** — 3-10 testable bullet points for downstream nodes

@@ -52,0 +52,0 @@ Write to: `$SESSION_DIR/nodes/{NODE_ID}/run_{RUN}/decision.md`

@@ -62,2 +62,8 @@ # Executor Protocol

If Design Intelligence sidecars are present (`design-brief.md`,
`design-tokens.json`, `design-mode.json`), read them for expected visual and
interaction constraints. They are not execution evidence by themselves; you
still need runtime screenshots, CLI output, or test results from the built
product.
### Step 3 — Execute Scenarios

@@ -113,3 +119,4 @@

- All scenarios PASS → verdict: PASS
- Any scenario FAIL with workaround → verdict: ITERATE
- Any scenario FAIL that is fixable by a trivial implementation change → verdict: ITERATE
and route to `hotfix`; do not edit product code in `test-execute`.
- Any scenario FAIL blocking core flow → verdict: FAIL

@@ -163,2 +170,5 @@ - Tool unavailable → status: blocked (not a verdict)

The complete machine-readable schema, valid baseline keys, and examples live in
[tier-coverage-schema.md](tier-coverage-schema.md).
**Enforced by `opc-harness validate`:**

@@ -165,0 +175,0 @@ - `tierCoverage.covered` and `tierCoverage.skipped` are required arrays

@@ -22,4 +22,15 @@ # Gate Protocol

**--base ref validation:** Pass `--base <project-root>` to validate file:line references against the filesystem. Fabricated refs count as 2 layers in the compound gate. When `--base` is provided and git history is available, the changeScopeCoverage layer checks that the eval mentions ≥30% of changed files. Note: `changeScopeCoverage` and `invalidRefCount` only activate when `--base` is provided and git is available — they are conditional layers.
**--base ref validation:** Pass `--base <project-root>` to validate file:line references against the filesystem. Fabricated refs count as 2 layers in the compound gate. When `--base` is provided and git history is available, the changeScopeCoverage layer checks that the eval mentions ≥30% of the changed files. Note: `changeScopeCoverage` and `invalidRefCount` only activate when `--base` is provided and git is available — they are conditional layers.
**changeScopeCoverage scope (`--change-commits`):** The set of "changed files" is scoped to the commits the flow actually **produced**, not a blind `git diff HEAD~1`. `finalize`/`advance` pass `--change-commits <csv>` from `flow-state.producedCommits` (recorded via `opc-harness record-commit`, see below). Behavior:
- **Empty set** (`--change-commits` present but empty, i.e. the flow committed nothing yet — e.g. it reviewed a session-local artifact): changeScopeCoverage **skips cleanly**. This removes the structural false-positive where a blind `HEAD~1` diff mis-attributed unrelated parallel commits or could not see an uncommitted artifact.
- **Non-empty set**: the layer diffs exactly those commits' files and still enforces the ≥30% coverage rule — the gate keeps biting on a genuine coverage gap.
- **Flag absent** (`--change-commits` not passed at all): legacy `git diff HEAD~1` fallback is preserved for direct manual `synthesize --base` calls.
**Recording produced commits:** After the orchestrator commits delivered code, it MUST record the commit so the gate can scope coverage to it:
```bash
opc-harness record-commit [--sha <sha>] # defaults to HEAD of the project root
```
This appends the (dedup'd, full-length) sha to `flow-state.producedCommits`. Fail-closed: an invalid sha or missing `flow-state.json` is a hard error, not a silent skip.
**Evaluator guidance (feedback loop):** When D2 triggers, the output includes `evaluatorGuidance` — a per-role object with `triggeredLayers` (which checks failed) and `hints` (actionable fix instructions). On ITERATE, the orchestrator SHOULD inject this guidance into the R2 evaluator prompt so the evaluator knows exactly what to fix.

@@ -141,3 +152,3 @@

- ❌ Overriding the synthesized verdict with your own judgment
- ❌ Determining the next node by reading skill.md tables — use `opc-harness route`
- ❌ Determining the next node by reading SKILL.md tables — use `opc-harness route`
- ❌ Writing gate handshake.json manually — `transition` does this

@@ -144,0 +155,0 @@ - ❌ Continuing after `allowed: false` without user consent

@@ -19,2 +19,8 @@ # Implementer Subagent Prompt

If Design Intelligence is active, also read the session-level sidecars it
created during preflight/prompt injection when present:
`design-brief.md`, `design-tokens.json`, and `design-mode.json`. Treat them as
resolved design constraints, not suggestions. The build brief remains the
primary specification when there is any conflict.
## Mode

@@ -25,15 +31,18 @@

### Build (first pass — no prior evaluation)
You are building from a plan. Read the wave plan below and implement it.
You are building from a structured brief. Read the build brief below — it is your primary specification. The brief was written by an architect and passed mechanical quality checks. Follow it literally; do not re-interpret design decisions or make technology choices the brief already settled.
**Build Brief (mandatory):** Read `{absolute path to $SESSION_DIR/nodes/brief/build-brief.md}`
**Brief Lint Result:** Read `{absolute path to $SESSION_DIR/nodes/brief/run_{BRIEF_RUN}/brief-lint-result.json}` (confirms quality gate passed)
**Quality Tier: {TIER}** — Read the tier baseline from `./pipeline/quality-tiers.md`. Every baseline checklist item is a requirement, not a nice-to-have. Address them during the first pass alongside functional requirements. The evaluator will score missing baseline items as warnings or criticals depending on tier.
### Fix (FAIL verdict — things are broken)
Read the build brief: {absolute path to $SESSION_DIR/nodes/brief/build-brief.md}
Read the evaluation: {absolute path to $SESSION_DIR/nodes/{NODE_ID}/run_{RUN}/eval.md}
Read the original plan: {absolute path to $SESSION_DIR/nodes/{NODE_ID}/plan.md}
Fix broken acceptance criteria and critical rubric failures (dimensions below 3). Things are broken — make them work. The evaluation tells you what failed; the original plan tells you what was intended. Use both.
Fix broken acceptance criteria and critical rubric failures (dimensions below 3). Things are broken — make them work. The brief is the source of truth for what was intended; the evaluation tells you what failed. Use both.
### Polish (ITERATE verdict — push toward excellence)
Read the build brief: {absolute path to $SESSION_DIR/nodes/brief/build-brief.md}
Read the evaluation: {absolute path to $SESSION_DIR/nodes/{NODE_ID}/run_{RUN}/eval.md}
Read the original plan: {absolute path to $SESSION_DIR/nodes/{NODE_ID}/plan.md}
All criteria pass but rubric quality isn't excellent yet. Focus on the lowest-scoring rubric dimensions and push them toward 4+. This is about refinement, not fixing breakage. The original plan provides context on intent; the evaluation tells you where quality falls short.
All criteria pass but rubric quality isn't excellent yet. Focus on the lowest-scoring rubric dimensions and push them toward 4+. This is about refinement, not fixing breakage. The brief provides context on intent; the evaluation tells you where quality falls short.

@@ -40,0 +49,0 @@ ## Node Plan

@@ -174,3 +174,3 @@ # Loop Protocol — Autonomous Multi-Unit Execution

Before writing plan.md, establish a global definition of done. Follow the "Definition of Done — Mandatory Pre-Flight" section in skill.md. The three questions (what does done look like, how to verify, how to evaluate) must be answered and written to `$SESSION_DIR/acceptance-criteria.md`.
Before writing plan.md, establish a global definition of done. Follow the "Definition of Done — Mandatory Pre-Flight" section in SKILL.md. The three questions (what does done look like, how to verify, how to evaluate) must be answered and written to `$SESSION_DIR/acceptance-criteria.md`.

@@ -208,3 +208,6 @@ Per-unit verify/eval lines in plan.md are derived from these global criteria.

Then immediately execute the first tick (don't wait for cron).
Then immediately execute the first tick (don't wait for cron). Like every tick,
the first tick begins by calling `opc-harness next-tick` — even fresh from init,
next-tick is what claims the unit (marks it in_progress) and confirms this
session owns the loop before any work starts.

@@ -216,4 +219,17 @@ ### Step 4 — Tick Execution

```
1. Read loop-state.json → get next_unit
2. Read plan.md → get unit details and acceptance criteria
1. CLAIM THE TICK FIRST — run `opc-harness next-tick`. This is the ownership +
concurrency gate, and it MUST run before ANY work. It acquires the lock,
verifies this session owns the loop, marks the tick in_progress, and returns
the unit to work on. Interpret the result:
- ready:false + owner_conflict:true → STOP. A different live Claude session
owns this loop. Do NOT read the unit, do NOT work, do NOT call
complete-tick. Exit the tick silently.
- ready:false + terminate:true → pipeline done/terminated → CronDelete, exit.
- ready:false + drain_required:true → run the backlog drain (Step 7a), then
call next-tick again.
- ready:false (other: in_progress / lock held) → a tick is mid-flight or
there is transient contention → skip this cron fire.
- ready:true → the tick is now claimed (status=in_progress); next_unit,
unit_type, context_hints, and handler are in the payload. Proceed.
2. Read plan.md → get the unit's verify:/eval: lines and acceptance criteria.
3. Skill check (pre-work): scan your session context for installed skills

@@ -242,5 +258,20 @@ designed for pre-task preparation (memory recall, context loading, etc.).

If any are found, invoke them now — before writing loop-state.
11. Write updated loop-state.json (see format below)
11. FINISH THE TICK — run `opc-harness complete-tick --unit <next_unit>
--artifacts <paths> --description "<summary>"`. This validates evidence and
advances the cursor to the next unit. Do NOT call next-tick again here — the
NEXT cron fire's step 1 advances the cursor and re-checks ownership before
any further work runs.
```
**Why next-tick leads every tick (do not "optimize" this back).** The ownership
and concurrency gates live inside `next-tick` and `complete-tick`. If work runs
*before* the first gated call, a foreign session (e.g. a durable cron reloaded in
a second process after compaction) will read `next_unit` straight from
loop-state.json, do a full tick of duplicate work, and only get BLOCKED at
`complete-tick` — after the damage. Putting `next-tick` first means the gate
fires with zero work done: a non-owner is refused immediately. The single
trailing `complete-tick` finishes and advances; the next cron fire's leading
`next-tick` picks up the new unit. One claim in, one finish out, no gap where an
un-gated tick can run.
### Step 5 — Verification Gate (per tick)

@@ -341,3 +372,3 @@

Each tick prompt MUST be self-contained. After context compaction, the orchestrator loses:
- skill.md procedural instructions
- SKILL.md procedural instructions
- CLAUDE.md project conventions

@@ -365,15 +396,25 @@ - Review independence requirements

Read $SESSION_DIR/acceptance-criteria.md for the definition of done.
Re-read the full loop-protocol.md and skill.md protocols — do NOT rely on memory from previous ticks.
Find the current unit's verify: and eval: lines in plan.md — these tell you HOW to verify this specific unit.
Key rules to re-verify each tick:
- Review units MUST dispatch ≥2 independent subagents via Agent tool (never self-review)
- Implement/fix units MUST produce a git commit
- UI units MUST include a screenshot artifact
- Use the unit's verify: line to run the correct verification command
- Use opc-harness complete-tick with actual artifact paths (never skip)
- On blocked/failed, include --description explaining why
- Pre-work: if any installed skills handle pre-task recall/preparation, invoke them before starting
- Post-work: if any installed skills handle post-task retro/capture, invoke them after completing
Execute the current next_unit. After completion, call opc-harness complete-tick, then opc-harness next-tick.
If next-tick returns terminate:true, call CronDelete to stop the loop.
Re-read the full loop-protocol.md and SKILL.md protocols — do NOT rely on memory from previous ticks.
Tick ordering (CRITICAL — the gate must fire BEFORE any work):
1. FIRST run: opc-harness next-tick. This is the ownership + concurrency gate; it also
claims the tick (marks it in_progress) and returns the unit to work on. Branch on the result:
- owner_conflict:true → STOP. A different live Claude session owns this loop.
Do NOT do any work, do NOT call complete-tick. Exit silently.
- terminate:true → call CronDelete to stop the loop, then exit.
- drain_required:true → run the backlog drain (Step 7a), then run next-tick again.
- ready:false (other) → a tick is in progress or the lock is held → skip this cron fire.
- ready:true → proceed; work on the returned next_unit.
2. Find the claimed unit's verify: and eval: lines in plan.md — these tell you HOW to verify it.
3. Execute the unit. Key rules to re-verify each tick:
- Review units MUST dispatch ≥2 independent subagents via Agent tool (never self-review)
- Implement/fix units MUST produce a git commit
- UI units MUST include a screenshot artifact
- Use the unit's verify: line to run the correct verification command
- On blocked/failed, include --description explaining why
- Pre-work: if any installed skills handle pre-task recall/preparation, invoke them before starting
- Post-work: if any installed skills handle post-task retro/capture, invoke them after completing
4. Finish: opc-harness complete-tick --unit <next_unit> --artifacts <actual paths> --description "<summary>".
This advances the cursor. Do NOT call next-tick again here — the next cron fire's step 1 advances
the cursor and re-checks ownership before any further work runs.
```

@@ -380,0 +421,0 @@

@@ -96,1 +96,7 @@ # Quality Tiers

No change to gate mechanics — the severity adjustment means a `polished` product missing dark mode gets 🟡, which triggers ITERATE, which triggers the implementer in Polish mode. The system naturally loops until the tier baseline is met.
### Execute Evidence
Completed `execute` node handshakes for `polished` and `delightful` flows must
include `tierCoverage`, enumerating which baseline keys were covered or skipped.
See [tier-coverage-schema.md](tier-coverage-schema.md) for the exact schema and
valid keys.

@@ -241,3 +241,3 @@ # Report Format

**Integration points:**
- Flow completion (`skill.md` § Flow Completion & Replay, step 3)
- Flow completion (`SKILL.md` § Flow Completion & Replay, step 3)
- Loop auto-termination (`loop-protocol.md` § Step 7, step 4)

@@ -244,0 +244,0 @@

@@ -113,5 +113,5 @@ # Role Evaluator Subagent Prompt

## Severity Calibration
- 🔴 Critical: Exploitable vulnerability, data loss, or production crash. Concrete and verifiable.
- 🟡 Warning: Real code smell, missing validation, or reliability risk. Concrete impact.
- 🔵 Suggestion: Improvement opportunity. Nice-to-have.
- 🔴 Critical (or `[CRITICAL]`): Exploitable vulnerability, data loss, or production crash. Concrete and verifiable.
- 🟡 Warning (or `[WARNING]`): Real code smell, missing validation, or reliability risk. Concrete impact.
- 🔵 Suggestion (or `[SUGGESTION]`): Improvement opportunity. Nice-to-have.
When in doubt, downgrade.

@@ -166,2 +166,6 @@

Severity markers — use EITHER emoji OR bracketed text (both are equivalent):
🔴 file:line — Issue description
[CRITICAL] file:line — Issue description
**IMPORTANT — Finding format requirements:**

@@ -168,0 +172,0 @@ - Every finding MUST have a `reasoning:` line explaining WHY this matters (not just what's wrong)

@@ -35,2 +35,11 @@ # Test Design Protocol

> **Ground truth is the BUILT CODE, never the brief.** The upstream `brief`
> describes *intended* behavior (ideal validation, dynamic re-render, reset-to-empty).
> The `build` node often ships a *simplified* version. If you design cases from the
> brief, you will assert behavior the build never implemented — phantom tests that
> fail against reality or, worse, pass vacuously. **Open the actual build artifacts
> and read them before writing any assertion.** When an artifact contradicts the
> brief, the artifact wins; note the divergence as a finding, do not test the brief's
> ideal.
### What You Produce

@@ -47,2 +56,7 @@

7. **Failure impact**: What breaks if this fails (the "so what?" test)
8. **Anchor** (P0/P1 mandatory): a `file:line` citation into a build artifact, or a
grep token, proving the behavior/value you assert actually exists in the built
code. Any concrete value in your Expected result (a length, an enum member, a sum,
a count) must be traceable to the source line that produces it. No anchor → the
case is unverifiable speculation and will be dropped.

@@ -72,2 +86,3 @@ ### Output Format

- **Expected**: {concrete expected result}
- **Anchor**: `{build-artifact-path}:{line}` — {token/snippet proving the asserted behavior is in the built code}
- **Failure impact**: {what breaks}

@@ -98,5 +113,8 @@

- Every case must have an expected result that is binary (PASS or FAIL, no "should be reasonable")
- Do not design cases for things you haven't read in the code — read the implementation first
- **Ground-truth rule (hard):** every P0/P1 case MUST carry an `Anchor` into a build
artifact (`file:line` or grep token) that you have actually opened and read. A case
asserting a value you cannot point to in the built code is a phantom test — delete
it. Reading the brief is not reading the code.
- Aim for 5-15 cases per role. Fewer is fine if scope is narrow. More than 20 suggests you're testing too broadly — split by feature
- Test cases MUST reference the actual implementation (endpoints, components, functions) — not hypothetical features
- Test cases MUST assert behavior present in the actual build artifacts (endpoints, components, functions, literal values) — never the brief's intended-but-unbuilt features. If the brief promised X and the build shipped X′, test X′ and flag the gap
- Include at least one negative test (what should fail/be rejected) per P0 feature

@@ -114,4 +132,18 @@ - For multi-platform projects: at least one test case per role must target a platform-specific failure mode (not just "it renders correctly"). Include cases that verify behavior ACROSS platforms, not just ON each platform independently

5. Write merged test plan to `$SESSION_DIR/nodes/test-design/run_{RUN}/test-plan.md`
6. Write handshake.json with all eval files as artifacts
7. The merged test-plan.md is the primary input for the downstream test-execute node
6. For mechanical execution, write `$SESSION_DIR/nodes/test-design/test-execution.json`
with the exact command the harness should run:
```json
{
"testCommand": "npm --prefix apps/web test -- --runInBand",
"cwd": "/absolute/project-or-package-dir",
"timeoutMs": 120000,
"prerequisites": ["dependencies installed in cwd"]
}
```
`cwd` should point at the package/project directory that owns the dependencies
(`package.json`, Playwright config, node_modules). If omitted, OPC will try one
conservative auto-resolution pass for a unique local JS package, then fall back
to the orchestrator cwd.
7. Write handshake.json with all eval files as artifacts
8. The merged test-plan.md is the primary input for the downstream test-execute node

@@ -146,1 +178,6 @@ ### Handshake

The executor does NOT design new test cases. If it discovers untested scenarios during execution, it notes them as "discovered gaps" but does not add them to the current run's scope.
When `test-execution.json` exists, the harness runs `testCommand` itself and
writes `test-command-result.json` plus provenance binding the result to the
source test plan. Hand-written `test-result` JSON without matching harness
provenance is weak evidence and must not pass the gate.
+343
-21

@@ -7,4 +7,58 @@ # OPC — One Person Company

## How It Works
[English](#english) | [中文](#中文)
---
## English
### What you can build
From a **single one-line brief**, OPC's `build-verify` flow ships a complete, production-quality product — design tokens injected and visual quality validated by the [design-intelligence](https://github.com/iamtouchskyer/opc-extensions) extension. Every screenshot below is a **real, clickable site** (not a mockup), built by the same 16-agent pipeline. Same system, six completely different design languages.
**▶ [Browse the live lookbook →](https://www.touchskyer.me/projects/opc/lookbook)**
<table>
<tr>
<td width="50%" valign="top">
<a href="https://www.touchskyer.me/projects/opc/lookbook/saas-dashboard/index.html"><img src="docs/assets/showcase/saas-dashboard.png" alt="Pulse — a SaaS analytics dashboard generated by OPC"></a>
<br><b>Pulse</b> · SaaS analytics dashboard<br><sub>Dark UI · data-viz</sub>
</td>
<td width="50%" valign="top">
<a href="https://www.touchskyer.me/projects/opc/lookbook/creative-portfolio/index.html"><img src="docs/assets/showcase/creative-portfolio.png" alt="Studio Kura — a creative agency portfolio generated by OPC"></a>
<br><b>Studio Kura</b> · Creative agency portfolio<br><sub>Brutalist · editorial · motion</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="https://www.touchskyer.me/projects/opc/lookbook/cumulus/index.html"><img src="docs/assets/showcase/cumulus.png" alt="Cumulus — a SaaS project-management dashboard generated by OPC"></a>
<br><b>Cumulus</b> · Project-management dashboard<br><sub>Claymorphism · pastel</sub>
</td>
<td width="50%" valign="top">
<a href="https://www.touchskyer.me/projects/opc/lookbook/devtool-docs/index.html"><img src="docs/assets/showcase/devtool-docs.png" alt="Conduit Docs — a developer documentation site generated by OPC"></a>
<br><b>Conduit Docs</b> · Developer documentation<br><sub>Docs · code · developer</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="https://www.touchskyer.me/projects/opc/lookbook/dtc-brand/index.html"><img src="docs/assets/showcase/dtc-brand.png" alt="Maison Terre — a DTC skincare e-commerce site generated by OPC"></a>
<br><b>Maison Terre</b> · DTC skincare e-commerce<br><sub>Brand · editorial</sub>
</td>
<td width="50%" valign="top">
<a href="https://www.touchskyer.me/projects/opc/lookbook/local-booking/index.html"><img src="docs/assets/showcase/local-booking.png" alt="Yuzuki — a local dining platform generated by OPC"></a>
<br><b>Yuzuki 柚月</b> · Local dining platform<br><sub>Booking · bilingual · mobile</sub>
</td>
</tr>
</table>
> Each card in the [lookbook](https://www.touchskyer.me/projects/opc/lookbook) flips on hover to reveal the **exact prompt** that built it — e.g. Pulse came from one line: `/opc build a production-quality SaaS analytics dashboard called "Pulse"...`
**OPC isn't only for UIs.** The same zero-trust pipeline also handles:
- **Whole products, unattended** — `/opc loop build features F1–F4 from PLAN.md` decomposes the work, schedules a durable cron, and runs **10+ hours** without you.
- **Code review** — `/opc review the auth changes` dispatches 2–5 independent role agents (security, backend, a11y, devil's advocate) in parallel and computes a mechanical verdict.
- **Full-stack features** — `/opc implement user auth with email/password` runs build → independent review → test-design → test-execute → gate.
- **Pre-release audits** — `/opc verify before release` runs acceptance + audit + e2e gates before you ship.
### How It Works
One principle: **the agent that does the work never evaluates it.**

@@ -18,3 +72,3 @@

1. **Task inference** — reads your request, picks a flow template (review, build-verify, full-stack, pre-release), and enters at the right node.
1. **Task inference** — reads your request, picks a flow template (quick, review, build-verify, full-stack, pre-release), and enters at the right node.

@@ -27,3 +81,3 @@ 2. **Typed nodes** — each node has a type (discussion, build, review, execute, gate) with specific protocols. Build nodes produce commits. Review nodes dispatch parallel subagents. Gate nodes compute verdicts from code, not LLM judgment.

### Quality Architecture
#### Quality Architecture

@@ -39,5 +93,5 @@ ![Zero-Trust Quality Architecture](docs/assets/design_philosophy.png)

## Quick Start
### Quick Start
### Install
#### Install

@@ -50,3 +104,3 @@ ```bash

#### Manual install (no npm)
##### Manual install (no npm)

@@ -58,3 +112,3 @@ ```bash

### Use it
#### Use it

@@ -84,3 +138,3 @@ ```bash

## Autonomous Loop
### Autonomous Loop

@@ -102,3 +156,3 @@ ```bash

### Guardrails (code-enforced, not prompt-level)
#### Guardrails (code-enforced, not prompt-level)

@@ -119,13 +173,27 @@ | Guard | Enforcement |

## Flow Templates
### Flow Templates
| Template | Nodes | When |
|----------|-------|------|
| **quick** | build → review → gate | "quick fix", "small change", one-liner, ≤3 files, **non-UI, no logic branches** |
| **review** | code-review → gate | PR review, audit, "find problems" |
| **build-verify** | build → code-review → test-design → test-execute → gate | "implement X", "fix bug Y" |
| **build-verify** | brief → build → code-review → test-design → test-execute; ITERATE → hotfix → test-execute; PASS → gate | "implement X", "fix bug Y" |
| **full-stack** | discuss → build → review → test → acceptance → audit → e2e → gates | Complex/vague requests |
| **pre-release** | acceptance → audit → e2e → gates | "verify before release" |
## Extensions
**`quick` vs `build-verify`** — `quick` drops the structured `brief` node and the
`test-design`/`test-execute` split, halving nodes and evaluator dispatches. It keeps
the core quality gate (independent `review` + `gate` verdict). Use it only for true
one-liners / single-file, non-UI, no-logic-branch changes. Anything with logic
branches, multiple files, or UI → use `build-verify`, where `test-design`'s enforced
L1–L5 coverage check earns its keep.
The **`brief`** node (entry of `build-verify`) produces a structured build brief —
resolved design tokens, file plan, component inventory, constraints — that the
`build` node follows. It measurably lifts first-pass build fidelity, but the brief
describes *intended* behavior: downstream `test-design` must test the **actual built
code**, not the brief's ideal, or it will assert behavior the build never implemented.
### Extensions
OPC has a capability-routed extension surface. Extensions live in

@@ -138,2 +206,7 @@ `~/.claude/skills/opc-extension/<name>/` — each with `ext.json` (capability

`ext.json.version` is recorded in `flow-state.json → extensionVersions` during
`init`, so protocol reports can distinguish "extension loaded" from "unknown
version". Executable hook metadata from `hook.mjs` still wins for runtime
capability behavior.
The companion repo **[opc-extensions](https://github.com/iamtouchskyer/opc-extensions)** ships 4 extensions: `design-intelligence` (theme injection + design coverage + VLM visual eval), `git-changeset-review`, `memex-recall`, and `session-logex`.

@@ -144,3 +217,3 @@

## Built-in Roles
### Built-in Roles

@@ -157,3 +230,3 @@ ```

### Custom Roles
#### Custom Roles

@@ -180,3 +253,3 @@ Add a `.md` file to `roles/`:

## Testing
### Testing

@@ -189,3 +262,3 @@ ```bash

## Requirements
### Requirements

@@ -195,5 +268,5 @@ - [Claude Code](https://claude.ai/code) (CLI, desktop app, or IDE extension)

- Core runtime has no npm dependencies, no MCP server, no build step
- Optional: `jq` for `opc install-hooks` context-compaction hooks
- `opc install-hooks` always installs the Node-based auto-flow guard; optional `jq` additionally enables context-compaction hooks
## Works better with memex (optional)
### Works better with memex (optional)

@@ -206,12 +279,261 @@ OPC works standalone — pair it with [memex](https://github.com/iamtouchskyer/memex) for cross-session memory. Memex remembers which roles were useful, which findings were false positives, and your project-specific context.

## What's New
### What's New
See [CHANGELOG.md](CHANGELOG.md) for version history.
## Community
### Community
Using OPC? Share your setup in [Discussions → Show and tell](https://github.com/iamtouchskyer/opc/discussions/categories/show-and-tell). Questions go in [Q&A](https://github.com/iamtouchskyer/opc/discussions/categories/q-a). Feature ideas in [Ideas](https://github.com/iamtouchskyer/opc/discussions/categories/ideas).
## License
### License
MIT
---
## 中文
### 你能用它造什么
从**一句话需求**出发,OPC 的 `build-verify` 流程就能交付一个完整、生产级的产品——设计令牌(design token)由 [design-intelligence](https://github.com/iamtouchskyer/opc-extensions) 扩展注入、视觉质量由它验证。下面每一张截图都是**真实可点击的站点**(不是设计稿),由同一套 16-agent 流水线构建。同一套系统,六种完全不同的设计语言。
**▶ [浏览在线作品集 →](https://www.touchskyer.me/projects/opc/lookbook)**
<table>
<tr>
<td width="50%" valign="top">
<a href="https://www.touchskyer.me/projects/opc/lookbook/saas-dashboard/index.html"><img src="docs/assets/showcase/saas-dashboard.png" alt="Pulse —— OPC 生成的 SaaS 分析仪表盘"></a>
<br><b>Pulse</b> · SaaS 分析仪表盘<br><sub>暗色 UI · 数据可视化</sub>
</td>
<td width="50%" valign="top">
<a href="https://www.touchskyer.me/projects/opc/lookbook/creative-portfolio/index.html"><img src="docs/assets/showcase/creative-portfolio.png" alt="Studio Kura —— OPC 生成的创意工作室作品集"></a>
<br><b>Studio Kura</b> · 创意工作室作品集<br><sub>粗野主义 · 编辑风 · 动效</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="https://www.touchskyer.me/projects/opc/lookbook/cumulus/index.html"><img src="docs/assets/showcase/cumulus.png" alt="Cumulus —— OPC 生成的项目管理仪表盘"></a>
<br><b>Cumulus</b> · 项目管理仪表盘<br><sub>黏土拟态 · 柔和色彩</sub>
</td>
<td width="50%" valign="top">
<a href="https://www.touchskyer.me/projects/opc/lookbook/devtool-docs/index.html"><img src="docs/assets/showcase/devtool-docs.png" alt="Conduit Docs —— OPC 生成的开发者文档站"></a>
<br><b>Conduit Docs</b> · 开发者文档站<br><sub>文档 · 代码 · 开发者</sub>
</td>
</tr>
<tr>
<td width="50%" valign="top">
<a href="https://www.touchskyer.me/projects/opc/lookbook/dtc-brand/index.html"><img src="docs/assets/showcase/dtc-brand.png" alt="Maison Terre —— OPC 生成的护肤品 DTC 电商站"></a>
<br><b>Maison Terre</b> · 护肤品 DTC 电商<br><sub>品牌 · 编辑风</sub>
</td>
<td width="50%" valign="top">
<a href="https://www.touchskyer.me/projects/opc/lookbook/local-booking/index.html"><img src="docs/assets/showcase/local-booking.png" alt="Yuzuki 柚月 —— OPC 生成的本地餐饮平台"></a>
<br><b>Yuzuki 柚月</b> · 本地餐饮平台<br><sub>预订 · 双语 · 移动端</sub>
</td>
</tr>
</table>
> [作品集](https://www.touchskyer.me/projects/opc/lookbook)里每张卡片悬停时会翻面,露出**生成它的原始 prompt**——比如 Pulse 就来自一句话:`/opc build a production-quality SaaS analytics dashboard called "Pulse"...`
**OPC 不只是用来做 UI。** 同一套零信任流水线还能:
- **无人值守造完整产品** —— `/opc loop build features F1–F4 from PLAN.md` 拆解任务、调度持久 cron,**连续运行 10+ 小时**无需干预。
- **代码审查** —— `/opc review the auth changes` 并行派发 2–5 个独立角色 agent(security、backend、a11y、devil's advocate),用代码计算裁决。
- **全栈功能** —— `/opc implement user auth with email/password` 跑 build → 独立 review → test-design → test-execute → gate。
- **发布前审计** —— `/opc verify before release` 在上线前跑 acceptance + audit + e2e 门禁。
### 工作原理
一条原则:**做事的 agent 永远不评判自己的工作。**
```
Task → Flow Selection → Node Execution → Gate Verdict → Route Next
↑ ↓
└──────── ITERATE/FAIL ────────┘
```
1. **任务推断** —— 读取你的请求,选一个 flow 模板(quick、review、build-verify、full-stack、pre-release),从正确的节点进入。
2. **类型化节点** —— 每个节点有类型(discussion、build、review、execute、gate)和特定协议。build 节点产出 commit;review 节点派发并行 subagent;gate 节点用代码而非 LLM 判断计算裁决。
3. **机械门禁** —— 裁决由 `opc-harness synthesize` 计算:任意 red = FAIL,任意 yellow = ITERATE,全 green = PASS。没有任何 LLM 能决定某个发现"是否足够重要"。
4. **循环上限** —— 每条边最多 3 次循环,每个节点最多 5 次重入,按 flow 总步数 20-30。震荡检测捕获 A↔B 循环。
#### 质量架构
![零信任质量架构](docs/assets/design_philosophy.png)
系统建立在一条零信任公理上:**每个关键输出都必须有独立的验证路径。** 四层:
- **L0 —— 零信任**:决策公理——不是代码,不是 prompt。每个关键输出都需要独立的验证路径。
- **L1 —— 塑造单个 agent**:在 token 生成过程中干预——人格设定、反模式表、强制输出结构、范围锚定、质量门禁。
- **L2 —— 设计 agent 流**:多 agent 协同——关注点分离、流拓扑(并行 review / 串行 build)、上下文隔离(基于文件的交接、全新 agent、不复用 session)。
- **L3 —— 确定性强制**:唯一不需要 LLM 配合的一层——机械操作(严重度计数、裁决规则、震荡 diff)和加固验证(找文件证据检查、模糊措辞扫描、引用校验)。
### 快速开始
#### 安装
```bash
npm install -g @touchskyer/opc
```
Skill 文件会自动复制到 `~/.claude/skills/opc/`。
##### 手动安装(不用 npm)
```bash
git clone https://github.com/iamtouchskyer/opc.git
cp -r opc ~/.claude/skills/opc
```
#### 使用
```bash
# 审查 —— 并行派发 2-5 个角色 agent
/opc review the auth changes
# 构建 —— 实现 + 独立 review + gate
/opc implement user authentication with email/password
# 自主循环 —— 拆解、调度 cron、无人值守运行
/opc loop build features F1-F4 from PLAN.md
# 交互模式 —— 先问清楚需求
/opc -i redesign the onboarding flow
# 显式指定角色
/opc security devil-advocate
# 流程控制
/opc skip # 跳过当前节点
/opc pass # 强制通过 gate
/opc stop # 终止,保留状态
/opc goto build # 跳转到指定节点
```
### 自主循环
```bash
/opc loop build the math tutoring app features F1-F4
```
会发生什么:
1. **Runbook 查找** —— `opc-harness runbook match "<task>"` 按 `--dir` flag → `OPC_RUNBOOKS_DIR` → `~/.opc/runbooks/` 顺序查找匹配的配方。命中则用它的 `units` / `flow` / `tier` 作为 plan;否则进入第 2 步。可用 `OPC_DISABLE_RUNBOOKS=1` 按次禁用。见 [docs/runbooks.md](docs/runbooks.md) 和 [examples/runbooks/add-feature.md](examples/runbooks/add-feature.md)。
2. **拆解**(仅 runbook miss)—— 把任务拆成原子单元(spec、implement、review、fix、e2e)
3. **完成定义** —— 任何工作开始前,为每个单元建立 verify/eval 标准
4. **调度** —— 持久 cron(进程重启后存活)每 10 分钟触发
5. **执行** —— 每个 tick 跑一个单元,走对应的 OPC flow
6. **守卫** —— `opc-harness` 强制:必须 git commit、≥2 个独立审查者、不可篡改 plan、不可伪造 state、artifact 新鲜度、tick 上限
7. **终止** —— plan 完成、tick 上限、或墙钟截止时自动停止
对于范围明确的任务,系统可**连续运行 10+ 小时**无需干预。
#### 守卫(代码强制,非 prompt 层)
| 守卫 | 强制方式 |
|------|---------|
| Write nonce | init 时生成随机 SHA256;state 只能由 harness 写入 |
| 原子写入 | write → rename(POSIX 原子);崩溃时不留半截 JSON |
| Plan 完整性 | init 时算 SHA256 哈希;每个 tick 校验 |
| 审查独立性 | ≥2 个 eval 文件,内容雷同则拒绝,行重叠则告警 |
| 必须 git commit | implement/fix 单元的 HEAD 必须变化 |
| 必须截图 | UI 单元必须产出 .png/.jpg artifact |
| Tick 上限 | maxTotalTicks(units×3)+ 24h 墙钟截止 |
| 震荡检测 | 4-6 个 tick 内出现 A↔B 模式 = 告警/硬停 |
| 并发 tick 互斥 | in_progress 状态阻止重叠的 cron 触发 |
| JSON 崩溃恢复 | 所有 JSON.parse 加 try/catch;结构化报错而非崩溃 |
| 外部校验器 | init 时检测并利用 pre-commit hook、测试套件 |
### 流模板
| 模板 | 节点 | 何时用 |
|------|------|--------|
| **quick** | build → review → gate | "快速修复"、"小改动"、一行改动、≤3 文件,**非 UI、无逻辑分支** |
| **review** | code-review → gate | PR 审查、审计、"找问题" |
| **build-verify** | brief → build → code-review → test-design → test-execute;ITERATE → hotfix → test-execute;PASS → gate | "实现 X"、"修复 bug Y" |
| **full-stack** | discuss → build → review → test → acceptance → audit → e2e → gates | 复杂/模糊的请求 |
| **pre-release** | acceptance → audit → e2e → gates | "发布前验证" |
**`quick` vs `build-verify`** —— `quick` 砍掉结构化的 `brief` 节点和 `test-design`/`test-execute` 拆分,节点数和评估器派发减半。它保留了核心质量门禁(独立 `review` + `gate` 裁决)。只在真正的一行改动 / 单文件、非 UI、无逻辑分支时用。任何带逻辑分支、多文件或 UI 的 → 用 `build-verify`,那里 `test-design` 强制的 L1–L5 覆盖检查才物有所值。
**`brief`** 节点(`build-verify` 的入口)产出一份结构化构建简报——解析后的设计令牌、文件计划、组件清单、约束——供 `build` 节点遵循。它能可量化地提升首次构建保真度,但简报描述的是*预期*行为:下游 `test-design` 必须测**实际构建出来的代码**,而非简报里的理想,否则它会断言构建从未实现的行为。
### 扩展
OPC 有一个能力路由(capability-routed)的扩展面。扩展位于 `~/.claude/skills/opc-extension/<name>/`——每个带一个 `ext.json`(能力声明)+ 一个 `hook.mjs`,导出 `promptAppend` / `verdictAppend` / `executeRun` / `artifactEmit` 中的任意 hook。不用 fork、不用重新构建。Hook 通过每扩展超时 + 熔断器沙箱化,所以一个坏掉的第三方扩展无法拖垮 harness。
`ext.json.version` 在 `init` 时记录到 `flow-state.json → extensionVersions`,所以协议报告能区分"扩展已加载"和"未知版本"。运行时能力行为仍以 `hook.mjs` 的可执行 hook 元数据为准。
配套仓库 **[opc-extensions](https://github.com/iamtouchskyer/opc-extensions)** 提供 4 个扩展:`design-intelligence`(主题注入 + 设计覆盖 + VLM 视觉评估)、`git-changeset-review`、`memex-recall`、`session-logex`。
完整编写指南:**[docs/extension-authoring.md](docs/extension-authoring.md)**——零 OPC 上下文的快速上手 + 参考,外加 `examples/extensions/_starter/` 里的起步模板。
### 内置角色
```
Product: pm, designer
User Lens: new-user, active-user, churned-user
Engineering: frontend, backend, devops, architect, engineer
Quality: security, tester, compliance, a11y
Specialist: planner, user-simulator, devil-advocate
```
**Devil's Advocate**(第 10 个人)在共识近乎一致、或决策不可逆时自动加入。配有一个自动验证脚本,检查它自己的发现。
#### 自定义角色
在 `roles/` 里加一个 `.md` 文件:
```markdown
---
tags: [review, build]
---
# Role Name
## Identity
One sentence: who you are and what you care about.
## Expertise
- **Area** — what you know about it
## When to Include
- Condition that triggers this role
```
即刻可用,无需配置。
### 测试
```bash
bash test/run-all.sh
```
100+ 测试文件,覆盖 init-loop、complete-tick、next-tick、审查独立性、JSON 崩溃恢复、复合防御、scope registry、criteria lint、pipeline E2E lint、D2 校准、发布打包,以及编排器层级的 E2E 流程测试。
### 环境要求
- [Claude Code](https://claude.ai/code)(CLI、桌面应用或 IDE 扩展)
- Node.js >= 18
- 核心运行时无 npm 依赖、无 MCP server、无构建步骤
- `opc install-hooks` 始终安装 Node 实现的 auto-flow guard;可选的 `jq` 额外启用上下文压缩 hook
### 搭配 memex 更好(可选)
OPC 可独立使用——搭配 [memex](https://github.com/iamtouchskyer/memex) 获得跨会话记忆。Memex 记住哪些角色有用、哪些发现是误报,以及你项目特定的上下文。
```bash
npm install -g @touchskyer/memex
```
### 更新日志
版本历史见 [CHANGELOG.md](CHANGELOG.md)。
### 社区
在用 OPC?到 [Discussions → Show and tell](https://github.com/iamtouchskyer/opc/discussions/categories/show-and-tell) 分享你的玩法。问题发 [Q&A](https://github.com/iamtouchskyer/opc/discussions/categories/q-a),功能想法发 [Ideas](https://github.com/iamtouchskyer/opc/discussions/categories/ideas)。
### 许可
MIT

@@ -38,9 +38,34 @@ #!/bin/bash

write_clean_eval_file() {
local target="$1" role="$2" verdict="${3:-PASS}"
{
echo "# $role Review"
echo "Role: $role"
echo "## Scope"
for i in $(seq 1 18); do echo "$role scope $i records a concrete review pass across the changed fixture."; done
echo "## Evidence"
for i in $(seq 1 18); do echo "$role evidence $i: command routing, artifacts, state history, and gate inputs were inspected."; done
echo "## Decision"
for i in $(seq 1 18); do echo "$role decision $i is $verdict after checking the relevant harness contract and provenance path."; done
echo "VERDICT: $verdict FINDINGS[0]"
} > "$target"
}
write_review_hs() {
local DIR="$1" NODE="$2" VERDICT="${3:-PASS}"
mkdir -p "$DIR/nodes/$NODE/run_1"
printf '# Review A\nPerspective: Security\nVERDICT: %s FINDINGS[0]\n' "$VERDICT" > "$DIR/nodes/$NODE/run_1/eval-a.md"
printf '# Review B\nPerspective: Performance\nVERDICT: %s FINDINGS[0]\n' "$VERDICT" > "$DIR/nodes/$NODE/run_1/eval-b.md"
printf '{"nodeId":"%s","nodeType":"review","runId":"run_1","status":"completed","summary":"Done","timestamp":"%s","artifacts":[{"type":"eval","path":"run_1/eval-a.md"},{"type":"eval","path":"run_1/eval-b.md"}],"verdict":"%s"}\n' \
"$NODE" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$VERDICT" > "$DIR/nodes/$NODE/handshake.json"
write_clean_eval_file "$DIR/nodes/$NODE/run_1/eval-skeptic-owner.md" "skeptic-owner" "$VERDICT"
write_clean_eval_file "$DIR/nodes/$NODE/run_1/eval-comprehensive-peer.md" "comprehensive-peer" "$VERDICT"
local artifacts='[{"type":"eval","path":"run_1/eval-skeptic-owner.md"},{"type":"eval","path":"run_1/eval-comprehensive-peer.md"}]'
local extra=''
if [ "$NODE" = "test-design" ]; then
write_complete_test_plan "$DIR/nodes/$NODE/run_1/test-plan.md"
cat > "$DIR/nodes/$NODE/test-execution.json" <<'JSON'
{"testCommand":"node -e \"process.exit(0)\"","prerequisites":["fixture command"]}
JSON
artifacts='[{"type":"eval","path":"run_1/eval-skeptic-owner.md"},{"type":"eval","path":"run_1/eval-comprehensive-peer.md"},{"type":"test-plan","path":"run_1/test-plan.md"},{"type":"test-plan","path":"test-execution.json"}]'
extra=',"testCommand":"node -e \"process.exit(0)\"","prerequisites":["fixture command"]'
fi
printf '{"nodeId":"%s","nodeType":"review","runId":"run_1","status":"completed","summary":"Done","timestamp":"%s","artifacts":%s,"verdict":"%s"%s}\n' \
"$NODE" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$artifacts" "$VERDICT" "$extra" > "$DIR/nodes/$NODE/handshake.json"
}

@@ -224,3 +249,2 @@

sleep 1; opc transition --from test-design --to test-execute --verdict PASS --flow build-verify --dir .harness 2>/dev/null > /dev/null
write_exec_hs ".harness" "test-execute"
sleep 1; opc transition --from test-execute --to gate --verdict PASS --flow build-verify --dir .harness 2>/dev/null > /dev/null

@@ -227,0 +251,0 @@ R=$(opc finalize --dir .harness)

@@ -101,6 +101,6 @@ #!/bin/bash

d = json.load(open('.h-edge/flow-state.json'))
d['edgeCounts']['gate→build'] = d['maxLoopsPerEdge']
d['edgeCounts']['gate→brief'] = d['maxLoopsPerEdge']
json.dump(d, open('.h-edge/flow-state.json', 'w'), indent=2)
"
OUT=$($HARNESS transition --from gate --to build --verdict FAIL --flow build-verify --dir .h-edge 2>/dev/null)
OUT=$($HARNESS transition --from gate --to brief --verdict FAIL --flow build-verify --dir .h-edge 2>/dev/null)
assert_field_eq "edge limit blocked" "$OUT" "allowed" "false"

@@ -120,6 +120,6 @@ assert_contains "maxLoopsPerEdge msg" "$OUT" "maxLoopsPerEdge"

for i in range(d['maxNodeReentry']):
d['history'].append({'nodeId': 'build', 'runId': f'run_{i}', 'timestamp': '2024-01-01T00:00:00Z'})
d['history'].append({'nodeId': 'brief', 'runId': f'run_{i}', 'timestamp': '2024-01-01T00:00:00Z'})
json.dump(d, open('.h-reentry/flow-state.json', 'w'), indent=2)
"
OUT=$($HARNESS transition --from gate --to build --verdict FAIL --flow build-verify --dir .h-reentry 2>/dev/null)
OUT=$($HARNESS transition --from gate --to brief --verdict FAIL --flow build-verify --dir .h-reentry 2>/dev/null)
assert_field_eq "reentry blocked" "$OUT" "allowed" "false"

@@ -146,3 +146,3 @@ assert_contains "maxNodeReentry msg" "$OUT" "maxNodeReentry"

rm -rf .h-idemp2 && $HARNESS init --flow build-verify --entry gate --dir .h-idemp2 >/dev/null 2>/dev/null
$HARNESS transition --from gate --to build --verdict FAIL --flow build-verify --dir .h-idemp2 >/dev/null 2>/dev/null
$HARNESS transition --from gate --to brief --verdict FAIL --flow build-verify --dir .h-idemp2 >/dev/null 2>/dev/null
python3 -c "

@@ -154,3 +154,3 @@ import json

"
OUT=$($HARNESS transition --from gate --to build --verdict FAIL --flow build-verify --dir .h-idemp2 2>/dev/null)
OUT=$($HARNESS transition --from gate --to brief --verdict FAIL --flow build-verify --dir .h-idemp2 2>/dev/null)
assert_field_eq "idempotency blocked" "$OUT" "allowed" "false"

@@ -172,3 +172,3 @@ assert_contains "idempotency guard" "$OUT" "idempotency"

echo "test evidence" > .h-backlog/nodes/test-execute/evidence.txt
OUT=$($HARNESS transition --from gate --to build --verdict ITERATE --flow build-verify --dir .h-backlog 2>/dev/null)
OUT=$($HARNESS transition --from gate --to brief --verdict ITERATE --flow build-verify --dir .h-backlog 2>/dev/null)
assert_field_eq "backlog required" "$OUT" "allowed" "false"

@@ -191,3 +191,3 @@ assert_contains "backlog missing msg" "$OUT" "backlog"

BL
OUT=$($HARNESS transition --from gate --to build --verdict ITERATE --flow build-verify --dir .h-backlog2 2>/dev/null)
OUT=$($HARNESS transition --from gate --to brief --verdict ITERATE --flow build-verify --dir .h-backlog2 2>/dev/null)
assert_field_eq "backlog satisfied" "$OUT" "allowed" "true"

@@ -207,3 +207,3 @@

BL
OUT=$($HARNESS transition --from gate --to build --verdict ITERATE --flow build-verify --dir .h-backlog3 2>/dev/null)
OUT=$($HARNESS transition --from gate --to brief --verdict ITERATE --flow build-verify --dir .h-backlog3 2>/dev/null)
assert_field_eq "insufficient entries" "$OUT" "allowed" "false"

@@ -210,0 +210,0 @@ assert_contains "entries count" "$OUT" "only has"

@@ -210,3 +210,3 @@ #!/bin/bash

# Finding 2 refs auth.ts:1 (import line) but talks about "database pooling" → weak ref
assert_contains "28: weak ref detected" "$OUT" "possible mismatch"
assert_contains "28: weak ref detected" "$OUT" "possible hallucination"

@@ -213,0 +213,0 @@ # ───────────────────────────────────────────────────────────────

@@ -169,6 +169,12 @@ #!/bin/bash

rm -rf .harness
$HARNESS init --flow build-verify --entry build --dir .harness 2>/dev/null
$HARNESS init --flow build-verify --entry brief --dir .harness 2>/dev/null
STATE=$(cat .harness/flow-state.json)
assert_field_eq "4.1: starts at build" "$STATE" "currentNode" '"build"'
assert_field_eq "4.1: starts at brief" "$STATE" "currentNode" '"brief"'
write_handshake .harness brief "Brief complete" "PASS" brief
ROUTE=$($HARNESS route --node brief --verdict PASS --flow build-verify)
NEXT=$(jq_field "$ROUTE" "next")
assert_contains "4.1b: brief → build" "$NEXT" "build"
$HARNESS transition --from brief --to build --verdict PASS --flow build-verify --dir .harness 2>/dev/null
write_handshake .harness build "Implementation complete" "PASS" build

@@ -210,3 +216,3 @@ ROUTE=$($HARNESS route --node build --verdict PASS --flow build-verify)

# ═══════════════════════════════════════════════════════════════
echo "=== E2E TEST 5: build-verify — gate FAIL loopback to build ==="
echo "=== E2E TEST 5: build-verify — gate FAIL loopback to brief ==="
# ═══════════════════════════════════════════════════════════════

@@ -231,3 +237,3 @@

NEXT=$(jq_field "$ROUTE" "next")
assert_contains "5.2: FAIL → back to build" "$NEXT" "build"
assert_contains "5.2: FAIL → back to brief" "$NEXT" "brief"

@@ -234,0 +240,0 @@ echo ""

@@ -183,5 +183,5 @@ #!/bin/bash

for round in 1 2 3; do
write_warning_eval .harness review "role${round}"
write_good_eval .harness review "role${round}"
write_good_eval .harness review "backup${round}"
write_handshake .harness review "Round $round" "ITERATE"
write_handshake .harness review "Round $round" "PASS"
$HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>/dev/null

@@ -194,5 +194,5 @@ write_handshake .harness gate "Gate iterates round $round" "ITERATE" gate

done
write_warning_eval .harness review "role4"
write_good_eval .harness review "role4"
write_good_eval .harness review "backup4"
write_handshake .harness review "Round 4" "ITERATE"
write_handshake .harness review "Round 4" "PASS"
TRANS_4=$($HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>/dev/null || echo '{"allowed":false}')

@@ -199,0 +199,0 @@ write_handshake .harness gate "Gate round 4" "ITERATE" gate

@@ -50,2 +50,9 @@ #!/bin/bash

fi
if [ "$node_type" = "brief" ]; then
local brief_run="$dir/nodes/$node/run_1"
mkdir -p "$brief_run"
write_golden_brief "$dir/nodes/$node/build-brief.md"
echo '{"pass":true}' > "$brief_run/brief-lint-result.json"
artifacts='[{"type":"brief","path":"build-brief.md"},{"type":"report","path":"run_1/brief-lint-result.json"}]'
fi
cat > "$path" << HSEOF

@@ -126,3 +133,5 @@ {

write_handshake .harness discuss "Discussion round complete" "PASS" discussion
$HARNESS transition --from discuss --to build --verdict PASS --flow full-stack --dir .harness 2>/dev/null
$HARNESS transition --from discuss --to brief --verdict PASS --flow full-stack --dir .harness 2>/dev/null
write_handshake .harness brief "Brief complete" "PASS" brief
$HARNESS transition --from brief --to build --verdict PASS --flow full-stack --dir .harness 2>/dev/null
write_handshake .harness build "Implementation done" "PASS" build

@@ -129,0 +138,0 @@ $HARNESS transition --from build --to code-review --verdict PASS --flow full-stack --dir .harness 2>/dev/null

#!/bin/bash
# Shim suite that runs Node.js built-in test-runner .test.mjs files under bin/lib/.
# Shim suite that runs Node.js built-in test-runner .test.mjs files under bin/.
# These files use `node --test` and live next to their modules so they can be run

@@ -12,3 +12,4 @@ # standalone during development; the shim exists so the shell-level suite

FAIL=0
for f in bin/lib/*.test.mjs; do
for f in bin/lib/*.test.mjs bin/hooks/*.test.mjs; do
[ -e "$f" ] || continue
echo "--- node --test $f ---"

@@ -15,0 +16,0 @@ if ! node --test "$f"; then

@@ -97,3 +97,3 @@ #!/bin/bash

OUT=$($HARNESS route --node gate --verdict FAIL --flow build-verify)
assert_field_eq "gate FAIL → build" "$OUT" "next" "\"build\""
assert_field_eq "gate FAIL → brief" "$OUT" "next" "\"brief\""

@@ -140,3 +140,3 @@ echo ""

assert_field_eq "created" "$OUT" "created" "true"
assert_field_eq "entry is build" "$OUT" "entry" "\"build\""
assert_field_eq "entry is brief" "$OUT" "entry" "\"brief\""
assert_file_exists "flow-state.json created" ".h-init/flow-state.json"

@@ -143,0 +143,0 @@

@@ -91,18 +91,20 @@ #!/bin/bash

rm -rf .h-trans && $HARNESS init --flow build-verify --dir .h-trans >/dev/null 2>/dev/null
# Write handshake for build node
mkdir -p .h-trans/nodes/build
cat > .h-trans/nodes/build/handshake.json << 'HS'
# Write handshake for brief node (first node in build-verify) with required artifacts
mkdir -p .h-trans/nodes/brief/run_1
write_golden_brief .h-trans/nodes/brief/build-brief.md
echo '{"pass":true}' > .h-trans/nodes/brief/run_1/brief-lint-result.json
cat > .h-trans/nodes/brief/handshake.json << 'HS'
{
"nodeId": "build", "nodeType": "build", "runId": "run_1",
"status": "completed", "summary": "built", "timestamp": "2024-01-01T00:00:00Z",
"artifacts": []
"nodeId": "brief", "nodeType": "brief", "runId": "run_1",
"status": "completed", "summary": "brief done", "timestamp": "2024-01-01T00:00:00Z",
"artifacts": [{"type":"brief","path":"build-brief.md"},{"type":"report","path":"run_1/brief-lint-result.json"}]
}
HS
sleep 1
OUT=$($HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir .h-trans 2>/dev/null)
OUT=$($HARNESS transition --from brief --to build --verdict PASS --flow build-verify --dir .h-trans 2>/dev/null)
assert_field_eq "transition ok" "$OUT" "allowed" "true"
assert_field_eq "next is code-review" "$OUT" "next" "\"code-review\""
assert_field_eq "next is build" "$OUT" "next" "\"build\""
# Verify state updated
CUR=$(python3 -c "import json; print(json.load(open('.h-trans/flow-state.json'))['currentNode'])")
if [ "$CUR" = "code-review" ]; then
if [ "$CUR" = "build" ]; then
echo " ✅ state.currentNode updated"

@@ -117,9 +119,9 @@ PASS=$((PASS + 1))

echo "--- 4.2: Transition from wrong node ---"
OUT=$($HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir .h-trans 2>/dev/null)
OUT=$($HARNESS transition --from brief --to build --verdict PASS --flow build-verify --dir .h-trans 2>/dev/null)
assert_field_eq "wrong node" "$OUT" "allowed" "false"
assert_contains "not at build" "$OUT" "not 'build'"
assert_contains "not at brief" "$OUT" "not 'brief'"
echo ""
echo "--- 4.3: Transition invalid edge ---"
OUT=$($HARNESS transition --from code-review --to gate --verdict PASS --flow build-verify --dir .h-trans 2>/dev/null)
OUT=$($HARNESS transition --from build --to gate --verdict PASS --flow build-verify --dir .h-trans 2>/dev/null)
assert_field_eq "invalid edge" "$OUT" "allowed" "false"

@@ -136,3 +138,3 @@ assert_contains "edge not in flow" "$OUT" "not in flow"

rm -rf .h-trans2 && $HARNESS init --flow build-verify --dir .h-trans2 >/dev/null 2>/dev/null
OUT=$($HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir .h-trans2 2>/dev/null)
OUT=$($HARNESS transition --from brief --to build --verdict PASS --flow build-verify --dir .h-trans2 2>/dev/null)
assert_field_eq "hs missing" "$OUT" "allowed" "false"

@@ -144,6 +146,6 @@ assert_contains "handshake missing" "$OUT" "handshake.json missing"

rm -rf .h-trans3 && $HARNESS init --flow build-verify --dir .h-trans3 >/dev/null 2>/dev/null
mkdir -p .h-trans3/nodes/build
cat > .h-trans3/nodes/build/handshake.json << 'HS'
mkdir -p .h-trans3/nodes/brief
cat > .h-trans3/nodes/brief/handshake.json << 'HS'
{
"nodeId": "build", "nodeType": "build", "runId": "run_1",
"nodeId": "brief", "nodeType": "brief", "runId": "run_1",
"status": "failed", "summary": "x", "timestamp": "2024-01-01T00:00:00Z",

@@ -153,3 +155,3 @@ "artifacts": []

HS
OUT=$($HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir .h-trans3 2>/dev/null)
OUT=$($HARNESS transition --from brief --to build --verdict PASS --flow build-verify --dir .h-trans3 2>/dev/null)
assert_field_eq "status not completed" "$OUT" "allowed" "false"

@@ -167,11 +169,13 @@ assert_contains "expected completed" "$OUT" "expected 'completed'"

"
mkdir -p .h-trans4/nodes/build
cat > .h-trans4/nodes/build/handshake.json << 'HS'
mkdir -p .h-trans4/nodes/brief/run_1
write_golden_brief .h-trans4/nodes/brief/build-brief.md
echo '{"pass":true}' > .h-trans4/nodes/brief/run_1/brief-lint-result.json
cat > .h-trans4/nodes/brief/handshake.json << 'HS'
{
"nodeId": "build", "nodeType": "build", "runId": "run_1",
"nodeId": "brief", "nodeType": "brief", "runId": "run_1",
"status": "completed", "summary": "x", "timestamp": "2024-01-01T00:00:00Z",
"artifacts": []
"artifacts": [{"type":"brief","path":"build-brief.md"},{"type":"report","path":"run_1/brief-lint-result.json"}]
}
HS
OUT=$($HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir .h-trans4 2>/dev/null)
OUT=$($HARNESS transition --from brief --to build --verdict PASS --flow build-verify --dir .h-trans4 2>/dev/null)
assert_field_eq "tamper detected" "$OUT" "allowed" "false"

@@ -200,3 +204,3 @@ assert_contains "direct edit" "$OUT" "direct edit"

rm -rf .h-gate && $HARNESS init --flow build-verify --entry gate --dir .h-gate >/dev/null 2>/dev/null
OUT=$($HARNESS transition --from gate --to build --verdict FAIL --flow build-verify --dir .h-gate 2>/dev/null)
OUT=$($HARNESS transition --from gate --to brief --verdict FAIL --flow build-verify --dir .h-gate 2>/dev/null)
assert_field_eq "gate transition ok" "$OUT" "allowed" "true"

@@ -207,3 +211,3 @@ assert_file_exists "gate handshake auto-written" ".h-gate/nodes/gate/handshake.json"

echo "--- 4.10: Run directory created ---"
assert_file_exists "run_1 dir exists" ".h-gate/nodes/build/run_1"
assert_file_exists "run_1 dir exists" ".h-gate/nodes/brief/run_1"

@@ -289,3 +293,3 @@ # ═══════════════════════════════════════════════════════════════

assert_field_eq "strict fails" "$OUT" "finalized" "false"
assert_contains "chain validation" "$OUT" "chain validation"
assert_contains "missing upstream handshake" "$OUT" "handshake for review is missing"

@@ -292,0 +296,0 @@ echo ""

@@ -92,6 +92,6 @@ #!/bin/bash

OUT=$($HARNESS skip --dir .h-skip 2>/dev/null)
assert_field_eq "skip from build" "$OUT" "skipped" "\"build\""
assert_field_eq "skip to code-review" "$OUT" "next" "\"code-review\""
assert_file_exists "skip handshake" ".h-skip/nodes/build/handshake.json"
SKIPPED=$(python3 -c "import json; print(json.load(open('.h-skip/nodes/build/handshake.json')).get('skipped',False))")
assert_field_eq "skip from brief" "$OUT" "skipped" "\"brief\""
assert_field_eq "skip to build" "$OUT" "next" "\"build\""
assert_file_exists "skip handshake" ".h-skip/nodes/brief/handshake.json"
SKIPPED=$(python3 -c "import json; print(json.load(open('.h-skip/nodes/brief/handshake.json')).get('skipped',False))")
if [ "$SKIPPED" = "True" ]; then

@@ -173,5 +173,5 @@ echo " ✅ handshake.skipped=true"

for i in 1 2 3; do
$HARNESS goto build --dir .h-reentry >/dev/null
$HARNESS goto brief --dir .h-reentry >/dev/null
done
OUT=$($HARNESS goto build --dir .h-reentry)
OUT=$($HARNESS goto brief --dir .h-reentry)
assert_contains "edge limit" "$OUT" "maxLoopsPerEdge"

@@ -219,8 +219,10 @@

rm -rf .h-trans && $HARNESS init --flow build-verify --dir .h-trans >/dev/null 2>/dev/null
mkdir -p .h-trans/nodes/build
cat > .h-trans/nodes/build/handshake.json << 'HS'
{"nodeId":"build","nodeType":"build","runId":"run_1","status":"completed","summary":"built","timestamp":"2024-01-01T00:00:00Z","artifacts":[]}
mkdir -p .h-trans/nodes/brief/run_1
write_golden_brief .h-trans/nodes/brief/build-brief.md
echo '{"pass":true}' > .h-trans/nodes/brief/run_1/brief-lint-result.json
cat > .h-trans/nodes/brief/handshake.json << 'HS'
{"nodeId":"brief","nodeType":"brief","runId":"run_1","status":"completed","summary":"brief done","timestamp":"2024-01-01T00:00:00Z","artifacts":[{"type":"brief","path":"build-brief.md"},{"type":"report","path":"run_1/brief-lint-result.json"}]}
HS
sleep 1
$HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir .h-trans 2>/dev/null
$HARNESS transition --from brief --to build --verdict PASS --flow build-verify --dir .h-trans 2>/dev/null
OUT=$($HARNESS viz --flow build-verify --dir .h-trans)

@@ -227,0 +229,0 @@ assert_contains "marker symbols" "$OUT" "✅"

@@ -78,5 +78,5 @@ #!/bin/bash

rm -rf .h-trans2 && $HARNESS init --flow build-verify --dir .h-trans2 >/dev/null 2>/dev/null
mkdir -p .h-trans2/nodes/build
echo "not json" > .h-trans2/nodes/build/handshake.json
OUT=$($HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir .h-trans2 2>/dev/null)
mkdir -p .h-trans2/nodes/brief
echo "not json" > .h-trans2/nodes/brief/handshake.json
OUT=$($HARNESS transition --from brief --to build --verdict PASS --flow build-verify --dir .h-trans2 2>/dev/null)
assert_field_eq "corrupt handshake" "$OUT" "allowed" "false"

@@ -83,0 +83,0 @@ assert_contains "parse handshake" "$OUT" "parse"

@@ -155,3 +155,3 @@ #!/bin/bash

# Try gate ITERATE transition — should detect corrupt upstream during backlog check
OUT=$($HARNESS transition --from gate --to build --verdict ITERATE --flow build-verify --dir . 2>/dev/null)
OUT=$($HARNESS transition --from gate --to brief --verdict ITERATE --flow build-verify --dir . 2>/dev/null)
# ITERATE triggers backlog check → corrupt upstream → error

@@ -158,0 +158,0 @@ if echo "$OUT" | grep -q "corrupt"; then

@@ -178,3 +178,3 @@ #!/usr/bin/env bash

# ITERATE from gate triggers backlog check on upstream test-execute
OUT=$($HARNESS transition --from gate --to build --verdict ITERATE --flow build-verify --dir . 2>/dev/null)
OUT=$($HARNESS transition --from gate --to brief --verdict ITERATE --flow build-verify --dir . 2>/dev/null)
assert_contains "$OUT" "corrupt" "corrupt upstream handshake detected"

@@ -186,7 +186,7 @@ assert_field_eq "$OUT" "['allowed']" "False" "transition blocked by corrupt upstream"

# ─────────────────────────────────────────────────────────────────
# REAL-5: Missing upstream handshake skips backlog check
# REAL-5: Missing upstream handshake fails closed
# flow-transition.mjs:170-172
# ─────────────────────────────────────────────────────────────────
echo ""
echo "── REAL-5: missing upstream handshake → backlog check skipped"
echo "── REAL-5: missing upstream handshake → gate fails closed"
D=$(mktemp -d)

@@ -217,6 +217,6 @@ cd "$D"

"
# PASS from gate — no upstream handshake → backlog check should be silently skipped → transition allowed
OUT=$($HARNESS transition --from gate --to build --verdict ITERATE --flow build-verify --dir . 2>/dev/null)
# Without upstream handshake, no findings.warning to trigger backlog enforcement
assert_field_eq "$OUT" "['allowed']" "True" "missing upstream handshake → backlog skipped → allowed"
# PASS from gate — no upstream handshake means OPC cannot prove prior node was clean.
OUT=$($HARNESS transition --from gate --to brief --verdict ITERATE --flow build-verify --dir . 2>/dev/null)
assert_field_eq "$OUT" "['allowed']" "False" "missing upstream handshake blocks gate transition"
assert_contains "$OUT" "handshake for test-execute is missing" "missing upstream handshake reported"
rm -rf "$D"

@@ -223,0 +223,0 @@ cd /tmp

@@ -206,3 +206,3 @@ #!/usr/bin/env bash

OUT=$($HARNESS viz --flow build-verify --dir . --json 2>/dev/null)
assert_field_eq "$OUT" "['nodes'][0]['id']" "build" "5.2a: JSON output has first node"
assert_field_eq "$OUT" "['nodes'][0]['id']" "brief" "5.2a: JSON output has first node"
assert_contains "$OUT" "loopbacks" "5.2b: JSON output has loopbacks array"

@@ -209,0 +209,0 @@ rm -rf "$D"

@@ -52,9 +52,9 @@ #!/usr/bin/env bash

# No init! Direct transition from gate node
OUT=$($HARNESS transition --from gate --to build --verdict FAIL --flow build-verify --dir . 2>/dev/null)
OUT=$($HARNESS transition --from gate --to brief --verdict FAIL --flow build-verify --dir . 2>/dev/null)
assert_field_eq "$OUT" "['allowed']" "True" "1.1a: transition without init succeeds (fresh state created)"
assert_field_eq "$OUT" "['next']" "build" "1.1b: next node is build"
assert_field_eq "$OUT" "['next']" "brief" "1.1b: next node is brief"
# Verify state was created with correct structure
assert_contains "$(cat flow-state.json)" '"version": "1.0"' "1.1c: fresh state has version"
assert_contains "$(cat flow-state.json)" '"flowTemplate": "build-verify"' "1.1d: fresh state has correct flow"
assert_contains "$(cat flow-state.json)" '"entryNode": "build"' "1.1e: fresh state entryNode = first template node"
assert_contains "$(cat flow-state.json)" '"entryNode": "brief"' "1.1e: fresh state entryNode = first template node"
assert_contains "$(cat flow-state.json)" '"maxTotalSteps": 25' "1.1f: fresh state has limits from template"

@@ -142,6 +142,6 @@

echo "NOT VALID JSON {{{" > nodes/test-execute/handshake.json
# Try to transition gate → build (ITERATE)
# Try to transition gate → brief (ITERATE)
# Wait for idempotency window
sleep 2
OUT=$($HARNESS transition --from gate --to build --verdict ITERATE --flow build-verify --dir . 2>/dev/null)
OUT=$($HARNESS transition --from gate --to brief --verdict ITERATE --flow build-verify --dir . 2>/dev/null)
assert_field_eq "$OUT" "['allowed']" "False" "3.1a: corrupt upstream handshake blocks transition"

@@ -148,0 +148,0 @@ assert_contains "$OUT" "corrupt" "3.1b: error mentions corrupt"

@@ -244,2 +244,17 @@ #!/bin/bash

echo "--- 2.2b: test-design mandatory role error explains skeptic-owner scope ---"
DIR=$(mktemp -d)
cd "$DIR"
$HARNESS init --flow build-verify --entry test-design --dir .harness 2>/dev/null
write_good_eval .harness test-design tester
write_good_eval .harness test-design engineer
write_complete_test_plan .harness/nodes/test-design/test-plan.md
write_handshake .harness test-design "Test design done" "PASS"
TRANS=$($HARNESS transition --from test-design --to test-execute --verdict PASS --flow build-verify --dir .harness 2>/dev/null)
if echo "$TRANS" | grep -q "reviews test plan completeness"; then
echo " ✅ test-design mandatory role error has context"; PASS=$((PASS+1))
else
echo " ❌ missing test-design mandatory role context (got: $TRANS)"; FAIL=$((FAIL+1))
fi
# Test 7: transition from review with ALL unknown roles — mandatory check skipped (no known role overlap)

@@ -246,0 +261,0 @@ echo "--- 2.3: transition allowed with all-unknown roles (no enforcement) ---"

@@ -37,1 +37,61 @@ #!/bin/bash

}
# ── Write a golden brief that passes brief-lint to a given path ──
write_golden_brief() {
local target="$1"
cat > "$target" << 'BRIEF'
## File Plan
- index.html — main entry, ~200 lines
- styles.css — all styles, ~150 lines
## Technology Decisions
- Chart.js v4.4.0 via https://cdn.jsdelivr.net/npm/chart.js@4.4.0
- Tailwind CSS v3.4.1 via CDN
## Design Tokens (resolved)
- Primary: #0EA5E9
- Background: #FFFFFF
- Text: #1E293B
## Component Inventory
- Dashboard: 4 cards showing KPI (¥126,560 revenue, 8,846 visits)
- Chart: line chart with 12 monthly data points
## Constraints
- Contrast: 4.5:1 body, 3:1 large text
- Responsive: 992px, 768px, 375px breakpoints
- Animation: 200ms ease-out transitions
BRIEF
}
write_complete_test_plan() {
local target="$1"
cat > "$target" << 'PLAN'
# Test Plan
## Unit smoke
Run npm test for unit coverage.
Cover module smoke behavior.
Assert basic render success.
## Contract edge case
Validate schema boundaries.
Cover invalid input.
Assert error code stability.
## Integration e2e flow
Run playwright test through the workflow.
Cover multi-step happy path.
Assert persisted state.
## UI visual accessibility
Capture screenshot at desktop and mobile viewport.
Check responsive layout.
Run a11y smoke checks.
## Tier baseline polish
Check typography hierarchy.
Check navigation affordance.
Check dark mode baseline.
PLAN
}

@@ -42,10 +42,16 @@ #!/bin/bash

set +e
OUT=$(HOME="$HOME_NO_JQ" PATH="$NO_JQ_PATH" "$NODE_BIN" "$REPO_ROOT/bin/opc.mjs" install-hooks 2>&1)
STATUS=$?
set -e
assert_contains "$OUT" "PreToolUse" "PreToolUse guard installs without jq"
assert_contains "$OUT" "jq not found" "missing jq only skips compaction hooks"
if [ "$STATUS" -ne 0 ]; then ok "install-hooks fails when jq is absent"; else fail "install-hooks should fail without jq"; fi
assert_contains "$OUT" "requires 'jq'" "missing jq error is explicit"
if [ ! -f "$HOME_NO_JQ/.claude/settings.json" ]; then ok "settings not written after failed prereq"; else fail "settings should not be written when prereq fails"; fi
NO_JQ_SETTINGS="$HOME_NO_JQ/.claude/settings.json"
NO_JQ_HOOKS=$(python3 - "$NO_JQ_SETTINGS" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
print("\n".join(d.get("hooks", {}).keys()))
PY
)
assert_contains "$NO_JQ_HOOKS" "PreToolUse" "no-jq settings contain PreToolUse"
assert_not_contains "$NO_JQ_HOOKS" "PreCompact" "no-jq settings omit PreCompact"
assert_not_contains "$NO_JQ_HOOKS" "PostCompact" "no-jq settings omit PostCompact"

@@ -56,3 +62,3 @@ HOME_OK="$TMP/home-ok"

OUT_OK=$(HOME="$HOME_OK" "$NODE_BIN" "$REPO_ROOT/bin/opc.mjs" install-hooks 2>&1)
assert_contains "$OUT_OK" "Verified: hook scripts present and jq available" "successful install verifies hook prereqs"
assert_contains "$OUT_OK" "PreCompact" "jq-enabled install registers compaction hooks"

@@ -59,0 +65,0 @@ SETTINGS="$HOME_OK/.claude/settings.json"

@@ -155,3 +155,3 @@ #!/bin/bash

rm -rf .h-lock4 && $HARNESS init --flow build-verify --entry gate --dir .h-lock4 >/dev/null 2>/dev/null
$HARNESS transition --from gate --to build --verdict FAIL --flow build-verify --dir .h-lock4 >/dev/null 2>/dev/null
$HARNESS transition --from gate --to brief --verdict FAIL --flow build-verify --dir .h-lock4 >/dev/null 2>/dev/null
assert_file_not_exists "lock removed after transition" ".h-lock4/flow-state.json.lock"

@@ -172,3 +172,3 @@

OUT=$($HARNESS skip --dir .h-stale 2>/dev/null)
assert_field_eq "stale lock stolen, skip succeeds" "$OUT" "skipped" "\"build\""
assert_field_eq "stale lock stolen, skip succeeds" "$OUT" "skipped" "\"brief\""
assert_file_not_exists "stale lock cleaned up" ".h-stale/flow-state.json.lock"

@@ -180,12 +180,12 @@

# Do two skips in sequence (not parallel — we can't easily do parallel in bash without &)
# First skip: build → code-review
# First skip: brief → build
OUT1=$($HARNESS skip --dir .h-conc 2>/dev/null)
assert_field_eq "first skip ok" "$OUT1" "skipped" "\"build\""
# Second skip: code-review → test-execute
assert_field_eq "first skip ok" "$OUT1" "skipped" "\"brief\""
# Second skip: build → code-review
OUT2=$($HARNESS skip --dir .h-conc 2>/dev/null)
assert_field_eq "second skip ok" "$OUT2" "skipped" "\"code-review\""
assert_field_eq "second skip ok" "$OUT2" "skipped" "\"build\""
# Verify state is consistent
CUR=$(python3 -c "import json; print(json.load(open('.h-conc/flow-state.json'))['currentNode'])")
STEPS=$(python3 -c "import json; print(json.load(open('.h-conc/flow-state.json'))['totalSteps'])")
if [ "$CUR" = "test-design" ] && [ "$STEPS" = "2" ]; then
if [ "$CUR" = "code-review" ] && [ "$STEPS" = "2" ]; then
echo " ✅ state consistent after sequential ops"

@@ -192,0 +192,0 @@ PASS=$((PASS + 1))

@@ -26,3 +26,5 @@ #!/bin/bash

TMP=$(mktemp -d -t opc-preflight-XXXXXX)
INIT_EMPTY_REPO="$REPO_ROOT/.tmp-opc-preflight-init-empty-$$"
cleanup() {
rm -rf "$INIT_EMPTY_REPO"
if [ "$FAIL" -eq 0 ]; then

@@ -98,2 +100,13 @@ rm -rf "$TMP"

# ── 1b. Init auto-preflight skips empty AC instead of writing confidence 0.1 ──
echo "§1b Init auto-preflight skips empty task"
INIT_EMPTY="$INIT_EMPTY_REPO"
mkdir -p "$INIT_EMPTY"
INIT_OUT=$(OPC_EXTENSIONS_DIR="$EXT_DIR" OPC_BREAKER_STATE=disabled $HARNESS_BIN init --flow-file "$FLOW_FILE" --dir "$INIT_EMPTY" 2>"$TMP/init-empty.err")
if echo "$INIT_OUT" | grep -q '"status":"skipped"' && [ ! -f "$INIT_EMPTY/design-mode.json" ]; then
ok "init auto-preflight skipped empty acceptance criteria"
else
fail "init empty AC should skip preflight without design-mode: $INIT_OUT $(cat "$TMP/init-empty.err")"
fi
# Create a run dir for the build node

@@ -187,2 +200,13 @@ BUILD_RUN="$HARNESS/nodes/build/run_1"

if [ -f "$HARNESS/di-state.json" ]; then
ok "di-state.json exists"
if grep -q '"preflight"' "$HARNESS/di-state.json"; then
ok "di-state.json records preflight state"
else
fail "di-state.json missing preflight state: $(cat "$HARNESS/di-state.json")"
fi
else
fail "di-state.json not written"
fi
# ── 7. No preflight on nodes without capability ─────────────────

@@ -189,0 +213,0 @@ echo "§4 No-op on non-matching node"

@@ -12,6 +12,14 @@ #!/bin/bash

$HARNESS init --flow build-verify --entry build --dir .harness 2>/dev/null
$HARNESS init --flow build-verify --entry brief --dir .harness 2>/dev/null
# ── Helper: advance build→code-review→test-design→test-execute→gate ──
# ── Helper: advance brief→build→code-review→test-design→test-execute→gate ──
advance_to_gate() {
mkdir -p .harness/nodes/brief/run_1
cat > .harness/nodes/brief/handshake.json <<'EOF'
{"nodeId":"brief","nodeType":"brief","runId":"run_1","status":"completed","verdict":"PASS","summary":"brief done","timestamp":"2026-01-01T00:00:30.000Z","artifacts":[{"type":"brief","path":"build-brief.md"},{"type":"report","path":"run_1/brief-lint-result.json"}]}
EOF
write_golden_brief .harness/nodes/brief/build-brief.md
echo '{"pass":true}' > .harness/nodes/brief/run_1/brief-lint-result.json
$HARNESS transition --from brief --to build --verdict PASS --flow build-verify --dir .harness 2>/dev/null >/dev/null
mkdir -p .harness/nodes/build

@@ -38,2 +46,3 @@ cat > .harness/nodes/build/handshake.json <<'EOF'

echo "# Eval B - test design secondary" > .harness/nodes/test-design/eval-b.md
write_complete_test_plan .harness/nodes/test-design/test-plan.md
$HARNESS transition --from test-design --to test-execute --verdict PASS --flow build-verify --dir .harness 2>/dev/null >/dev/null

@@ -49,3 +58,3 @@

loopback_gate_to_build() {
loopback_gate_to_brief() {
mkdir -p .harness/nodes/gate

@@ -56,3 +65,3 @@ cat > .harness/nodes/gate/handshake.json <<'EOF'

echo "- fix" > .harness/backlog.md
$HARNESS transition --from gate --to build --verdict FAIL --flow build-verify --dir .harness 2>/dev/null >/dev/null
$HARNESS transition --from gate --to brief --verdict FAIL --flow build-verify --dir .harness 2>/dev/null >/dev/null
}

@@ -62,23 +71,24 @@

advance_to_gate
loopback_gate_to_build
loopback_gate_to_brief
# Loop 2
advance_to_gate
loopback_gate_to_build
loopback_gate_to_brief
# Loop 3
advance_to_gate
loopback_gate_to_build
loopback_gate_to_brief
# ── Test 1: after 3 loopbacks, edges are blocked at limit ──
echo "1. After 3 loops, build→code-review edge (count=3) is blocked on 4th attempt"
mkdir -p .harness/nodes/build
cat > .harness/nodes/build/handshake.json <<'EOF'
{"nodeId":"build","nodeType":"build","runId":"run_1","status":"completed","verdict":"PASS","summary":"ok","timestamp":"2026-01-01T00:01:00.000Z","artifacts":[{"type":"code","path":"x"}]}
echo "1. After 3 loops, brief→build edge (count=3) is blocked on 4th attempt"
mkdir -p .harness/nodes/brief/run_1
cat > .harness/nodes/brief/handshake.json <<'EOF'
{"nodeId":"brief","nodeType":"brief","runId":"run_1","status":"completed","verdict":"PASS","summary":"brief done","timestamp":"2026-01-01T00:00:30.000Z","artifacts":[{"type":"brief","path":"build-brief.md"},{"type":"report","path":"run_1/brief-lint-result.json"}]}
EOF
touch .harness/nodes/build/x
TRANS=$($HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir .harness 2>/dev/null || true)
write_golden_brief .harness/nodes/brief/build-brief.md
echo '{"pass":true}' > .harness/nodes/brief/run_1/brief-lint-result.json
TRANS=$($HARNESS transition --from brief --to build --verdict PASS --flow build-verify --dir .harness 2>/dev/null || true)
ALLOWED=$(echo "$TRANS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('allowed', True))" 2>/dev/null)
if [ "$ALLOWED" = "False" ]; then
echo " ✅ 4th traversal of build→code-review blocked (maxLoopsPerEdge=3)"
echo " ✅ 4th traversal of brief→build blocked (maxLoopsPerEdge=3)"
PASS=$((PASS + 1))

@@ -85,0 +95,0 @@ else

@@ -91,5 +91,6 @@ #!/bin/bash

cat > .harness/nodes/code-review/handshake.json <<'EOF'
{"nodeId":"code-review","nodeType":"review","runId":"run_1","status":"completed","verdict":"PASS","summary":"Review passed","timestamp":"2026-01-01T00:00:00.000Z","artifacts":[{"type":"eval","path":"eval-frontend.md"}]}
{"nodeId":"code-review","nodeType":"review","runId":"run_1","status":"completed","verdict":"PASS","summary":"Review passed","timestamp":"2026-01-01T00:00:00.000Z","artifacts":[{"type":"eval","path":"eval-frontend.md"},{"type":"eval","path":"eval-backend.md"}]}
EOF
touch .harness/nodes/code-review/eval-frontend.md
touch .harness/nodes/code-review/eval-backend.md
$HARNESS transition --from code-review --to test-design --verdict PASS --flow build-verify --dir .harness 2>/dev/null >/dev/null

@@ -100,5 +101,7 @@

cat > .harness/nodes/test-design/handshake.json <<'EOF'
{"nodeId":"test-design","nodeType":"review","runId":"run_1","status":"completed","verdict":"PASS","summary":"Tests designed","timestamp":"2026-01-01T00:00:00.000Z","artifacts":[{"type":"eval","path":"test-plan.md"}]}
{"nodeId":"test-design","nodeType":"review","runId":"run_1","status":"completed","verdict":"PASS","summary":"Tests designed","timestamp":"2026-01-01T00:00:00.000Z","artifacts":[{"type":"eval","path":"eval-a.md"},{"type":"eval","path":"eval-b.md"}]}
EOF
touch .harness/nodes/test-design/test-plan.md
touch .harness/nodes/test-design/eval-a.md
touch .harness/nodes/test-design/eval-b.md
write_complete_test_plan .harness/nodes/test-design/test-plan.md
$HARNESS transition --from test-design --to test-execute --verdict PASS --flow build-verify --dir .harness 2>/dev/null >/dev/null

@@ -105,0 +108,0 @@

@@ -36,2 +36,3 @@ #!/bin/bash

echo "# Eval B" > .harness/nodes/test-design/eval-b.md
write_complete_test_plan .harness/nodes/test-design/test-plan.md
$HARNESS transition --from test-design --to test-execute --verdict PASS --flow build-verify --dir .harness 2>/dev/null >/dev/null

@@ -46,8 +47,8 @@

# ── Test 1: gate FAIL → routes back to build ──
echo "1. gate FAIL → next=build (loopback)"
# ── Test 1: gate FAIL → routes back to brief ──
echo "1. gate FAIL → next=brief (loopback)"
ROUTE=$($HARNESS route --node gate --verdict FAIL --flow build-verify 2>/dev/null)
NEXT=$(echo "$ROUTE" | python3 -c "import sys,json; print(json.load(sys.stdin)['next'])")
if [ "$NEXT" = "build" ]; then
echo " ✅ FAIL → build"
if [ "$NEXT" = "brief" ]; then
echo " ✅ FAIL → brief"
PASS=$((PASS + 1))

@@ -59,8 +60,8 @@ else

# ── Test 2: gate ITERATE → routes back to build ──
echo "2. gate ITERATE → next=build"
# ── Test 2: gate ITERATE → routes back to brief ──
echo "2. gate ITERATE → next=brief"
ROUTE2=$($HARNESS route --node gate --verdict ITERATE --flow build-verify 2>/dev/null)
NEXT2=$(echo "$ROUTE2" | python3 -c "import sys,json; print(json.load(sys.stdin)['next'])")
if [ "$NEXT2" = "build" ]; then
echo " ✅ ITERATE → build"
if [ "$NEXT2" = "brief" ]; then
echo " ✅ ITERATE → brief"
PASS=$((PASS + 1))

@@ -73,3 +74,3 @@ else

# ── Test 3: transition with FAIL loopback succeeds ──
echo "3. transition gate → build (FAIL loopback) allowed"
echo "3. transition gate → brief (FAIL loopback) allowed"
mkdir -p .harness/nodes/gate

@@ -81,3 +82,3 @@ cat > .harness/nodes/gate/handshake.json <<'EOF'

echo "- Fix null reference" > .harness/backlog.md
TRANS=$($HARNESS transition --from gate --to build --verdict FAIL --flow build-verify --dir .harness 2>/dev/null)
TRANS=$($HARNESS transition --from gate --to brief --verdict FAIL --flow build-verify --dir .harness 2>/dev/null)
ALLOWED=$(echo "$TRANS" | python3 -c "import sys,json; print(json.load(sys.stdin)['allowed'])")

@@ -92,7 +93,7 @@ if [ "$ALLOWED" = "True" ]; then

# ── Test 4: currentNode is build after loopback ──
echo "4. currentNode = build after loopback"
# ── Test 4: currentNode is brief after loopback ──
echo "4. currentNode = brief after loopback"
NODE=$(python3 -c "import json; print(json.load(open('.harness/flow-state.json'))['currentNode'])")
if [ "$NODE" = "build" ]; then
echo " ✅ currentNode=build"
if [ "$NODE" = "brief" ]; then
echo " ✅ currentNode=brief"
PASS=$((PASS + 1))

@@ -99,0 +100,0 @@ else

@@ -91,2 +91,108 @@ #!/usr/bin/env bash

echo ""
echo "=== TEST GROUP 5: seal — brief artifacts satisfy validator ==="
D5="$TMPD/s5"
mkdir -p "$D5/nodes/brief/run_1"
echo '{"version":"1.0","flowTemplate":"build-verify","currentNode":"brief","entryNode":"brief","totalSteps":0,"_written_by":"opc-harness","_write_nonce":"abc","_last_modified":"2025-01-01","history":[],"edgeCounts":{}}' > "$D5/flow-state.json"
cat > "$D5/nodes/brief/build-brief.md" <<'BRIEF'
## File Plan
- index.html — main entry, ~200 lines
- styles.css — all styles, ~150 lines
## Technology Decisions
- Chart.js v4.4.0 via https://cdn.jsdelivr.net/npm/chart.js@4.4.0
- Tailwind CSS v3.4.1 via CDN
## Design Tokens (resolved)
- Primary: #0EA5E9
- Background: #FFFFFF
- Text: #1E293B
## Component Inventory
- Dashboard: 4 cards showing KPI (¥126,560 revenue, 8,846 visits)
- Button: copy share link control with "Copied!" confirmation
## Constraints
- Contrast: 4.5:1 body, 3:1 large text
- Responsive: 992px, 768px, 375px breakpoints
- Animation: 200ms ease-out transitions
BRIEF
echo '{"pass":true}' > "$D5/nodes/brief/run_1/brief-lint-result.json"
SEAL_BRIEF=$(cd "$D5" && $HARNESS seal --node brief --dir "$D5" 2>/dev/null)
check "brief seal has no validation errors" 'echo "$SEAL_BRIEF" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d[\"validationErrors\"]==[], d[\"validationErrors\"]"'
check "brief handshake includes brief artifact" 'python3 - "$D5/nodes/brief/handshake.json" <<PY
import json,sys
d=json.load(open(sys.argv[1]))
assert any(a["type"]=="brief" for a in d["artifacts"])
PY'
check "brief handshake includes report artifact" 'python3 - "$D5/nodes/brief/handshake.json" <<PY
import json,sys
d=json.load(open(sys.argv[1]))
assert any(a["type"]=="report" for a in d["artifacts"])
PY'
echo ""
echo "=== TEST GROUP 6: seal — recursive source and canonical eval parser ==="
D6="$TMPD/s6"
mkdir -p "$D6/nodes/build/run_1/src" "$D6/nodes/review/run_1"
echo '{"version":"1.0","flowTemplate":"build-verify","currentNode":"build","entryNode":"build","totalSteps":0,"_written_by":"opc-harness","_write_nonce":"abc","_last_modified":"2025-01-01","history":[],"edgeCounts":{}}' > "$D6/flow-state.json"
echo 'export const x = 1;' > "$D6/nodes/build/run_1/src/app.ts"
echo '.copy { color: black; }' > "$D6/nodes/build/run_1/src/app.css"
SEAL_BUILD=$(cd "$D6" && $HARNESS seal --node build --dir "$D6" 2>/dev/null)
check "build seal finds recursive source artifacts" 'python3 - "$D6/nodes/build/handshake.json" <<PY
import json,sys
d=json.load(open(sys.argv[1]))
sources=[a for a in d["artifacts"] if a["type"]=="source"]
assert len(sources)>=2, sources
PY'
cat > "$D6/nodes/review/run_1/eval-frontend.md" <<'EOF'
# Frontend Review
No 🔴 critical findings remain. The prior 🟡 issue was fixed.
VERDICT: PASS FINDINGS[0]
EOF
cat > "$D6/nodes/review/run_1/eval-skeptic-owner.md" <<'EOF'
# Skeptic Owner Review
[CRITICAL] src/app.ts:1 — demo critical finding
Reasoning: This line is intentionally cited.
→ Fix: remove the demo issue.
VERDICT: FAIL FINDINGS[1]
EOF
SEAL_REVIEW=$(cd "$D6" && $HARNESS seal --node review --dir "$D6" 2>/dev/null)
check "referential severity prose does not inflate warnings" 'python3 - "$D6/nodes/review/handshake.json" <<PY
import json,sys
d=json.load(open(sys.argv[1]))
assert d["findings"]["warning"] == 0, d["findings"]
PY'
check "text severity critical makes seal FAIL" 'python3 - "$D6/nodes/review/handshake.json" <<PY
import json,sys
d=json.load(open(sys.argv[1]))
assert d["verdict"] == "FAIL", d
assert d["findings"]["critical"] == 1, d["findings"]
PY'
echo ""
echo "=== TEST GROUP 7: seal — test-execution spec is not result evidence ==="
D7="$TMPD/s7"
mkdir -p "$D7/nodes/test-execute/run_1"
echo '{"version":"1.0","flowTemplate":"build-verify","currentNode":"test-execute","entryNode":"test-execute","totalSteps":0,"_written_by":"opc-harness","_write_nonce":"abc","_last_modified":"2025-01-01","history":[],"edgeCounts":{}}' > "$D7/flow-state.json"
cat > "$D7/nodes/test-execute/test-execution.json" <<'JSON'
{ "testCommand": "echo ok" }
JSON
echo "ok" > "$D7/nodes/test-execute/run_1/cli-output.txt"
SEAL_EXEC=$(cd "$D7" && $HARNESS seal --node test-execute --dir "$D7" 2>/dev/null)
check "test-execution.json is classified as plan/spec" 'python3 - "$D7/nodes/test-execute/handshake.json" <<PY
import json,sys
d=json.load(open(sys.argv[1]))
assert any(a["type"]=="test-plan" and a["path"]=="test-execution.json" for a in d["artifacts"]), d["artifacts"]
assert not any(a["type"]=="test-result" and a["path"]=="test-execution.json" for a in d["artifacts"]), d["artifacts"]
PY'
check "test-execution spec does not trigger result provenance errors" 'echo "$SEAL_EXEC" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d[\"validationErrors\"]==[], d[\"validationErrors\"]"'
echo ""
echo "==========================================="

@@ -93,0 +199,0 @@ echo " Results: $PASS passed, $FAIL failed"

@@ -167,6 +167,14 @@ #!/bin/bash

🔵 Test plan covers all critical paths
→ No changes needed
Reasoning: Comprehensive coverage of unit, integration, and E2E tests.
🔵 Test plan covers all critical paths — keep the current layer spread
→ Preserve the five-layer structure and command-backed assertions
Reasoning: The plan covers unit, contract, integration, UI, and polish layers.
🔵 Test commands are actionable — keep commands close to each layer
→ Keep the shell commands in the plan rather than only describing intent
Reasoning: Executable commands let test-execute run the plan mechanically.
🔵 Risk ordering is clear — retain priority labels on critical paths
→ Keep priority labels on cases that protect release-blocking behavior
Reasoning: Priorities make it obvious which failures should block the gate.
The test plan includes good coverage.

@@ -219,5 +227,66 @@ Additional padding line 1.

VERDICT: PASS FINDINGS[1]
VERDICT: PASS FINDINGS[3]
EVALEOF
cat > .harness/nodes/test-design/run_1/eval-skeptic-owner.md <<'EVALEOF'
# Skeptic Owner Test Plan Review
## Coverage
🔵 P0 cases are anchored — keep the behavior-backed case format
→ Retain anchors for release-blocking test cases
Reasoning: Anchors make the test plan harder to fake.
🔵 Negative paths are covered — keep invalid input and edge cases
→ Preserve rejection-path coverage in the contract layer
Reasoning: Negative-path tests catch regressions that happy-path suites miss.
🔵 Execution path is explicit — keep commands attached to the plan
→ Keep command lines in the relevant sections
Reasoning: The next node can execute the plan without interpreting prose.
## Summary
The test plan is reviewable as a test artifact.
The review intentionally discusses coverage rather than source locations.
The test-design node should not require code file references in this eval.
Additional coverage note 1.
Additional coverage note 2.
Additional coverage note 3.
Additional coverage note 4.
Additional coverage note 5.
Additional coverage note 6.
Additional coverage note 7.
Additional coverage note 8.
Additional coverage note 9.
Additional coverage note 10.
Additional coverage note 11.
Additional coverage note 12.
Additional coverage note 13.
Additional coverage note 14.
Additional coverage note 15.
Additional coverage note 16.
Additional coverage note 17.
Additional coverage note 18.
Additional coverage note 19.
Additional coverage note 20.
Additional coverage note 21.
Additional coverage note 22.
Additional coverage note 23.
Additional coverage note 24.
Additional coverage note 25.
Additional coverage note 26.
Additional coverage note 27.
Additional coverage note 28.
Additional coverage note 29.
Additional coverage note 30.
Additional coverage note 31.
Additional coverage note 32.
Additional coverage note 33.
Additional coverage note 34.
Additional coverage note 35.
VERDICT: PASS FINDINGS[3]
EVALEOF
# Complete test plan covering all 5 layers

@@ -230,2 +299,3 @@ cat > .harness/nodes/test-design/run_1/test-plan.md <<'EOF'

- Jest coverage must be > 80%
- Verify smoke tests fail on a broken entrypoint

@@ -240,2 +310,3 @@ ## L2: Contract / Edge Cases

- Integration test with real database
- Verify external service failure returns a controlled error

@@ -255,2 +326,4 @@ ## L4: UI / Visual / A11y

assert_not_contains "no missing layers" "$OUT" "test plan missing layers"
assert_not_contains "test-design eval may omit code refs" "$OUT" "0 file:line references"
assert_field_eq "test-design complete plan without code refs → PASS" "$OUT" "verdict" '"PASS"'

@@ -285,3 +358,4 @@ echo ""

assert_not_contains "no layer check for code-review" "$OUT" "test plan missing"
assert_contains "code-review still requires code refs" "$OUT" "0 file:line references"
print_results

@@ -219,2 +219,4 @@ #!/bin/bash

assert_contains "explains unknown" "$OUT" "unknown baseline key"
assert_contains "prints valid key list" "$OUT" "Valid keys for polished"
assert_contains "points to tierCoverage schema" "$OUT" "pipeline/tier-coverage-schema.md"

@@ -221,0 +223,0 @@ echo ""

@@ -26,9 +26,26 @@ #!/usr/bin/env bash

local dir="$1" node="$2"
local verdict="${3:-PASS}"
local finding="${4:-FINDINGS[0]}"
local line="VERDICT: $verdict $finding"
mkdir -p "$dir/nodes/$node/run_1"
printf '# E1\nVERDICT: PASS FINDINGS[0]\n' > "$dir/nodes/$node/run_1/eval-a.md"
printf '# E2\nVERDICT: PASS FINDINGS[0]\n' > "$dir/nodes/$node/run_1/eval-b.md"
printf '{"nodeId":"%s","nodeType":"review","runId":"run_1","status":"completed","summary":"Done","timestamp":"%s","artifacts":[{"type":"eval","path":"run_1/eval-a.md"},{"type":"eval","path":"run_1/eval-b.md"}],"verdict":"PASS"}\n' \
"$node" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$dir/nodes/$node/handshake.json"
printf '# E1\n%s\n' "$line" > "$dir/nodes/$node/run_1/eval-a.md"
printf '# E2\n%s\n' "$line" > "$dir/nodes/$node/run_1/eval-b.md"
printf '{"nodeId":"%s","nodeType":"review","runId":"run_1","status":"completed","summary":"Done","timestamp":"%s","artifacts":[{"type":"eval","path":"run_1/eval-a.md"},{"type":"eval","path":"run_1/eval-b.md"}],"verdict":"%s"}\n' \
"$node" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$verdict" > "$dir/nodes/$node/handshake.json"
}
set_gate_state() {
python3 - <<'PY'
import json
path = ".harness/flow-state.json"
data = json.load(open(path))
data["currentNode"] = "gate"
data["history"] = [
{"nodeId": "review", "runId": "run_1", "timestamp": "2026-01-01T00:00:00.000Z"},
{"nodeId": "gate", "runId": "run_1", "timestamp": "2026-01-01T00:01:00.000Z"},
]
open(path, "w").write(json.dumps(data, indent=2) + "\n")
PY
}
echo "=== TEST GROUP 1: --to null delegates to finalize ==="

@@ -62,2 +79,65 @@

echo ""
echo "=== TEST GROUP 4: sealed ITERATE cannot be overridden by CLI PASS ==="
D3="$TMPD/t3"
mkdir -p "$D3" && cd "$D3"
$HARNESS init --flow review --entry review --dir .harness > /dev/null 2>&1
write_review_hs ".harness" "review" "ITERATE" "FINDINGS[1]"
RESULT3=$(cd "$D3" && $HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>&1)
check "review PASS edge rejects sealed ITERATE" 'echo "$RESULT3" | grep -q "sealed verdict is.*ITERATE"'
echo ""
echo "=== TEST GROUP 5: direct finalize blocks upstream non-PASS verdict ==="
D4="$TMPD/t4"
mkdir -p "$D4" && cd "$D4"
$HARNESS init --flow review --entry review --dir .harness > /dev/null 2>&1
write_review_hs ".harness" "review" "ITERATE" "FINDINGS[1]"
set_gate_state
RESULT4=$(cd "$D4" && $HARNESS finalize --dir .harness 2>&1)
check "finalize rejects gate with upstream ITERATE" 'echo "$RESULT4" | grep -q "sealed verdict for review is ITERATE"'
echo ""
echo "=== TEST GROUP 6: direct finalize blocks corrupt upstream handshake ==="
D5="$TMPD/t5"
mkdir -p "$D5" && cd "$D5"
$HARNESS init --flow review --entry review --dir .harness > /dev/null 2>&1
write_review_hs ".harness" "review"
set_gate_state
printf '{broken json\n' > .harness/nodes/review/handshake.json
RESULT5=$(cd "$D5" && $HARNESS finalize --dir .harness 2>&1)
check "finalize rejects corrupt upstream handshake" 'echo "$RESULT5" | grep -q "handshake for review is corrupt"'
echo ""
echo "=== TEST GROUP 7: direct finalize blocks missing upstream handshake ==="
D6="$TMPD/t6"
mkdir -p "$D6" && cd "$D6"
$HARNESS init --flow review --entry review --dir .harness > /dev/null 2>&1
write_review_hs ".harness" "review"
set_gate_state
rm -f .harness/nodes/review/handshake.json
rm -rf .harness/nodes/review/run_1
RESULT6=$(cd "$D6" && $HARNESS finalize --dir .harness 2>&1)
check "finalize rejects missing upstream handshake" 'echo "$RESULT6" | grep -q "handshake for review is missing"'
echo ""
echo "=== TEST GROUP 8: direct finalize blocks missing review eval artifact ==="
D7="$TMPD/t7"
mkdir -p "$D7" && cd "$D7"
$HARNESS init --flow review --entry review --dir .harness > /dev/null 2>&1
write_review_hs ".harness" "review"
set_gate_state
rm -rf .harness/nodes/review/run_1
RESULT7=$(cd "$D7" && $HARNESS finalize --dir .harness 2>&1)
check "finalize rejects missing review eval artifact" 'echo "$RESULT7" | grep -q "review eval artifact for review unreadable"'
echo ""
echo "==========================================="

@@ -64,0 +144,0 @@ echo " Results: $PASS passed, $FAIL failed"

---
name: opc
version: 0.10.2
description: "OPC — One Person Company. Digraph-based task pipeline with independent multi-role evaluation. Builds, reviews, analyzes, and brainstorms with specialist agents. Every path ends with evaluation. /opc <task>, /opc -i <task>, /opc <role> [role...]"
---
# OPC — One Person Company
One principle: **the agent that does the work never evaluates it.**
A full team in a single skill. The digraph engine handles any task — building code, reviewing code, analyzing problems, brainstorming designs. It infers which flow and entry point to use from the task itself, and every path ends with independent evaluation.
## Invocation
**Harness path:** The `opc-harness` binary lives at `bin/opc-harness.mjs` relative to this skill's install directory. Resolve it once at session start:
```bash
OPC_HARNESS="$HOME/.claude/skills/opc/bin/opc-harness.mjs"
```
All `opc-harness` references below mean `node "$OPC_HARNESS"`. Set this as a shell variable and reuse it throughout the session.
```
/opc <task> # auto mode — infer flow and roles from the task
/opc -i <task> # interactive mode — ask questions before dispatch
/opc <role> [role...] # explicit roles — skip role selection, dispatch directly
/opc loop <task> # autonomous loop — decompose, schedule cron, run 24h unattended
/opc skip # skip current node, advance via PASS edge
/opc pass # force-pass current gate
/opc stop # terminate flow, preserve session state
/opc goto <nodeId> # manual jump to a node (cycle limits still enforced)
```
## Task Inference + Flow Selection
The orchestrator reads the task, selects a flow template, and determines the entry point.
| Task says... | Flow template | Default entry |
|---|---|---|
| "review", "audit", "check", "before we merge", "找问题", "开源前看看" | review | review |
| "analyze", "diagnose", "what's wrong with", "分析" | review | review |
| "build", "implement", "create", "fix bug", "帮我实现", "重构成..." | build-verify | build |
| "brainstorm", "explore options", "what are the approaches", "有什么方案" | build-verify | build |
| "plan", "decompose", "break this down", "scope", "estimate", "拆一下" | build-verify | build |
| "verify", "test", "QA", "check before release", "发布前验收" | pre-release | acceptance |
| "post-release", "user test", "onboarding check", "用户验收" | pre-release | acceptance |
| Complex, vague, or multi-keyword request | full-stack | discuss |
| `/opc loop` or multi-unit feature backlog | **loop-protocol** | plan decomposition |
**Entry override** — user context can shift the entry point (only if target ∈ template nodes):
| User has... | Entry override |
|---|---|
| A vague idea or brief | First node in template |
| A spec or design doc | build (if ∈ template) |
| An implementation plan | build (if ∈ template) |
| Code/artifact that needs evaluation | review, code-review, or test-design (if ∈ template) |
| Everything done, needs acceptance | acceptance (if ∈ template) |
**Priority rules:**
- `/opc loop <task>` = enter autonomous loop mode. Follow `./pipeline/loop-protocol.md`: first check `.opc/runbooks/` for a matching runbook, otherwise decompose task into units. Initialize loop state, start cron, execute ticks. Each tick runs the appropriate OPC flow for that unit type.
- `/opc <role> [role...]` without a task = review of current codebase using review flow with named roles.
- `/opc` with no arguments = prompt user to describe their task.
- If task matches multiple rows, prefer the flow that includes build — code changes must precede review.
Show triage result:
```
📌 Flow: {flow template name}
📍 Entry: {entry node}
⚡ Interaction: auto / interactive
Rationale: {1 sentence}
```
**Override:** If user explicitly names a task type, respect that. Users can adjust after seeing triage.
## Flow Templates
Flow graph structures (nodes, edges, limits) are defined in `opc-harness` code. The orchestrator uses `opc-harness route` to determine next nodes — **do not look up edges yourself**.
Each template below describes which agents to dispatch at each node and which protocol to use.
### legacy-linear
Equivalent to v0.4.x behavior. Used as internal fallback only.
| Node | Type | Agents | Protocol |
|------|------|--------|----------|
| design | discussion | [planner] | design exploration |
| plan | build | [planner] | task decomposition |
| build | build | [implementer] | implementer-prompt.md |
| evaluate | review | [selected roles] | role-evaluator-prompt.md |
| deliver | build | — | commit + report |
### review
| Node | Type | Agents | Protocol |
|------|------|--------|----------|
| review | review | [selected roles] | role-evaluator-prompt.md |
| gate | gate | — | gate-protocol.md |
Gate loopback: FAIL/ITERATE → review (multi-round with prior findings as context). Review is not limited to code — it evaluates any artifact: architecture proposals, documents, strategies, products.
### build-verify
| Node | Type | Agents | Protocol |
|------|------|--------|----------|
| build | build | [implementer] | implementer-prompt.md |
| code-review | review | [selected roles] | role-evaluator-prompt.md |
| test-design | review | [tester, + user/domain roles] | test-design-protocol.md |
| test-execute | execute | [orchestrator] | executor-protocol.md |
| gate | gate | — | gate-protocol.md |
**test-design** is a review node where multiple roles design test cases (API tests, E2E UI tests, edge cases) without executing them. **test-execute** runs the designed test plan and captures evidence. Principle: *the person who decides what to test must not be the person who runs the tests.*
### full-stack
The complete flow with discussion, multi-stage gates, and E2E verification.
| Node | Type | Agents | Protocol |
|------|------|--------|----------|
| discuss | discussion | [architect, engineer, tester] | discussion-protocol.md |
| build | build | [implementer] | implementer-prompt.md |
| code-review | review | [frontend, backend] | role-evaluator-prompt.md |
| test-design | review | [tester, + user/domain roles] | test-design-protocol.md |
| test-execute | execute | [orchestrator] | executor-protocol.md |
| gate-test | gate | — | gate-protocol.md |
| acceptance | review | [pm, designer] | role-evaluator-prompt.md |
| gate-acceptance | gate | — | gate-protocol.md |
| audit | review | [security, compliance, a11y] | role-evaluator-prompt.md |
| gate-audit | gate | — | gate-protocol.md |
| e2e-user | execute | [new-user, active-user, churned-user] | executor-protocol.md |
| gate-e2e | gate | — | gate-protocol.md |
| ux-simulation | execute | [new-user, active-user, churned-user] | ux-simulation-protocol.md + ux-observer-protocol.md |
| gate-final | gate | — | gate-protocol.md |
### pre-release
| Node | Type | Agents | Protocol |
|------|------|--------|----------|
| acceptance | review | [pm, designer] | role-evaluator-prompt.md |
| gate-acceptance | gate | — | gate-protocol.md |
| audit | review | [security, compliance, a11y] | role-evaluator-prompt.md |
| gate-audit | gate | — | gate-protocol.md |
| e2e-user | execute | [new-user, active-user, churned-user] | executor-protocol.md |
| gate-e2e | gate | — | gate-protocol.md |
---
## Getting Started
**Before task inference**, check for existing state:
1. Run `opc-harness ls` to discover active flows. If any exist for the current project, show them and ask whether to resume or start fresh.
2. If `.harness/` has `wave-*` files but no `flow-state.json` → **legacy v0.4.x format detected**. Print: "Detected v0.4.x .harness/ format. Please delete .harness/ and re-run, or manually migrate." Do not proceed.
3. Otherwise → fresh start.
After flow selection, initialize:
```bash
opc-harness init --flow {TEMPLATE} --entry {ENTRY_NODE}
```
Init auto-creates `~/.opc/sessions/{project-hash}/{session-id}/` and updates the `latest` symlink. **All subsequent harness commands automatically resolve to the latest session dir** — you do NOT need to pass `--dir` or capture the output. Just run commands normally:
```bash
opc-harness route --node review --verdict PASS --flow {TEMPLATE}
opc-harness transition --from review --to gate --verdict PASS --flow {TEMPLATE}
opc-harness viz --flow {TEMPLATE}
```
**Multi-window safety:** Each `init` creates a new session dir. If multiple OPC windows run on the same project, the last one to `init` becomes `latest`. To pin a specific session, pass `--dir <path>` explicitly.
**Backward compat:** Pass `--dir .harness` to init for a project-local harness dir.
**Show flow graph** — immediately after init, run `opc-harness viz --flow {TEMPLATE}` and display the ASCII output to the user. This gives them a visual map of the entire flow before execution begins.
Before starting, extract **acceptance criteria** — 3-7 concrete, testable bullet points. Evaluators grade against these.
### Quality Tier Selection — Mandatory Pre-Flight
Before the Definition of Done questions, the orchestrator MUST select a **quality tier**. See `./pipeline/quality-tiers.md` for full definitions.
| Tier | When | Baseline |
|------|------|----------|
| `functional` | CLI, API, backend, library, infra | No UI craft requirements |
| `polished` | UI, frontend, website, dashboard, docs | Dark/light, responsive, loading/error/empty states, favicon, focus styles |
| `delightful` | Showcase, demo, pitch, consumer product | All of polished + transitions, animations, micro-interactions, onboarding |
**Selection rules:**
1. User explicitly specifies tier → use it
2. Task involves UI/frontend → default `polished`
3. Task is CLI/API/backend → default `functional`
4. Task includes "showcase", "demo", "pitch", "delightful", "beautiful" → `delightful`
5. Interactive mode → ask the user
Show tier selection:
```
🎯 Quality Tier: {tier}
Baseline: {N items from tier checklist}
```
The tier's baseline checklist items are **automatically appended** to acceptance criteria under a "## Quality Baseline ({tier})" section in `acceptance-criteria.md` (in the session dir). The implementer and evaluator both receive the tier as context.
### Definition of Done — Mandatory Pre-Flight (all modes)
Before dispatching ANY work, the orchestrator MUST establish a clear definition of done. This applies to **both auto and interactive modes** — the only difference is how the answers are obtained (inferred vs asked).
**Three questions that must have answers before the first node executes:**
1. **What does "done" look like?** — Concrete, observable outcomes. Not "implement auth" but "user can log in with email/password, session persists across refresh, logout clears session."
2. **How will we verify it?** — Map each outcome to a verification method:
- Code change → which tests? (`npm test`, specific test file, new test to write?)
- UI change → which page/component to screenshot? What should be visible?
- API change → which endpoint to curl? What response shape?
- Refactor → which existing tests must still pass?
3. **How will we evaluate quality?** — What should reviewers look for beyond "it works"?
- Performance constraints? ("page load < 2s")
- Security concerns? ("no PII in logs")
- Compatibility? ("works in Safari")
- Edge cases? ("handles empty input, 10k items, unicode")
**In auto mode**: infer answers from the task description + codebase context (package.json scripts, existing tests, CLAUDE.md rules). Show inferred answers to user for confirmation. If task is too vague to infer concrete verification methods → **ask, even in auto mode.** A vague task is worse than a 30-second clarification.
**In interactive mode (`-i`)**: ask directly, grouped with role-specific questions.
**In loop mode (`/opc loop`)**: these answers go into `plan.md` per unit, so every tick knows how to verify itself even after context compaction.
Write the finalized acceptance criteria to `acceptance-criteria.md` (in the session dir) and include them in every subagent prompt.
**Design Reproduction Pre-Flight:** When the task involves reproducing/replicating a visual design from a reference image (keywords: 复刻, replicate, reproduce, reference image, 参考图, design reproduction), the orchestrator MUST run these additional init steps:
1. **Detect reference image** — user provides a path (e.g., `/Users/.../ref.jpg`). Confirm the file exists.
2. **Extract design spec** — run `analyze_reference.py` to generate a structured spec:
```bash
python3 ~/.claude/skills/image-x/scripts/analyze_reference.py <ref_image> --output <session_dir>/spec.json
```
3. **Write `## Reference` section** in `acceptance-criteria.md`:
```markdown
## Reference
- reference_image: /absolute/path/to/ref.jpg
- design_spec: /absolute/path/to/session/spec.json
```
4. **Set quality baseline** for design reproduction:
```markdown
## Quality Baseline (polished)
- design-diff overall ≥ 4.0
- zero major diffs
```
This enables the full automated loop: build reads spec.json → implementer produces HTML → test-execute screenshots + VLM design-diff → gate reads diffs → ITERATE feeds diffs back to build. See `./pipeline/executor-protocol.md` § "Design Reproduction Mode" for test-execute details.
**Criteria Lint — Mandatory Gate:** After writing `acceptance-criteria.md`, run `opc-harness criteria-lint acceptance-criteria.md` (use the session dir path). If it fails, revise and re-run (max 3 auto-fix attempts in auto mode, user-driven in interactive mode). See `./pipeline/criteria-lint.md` for the mechanical checks. Init is gated — `opc-harness init` refuses to start if criteria-lint hasn't passed.
### Task Scope — Mandatory for Loop Mode
In loop mode, every `plan.md` MUST include a `## Task Scope` section listing the user's original requirements:
```markdown
## Task Scope
- SCOPE-1: Backend API for user auth
- SCOPE-2: Frontend login page with form validation
- SCOPE-3: Browser E2E tests covering login flow
- SCOPE-4: Unit tests with 100% coverage on new code
```
The harness enforces this mechanically:
- **init-loop** refuses to start if `## Task Scope` is missing (bypass: `--skip-scope`)
- **complete-tick** on the final tick checks that every SCOPE-N item was covered by at least one completed unit (keyword overlap or explicit reference). Uncovered items = hard error, pipeline cannot complete (bypass: `--skip-scope-check`)
- **next-tick** termination output includes `uncovered_scope` if any items lack coverage
This prevents the #1 failure mode: LLM decomposition misses part of the original task, pipeline declares "complete" while major scope items are untouched.
### Interactive Mode Details (with `-i`)
Ask targeted questions derived from selected roles — what does each role need that can't be inferred from the codebase? Aim for 3-5 grouped questions, merged with the Definition of Done questions above.
- Engineering roles usually read code directly — no extra context needed.
- Product and user roles benefit most: "Who are your target users?", "What's the product stage?"
- Security and Compliance may need: "Do you handle PII?", "Target markets?"
**Persona construction** for user roles: In auto mode, infer from project context. In interactive mode, ask directly.
### Project Context
Subagents don't inherit CLAUDE.md or project instructions automatically. When dispatching any subagent, **forward relevant project context**: dev workflow rules, precommit checks, coding conventions, test commands. Include this in every subagent prompt.
### Superpowers Integration
If `superpowers` skills are available, use them: brainstorming for design, plan writing, subagent-driven development for build, and branch delivery.
---
## Built-in Roles
```
Product: pm, designer
User Lens: new-user, active-user, churned-user
Engineering: frontend, backend, devops, architect, engineer
Quality: security, tester, compliance, a11y
Specialist: planner, user-simulator, devil-advocate
```
Role definitions live in `roles/<name>.md`. Add a `.md` file to `roles/` to create a custom role.
### Role Discovery
The orchestrator searches for role definitions in this order (later sources override earlier ones with the same filename):
1. **Built-in roles** — `roles/<name>.md` in OPC's install directory
2. **Flow template roles** — if the active flow template specifies `rolesDir`, scan `_resolvedRolesDir/<name>.md`. Custom roles with the same name as a built-in one take precedence for this flow.
3. **Dynamic roles** — created on-the-fly during execution (see below)
**How to check for custom roles:** After `opc-harness init`, if the flow template was loaded from `~/.claude/flows/`, check `FLOW_TEMPLATES[template]._resolvedRolesDir`. If it exists and is a directory, scan it for `.md` files and merge into the role pool.
**Protocol discovery** works the same way: if the flow template specifies `protocolDir`, protocols in `_resolvedProtocolDir/<name>.md` supplement or override built-in protocols in `pipeline/`.
### Role Selection
1. **Tag filter** — from the flow template, you know the node type. Map to stage tags:
| Node type | Stage tags |
|-----------|-----------|
| review | review |
| build | build |
| execute | execute, post-release, verification |
| discussion | brainstorm, plan, discussion |
| gate | (no roles dispatched) |
Read the `tags:` front matter from each `roles/<name>.md`. Keep only roles whose tags include at least one matching stage tag.
2. **Select from filtered pool** — pick 2-5 roles with distinct angles. Read each candidate's "When to Include" section to decide relevance.
- **Mandatory roles always included** — roles with `mandatory: true` in front matter are auto-included in every review node. The orchestrator cannot remove them. Currently: `skeptic-owner`.
- Each dispatched agent must have a DISTINCT angle. If two would produce 80%+ overlapping output, pick one.
- Not every task needs every role. A CSS fix doesn't need Security.
- **Devil's Advocate auto-inclusion:** When a discussion node reaches Round 2 with near-unanimous agreement (all agents converge on the same approach), the orchestrator SHOULD include devil-advocate in a subsequent review pass. Consensus is a signal to challenge, not to proceed. For irreversible decisions (data deletion, public API contracts, destructive migrations), devil-advocate is MANDATORY.
- If user specified roles explicitly, use those — skip tag filtering entirely.
**Dynamic Role Creation:** If the task requires expertise not covered by any candidate, create a role on-the-fly following the same format (Identity + Expertise + When to Include + Anti-Patterns). Write to `$SESSION_DIR/nodes/{nodeId}/dynamic-role-{name}.md`. Max 5 dynamic roles per flow run.
Show role selection:
```
📋 Agents:
- frontend — <specific scope>
- security — <specific scope>
...
Launching {N} agents...
```
---
## Node Execution
**Auto mode = no pause.** In auto mode, the orchestrator MUST NOT pause to ask "should I continue?", "this will take a while", or "want to stop here?". The only acceptable reasons to stop are:
- Escape hatch triggered (cycle limit hit, stall detected, blocked transition)
- Tool failure after retry
- Context critically low (write state to disk, tell user to re-invoke)
Anything else = keep executing. The user chose auto mode precisely because they don't want interruptions. If the pipeline has 14 nodes, run all 14 nodes. Do not ask permission at node 4.
The orchestrator uses **cursor-based execution** — `flow-state.json.currentNode` is the single pointer. No topological sort.
### Execution Loop
```
1. Read flow-state.json → currentNode
2. Look up currentNode in the flow template table above → get type, agents, protocol
3. Execute based on node type (see below)
4. After execution:
- opc-harness validate → check handshake.json
- Update progress.md with narrative line
- opc-harness route --node {current} --verdict PASS --flow {template} → get next
- opc-harness transition --from {current} --to {next} --verdict PASS --flow {template}
- **Show flow viz**: run `opc-harness viz --flow {template}` and display to user
- Loop back to step 1
5. When route returns next=null → flow complete → Deliver → **Prompt replay** (see below)
```
### Node Type: discussion
Follow `./pipeline/discussion-protocol.md`.
1. Dispatch agents for 3 rounds. **Round 1: parallel** (agents are independent — no reason to serialize). Round 2: serial with context injection (each agent sees Round 1 outputs, writes diffs only). Round 3: facilitator convergence.
2. **Orchestrator writes handshake.json** after collecting all artifacts (agents don't write it).
3. Discussion nodes produce no verdict — the decision artifact feeds downstream.
### Node Type: build
Follow `./pipeline/implementer-prompt.md` in Build/Fix/Polish mode.
1. Dispatch implementer subagent.
2. **Single agent** → agent writes its own handshake.json.
3. **Multiple agents** (parallel, with `isolation: "worktree"`) → orchestrator merges artifacts and writes handshake.json.
4. With superpowers: invoke `superpowers:subagent-driven-development`.
### Node Type: review
Follow `./pipeline/role-evaluator-prompt.md`.
1. Select roles per Role Selection rules.
2. Dispatch evaluators — parallel if no dependencies, serial with context injection if dependencies exist.
3. Each agent writes `eval-{role}.md` to `$SESSION_DIR/nodes/{NODE_ID}/run_{RUN}/`.
4. **Orchestrator writes handshake.json** after all agents return, merging all eval files into artifacts[].
5. Before dispatching, build context brief using `./pipeline/context-brief.md` (for review/analysis tasks).
**Critical — Review Independence:**
- Review MUST use independent subagents (Agent tool), never the orchestrator reviewing its own build output.
- In loop mode, review MUST be a separate tick/unit from implementation. Never combine build + review in one tick.
- The orchestrator MUST NOT filter, downgrade, or dismiss findings before writing the handshake. All findings pass through to the gate.
### Node Type: execute
Follow `./pipeline/executor-protocol.md`.
**Executor nodes are executed by the orchestrator directly — not as a subagent.** This is because executors need full tool access (Bash, Playwright, Skills).
1. Smoke test tool availability.
2. Execute acceptance criteria scenarios.
3. Capture evidence (CLI output, screenshots).
4. **Orchestrator writes handshake.json** with evidence artifacts.
5. Handshake validation enforces: execute nodes must have evidence artifacts.
### Node Type: gate
Follow `./pipeline/gate-protocol.md`.
**Gate nodes are executed by the orchestrator directly — no subagent dispatch.**
1. `opc-harness synthesize --node {upstream}` → get verdict.
2. Mechanical validation (severity emojis, file refs, fix suggestions).
3. `opc-harness route --node {gate} --verdict {V} --flow {template}` → get next node.
4. `opc-harness transition --from {gate} --to {next} --verdict {V} --flow {template}` → validates edge, writes gate handshake, updates state.
5. Notify user: pass/loopback/done/blocked.
---
## Verdict & Loopback
Gate nodes produce verdicts via `opc-harness synthesize` (code, not LLM judgment):
- Any 🔴 → FAIL
- Any 🟡 → ITERATE
- All 🔵/LGTM → PASS
- Any BLOCKED → BLOCKED
**Code enforces all limits:**
- `maxLoopsPerEdge` = 3 (same edge can't be traversed more than 3 times)
- `maxTotalSteps` = 20-30 (depending on flow template)
- `maxNodeReentry` = 5 (same node can't be entered more than 5 times)
**Oscillation detection:** After a loopback, run `opc-harness diff` on consecutive evaluations. If `oscillation: true`, surface to user.
**Escape hatches:**
- `/opc skip` — skip current node, advance via PASS edge
- `/opc pass` — force gate to PASS
- `/opc stop` — terminate flow, preserve state
- `/opc goto <nodeId>` — manual jump (cycle limits still enforced via `transition`)
When transition returns `allowed: false` → show the user why (which limit hit) and offer escape options. Never continue without user consent.
---
## File-Based State
```
$SESSION_DIR/ # ~/.opc/sessions/{hash}/{id}/ or .harness/ if --dir used
├── flow-state.json # Current node, execution history, edge counts, limits
├── progress.md # Human-readable narrative log
└── nodes/
└── {nodeId}/
├── handshake.json # Machine-readable envelope (summary + verdict + artifact paths)
└── run_{N}/
├── eval.md # Single evaluator output (detailed findings)
├── eval-{role}.md # Per-role evaluator output (multi-role)
├── round-1-{role}.md # Discussion round 1
├── round-2-{role}.md # Discussion round 2 (diffs only)
├── decision.md # Discussion facilitator decision
├── screenshot-{N}.png # Executor GUI evidence
└── command-output-{N}.txt # Executor CLI evidence
```
**Relationships:**
- `handshake.json` = envelope. Its `artifacts[]` points to detailed files (eval.md, screenshots, etc.)
- `flow-state.json` = sole source of truth for execution position and history
- `eval.md` / `eval-{role}.md` = human-readable findings (read by `synthesize` to compute verdict)
- `progress.md` = narrative projection of flow execution (for humans)
---
## Prompt Templates
All templates live in `./pipeline/`:
- `evaluator-prompt.md` — Single generic evaluator
- `role-evaluator-prompt.md` — Role-specific evaluator (review, analysis, brainstorm outputs)
- `implementer-prompt.md` — Implementer (Build / Fix / Polish modes)
- `discussion-protocol.md` — Multi-agent discussion (round-robin, 3 rounds, facilitator)
- `gate-protocol.md` — Verdict aggregation + code-based routing + transition + **findings disposition**
- `executor-protocol.md` — CLI/GUI execution with evidence requirements
- `test-design-protocol.md` — **Test case design** (review node, multi-role test planning before execution)
- `loop-protocol.md` — **Autonomous multi-unit execution** (plan decomposition → cron loop → auto-terminate)
- `handoff-template.md` — Handshake.json specification
- `context-brief.md` — Design context brief procedure
- `report-format.md` — Presentation templates + JSON schema + replay
- `quality-tiers.md` — Tier definitions + baseline checklists + severity calibration
- `ux-simulation-protocol.md` — **UX simulation gate** (red flag detection, delta comparison, ordinal tier fit)
- `ux-observer-protocol.md` — **UX observer dispatch** (persona-based pattern observation, closed enum red flags)
- `criteria-lint.md` — **DoD mechanical lint** (single-pass structure + content checks, pre-init gate)
---
## External Flow Templates
Custom flows can be defined as JSON files in `~/.claude/flows/`. The harness loads them at startup and merges them into the template registry. Built-in templates take precedence (external cannot override).
**JSON schema:**
```json
{
"nodes": ["discover", "build", "review", "gate"],
"edges": {
"discover": { "PASS": "build" },
"build": { "PASS": "review" },
"review": { "PASS": "gate" },
"gate": { "PASS": null, "FAIL": "build", "ITERATE": "build" }
},
"limits": { "maxLoopsPerEdge": 3, "maxTotalSteps": 20, "maxNodeReentry": 5 },
"nodeTypes": {
"discover": "discussion", "build": "build",
"review": "review", "gate": "gate"
},
"softEvidence": true,
"opc_compat": ">=0.10",
"contextSchema": {
"build": {
"required": ["task"],
"rules": { "task": "non-empty-string" }
}
}
}
```
**Validation rules:**
- `nodes`, `edges`, `limits` are required
- All edge sources and targets must be in `nodes`
- `nodeTypes` values must be: `discussion`, `build`, `review`, `execute`, `gate`
- `opc_compat` uses `>=X.Y` semver range (current harness compatibility: 0.10.0)
- Prototype pollution names (`__proto__`, `constructor`, `prototype`) are rejected
**Optional fields:**
- `softEvidence: true` — downgrades missing-evidence errors to warnings for execute nodes
- `contextSchema` — per-node validation rules for `flow-context.json`
- `opc_compat` — minimum harness version required
**contextSchema rules:**
- `non-empty-string` — must be a non-empty string
- `non-empty-array` — must be a non-empty array
- `non-empty-object` — must be a non-empty plain object (not array)
- `positive-integer` — must be a positive integer > 0
---
## Harness Command Reference
All commands output JSON to stdout. Errors go to stderr. All output is machine-parseable.
### Flow Commands
| Command | Usage | Description |
|---------|-------|-------------|
| `init` | `--flow <tpl> [--entry <node>] [--dir <p>]` | Initialize flow state. Creates `flow-state.json` and node directories. |
| `route` | `--node <id> --verdict <V> --flow <tpl>` | Get next node from graph edges. Returns `{next, allowed}`. |
| `transition` | `--from <n> --to <n> --verdict <V> --flow <tpl> --dir <p>` | Execute state transition. Validates edge, checks limits, writes gate handshake, enforces backlog. |
| `validate` | `<handshake.json>` | Validate handshake schema (required fields, evidence check for execute nodes). |
| `validate-chain` | `[--dir <p>]` | Validate entire execution path — checks all handshakes match history. |
| `validate-context` | `--flow <tpl> --node <id> [--dir <p>]` | Validate `flow-context.json` against contextSchema rules. |
| `finalize` | `[--dir <p>] [--strict]` | Finalize terminal node. Marks flow as completed. |
| `viz` | `--flow <tpl> [--dir <p>] [--json]` | Visualize flow graph (ASCII or JSON). Shows ▶ current, ✅ visited, ○ pending. |
| `replay` | `[--dir <p>]` | Export full replay data as JSON (flow state + handshakes + run artifacts). |
### Escape Hatches
| Command | Usage | Description |
|---------|-------|-------------|
| `skip` | `[--dir <p>] [--flow <tpl>]` | Skip current node, advance via PASS edge. Writes skip handshake. |
| `pass` | `[--dir <p>]` | Force-pass current gate node. Only works on gate-type nodes. |
| `stop` | `[--dir <p>]` | Terminate flow, preserve state. Sets status to "stopped". |
| `goto` | `<nodeId> [--dir <p>]` | Manual jump to any node. Cycle limits still enforced. |
| `ls` | `[--base <p>]` | List all active flows (scans `~/.opc/sessions/` and project-local `.harness*` directories). |
### Eval Commands
| Command | Usage | Description |
|---------|-------|-------------|
| `verify` | `<file>` | Parse evaluation markdown → JSON (severity counts, verdict, findings). |
| `synthesize` | `<dir> --node <id> [--run N] [--base <dir>] [--no-strict] [--iteration N]` | Merge all evaluations for a node → aggregate verdict. D2 compound gate enforced by default (≥3 layers → FAIL); `--no-strict` for shadow mode. `--base` validates file:line refs. |
| `report` | `<dir> --mode <m> --task <t>` | Generate full report JSON with presentation data. |
| `diff` | `<file1> <file2>` | Compare two evaluation rounds. Detects oscillation. |
### Loop Commands (Layer 2 — Zero Trust)
| Command | Usage | Description |
|---------|-------|-------------|
| `init-loop` | `[--plan <file>] [--dir <p>]` | Initialize loop state from plan.md. Validates plan structure, detects test/lint scripts. |
| `complete-tick` | `--unit <id> --artifacts <a,b> [--description <text>] [--dir <p>]` | Complete tick with evidence. Validates artifacts per unit type, checks plan hash, overlap detection. |
| `next-tick` | `[--dir <p>]` | Get next unit. Checks stall/oscillation, returns `{ready, unit, terminate}`. |
### Transition Details
The `transition` command enforces:
- **Edge validation** — only declared edges are allowed
- **Cycle limits** — `maxLoopsPerEdge`, `maxTotalSteps`, `maxNodeReentry`
- **Idempotency** — repeated identical transitions are silently accepted
- **Gate detection** — uses `nodeTypes[from] === "gate"` (not name prefix)
- **Pre-transition validation** — upstream handshake must exist and be valid
- **Backlog enforcement** — if upstream has warnings, `backlog.md` must exist for FAIL/ITERATE transitions
---
## Resilience
**Agent spawn failures:** Retry once. If it fails again, surface to user.
**Context compaction resilience:** OPC provides PreCompact/PostCompact hooks that automatically snapshot state and inject resume context after compaction. Run `opc install-hooks` to register them. These optional shell hooks require `jq`. When auto-compact fires:
1. **PreCompact** writes a resume brief to `$SESSION_DIR/resume-brief.md`
2. **PostCompact** injects the brief as `additionalContext` into the new context
3. The orchestrator sees the injection and resumes the flow automatically
If hooks are not installed, the fallback behavior is: flow-state.json persists on disk, but the orchestrator must be manually re-invoked via `/opc` (which runs `opc-harness ls` to discover active flows).
**State recovery:** On resume, run `opc-harness validate-chain`. If inconsistent → surface to user, do not auto-repair.
**Legacy detection:** If `.harness/` in project root has `wave-*` files but no `flow-state.json` → refuse to run. Print migration instructions.
**Fresh context per agent.** Always spawn new subagents. Files carry state; agents bring fresh capacity.
---
## Flow Completion & Replay
When the flow completes (route returns `next=null`):
1. Show final viz: `opc-harness viz --flow {template}`
2. Show summary: total steps, nodes visited, any loopbacks
3. **Generate HTML report** (use the session dir from init output, or find it via `opc-harness ls`):
```bash
node "$OPC_HARNESS/../opc-report.mjs" --dir <session-dir> --output <session-dir>/report.html --title "{task summary}"
```
This produces a self-contained dark-theme HTML report with mechanically parsed stats, pipeline visualization, findings tables, and R2 fix tracking. Open it for the user.
4. **Prompt the user:**
```
✅ Flow complete! Report: $SESSION_DIR/report.html
Want to see the replay? Run: /opc replay
```

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display