🎩 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.1
to
0.10.2
+62
bin/hooks/opc-post-compact.sh
#!/usr/bin/env bash
# OPC PostCompact hook — inject resume context after context compaction.
# Outputs additionalContext JSON so the model knows to resume the OPC flow.
#
# Register: opc install-hooks
# Trigger: Claude Code PostCompact event (manual or auto)
set -euo pipefail
OPC_HARNESS="${OPC_HARNESS:-$HOME/.claude/skills/opc/bin/opc-harness.mjs}"
[ -f "$OPC_HARNESS" ] || exit 0
# 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
| @json
' 2>/dev/null)
[ -z "$LATEST" ] || [ "$LATEST" = "null" ] && exit 0
DIR=$(echo "$LATEST" | jq -r '.dir')
FLOW=$(echo "$LATEST" | jq -r '.flow')
NODE=$(echo "$LATEST" | jq -r '.currentNode')
STEPS=$(echo "$LATEST" | jq -r '.totalSteps')
[ -d "$DIR" ] || exit 0
# Build resume context message
CONTEXT="[OPC RESUME] You have an in-progress OPC flow that was interrupted by context compaction.
- Session dir: $DIR
- Flow: $FLOW
- Current node: $NODE
- Steps completed: $STEPS
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"
# If resume-brief.md exists (written by PreCompact), append it
BRIEF="$DIR/resume-brief.md"
if [ -f "$BRIEF" ]; then
BRIEF_CONTENT=$(cat "$BRIEF")
CONTEXT="$CONTEXT
--- Resume Brief ---
$BRIEF_CONTENT"
fi
# Escape for JSON
ESCAPED=$(echo "$CONTEXT" | jq -Rs .)
# Output hook JSON — use top-level systemMessage (hookSpecificOutput only supports PreToolUse/PostToolUse/UserPromptSubmit)
cat <<EOF
{
"systemMessage": $ESCAPED
}
EOF
#!/usr/bin/env bash
# OPC PreCompact hook — snapshot active flow state before context compaction.
# Writes a resume brief so PostCompact can inject it into the new context.
#
# Register: opc install-hooks
# Trigger: Claude Code PreCompact event (manual or auto)
set -euo pipefail
OPC_HARNESS="${OPC_HARNESS:-$HOME/.claude/skills/opc/bin/opc-harness.mjs}"
[ -f "$OPC_HARNESS" ] || exit 0
# Find in-progress flows
FLOW_JSON=$(node "$OPC_HARNESS" ls 2>/dev/null) || exit 0
# Pick the latest in-progress flow by lastModified
LATEST=$(echo "$FLOW_JSON" | jq -r '
[.flows[] | select(.status == "in_progress")]
| sort_by(.lastModified) | last // empty
| @json
' 2>/dev/null)
[ -z "$LATEST" ] || [ "$LATEST" = "null" ] && exit 0
DIR=$(echo "$LATEST" | jq -r '.dir')
FLOW=$(echo "$LATEST" | jq -r '.flow')
NODE=$(echo "$LATEST" | jq -r '.currentNode')
STEPS=$(echo "$LATEST" | jq -r '.totalSteps')
[ -d "$DIR" ] || exit 0
# Check for acceptance criteria
AC_FILE="$DIR/acceptance-criteria.md"
AC_NOTE=""
if [ -f "$AC_FILE" ]; then
AC_NOTE="- **Acceptance criteria**: $AC_FILE"
fi
# Write resume brief
cat > "$DIR/resume-brief.md" <<EOF
# OPC Resume Brief
- **Session dir**: $DIR
- **Flow**: $FLOW
- **Current node**: $NODE
- **Steps completed**: $STEPS
- **Snapshot time**: $(date -u +%Y-%m-%dT%H:%M:%SZ)
$AC_NOTE
## Resume instructions
1. Run \`opc-harness ls\` to confirm flow state
2. Re-read \`$DIR/acceptance-criteria.md\`
3. Continue executing node **$NODE** in the **$FLOW** flow
4. Follow the standard OPC protocol for this node type
EOF
exit 0
// audit.mjs — Process conformance metrics across OPC sessions
// Mechanical checks only — no LLM, no network.
import { readFileSync, readdirSync, existsSync, statSync } from "fs";
import { join, basename } from "path";
import { getFlag, getSessionsBaseDir } from "./util.mjs";
const THIN_EVAL_THRESHOLD = 50; // lines
// ── Helpers ─────────────────────────────────────────────────────
function scanSessions(projectDir) {
const sessions = [];
try {
const base = getSessionsBaseDir(projectDir);
if (!existsSync(base)) return sessions;
const entries = readdirSync(base, { withFileTypes: true });
for (const e of entries) {
if (!e.isDirectory() || e.name === "latest") continue;
const dir = join(base, e.name);
const sp = join(dir, "flow-state.json");
if (!existsSync(sp)) continue;
try {
const state = JSON.parse(readFileSync(sp, "utf8"));
const st = statSync(sp);
sessions.push({ dir, id: e.name, state, mtime: st.mtime });
} catch { /* corrupt */ }
}
} catch { /* no sessions dir */ }
return sessions.sort((a, b) => a.mtime - b.mtime);
}
function findEvalFiles(sessionDir) {
// Returns [{nodeId, runId, file, path, lineCount}]
const results = [];
const nodesDir = join(sessionDir, "nodes");
if (!existsSync(nodesDir)) return results;
try {
for (const nodeEntry of readdirSync(nodesDir, { withFileTypes: true })) {
if (!nodeEntry.isDirectory()) continue;
const nodeDir = join(nodesDir, nodeEntry.name);
// Scan run_* dirs
for (const runEntry of readdirSync(nodeDir, { withFileTypes: true })) {
if (!runEntry.isDirectory() || !runEntry.name.startsWith("run_")) continue;
const runDir = join(nodeDir, runEntry.name);
for (const f of readdirSync(runDir)) {
if (f.startsWith("eval") && f.endsWith(".md")) {
const fp = join(runDir, f);
try {
const content = readFileSync(fp, "utf8");
results.push({
nodeId: nodeEntry.name,
runId: runEntry.name,
file: f,
path: fp,
lineCount: content.split("\n").length,
});
} catch { /* unreadable */ }
}
}
}
}
} catch { /* unreadable */ }
return results;
}
function extractRoleName(evalFileName) {
// eval-frontend.md → frontend, eval-skeptic-owner.md → skeptic-owner
if (evalFileName === "eval.md") return "evaluator";
return evalFileName.replace(/^eval-/, "").replace(/\.md$/, "");
}
// ── Conformance Checks ──────────────────────────────────────────
function checkSkepticOwner(evalFiles) {
const skepticNames = new Set(["skeptic-owner", "devil-advocate"]);
return evalFiles.some(e => skepticNames.has(extractRoleName(e.file)));
}
function checkRoleDiversity(evalFiles) {
// Group by nodeId, check each review node has ≥2 distinct evals
const byNode = {};
for (const e of evalFiles) {
if (!byNode[e.nodeId]) byNode[e.nodeId] = new Set();
byNode[e.nodeId].add(extractRoleName(e.file));
}
const nodes = Object.values(byNode);
if (nodes.length === 0) return null;
const passing = nodes.filter(roles => roles.size >= 2).length;
return passing / nodes.length;
}
function checkEvalDepth(evalFiles) {
if (evalFiles.length === 0) return null;
const nonThin = evalFiles.filter(e => e.lineCount >= THIN_EVAL_THRESHOLD).length;
return nonThin / evalFiles.length;
}
function checkNoManualBypass(state) {
if (!Array.isArray(state.history)) return true;
return !state.history.some(h => h.skipped || h.forcePassed);
}
function checkAcceptanceCriteria(sessionDir) {
return existsSync(join(sessionDir, "acceptance-criteria.md"));
}
function checkFlowCompleted(state) {
return state.status === "completed" || state.status === "finalized";
}
// ── Main ────────────────────────────────────────────────────────
export function cmdAudit(args) {
const format = getFlag(args, "format", "table");
const lastN = getFlag(args, "last", null);
const projectDir = getFlag(args, "base", process.cwd());
let sessions = scanSessions(projectDir);
if (lastN) {
sessions = sessions.slice(-parseInt(lastN, 10));
}
if (sessions.length === 0) {
console.error("No OPC sessions found.");
process.exit(1);
}
const scorecards = [];
for (const { dir, id, state, mtime } of sessions) {
const evalFiles = findEvalFiles(dir);
const checks = {
skeptic_owner_present: checkSkepticOwner(evalFiles),
role_diversity: checkRoleDiversity(evalFiles),
eval_depth: checkEvalDepth(evalFiles),
no_manual_bypass: checkNoManualBypass(state),
acceptance_criteria_exists: checkAcceptanceCriteria(dir),
flow_completed: checkFlowCompleted(state),
};
// Conformance score: average of non-null checks (bools → 1/0, ratios as-is)
const values = Object.values(checks).filter(v => v !== null);
const numericValues = values.map(v => (v === true ? 1 : v === false ? 0 : v));
const conformanceScore = numericValues.length > 0
? numericValues.reduce((a, b) => a + b, 0) / numericValues.length
: null;
scorecards.push({
id,
flow: state.flowTemplate || "unknown",
tier: state.tier || null,
timestamp: mtime.toISOString(),
totalSteps: state.totalSteps || 0,
evalFileCount: evalFiles.length,
checks,
conformance_score: conformanceScore != null ? Math.round(conformanceScore * 100) / 100 : null,
});
}
// Aggregate
const validScores = scorecards.filter(s => s.conformance_score != null).map(s => s.conformance_score);
const avgConformance = validScores.length > 0
? Math.round((validScores.reduce((a, b) => a + b, 0) / validScores.length) * 100) / 100
: null;
// Worst check: which check fails most often
const checkFailCounts = {};
for (const sc of scorecards) {
for (const [k, v] of Object.entries(sc.checks)) {
if (v === false || (typeof v === "number" && v < 0.5)) {
checkFailCounts[k] = (checkFailCounts[k] || 0) + 1;
}
}
}
const worstCheck = Object.entries(checkFailCounts).sort((a, b) => b[1] - a[1])[0]?.[0] || null;
// Weekly trend (group by ISO week)
const weeklyGroups = {};
for (const sc of scorecards) {
if (sc.conformance_score == null) continue;
const d = new Date(sc.timestamp);
const week = `${d.getFullYear()}-W${String(Math.ceil(((d - new Date(d.getFullYear(), 0, 1)) / 86400000 + 1) / 7)).padStart(2, "0")}`;
if (!weeklyGroups[week]) weeklyGroups[week] = [];
weeklyGroups[week].push(sc.conformance_score);
}
const trend = Object.entries(weeklyGroups)
.sort((a, b) => a[0].localeCompare(b[0]))
.map(([week, scores]) => ({
week,
avg: Math.round((scores.reduce((a, b) => a + b, 0) / scores.length) * 100) / 100,
count: scores.length,
}));
const output = {
sessions: scorecards,
aggregate: {
total_sessions: scorecards.length,
avg_conformance: avgConformance,
worst_check: worstCheck,
worst_check_fail_count: checkFailCounts[worstCheck] || 0,
trend,
},
};
if (format === "json") {
console.log(JSON.stringify(output, null, 2));
return;
}
// Table format
console.log(`\n OPC Process Conformance Audit — ${scorecards.length} session(s)\n`);
const pad = (s, n) => s.slice(0, n).padEnd(n);
console.log(` ${pad("Session",24)} ${pad("Flow",14)} ${pad("Tier",5)} Skept Div Depth NoBP AC Done Score`);
console.log(" " + "─".repeat(100));
for (const sc of scorecards) {
const c = sc.checks;
const fmt = (v) => v === null ? " — " : v === true ? " ✓ " : v === false ? " ✗ " : `${(v * 100).toFixed(0).padStart(3)}%`;
const score = sc.conformance_score != null ? (sc.conformance_score * 100).toFixed(0) + "%" : "—";
console.log(` ${pad(sc.id,24)} ${pad(sc.flow||"?",14)} ${pad(sc.tier||"—",5)} ${fmt(c.skeptic_owner_present)} ${fmt(c.role_diversity)} ${fmt(c.eval_depth)} ${fmt(c.no_manual_bypass)} ${fmt(c.acceptance_criteria_exists)} ${fmt(c.flow_completed)} ${score}`);
}
console.log("\n " + "─".repeat(100));
console.log(` Avg conformance: ${avgConformance != null ? (avgConformance * 100).toFixed(0) + "%" : "—"}`);
if (worstCheck) console.log(` Worst check: ${worstCheck} (failed in ${checkFailCounts[worstCheck]}/${scorecards.length} sessions)`);
if (trend.length > 1) {
console.log(` Trend: ${trend.map(t => `${t.week}:${(t.avg * 100).toFixed(0)}%`).join(" → ")}`);
}
console.log();
}
// criteria-lint-content.test.mjs — content checks, warnings, return shape
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { runLint } from "./criteria-lint.mjs";
import { failChecks, validDoc, warnChecks } from "./criteria-lint.test-helpers.mjs";
describe("runLint — content checks", () => {
test("no-vague-outcomes fails on vague word without measurement", () => {
const r = runLint(validDoc({
outcomes: [
"- OUT-1: The API is fast",
"- OUT-2: Returns error on invalid input",
"- OUT-3: Response body contains result field",
],
}));
assert.ok(failChecks(r).includes("no-vague-outcomes"));
const f = r.failures.find((f) => f.check === "no-vague-outcomes");
assert.ok(f.message.includes("OUT-1"));
assert.ok(f.message.includes("fast"));
});
test("no-vague-outcomes passes when vague word has measurement", () => {
const r = runLint(validDoc({
outcomes: [
"- OUT-1: The API is fast — responds within 200ms",
"- OUT-2: Returns error on invalid input",
"- OUT-3: Response body contains result field",
],
}));
assert.ok(!failChecks(r).includes("no-vague-outcomes"));
});
test("no-vague-outcomes passes with no vague words", () => {
const r = runLint(validDoc());
assert.ok(!failChecks(r).includes("no-vague-outcomes"));
});
test("no-impossible-to-fail flags 'should work' without concrete test", () => {
const r = runLint(validDoc({
outcomes: [
"- OUT-1: The feature should work",
"- OUT-2: Returns error on failure",
"- OUT-3: Response contains result",
],
}));
assert.ok(failChecks(r).includes("no-impossible-to-fail"));
const f = r.failures.find((f) => f.check === "no-impossible-to-fail");
assert.ok(f.message.includes("OUT-1"));
assert.ok(f.message.includes("should work"));
});
test("no-impossible-to-fail passes when 'as expected' has concrete test", () => {
const r = runLint(validDoc({
outcomes: [
"- OUT-1: Endpoint as expected returns status code 200",
"- OUT-2: Returns error on failure",
"- OUT-3: Response contains result",
],
}));
assert.ok(!failChecks(r).includes("no-impossible-to-fail"));
});
test("verification-not-manual fails on manual-only verification", () => {
const r = runLint(validDoc({
verification: [
"- OUT-1: manual inspection of output",
"- OUT-2: assert error response",
"- OUT-3: assert field exists",
].join("\n"),
}));
assert.ok(failChecks(r).includes("verification-not-manual"));
const f = r.failures.find((f) => f.check === "verification-not-manual");
assert.ok(f.message.includes("OUT-1"));
});
test("verification-not-manual passes when manual + mechanical", () => {
const r = runLint(validDoc({
verification: [
"- OUT-1: manual inspection, also asserts status code matches",
"- OUT-2: assert error response",
"- OUT-3: assert field exists",
].join("\n"),
}));
assert.ok(!failChecks(r).includes("verification-not-manual"));
});
test("outcomes-unique fails when two outcomes are >80% similar", () => {
const r = runLint(validDoc({
outcomes: [
"- OUT-1: The system writes records to the database table and logs the count of rows written",
"- OUT-2: The system writes records to the database table and logs the count of rows written successfully",
"- OUT-3: Error handling returns 400 on invalid input",
],
}));
assert.ok(failChecks(r).includes("outcomes-unique"));
const f = r.failures.find((f) => f.check === "outcomes-unique");
assert.ok(f.message.includes("OUT-1"));
assert.ok(f.message.includes("OUT-2"));
});
test("outcomes-unique passes with distinct outcomes", () => {
const r = runLint(validDoc());
assert.ok(!failChecks(r).includes("outcomes-unique"));
});
test("pipeline-e2e-trigger fails when pipeline keyword present but no e2e trigger OUT", () => {
const r = runLint(validDoc({
outcomes: [
"- OUT-1: The pipeline processes records correctly and returns status code 200",
"- OUT-2: Error handling rejects invalid pipeline input",
"- OUT-3: Logging outputs pipeline stage metrics",
],
}));
assert.ok(failChecks(r).includes("pipeline-e2e-trigger"));
const f = r.failures.find((f) => f.check === "pipeline-e2e-trigger");
assert.ok(f.message.includes("pipeline"));
});
test("pipeline-e2e-trigger passes when e2e trigger outcome exists", () => {
const r = runLint(validDoc({
outcomes: [
"- OUT-1: The pipeline processes records and returns 200",
"- OUT-2: Error handling rejects invalid input",
"- OUT-3: End-to-end trigger verification from upstream to downstream passes",
],
}));
assert.ok(!failChecks(r).includes("pipeline-e2e-trigger"));
});
test("pipeline-e2e-trigger skipped when no pipeline keywords", () => {
const r = runLint(validDoc());
assert.ok(!failChecks(r).includes("pipeline-e2e-trigger"));
});
});
describe("runLint — warnings", () => {
test("scope-empty warns when Out of Scope has no bullet items", () => {
const r = runLint(validDoc({ scope: "\n(empty)\n" }));
assert.ok(warnChecks(r).includes("scope-empty"));
});
test("scope-empty no warning when Out of Scope has items", () => {
const r = runLint(validDoc());
assert.ok(!warnChecks(r).includes("scope-empty"));
});
test("no-failure-modes warns when no outcomes mention failure/error", () => {
const r = runLint(validDoc({
outcomes: [
"- OUT-1: Returns status code 200 for valid requests",
"- OUT-2: Response contains JSON body with result field",
"- OUT-3: Response includes timestamp in ISO format",
],
}));
assert.ok(warnChecks(r).includes("no-failure-modes"));
});
test("no-failure-modes no warning when an outcome mentions error", () => {
const r = runLint(validDoc());
assert.ok(!warnChecks(r).includes("no-failure-modes"));
});
test("high-outcome-count warns with 6 outcomes", () => {
const outs = Array.from({ length: 6 }, (_, i) =>
`- OUT-${i + 1}: Unique outcome ${i + 1} returns status code ${200 + i * 100}`
);
const ver = outs.map((o) => `- ${o.match(/OUT-\d+/)[0]}: assert result`).join("\n");
const r = runLint(validDoc({ outcomes: outs, verification: ver }));
assert.ok(warnChecks(r).includes("high-outcome-count"));
assert.ok(r.warnings.find((w) => w.check === "high-outcome-count").message.includes("6"));
});
test("high-outcome-count no warning with 5 outcomes", () => {
const outs = Array.from({ length: 5 }, (_, i) =>
`- OUT-${i + 1}: Unique outcome ${i + 1} returns status code ${200 + i * 100}`
);
const ver = outs.map((o) => `- ${o.match(/OUT-\d+/)[0]}: assert result`).join("\n");
const r = runLint(validDoc({ outcomes: outs, verification: ver }));
assert.ok(!warnChecks(r).includes("high-outcome-count"));
});
});
describe("runLint — return value shape", () => {
test("returns correct shape with all fields", () => {
const r = runLint(validDoc());
assert.ok(typeof r.passed === "number");
assert.ok(typeof r.checksRun === "number");
assert.ok(Array.isArray(r.failures));
assert.ok(Array.isArray(r.warnings));
});
test("passed = checksRun - failures.length", () => {
const r = runLint(validDoc());
assert.equal(r.passed, r.checksRun - r.failures.length);
});
test("failure entries have check and message", () => {
const text = "nothing here";
const r = runLint(text);
assert.ok(r.failures.length > 0);
for (const f of r.failures) {
assert.ok(typeof f.check === "string");
assert.ok(typeof f.message === "string");
}
});
});
export function validDoc(opts = {}) {
const outcomes = opts.outcomes ?? [
"- OUT-1: The API returns status code 200 for valid requests",
"- OUT-2: The API returns status code 400 for invalid input with error details",
"- OUT-3: The response contains a JSON body with a `result` field",
];
const verification = opts.verification ?? outcomes
.map((o) => {
const id = o.match(/OUT-\d+/)[0];
return `- ${id}: assert HTTP status code matches expected value`;
})
.join("\n");
const quality = opts.quality ?? "- No N+1 queries";
const scope = opts.scope ?? "- No UI changes";
const extra = opts.extra ?? "";
return [
"## Outcomes",
outcomes.join("\n"),
"",
"## Verification",
verification,
"",
"## Quality Constraints",
quality,
"",
"## Out of Scope",
scope,
extra,
].join("\n");
}
export function failChecks(result) {
return result.failures.map((f) => f.check);
}
export function warnChecks(result) {
return result.warnings.map((w) => w.check);
}
// criteria-lint.test.mjs — structural checks
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { runLint } from "./criteria-lint.mjs";
import { failChecks, validDoc } from "./criteria-lint.test-helpers.mjs";
describe("runLint — structural checks", () => {
test("valid doc passes all checks", () => {
const r = runLint(validDoc());
assert.equal(r.failures.length, 0);
assert.equal(r.checksRun, 12);
assert.equal(r.passed, 12);
});
test("outcomes-exist fails when no Outcomes section", () => {
const text = "## Verification\nstuff\n## Quality Constraints\nq\n## Out of Scope\n- x";
const r = runLint(text);
assert.ok(failChecks(r).includes("outcomes-exist"));
});
test("outcomes-exist passes with Outcomes section", () => {
const r = runLint(validDoc());
assert.ok(!failChecks(r).includes("outcomes-exist"));
});
test("outcomes-count fails with 2 outcomes", () => {
const r = runLint(validDoc({
outcomes: [
"- OUT-1: Returns 200",
"- OUT-2: Returns error details on failure",
],
verification: "- OUT-1: assert status\n- OUT-2: assert error",
}));
assert.ok(failChecks(r).includes("outcomes-count"));
assert.ok(r.failures.find((f) => f.check === "outcomes-count").message.includes("2"));
});
test("outcomes-count fails with 8 outcomes", () => {
const outs = Array.from({ length: 8 }, (_, i) =>
`- OUT-${i + 1}: Outcome number ${i + 1} returns status code ${200 + i}`
);
const ver = outs.map((o) => {
const id = o.match(/OUT-\d+/)[0];
return `- ${id}: assert result`;
}).join("\n");
const r = runLint(validDoc({ outcomes: outs, verification: ver }));
assert.ok(failChecks(r).includes("outcomes-count"));
});
test("outcomes-count passes with 3-7 outcomes", () => {
const r = runLint(validDoc());
assert.ok(!failChecks(r).includes("outcomes-count"));
});
test("verification-exists fails when missing", () => {
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";
const r = runLint(text);
assert.ok(failChecks(r).includes("verification-exists"));
});
test("verification-mapped fails when OUT-N missing from verification", () => {
const r = runLint(validDoc({
verification: "- OUT-1: check\n- OUT-2: check",
}));
assert.ok(failChecks(r).includes("verification-mapped"));
assert.ok(r.failures.find((f) => f.check === "verification-mapped").message.includes("OUT-3"));
});
test("verification-mapped passes when all outcomes mapped", () => {
const r = runLint(validDoc());
assert.ok(!failChecks(r).includes("verification-mapped"));
});
test("quality-section fails when missing", () => {
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);
assert.ok(failChecks(r).includes("quality-section"));
});
test("scope-section fails when missing", () => {
const text = "## Outcomes\n- OUT-1: a\n- OUT-2: b\n- OUT-3: c\n## Verification\nOUT-1 OUT-2 OUT-3\n## Quality Constraints\nq";
const r = runLint(text);
assert.ok(failChecks(r).includes("scope-section"));
});
test("tier-section fails when tier given but no Quality Baseline section", () => {
const r = runLint(validDoc(), "functional");
assert.ok(failChecks(r).includes("tier-section"));
assert.ok(r.failures.find((f) => f.check === "tier-section").message.includes("functional"));
});
test("tier-section passes when Quality Baseline section exists", () => {
const doc = validDoc({ extra: "\n## Quality Baseline\n- baseline stuff" });
const r = runLint(doc, "polished");
assert.ok(!failChecks(r).includes("tier-section"));
});
test("tier-section skipped when tier is undefined", () => {
const r = runLint(validDoc());
assert.ok(!failChecks(r).includes("tier-section"));
});
test("tier-section skipped when tier is invalid", () => {
const r = runLint(validDoc(), "bogus");
assert.ok(!failChecks(r).includes("tier-section"));
});
});
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { parseEvaluation } from "./eval-parser.mjs";
describe("parseEvaluation", () => {
test("detects verdict when present", () => {
const r = parseEvaluation("VERDICT: PASS FINDINGS[0]");
assert.equal(r.verdict_present, true);
assert.equal(r.verdict, "PASS FINDINGS[0]");
});
test("verdict absent", () => {
const r = parseEvaluation("No verdict here");
assert.equal(r.verdict_present, false);
assert.equal(r.verdict, "");
});
test("counts severities", () => {
const text = [
"🔴 src/a.js:1 — critical bug",
"🔴 src/b.js:2 — another critical",
"🟡 src/c.js:3 — a warning",
"🔵 src/d.js:4 — a suggestion",
].join("\n");
const r = parseEvaluation(text);
assert.equal(r.critical, 2);
assert.equal(r.warning, 1);
assert.equal(r.suggestion, 1);
assert.equal(r.findings_count, 4);
});
test("detects file references", () => {
const r = parseEvaluation("🔴 src/foo.js:10 — issue");
assert.equal(r.has_file_refs, true);
});
test("no file refs when absent", () => {
const r = parseEvaluation("🔴 some issue without file ref — bad");
assert.equal(r.has_file_refs, false);
});
test("detects hedging", () => {
const r = parseEvaluation("🔴 src/a.js:1 — you might want to fix this");
assert.ok(r.hedging_detected.length > 0);
});
test("parses finding with file, line, fix, reasoning", () => {
const text = [
"🔴 src/app.js:42 — missing null check",
"→ Add a null guard before access",
"Reasoning: prevents runtime crash",
].join("\n");
const r = parseEvaluation(text);
assert.equal(r.findings.length, 1);
const f = r.findings[0];
assert.equal(f.severity, "critical");
assert.equal(f.file, "src/app.js");
assert.equal(f.line, 42);
assert.equal(f.fix, "Add a null guard before access");
assert.equal(f.reasoning, "prevents runtime crash");
});
test("skips section labels like '🔴 Must Fix:'", () => {
const text = "🔴 Must Fix:\n🔴 src/a.js:1 — real finding";
const r = parseEvaluation(text);
assert.equal(r.findings_count, 1);
});
test("skips empty markers (None., N/A) after section label", () => {
const text = "🔴 Must Fix:\n- None.\n🟡 Warnings:\nN/A";
const r = parseEvaluation(text);
assert.equal(r.findings_count, 0);
});
test("skips bare emoji filler lines like '🔴 None.'", () => {
const r = parseEvaluation("🔴 None.\n🟡 N/A\n🔵 nothing");
assert.equal(r.findings_count, 0);
});
test("thinEval when lineCount < 50", () => {
const r = parseEvaluation("short\neval");
assert.equal(r.thinEval, true);
assert.ok(r.lineCount < 50);
});
test("not thinEval when lineCount >= 50", () => {
const lines = Array.from({ length: 60 }, (_, i) => `Line ${i}`);
const r = parseEvaluation(lines.join("\n"));
assert.equal(r.thinEval, false);
});
test("CRLF normalization", () => {
const r = parseEvaluation("VERDICT: PASS\r\n🔴 src/a.js:1 — bug\r\n");
assert.equal(r.verdict_present, true);
assert.equal(r.findings_count, 1);
});
test("verdictCountMatch when FINDINGS[N] matches", () => {
const text = "VERDICT: PASS FINDINGS[2]\n🔴 a.js:1 — x\n🟡 b.js:2 — y";
const r = parseEvaluation(text);
assert.equal(r.verdict_count_match, true);
});
test("verdictCountMatch false when mismatch", () => {
const text = "VERDICT: PASS FINDINGS[5]\n🔴 a.js:1 — x";
const r = parseEvaluation(text);
assert.equal(r.verdict_count_match, false);
});
test("verdictCountMatch null when no FINDINGS[N] but findings exist", () => {
const text = "VERDICT: PASS\n🔴 a.js:1 — x";
const r = parseEvaluation(text);
assert.equal(r.verdict_count_match, null);
});
test("noCodeRefs true when no file:line refs", () => {
const r = parseEvaluation("Just text with no refs");
assert.equal(r.noCodeRefs, true);
});
test("fileLineRefCount counts all refs", () => {
const r = parseEvaluation("🔴 a.js:1 — x\n🔴 b.js:2 — y");
assert.equal(r.fileLineRefCount, 2);
});
test("lowUniqueContent detects copy-paste padding", () => {
const lines = Array.from({ length: 30 }, () => "This is a repeated padding line");
const r = parseEvaluation(lines.join("\n"));
assert.equal(r.lowUniqueContent, true);
});
test("singleHeading true when only one heading and >=30 lines", () => {
const lines = ["# Review", ...Array.from({ length: 35 }, (_, i) => `Content line ${i}`)];
const r = parseEvaluation(lines.join("\n"));
assert.equal(r.singleHeading, true);
});
test("singleHeading false with multiple headings", () => {
const lines = ["# Review", "## Section", ...Array.from({ length: 35 }, (_, i) => `Line ${i}`)];
const r = parseEvaluation(lines.join("\n"));
assert.equal(r.singleHeading, false);
});
test("aspirationalClaims detected", () => {
const text = [
"🔴 a.js:1 — should consider refactoring this",
"🔴 b.js:2 — worth exploring alternatives here",
"🔴 c.js:3 — it would be nice to add tests",
].join("\n");
const r = parseEvaluation(text);
assert.equal(r.aspirationalClaims, true);
assert.equal(r.aspirationalLineCount, 3);
});
test("aspirationalClaims false for actionable findings", () => {
const text = [
"🔴 a.js:1 — missing null check causes crash",
"🔴 b.js:2 — SQL injection vulnerability",
].join("\n");
const r = parseEvaluation(text);
assert.equal(r.aspirationalClaims, false);
});
test("findingDensityLow when few emoji lines in long doc", () => {
const lines = [
"🔴 a.js:1 — one finding",
...Array.from({ length: 55 }, (_, i) => `Padding line number ${i} for density`),
];
const r = parseEvaluation(lines.join("\n"));
assert.equal(r.findingDensityLow, true);
});
test("missingReasoningRatio and missingFixRatio", () => {
const text = "🔴 a.js:1 — issue without fix or reasoning";
const r = parseEvaluation(text);
assert.equal(r.missingReasoningRatio, 100);
assert.equal(r.missingFixRatio, 100);
assert.equal(r.findingsWithoutReasoning, 1);
assert.equal(r.findingsWithoutFix, 1);
});
test("fix and reasoning bring ratios to 0", () => {
const text = [
"🔴 a.js:1 — issue",
"→ Fix it",
"Reasoning: because",
].join("\n");
const r = parseEvaluation(text);
assert.equal(r.missingReasoningRatio, 0);
assert.equal(r.missingFixRatio, 0);
});
test("lineLengthVarianceLow for uniform lines", () => {
const lines = Array.from({ length: 20 }, () => "Exactly the same length line here!!");
const r = parseEvaluation(lines.join("\n"));
assert.equal(r.lineLengthVarianceLow, true);
});
test("headingCount counts h1-h3 headings", () => {
const text = "# H1\n## H2\n### H3\n#### H4 not counted";
const r = parseEvaluation(text);
assert.equal(r.headingCount, 3);
});
});
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import {
SEVERITY_MAP,
SEVERITY_RE,
FILE_REF_RE,
HEDGING_RE,
ASPIRATIONAL_RE,
VERDICT_RE,
FINDINGS_N_RE,
checkEvalDistinctness,
} from "./eval-parser.mjs";
describe("SEVERITY_RE", () => {
test("matches 🔴 🟡 🔵", () => {
for (const emoji of ["🔴", "🟡", "🔵"]) {
assert.ok(SEVERITY_RE.test(emoji));
assert.ok(SEVERITY_RE.test(`[${emoji}]`));
assert.ok(SEVERITY_RE.test(`foo ${emoji} bar`));
}
});
test("does not match other emoji", () => {
assert.ok(!SEVERITY_RE.test("🟢"));
assert.ok(!SEVERITY_RE.test("plain text"));
});
});
describe("FILE_REF_RE", () => {
test("matches file:line references", () => {
assert.ok(FILE_REF_RE.test("src/foo.js:42"));
assert.ok(FILE_REF_RE.test("lib/bar/baz.ts:1"));
});
test("does not match without line number", () => {
assert.ok(!FILE_REF_RE.test("src/foo.js"));
});
});
describe("HEDGING_RE", () => {
test("matches hedging words", () => {
assert.ok(HEDGING_RE.test("you might want to"));
assert.ok(HEDGING_RE.test("could potentially break"));
assert.ok(HEDGING_RE.test("Consider adding"));
});
test("does not match unrelated text", () => {
assert.ok(!HEDGING_RE.test("This is broken"));
});
});
describe("ASPIRATIONAL_RE", () => {
test("matches aspirational phrases", () => {
assert.ok(ASPIRATIONAL_RE.test("should consider refactoring"));
assert.ok(ASPIRATIONAL_RE.test("worth exploring alternatives"));
assert.ok(ASPIRATIONAL_RE.test("it would be nice to add"));
assert.ok(ASPIRATIONAL_RE.test("may want to refactor"));
assert.ok(ASPIRATIONAL_RE.test("could be improved"));
assert.ok(ASPIRATIONAL_RE.test("ideally we would"));
assert.ok(ASPIRATIONAL_RE.test("down the road"));
});
test("does not match actionable text", () => {
assert.ok(!ASPIRATIONAL_RE.test("must fix this bug"));
assert.ok(!ASPIRATIONAL_RE.test("long-term improvement"));
});
});
describe("VERDICT_RE", () => {
test("matches verdict lines", () => {
const m = "VERDICT: PASS FINDINGS[0]".match(VERDICT_RE);
assert.ok(m);
assert.equal(m[1], "PASS FINDINGS[0]");
});
test("case insensitive", () => {
assert.ok(VERDICT_RE.test("verdict: fail"));
});
test("no match without colon", () => {
assert.ok(!VERDICT_RE.test("VERDICT PASS"));
});
});
describe("FINDINGS_N_RE", () => {
test("extracts count", () => {
const m = "FINDINGS[3]".match(FINDINGS_N_RE);
assert.equal(m[1], "3");
});
test("no match without brackets", () => {
assert.ok(!FINDINGS_N_RE.test("FINDINGS 3"));
});
});
describe("SEVERITY_MAP", () => {
test("maps emoji to severity names", () => {
assert.equal(SEVERITY_MAP["🔴"], "critical");
assert.equal(SEVERITY_MAP["🟡"], "warning");
assert.equal(SEVERITY_MAP["🔵"], "suggestion");
});
});
describe("checkEvalDistinctness", () => {
test("returns empty for <2 items", () => {
const r = checkEvalDistinctness([{ path: "a", content: "x" }]);
assert.deepEqual(r, { errors: [], warnings: [] });
});
test("returns empty for non-array", () => {
const r = checkEvalDistinctness(null);
assert.deepEqual(r, { errors: [], warnings: [] });
});
test("identical content → error", () => {
const r = checkEvalDistinctness([
{ path: "a.md", content: "same" },
{ path: "b.md", content: "same" },
]);
assert.equal(r.errors.length, 1);
assert.ok(r.errors[0].includes("identical"));
});
test(">70% overlap → warning", () => {
const shared = Array.from({ length: 8 }, (_, i) => `This is shared line number ${i}`);
const a = [...shared, "unique a line that is long enough"].join("\n");
const b = [...shared, "unique b line that is long enough"].join("\n");
const r = checkEvalDistinctness([
{ path: "a.md", content: a },
{ path: "b.md", content: b },
]);
assert.equal(r.warnings.length >= 1, true);
assert.ok(r.warnings.some((w) => w.includes("overlap")));
});
test("identical headings → warning", () => {
const r = checkEvalDistinctness([
{ path: "a.md", content: "# Code Review\nContent A is different" },
{ path: "b.md", content: "# Code Review\nContent B is different" },
]);
assert.ok(r.warnings.some((w) => w.includes("identical headings")));
});
test("identical role tags → error", () => {
const r = checkEvalDistinctness([
{ path: "a.md", content: "Role: Security\nDifferent content A" },
{ path: "b.md", content: "Role: Security\nDifferent content B" },
]);
assert.ok(r.errors.some((e) => e.includes("role tag")));
});
test("distinct evals → no errors/warnings", () => {
const r = checkEvalDistinctness([
{ path: "a.md", content: "# Security Review\nRole: Security\nAll good" },
{ path: "b.md", content: "# Perf Review\nRole: Performance\nNeeds work" },
]);
assert.equal(r.errors.length, 0);
assert.equal(r.warnings.length, 0);
});
});
// flow-core-consistency.test.mjs — consistency, validators, routing
import { test, describe, beforeEach, afterEach } from "node:test";
import assert from "node:assert/strict";
import { validateHandshakeData, RULE_VALIDATORS, cmdRoute } from "./flow-core.mjs";
import { validHandshake } from "./flow-core.test-helpers.mjs";
describe("validateHandshakeData — consistency and loopback", () => {
test("findings.critical > 0 with PASS verdict → error", () => {
const { errors } = validateHandshakeData(validHandshake({
verdict: "PASS",
findings: { critical: 1, warning: 0, suggestion: 0 },
}));
assert.ok(errors.some((e) => e.includes("findings.critical > 0")));
});
test("findings.critical > 0 with FAIL verdict → no consistency error", () => {
const { errors } = validateHandshakeData(validHandshake({
verdict: "FAIL",
findings: { critical: 1, warning: 0, suggestion: 0 },
}));
assert.ok(!errors.some((e) => e.includes("critical findings")));
});
test("findings.critical = 0 with PASS → no consistency error", () => {
const { errors } = validateHandshakeData(validHandshake({
verdict: "PASS",
findings: { critical: 0, warning: 0, suggestion: 1 },
}));
assert.ok(!errors.some((e) => e.includes("critical findings")));
});
test("no findings object → no consistency error", () => {
const { errors } = validateHandshakeData(validHandshake({ verdict: "PASS" }));
assert.ok(!errors.some((e) => e.includes("critical findings")));
});
test("loopback not an object → error", () => {
const { errors } = validateHandshakeData(validHandshake({ loopback: "bad" }));
assert.ok(errors.some((e) => e.includes("loopback must be an object")));
});
test("loopback missing from → error", () => {
const { errors } = validateHandshakeData(validHandshake({
loopback: { reason: "retry", iteration: 1 },
}));
assert.ok(errors.some((e) => e.includes("loopback.from")));
});
test("loopback missing reason → error", () => {
const { errors } = validateHandshakeData(validHandshake({
loopback: { from: "gate", iteration: 1 },
}));
assert.ok(errors.some((e) => e.includes("loopback.reason")));
});
test("loopback.iteration not a number → error", () => {
const { errors } = validateHandshakeData(validHandshake({
loopback: { from: "gate", reason: "retry", iteration: "one" },
}));
assert.ok(errors.some((e) => e.includes("loopback.iteration")));
});
test("valid loopback → no errors", () => {
const { errors } = validateHandshakeData(validHandshake({
loopback: { from: "gate", reason: "retry", iteration: 1 },
}));
assert.ok(!errors.some((e) => e.includes("loopback")));
});
test("loopback null → no errors", () => {
const { errors } = validateHandshakeData(validHandshake({ loopback: null }));
assert.ok(!errors.some((e) => e.includes("loopback")));
});
});
describe("validateHandshakeData — tier coverage", () => {
test("execute node with tier but no tierCoverage → error", () => {
const { errors } = validateHandshakeData(validHandshake({
nodeType: "execute",
artifacts: [{ type: "test-result", path: "test.log" }],
}), {
tier: "polished",
tierBaselineKeys: ["TC-1"],
});
assert.ok(errors.some((e) => e.includes("tierCoverage object")));
});
test("execute node with tier and tierCoverage.covered not array → error", () => {
const { errors } = validateHandshakeData(validHandshake({
nodeType: "execute",
artifacts: [{ type: "test-result", path: "test.log" }],
tierCoverage: { covered: "bad", skipped: [] },
}), {
tier: "polished",
tierBaselineKeys: ["TC-1"],
});
assert.ok(errors.some((e) => e.includes("tierCoverage.covered must be an array")));
});
test("execute node with tier and skipped entry missing reason → error", () => {
const { errors } = validateHandshakeData(validHandshake({
nodeType: "execute",
artifacts: [{ type: "test-result", path: "test.log" }],
tierCoverage: { covered: ["TC-1"], skipped: [{ key: "TC-2" }] },
}), {
tier: "polished",
tierBaselineKeys: ["TC-1", "TC-2"],
});
assert.ok(errors.some((e) => e.includes("missing 'reason'")));
});
test("execute node with tier and skipped entry with short reason → error", () => {
const { errors } = validateHandshakeData(validHandshake({
nodeType: "execute",
artifacts: [{ type: "test-result", path: "test.log" }],
tierCoverage: { covered: ["TC-1"], skipped: [{ key: "TC-2", reason: "n/a" }] },
}), {
tier: "polished",
tierBaselineKeys: ["TC-1", "TC-2"],
});
assert.ok(errors.some((e) => e.includes("missing 'reason'")));
});
test("functional tier (no required keys) → no tierCoverage needed", () => {
const { errors } = validateHandshakeData(validHandshake({
nodeType: "execute",
artifacts: [{ type: "test-result", path: "test.log" }],
}), {
tier: "functional",
tierBaselineKeys: [],
});
assert.ok(!errors.some((e) => e.includes("tierCoverage")));
});
});
describe("RULE_VALIDATORS", () => {
test("non-empty-array: valid", () => {
assert.equal(RULE_VALIDATORS["non-empty-array"]([1]), true);
});
test("non-empty-array: empty → false", () => {
assert.equal(RULE_VALIDATORS["non-empty-array"]([]), false);
});
test("non-empty-array: non-array → false", () => {
assert.equal(RULE_VALIDATORS["non-empty-array"]("hi"), false);
});
test("non-empty-object: valid", () => {
assert.equal(RULE_VALIDATORS["non-empty-object"]({ a: 1 }), true);
});
test("non-empty-object: empty → false", () => {
assert.equal(RULE_VALIDATORS["non-empty-object"]({}), false);
});
test("non-empty-object: array → false", () => {
assert.equal(RULE_VALIDATORS["non-empty-object"]([1]), false);
});
test("non-empty-object: null → falsy", () => {
assert.ok(!RULE_VALIDATORS["non-empty-object"](null));
});
test("non-empty-string: valid", () => {
assert.equal(RULE_VALIDATORS["non-empty-string"]("hi"), true);
});
test("non-empty-string: empty → false", () => {
assert.equal(RULE_VALIDATORS["non-empty-string"](""), false);
});
test("non-empty-string: non-string → false", () => {
assert.equal(RULE_VALIDATORS["non-empty-string"](42), false);
});
test("positive-integer: valid", () => {
assert.equal(RULE_VALIDATORS["positive-integer"](5), true);
});
test("positive-integer: zero → false", () => {
assert.equal(RULE_VALIDATORS["positive-integer"](0), false);
});
test("positive-integer: negative → false", () => {
assert.equal(RULE_VALIDATORS["positive-integer"](-1), false);
});
test("positive-integer: float → false", () => {
assert.equal(RULE_VALIDATORS["positive-integer"](1.5), false);
});
});
describe("cmdRoute", () => {
let logOutput, errOutput, exitCode;
let origLog, origErr, origExit;
beforeEach(() => {
logOutput = [];
errOutput = [];
exitCode = null;
origLog = console.log;
origErr = console.error;
origExit = process.exit;
console.log = (...a) => logOutput.push(a.join(" "));
console.error = (...a) => errOutput.push(a.join(" "));
process.exit = (code) => { exitCode = code; throw new Error("EXIT"); };
});
afterEach(() => {
console.log = origLog;
console.error = origErr;
process.exit = origExit;
});
test("missing --node → exits 1", () => {
assert.throws(() => cmdRoute(["--verdict", "PASS", "--flow", "linear"]), /EXIT/);
assert.equal(exitCode, 1);
});
test("missing --verdict → exits 1", () => {
assert.throws(() => cmdRoute(["--node", "gate-1", "--flow", "linear"]), /EXIT/);
assert.equal(exitCode, 1);
});
test("node not in flow → valid=false", () => {
cmdRoute(["--node", "nonexistent", "--verdict", "PASS", "--flow", "review"]);
const out = JSON.parse(logOutput[0]);
assert.equal(out.valid, false);
assert.ok(out.error.includes("not in flow"));
});
test("valid route → valid=true with next node", () => {
cmdRoute(["--node", "review", "--verdict", "PASS", "--flow", "review"]);
const out = JSON.parse(logOutput[0]);
assert.equal(out.valid, true);
assert.equal(out.next, "gate");
});
test("no edge for verdict → valid=false", () => {
cmdRoute(["--node", "review", "--verdict", "BLOCKED", "--flow", "review"]);
const out = JSON.parse(logOutput[0]);
assert.equal(out.valid, false);
assert.ok(out.error.includes("no edge for verdict"));
});
});
export function validHandshake(overrides = {}) {
return {
nodeId: "gate-1",
nodeType: "gate",
runId: "run_1",
status: "completed",
verdict: "PASS",
summary: "All good",
timestamp: new Date().toISOString(),
artifacts: [],
...overrides,
};
}
// flow-core.test.mjs — handshake field/evidence validation
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { validateHandshakeData } from "./flow-core.mjs";
import { validHandshake } from "./flow-core.test-helpers.mjs";
describe("validateHandshakeData — fields, evidence, review", () => {
test("valid handshake → no errors", () => {
const { errors, warnings } = validateHandshakeData(validHandshake());
assert.equal(errors.length, 0);
assert.equal(warnings.length, 0);
});
for (const field of ["nodeId", "nodeType", "runId", "status", "summary", "timestamp"]) {
test(`missing '${field}' → error`, () => {
const data = validHandshake({ [field]: undefined });
delete data[field];
const { errors } = validateHandshakeData(data);
assert.ok(errors.some((e) => e.includes(field)), `expected error about ${field}`);
});
test(`empty string '${field}' → error`, () => {
const data = validHandshake({ [field]: "" });
const { errors } = validateHandshakeData(data);
assert.ok(errors.some((e) => e.includes(field)));
});
}
test("invalid nodeType → error", () => {
const { errors } = validateHandshakeData(validHandshake({ nodeType: "bogus" }));
assert.ok(errors.some((e) => e.includes("invalid nodeType")));
});
test("invalid status → error", () => {
const { errors } = validateHandshakeData(validHandshake({ status: "running" }));
assert.ok(errors.some((e) => e.includes("invalid status")));
});
test("invalid verdict → error", () => {
const { errors } = validateHandshakeData(validHandshake({ verdict: "MAYBE" }));
assert.ok(errors.some((e) => e.includes("invalid verdict")));
});
test("null verdict is allowed", () => {
const { errors } = validateHandshakeData(validHandshake({ verdict: null }));
assert.ok(!errors.some((e) => e.includes("verdict")));
});
test("artifacts not an array → error", () => {
const { errors } = validateHandshakeData(validHandshake({ artifacts: "nope" }));
assert.ok(errors.some((e) => e.includes("artifacts must be an array")));
});
test("artifact missing type → error (with baseDir)", () => {
const { errors } = validateHandshakeData(validHandshake({
artifacts: [{ path: "foo.txt" }],
}), { baseDir: "/tmp" });
assert.ok(errors.some((e) => e.includes("missing type or path")));
});
test("artifact missing path → error (with baseDir)", () => {
const { errors } = validateHandshakeData(validHandshake({
artifacts: [{ type: "log" }],
}), { baseDir: "/tmp" });
assert.ok(errors.some((e) => e.includes("missing type or path")));
});
test("artifact with nonexistent file → error (with baseDir)", () => {
const { errors } = validateHandshakeData(validHandshake({
artifacts: [{ type: "log", path: "does-not-exist.txt" }],
}), { baseDir: "/tmp/nonexistent-base-xyz" });
assert.ok(errors.some((e) => e.includes("file not found")));
});
test("execute node completed with no evidence → error", () => {
const { errors } = validateHandshakeData(validHandshake({
nodeType: "execute",
artifacts: [],
}), { checkEvidence: true });
assert.ok(errors.some((e) => e.includes("executor node missing evidence")));
});
test("execute node completed with evidence → no error", () => {
const { errors } = validateHandshakeData(validHandshake({
nodeType: "execute",
artifacts: [
{ type: "test-result", path: "test.log" },
],
}), { checkEvidence: true });
assert.ok(!errors.some((e) => e.includes("completed execute node needs evidence")));
});
test("execute node not completed → evidence not checked", () => {
const { errors } = validateHandshakeData(validHandshake({
nodeType: "execute",
status: "blocked",
artifacts: [],
}));
assert.ok(!errors.some((e) => e.includes("needs evidence")));
});
test("non-execute node → evidence not checked", () => {
const { errors } = validateHandshakeData(validHandshake({
nodeType: "gate",
artifacts: [],
}));
assert.ok(!errors.some((e) => e.includes("needs evidence")));
});
test("softEvidence → warning instead of error for missing evidence", () => {
const { errors, warnings } = validateHandshakeData(validHandshake({
nodeType: "execute",
artifacts: [],
}), { checkEvidence: true, softEvidence: true });
assert.ok(!errors.some((e) => e.includes("needs evidence")));
assert.ok(warnings.some((w) => w.includes("missing standard evidence")));
});
test("polished tier: missing screenshot → error", () => {
const { errors } = validateHandshakeData(validHandshake({
nodeType: "execute",
artifacts: [
{ type: "test-result", path: "test.log" },
],
}), { checkEvidence: true, tier: "polished" });
assert.ok(errors.some((e) => e.includes("screenshot")));
});
test("polished tier: missing cli/test → error", () => {
const { errors } = validateHandshakeData(validHandshake({
nodeType: "execute",
artifacts: [
{ type: "screenshot", path: "shot.png" },
],
}), { checkEvidence: true, tier: "polished" });
assert.ok(errors.some((e) => e.includes("cli-output or test-result")));
});
test("polished tier: both screenshot + test → no tier errors", () => {
const { errors } = validateHandshakeData(validHandshake({
nodeType: "execute",
artifacts: [
{ type: "screenshot", path: "shot.png" },
{ type: "test-result", path: "test.log" },
],
}), { checkEvidence: true, tier: "polished" });
assert.ok(!errors.some((e) => e.includes("screenshot")));
assert.ok(!errors.some((e) => e.includes("cli-output/test-result")));
});
test("delightful tier: needs ≥2 screenshots", () => {
const { errors } = validateHandshakeData(validHandshake({
nodeType: "execute",
artifacts: [
{ type: "screenshot", path: "one.png" },
{ type: "test-result", path: "test.log" },
],
}), { checkEvidence: true, tier: "delightful" });
assert.ok(errors.some((e) => e.includes("≥2 screenshot evidence")));
});
test("delightful tier: 2 screenshots + test → no tier errors", () => {
const { errors } = validateHandshakeData(validHandshake({
nodeType: "execute",
artifacts: [
{ type: "screenshot", path: "one.png" },
{ type: "screenshot", path: "two.png" },
{ type: "test-result", path: "test.log" },
],
}), { checkEvidence: true, tier: "delightful" });
assert.ok(!errors.some((e) => e.includes("screenshots")));
});
test("review node completed with <2 eval artifacts → error", () => {
const { errors } = validateHandshakeData(validHandshake({
nodeType: "review",
artifacts: [
{ type: "eval", path: "one.md" },
],
}));
assert.ok(errors.some((e) => e.includes("≥2 eval artifacts")));
});
test("review node completed with 0 eval artifacts → error", () => {
const { errors } = validateHandshakeData(validHandshake({
nodeType: "review",
artifacts: [
{ type: "log", path: "log.txt" },
],
}));
assert.ok(errors.some((e) => e.includes("≥2 eval artifacts")));
});
test("review node completed with ≥2 eval artifacts → no independence error", () => {
const { errors } = validateHandshakeData(validHandshake({
nodeType: "review",
artifacts: [
{ type: "eval", path: "a.md" },
{ type: "eval", path: "b.md" },
],
}));
assert.ok(!errors.some((e) => e.includes("independent eval")));
});
test("review node not completed → independence not checked", () => {
const { errors } = validateHandshakeData(validHandshake({
nodeType: "review",
status: "blocked",
artifacts: [],
}));
assert.ok(!errors.some((e) => e.includes("independent eval")));
});
});
// loop-p1p3.test.mjs — Tests for P1 (projectDir) + P3 (structured stall errors)
// Run: node --test bin/lib/loop-p1p3.test.mjs
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { mkdtempSync, writeFileSync, mkdirSync, rmSync, existsSync, readFileSync } from "fs";
import { join } from "path";
import { tmpdir } from "os";
import { execFileSync } from "child_process";
import { getGitHeadHash, detectPreCommitHooks, detectTestScript } from "./loop-helpers.mjs";
const HARNESS = join(import.meta.dirname, "..", "opc-harness.mjs");
function runHarness(args, { cwd } = {}) {
try {
const out = execFileSync("node", [HARNESS, ...args], {
encoding: "utf8",
timeout: 10000,
cwd: cwd || undefined,
env: { ...process.env, OPC_TICK_TIMEOUT_HOURS: "0.001" },
});
return JSON.parse(out.trim().split("\n").pop());
} catch (err) {
const output = (err.stdout || "") + (err.stderr || "");
const lines = output.trim().split("\n").filter(Boolean);
for (let i = lines.length - 1; i >= 0; i--) {
try { return JSON.parse(lines[i]); } catch { /* continue */ }
}
throw new Error(`harness failed: ${output}`);
}
}
// ── P1: loop-helpers accept projectDir ─────────────────────────
describe("P1: getGitHeadHash with projectDir", () => {
test("returns hash when given valid git repo dir", () => {
// Use the OPC skill dir itself (it's a git repo or inside one)
const opcDir = join(import.meta.dirname, "..", "..");
const hash = getGitHeadHash(opcDir);
// May or may not be a git repo, but shouldn't throw
if (hash) {
assert.match(hash, /^[0-9a-f]{40}$/);
}
});
test("returns null for non-git directory", () => {
const tmp = mkdtempSync(join(tmpdir(), "p1-test-"));
try {
const hash = getGitHeadHash(tmp);
assert.equal(hash, null);
} finally {
rmSync(tmp, { recursive: true });
}
});
test("returns null for non-existent directory", () => {
const hash = getGitHeadHash("/tmp/definitely-does-not-exist-xyz");
assert.equal(hash, null);
});
});
describe("P1: detectPreCommitHooks with projectDir", () => {
test("returns false for empty directory", () => {
const tmp = mkdtempSync(join(tmpdir(), "p1-hooks-"));
try {
assert.equal(detectPreCommitHooks(tmp), false);
} finally {
rmSync(tmp, { recursive: true });
}
});
test("returns true when .husky/pre-commit exists", () => {
const tmp = mkdtempSync(join(tmpdir(), "p1-hooks-"));
try {
mkdirSync(join(tmp, ".husky"), { recursive: true });
writeFileSync(join(tmp, ".husky", "pre-commit"), "#!/bin/sh\nexit 0\n");
assert.equal(detectPreCommitHooks(tmp), true);
} finally {
rmSync(tmp, { recursive: true });
}
});
});
describe("P1: detectTestScript with projectDir", () => {
test("returns all false for dir without package.json", () => {
const tmp = mkdtempSync(join(tmpdir(), "p1-pkg-"));
try {
const result = detectTestScript(tmp);
assert.deepEqual(result, { test: false, lint: false, typecheck: false });
} finally {
rmSync(tmp, { recursive: true });
}
});
test("detects test script from package.json in specified dir", () => {
const tmp = mkdtempSync(join(tmpdir(), "p1-pkg-"));
try {
writeFileSync(join(tmp, "package.json"), JSON.stringify({
scripts: { test: "vitest", lint: "eslint .", typecheck: "tsc --noEmit" }
}));
const result = detectTestScript(tmp);
assert.deepEqual(result, { test: "npm run test", lint: "npm run lint", typecheck: "npm run typecheck" });
} finally {
rmSync(tmp, { recursive: true });
}
});
});
// ── P1: init-loop --project-dir ────────────────────────────────
describe("P1: init-loop --project-dir", () => {
test("stores projectDir in loop-state.json", () => {
const tmp = mkdtempSync(join(tmpdir(), "p1-init-"));
const projDir = mkdtempSync(join(tmpdir(), "p1-proj-"));
try {
// Write minimal plan
writeFileSync(join(tmp, "plan.md"), [
"## Task Scope",
"- SCOPE-1: test",
"",
"## Units",
"- T1.1: implement — do stuff",
" - verify: echo ok",
"- T1.2: review — check stuff",
" - eval: quality check",
].join("\n"));
// Write acceptance criteria
writeFileSync(join(tmp, "acceptance-criteria.md"), [
"# Acceptance Criteria",
"## Outcomes",
"- OUT-1: thing works",
" - VERIFY: run test",
"## Verification",
"```bash",
"echo ok",
"```",
"- OUT-1: verified by running test",
"## Quality Constraints",
"- backward compat",
"## Out of Scope",
"- nothing",
].join("\n"));
const result = runHarness(["init-loop", "--dir", tmp, "--project-dir", projDir, "--skip-lint"], { cwd: tmp });
assert.equal(result.initialized, true);
// Read state and verify projectDir
const state = JSON.parse(readFileSync(join(tmp, "loop-state.json"), "utf8"));
assert.equal(state.projectDir, projDir);
} finally {
rmSync(tmp, { recursive: true });
rmSync(projDir, { recursive: true });
}
});
test("rejects non-existent --project-dir", () => {
const tmp = mkdtempSync(join(tmpdir(), "p1-init-bad-"));
try {
writeFileSync(join(tmp, "plan.md"), "## Task Scope\n- SCOPE-1: x\n\n- T1.1: implement — x\n- T1.2: review — x\n");
const result = runHarness(["init-loop", "--dir", tmp, "--project-dir", "/tmp/no-such-dir-xyz123"], { cwd: tmp });
assert.equal(result.initialized, false);
assert.ok(result.errors.some(e => e.includes("does not exist")));
} finally {
rmSync(tmp, { recursive: true });
}
});
});
// ── P3: Structured stall errors ────────────────────────────────
describe("P3: complete-tick structured terminal error", () => {
test("returns status/reason/detail/hint when loop is terminated", () => {
const tmp = mkdtempSync(join(tmpdir(), "p3-term-"));
try {
const state = {
tick: 2,
unit: "F1.1",
status: "stalled",
next_unit: "F1.2",
_written_by: "opc-harness/1.0",
_write_nonce: "abc123",
_last_modified: new Date().toISOString(),
};
writeFileSync(join(tmp, "loop-state.json"), JSON.stringify(state));
const result = runHarness(["complete-tick", "--dir", tmp, "--unit", "F1.2", "--artifacts", "", "--description", "test"], { cwd: tmp });
assert.equal(result.completed, false);
assert.equal(result.status, "terminal");
assert.ok(result.reason);
assert.ok(result.detail);
assert.ok(result.hint);
} finally {
rmSync(tmp, { recursive: true });
}
});
});
describe("P3: next-tick structured stall output", () => {
test("in_progress timeout includes status/detail/hint", () => {
const tmp = mkdtempSync(join(tmpdir(), "p3-stall-"));
try {
const oldTime = new Date(Date.now() - 2 * 3600000).toISOString(); // 2h ago
const state = {
tick: 1,
unit: "F1.1",
status: "in_progress",
next_unit: "F1.2",
plan_file: join(tmp, "plan.md"),
_written_by: "opc-harness/1.0",
_write_nonce: "abc123",
_in_progress_since: oldTime,
_last_modified: oldTime,
_tick_history: [],
};
writeFileSync(join(tmp, "loop-state.json"), JSON.stringify(state));
writeFileSync(join(tmp, "plan.md"), "- F1.1: implement — x\n- F1.2: review — y\n");
const result = runHarness(["next-tick", "--dir", tmp], { cwd: tmp });
assert.equal(result.ready, false);
assert.equal(result.terminate, true);
assert.equal(result.status, "stalled");
assert.ok(result.detail);
assert.ok(result.hint);
} finally {
rmSync(tmp, { recursive: true });
}
});
});
// ── P3: 3-tick stall detection ─────────────────────────────────
describe("P3: next-tick 3-tick stall detection", () => {
test("terminates with structured output after 3 consecutive same-unit ticks", () => {
const tmp = mkdtempSync(join(tmpdir(), "p3-3tick-"));
try {
writeFileSync(join(tmp, "plan.md"), "- F1.1: implement — x\n- F1.2: review — y\n");
const state = {
tick: 3,
unit: "F1.1",
status: "completed",
next_unit: "F1.1",
plan_file: join(tmp, "plan.md"),
_written_by: "opc-harness/1.0",
_write_nonce: "abc123",
_last_modified: new Date().toISOString(),
_tick_history: [
{ unit: "F1.1", tick: 1, status: "blocked" },
{ unit: "F1.1", tick: 2, status: "blocked" },
{ unit: "F1.1", tick: 3, status: "failed" },
],
};
writeFileSync(join(tmp, "loop-state.json"), JSON.stringify(state));
const result = runHarness(["next-tick", "--dir", tmp], { cwd: tmp });
assert.equal(result.ready, false);
assert.equal(result.terminate, true);
assert.equal(result.status, "stalled");
assert.ok(result.detail);
assert.ok(result.hint);
assert.ok(result.reason.includes("stalled"));
} finally {
rmSync(tmp, { recursive: true });
}
});
});
// ── P3: 6-tick oscillation detection ───────────────────────────
describe("P3: next-tick oscillation stall detection", () => {
test("terminates with structured output after A-B-A-B-A-B pattern", () => {
const tmp = mkdtempSync(join(tmpdir(), "p3-osc-"));
try {
writeFileSync(join(tmp, "plan.md"), "- F1.1: implement — x\n- F1.2: review — y\n");
const state = {
tick: 6,
unit: "F1.2",
status: "completed",
next_unit: "F1.1",
plan_file: join(tmp, "plan.md"),
_written_by: "opc-harness/1.0",
_write_nonce: "abc123",
_last_modified: new Date().toISOString(),
_tick_history: [
{ unit: "F1.1", tick: 1, status: "completed" },
{ unit: "F1.2", tick: 2, status: "completed" },
{ unit: "F1.1", tick: 3, status: "completed" },
{ unit: "F1.2", tick: 4, status: "completed" },
{ unit: "F1.1", tick: 5, status: "completed" },
{ unit: "F1.2", tick: 6, status: "completed" },
],
};
writeFileSync(join(tmp, "loop-state.json"), JSON.stringify(state));
const result = runHarness(["next-tick", "--dir", tmp], { cwd: tmp });
assert.equal(result.ready, false);
assert.equal(result.terminate, true);
assert.equal(result.status, "stalled");
assert.ok(result.detail);
assert.ok(result.hint);
assert.ok(result.reason.includes("oscillation"));
} finally {
rmSync(tmp, { recursive: true });
}
});
});
// ── P3: maxTotalTicks exhaustion ───────────────────────────────
describe("P3: next-tick maxTotalTicks exhaustion", () => {
test("terminates with structured output when tick >= _max_total_ticks", () => {
const tmp = mkdtempSync(join(tmpdir(), "p3-max-"));
try {
writeFileSync(join(tmp, "plan.md"), "- F1.1: implement — x\n- F1.2: review — y\n");
const state = {
tick: 6,
unit: "F1.1",
status: "completed",
next_unit: "F1.2",
plan_file: join(tmp, "plan.md"),
_written_by: "opc-harness/1.0",
_write_nonce: "abc123",
_last_modified: new Date().toISOString(),
_tick_history: [{ unit: "F1.1", tick: 1, status: "completed" }],
_max_total_ticks: 6,
};
writeFileSync(join(tmp, "loop-state.json"), JSON.stringify(state));
const result = runHarness(["next-tick", "--dir", tmp], { cwd: tmp });
assert.equal(result.ready, false);
assert.equal(result.terminate, true);
assert.equal(result.status, "terminated");
assert.ok(result.detail);
assert.ok(result.hint);
assert.ok(result.reason.includes("maxTotalTicks"));
} finally {
rmSync(tmp, { recursive: true });
}
});
});
// ── P3: wall-clock deadline ────────────────────────────────────
describe("P3: next-tick wall-clock deadline", () => {
test("terminates with structured output when duration exceeded", () => {
const tmp = mkdtempSync(join(tmpdir(), "p3-wall-"));
try {
writeFileSync(join(tmp, "plan.md"), "- F1.1: implement — x\n- F1.2: review — y\n");
const state = {
tick: 1,
unit: "F1.1",
status: "completed",
next_unit: "F1.2",
plan_file: join(tmp, "plan.md"),
_written_by: "opc-harness/1.0",
_write_nonce: "abc123",
_last_modified: new Date().toISOString(),
_tick_history: [{ unit: "F1.1", tick: 1, status: "completed" }],
_started_at: new Date(Date.now() - 25 * 3600000).toISOString(),
_max_duration_hours: 24,
};
writeFileSync(join(tmp, "loop-state.json"), JSON.stringify(state));
const result = runHarness(["next-tick", "--dir", tmp], { cwd: tmp });
assert.equal(result.ready, false);
assert.equal(result.terminate, true);
assert.equal(result.status, "terminated");
assert.ok(result.detail);
assert.ok(result.hint);
assert.ok(result.reason.includes("wall-clock"));
} finally {
rmSync(tmp, { recursive: true });
}
});
});
// ── Fix #4: Stall detection false positive ────────────────────
describe("P3: stall detection does NOT fire when last tick succeeded", () => {
test("3 same-unit ticks with last one completed = no stall", () => {
const tmp = mkdtempSync(join(tmpdir(), "p3-nostall-"));
try {
writeFileSync(join(tmp, "plan.md"), "- F1.1: implement — x\n- F1.2: review — y\n");
const state = {
tick: 3,
unit: "F1.1",
status: "completed",
next_unit: "F1.1",
plan_file: join(tmp, "plan.md"),
_written_by: "opc-harness/1.0",
_write_nonce: "abc123",
_last_modified: new Date().toISOString(),
_tick_history: [
{ unit: "F1.1", tick: 1, status: "blocked" },
{ unit: "F1.1", tick: 2, status: "blocked" },
{ unit: "F1.1", tick: 3, status: "completed" },
],
};
writeFileSync(join(tmp, "loop-state.json"), JSON.stringify(state));
const result = runHarness(["next-tick", "--dir", tmp], { cwd: tmp });
// Should NOT stall — last tick succeeded
assert.equal(result.terminate, undefined || false);
assert.equal(result.ready, true);
} finally {
rmSync(tmp, { recursive: true });
}
});
});
// ── Fix #3: _runTestScript coverage ───────────────────────────
describe("_runTestScript: timeout vs real failure distinction", () => {
test("passing test returns exitCode 0", () => {
const tmp = mkdtempSync(join(tmpdir(), "p3-run-"));
try {
// Create a fake project with passing test script
writeFileSync(join(tmp, "package.json"), JSON.stringify({
scripts: { test: "echo PASS" }
}));
writeFileSync(join(tmp, "plan.md"), "- F1.1: implement — x\n- F1.2: review — y\n");
const state = {
tick: 0,
unit: null,
status: "in_progress",
next_unit: "F1.1",
plan_file: join(tmp, "plan.md"),
_written_by: "opc-harness/1.0",
_write_nonce: "abc123",
_last_modified: new Date().toISOString(),
_git_head: null,
_tick_history: [],
_external_validators: { test_script: "echo PASS", pre_commit_hooks: false },
projectDir: tmp,
};
writeFileSync(join(tmp, "loop-state.json"), JSON.stringify(state));
// Create a dummy artifact
writeFileSync(join(tmp, "result.json"), JSON.stringify({ tests_run: 1, passed: 1, _command: "echo PASS", exitCode: 0 }));
const result = runHarness(["complete-tick", "--dir", tmp, "--unit", "F1.1", "--artifacts", join(tmp, "result.json"), "--description", "test"], { cwd: tmp });
// Should not have timeout error
const hasTimeout = (result.errors || []).some(e => e.includes("TIMED OUT"));
assert.equal(hasTimeout, false);
} finally {
rmSync(tmp, { recursive: true });
}
});
test("failing test returns exitCode != 0 with clear message", () => {
const tmp = mkdtempSync(join(tmpdir(), "p3-fail-"));
try {
writeFileSync(join(tmp, "package.json"), JSON.stringify({
scripts: { test: "exit 1" }
}));
writeFileSync(join(tmp, "plan.md"), "- F1.1: implement — x\n- F1.2: review — y\n");
const state = {
tick: 0,
unit: null,
status: "in_progress",
next_unit: "F1.1",
plan_file: join(tmp, "plan.md"),
_written_by: "opc-harness/1.0",
_write_nonce: "abc123",
_last_modified: new Date().toISOString(),
_git_head: null,
_tick_history: [],
_external_validators: { test_script: "exit 1", pre_commit_hooks: false },
projectDir: tmp,
};
writeFileSync(join(tmp, "loop-state.json"), JSON.stringify(state));
writeFileSync(join(tmp, "result.json"), JSON.stringify({ tests_run: 1, passed: 0, _command: "exit 1", exitCode: 1 }));
const result = runHarness(["complete-tick", "--dir", tmp, "--unit", "F1.1", "--artifacts", join(tmp, "result.json"), "--description", "test"], { cwd: tmp });
assert.equal(result.completed, false);
const hasTestFail = (result.errors || []).some(e => e.includes("exit 1") && !e.includes("TIMED OUT"));
assert.equal(hasTestFail, true);
} finally {
rmSync(tmp, { recursive: true });
}
});
});
// ── Full lifecycle integration test ──────────────────────────
describe("Full lifecycle: init → next-tick → complete-tick → terminate", () => {
test("complete loop with implement + review units", () => {
const tmp = mkdtempSync(join(tmpdir(), "lifecycle-"));
try {
// Set up a git repo so commit checks work
execFileSync("git", ["init"], { cwd: tmp });
execFileSync("git", ["config", "user.email", "test@test.com"], { cwd: tmp });
execFileSync("git", ["config", "user.name", "Test"], { cwd: tmp });
// Write plan with implement + review
writeFileSync(join(tmp, "plan.md"), [
"## Task Scope",
"- SCOPE-1: add greeting feature",
"",
"## Units",
"- T1.1: implement — add greeting module",
" - verify: node -e \"require('./greet')\"",
"- T1.2: review — check greeting quality",
" - eval: code quality review",
].join("\n"));
// Write acceptance criteria
writeFileSync(join(tmp, "acceptance-criteria.md"), [
"# Acceptance Criteria",
"## Outcomes",
"- OUT-1: greeting module exists",
" - VERIFY: node -e \"require('./greet')\"",
"## Verification",
"```bash",
"node -e \"require('./greet')\"",
"```",
"- OUT-1: verified by requiring module",
"## Quality Constraints",
"- clean code",
"## Out of Scope",
"- nothing",
].join("\n"));
// Initial commit
writeFileSync(join(tmp, "README.md"), "# Test\n");
execFileSync("git", ["add", "."], { cwd: tmp });
execFileSync("git", ["commit", "-m", "init"], { cwd: tmp });
// ── Step 1: init-loop ──
const initResult = runHarness(["init-loop", "--dir", tmp, "--project-dir", tmp, "--skip-lint"], { cwd: tmp });
assert.equal(initResult.initialized, true);
assert.equal(initResult.first_unit, "T1.1");
assert.equal(initResult.total_units, 2);
// ── Step 2: next-tick → should give T1.1 ──
const tick1 = runHarness(["next-tick", "--dir", tmp], { cwd: tmp });
assert.equal(tick1.ready, true);
assert.equal(tick1.next_unit, "T1.1");
assert.equal(tick1.unit_type, "implement");
assert.equal(tick1.tick, 1);
// Simulate implement: create file + commit + artifact
writeFileSync(join(tmp, "greet.js"), "module.exports = () => 'hello';\n");
const artifactPath = join(tmp, "test-result.json");
writeFileSync(artifactPath, JSON.stringify({ tests_run: 1, passed: 1, _command: "node -e \"require('./greet')\"", exitCode: 0 }));
execFileSync("git", ["add", "."], { cwd: tmp });
execFileSync("git", ["commit", "-m", "feat: add greeting"], { cwd: tmp });
// Touch artifact after commit so it's fresh
writeFileSync(artifactPath, JSON.stringify({ tests_run: 1, passed: 1, _command: "node -e \"require('./greet')\"", exitCode: 0 }));
// ── Step 3: complete-tick T1.1 ──
const complete1 = runHarness([
"complete-tick", "--dir", tmp,
"--unit", "T1.1",
"--artifacts", artifactPath,
"--description", "added greeting module covering SCOPE-1",
], { cwd: tmp });
assert.equal(complete1.completed, true, `Expected completed=true, got errors: ${JSON.stringify(complete1.errors)}`);
assert.equal(complete1.tick, 1);
assert.equal(complete1.next_unit, "T1.2");
assert.equal(complete1.terminate, false);
// ── Step 4: next-tick → should give T1.2 ──
const tick2 = runHarness(["next-tick", "--dir", tmp], { cwd: tmp });
assert.equal(tick2.ready, true);
assert.equal(tick2.next_unit, "T1.2");
assert.equal(tick2.unit_type, "review");
// Simulate review: write 2 eval files with LGTM
const eval1 = join(tmp, "eval-frontend.md");
const eval2 = join(tmp, "eval-backend.md");
writeFileSync(eval1, "# Frontend Review\n\n🔵 Suggestion: add JSDoc\n\nOverall: LGTM\n");
writeFileSync(eval2, "# Backend Review\n\n🔵 Suggestion: add types\n\nOverall: LGTM — clean implementation\n");
// ── Step 5: complete-tick T1.2 ──
const complete2 = runHarness([
"complete-tick", "--dir", tmp,
"--unit", "T1.2",
"--artifacts", `${eval1},${eval2}`,
"--description", "review passed with minor suggestions",
"--skip-scope-check",
], { cwd: tmp });
assert.equal(complete2.completed, true, `Expected completed=true, got errors: ${JSON.stringify(complete2.errors)}`);
assert.equal(complete2.tick, 2);
assert.equal(complete2.next_unit, null);
assert.equal(complete2.terminate, true);
assert.equal(complete2.verdict, "PASS");
// ── Step 6: next-tick → should terminate (pipeline_complete) ──
const tick3 = runHarness(["next-tick", "--dir", tmp], { cwd: tmp });
assert.equal(tick3.ready, false);
assert.equal(tick3.terminate, true);
assert.ok(tick3.reason, `tick3 has no reason, full result: ${JSON.stringify(tick3)}`);
assert.ok(tick3.reason.includes("pipeline_complete") || tick3.reason.includes("already") || tick3.reason.includes("null"), `unexpected reason: ${tick3.reason}`);
// Verify final state
const finalState = JSON.parse(readFileSync(join(tmp, "loop-state.json"), "utf8"));
assert.equal(finalState.status, "pipeline_complete");
assert.equal(finalState.tick, 2);
// Verify progress.md was written
assert.ok(existsSync(join(tmp, "progress.md")));
const progress = readFileSync(join(tmp, "progress.md"), "utf8");
assert.ok(progress.includes("Tick 1"));
assert.ok(progress.includes("Tick 2"));
} finally {
rmSync(tmp, { recursive: true });
}
});
});
// tier-baselines.test.mjs — unit tests for tier-baselines.mjs
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import {
VALID_TIERS,
TEST_LAYERS,
TEST_LAYER_LABELS,
TEST_LAYER_KEYWORDS,
TIER_BASELINES,
RED_FLAGS,
RED_FLAG_KEYS,
TRUST_SIGNAL_KEYS,
TIER_FIT_BUCKETS,
DELTA_ASSESSMENTS,
WARNING_THRESHOLDS,
getRedFlagSeverity,
parseRedFlagOverrides,
getBaselineForTier,
getSeverity,
generateTierTestCases,
getRequiredBaselineKeys,
getAllBaselineKeys,
checkBaselineCoverage,
} from "./tier-baselines.mjs";
describe("VALID_TIERS", () => {
test("is a Set with exactly 3 tiers", () => {
assert.ok(VALID_TIERS instanceof Set);
assert.equal(VALID_TIERS.size, 3);
for (const t of ["functional", "polished", "delightful"]) {
assert.ok(VALID_TIERS.has(t));
}
});
});
describe("TEST_LAYERS / TEST_LAYER_LABELS / TEST_LAYER_KEYWORDS", () => {
test("TEST_LAYERS has 5 entries L1–L5", () => {
assert.deepEqual(TEST_LAYERS, ["L1", "L2", "L3", "L4", "L5"]);
});
test("TEST_LAYER_LABELS has a label for each layer", () => {
for (const layer of TEST_LAYERS) {
assert.equal(typeof TEST_LAYER_LABELS[layer], "string");
assert.ok(TEST_LAYER_LABELS[layer].length > 0);
}
});
test("TEST_LAYER_KEYWORDS has a non-empty array for each layer", () => {
for (const layer of TEST_LAYERS) {
assert.ok(Array.isArray(TEST_LAYER_KEYWORDS[layer]));
assert.ok(TEST_LAYER_KEYWORDS[layer].length > 0);
}
});
});
describe("TIER_BASELINES", () => {
test("is a non-empty array", () => {
assert.ok(Array.isArray(TIER_BASELINES));
assert.ok(TIER_BASELINES.length > 0);
});
test("each item has required keys", () => {
for (const item of TIER_BASELINES) {
assert.equal(typeof item.key, "string");
assert.equal(typeof item.label, "string");
assert.ok(Array.isArray(item.keywords));
assert.ok(item.keywords.length > 0);
assert.equal(typeof item.severity, "object");
assert.equal(typeof item.testCase, "object");
}
});
test("severity has all 3 tiers", () => {
for (const item of TIER_BASELINES) {
for (const tier of VALID_TIERS) {
assert.ok(tier in item.severity, `${item.key} missing severity for ${tier}`);
}
}
});
test("testCase has required fields", () => {
for (const item of TIER_BASELINES) {
const tc = item.testCase;
assert.equal(typeof tc.category, "string");
assert.equal(typeof tc.description, "string");
assert.ok(Array.isArray(tc.steps));
assert.equal(typeof tc.expected, "string");
assert.equal(typeof tc.failureImpact, "string");
}
});
});
describe("RED_FLAGS / RED_FLAG_KEYS", () => {
test("RED_FLAGS is a non-empty array with required keys", () => {
assert.ok(RED_FLAGS.length > 0);
for (const flag of RED_FLAGS) {
assert.equal(typeof flag.key, "string");
assert.equal(typeof flag.label, "string");
assert.equal(typeof flag.severity, "object");
}
});
test("each flag severity has all 3 tiers", () => {
for (const flag of RED_FLAGS) {
for (const tier of VALID_TIERS) {
assert.ok(tier in flag.severity, `${flag.key} missing severity for ${tier}`);
}
}
});
test("RED_FLAG_KEYS is a Set matching RED_FLAGS keys", () => {
assert.ok(RED_FLAG_KEYS instanceof Set);
assert.equal(RED_FLAG_KEYS.size, RED_FLAGS.length);
for (const flag of RED_FLAGS) {
assert.ok(RED_FLAG_KEYS.has(flag.key));
}
});
});
describe("TRUST_SIGNAL_KEYS", () => {
test("is a non-empty Set of strings", () => {
assert.ok(TRUST_SIGNAL_KEYS instanceof Set);
assert.ok(TRUST_SIGNAL_KEYS.size > 0);
for (const k of TRUST_SIGNAL_KEYS) {
assert.equal(typeof k, "string");
}
});
});
describe("TIER_FIT_BUCKETS", () => {
test("contains expected buckets", () => {
assert.ok(TIER_FIT_BUCKETS instanceof Set);
assert.equal(TIER_FIT_BUCKETS.size, 4);
for (const b of ["free-only", "below-tier", "at-tier", "above-tier"]) {
assert.ok(TIER_FIT_BUCKETS.has(b));
}
});
});
describe("DELTA_ASSESSMENTS", () => {
test("contains expected assessments", () => {
assert.ok(DELTA_ASSESSMENTS instanceof Set);
assert.equal(DELTA_ASSESSMENTS.size, 4);
for (const a of ["regression", "same", "improvement", "significant-improvement"]) {
assert.ok(DELTA_ASSESSMENTS.has(a));
}
});
});
describe("WARNING_THRESHOLDS", () => {
test("has correct values per tier", () => {
assert.equal(WARNING_THRESHOLDS.functional, 3);
assert.equal(WARNING_THRESHOLDS.polished, 2);
assert.equal(WARNING_THRESHOLDS.delightful, 1);
});
});
describe("getRedFlagSeverity", () => {
test("returns correct severity for valid flag and tier", () => {
assert.equal(getRedFlagSeverity("broken-link", "functional"), "critical");
assert.equal(getRedFlagSeverity("default-favicon", "polished"), "warning");
assert.equal(getRedFlagSeverity("default-favicon", "delightful"), "critical");
});
test("returns null for flag with null severity at tier", () => {
assert.equal(getRedFlagSeverity("default-favicon", "functional"), null);
});
test("returns null for invalid tier", () => {
assert.equal(getRedFlagSeverity("broken-link", "bogus"), null);
});
test("returns null for unknown key", () => {
assert.equal(getRedFlagSeverity("nonexistent-key", "functional"), null);
});
test("returns suggestion for 'other' key", () => {
assert.equal(getRedFlagSeverity("other", "functional"), "suggestion");
assert.equal(getRedFlagSeverity("other", "delightful"), "suggestion");
});
test("applies overrides — replaces severity", () => {
const overrides = new Map([["broken-link", "warning"]]);
assert.equal(getRedFlagSeverity("broken-link", "functional", overrides), "warning");
});
test("applies overrides — null/dash override returns null", () => {
const dashOverride = new Map([["broken-link", "—"]]);
assert.equal(getRedFlagSeverity("broken-link", "functional", dashOverride), null);
const hyphenOverride = new Map([["broken-link", "-"]]);
assert.equal(getRedFlagSeverity("broken-link", "functional", hyphenOverride), null);
const nullOverride = new Map([["broken-link", "null"]]);
assert.equal(getRedFlagSeverity("broken-link", "functional", nullOverride), null);
});
});
describe("parseRedFlagOverrides", () => {
test("parses valid content into a Map", () => {
const content = "- broken-link: warning\n- default-favicon: critical";
const result = parseRedFlagOverrides(content);
assert.ok(result instanceof Map);
assert.equal(result.size, 2);
assert.equal(result.get("broken-link"), "warning");
assert.equal(result.get("default-favicon"), "critical");
});
test("returns null for empty content", () => {
assert.equal(parseRedFlagOverrides(""), null);
});
test("returns null for content with no valid lines", () => {
assert.equal(parseRedFlagOverrides("just some text\nanother line"), null);
});
test("handles mixed valid/invalid lines", () => {
const content = "# comment\n- broken-link: warning\ninvalid line\n- lorem-ipsum: —";
const result = parseRedFlagOverrides(content);
assert.equal(result.size, 2);
assert.equal(result.get("broken-link"), "warning");
assert.equal(result.get("lorem-ipsum"), "—");
});
});
describe("getBaselineForTier", () => {
test("returns items with non-null severity for valid tier", () => {
const items = getBaselineForTier("delightful");
assert.ok(items.length > 0);
for (const item of items) {
assert.notEqual(item.severity.delightful, null);
}
});
test("functional returns fewer items than delightful", () => {
const func = getBaselineForTier("functional");
const del = getBaselineForTier("delightful");
assert.ok(func.length < del.length);
});
test("returns empty array for invalid tier", () => {
assert.deepEqual(getBaselineForTier("bogus"), []);
});
});
describe("getSeverity", () => {
test("returns correct severity for item and tier", () => {
const typo = TIER_BASELINES.find((i) => i.key === "typography");
assert.equal(getSeverity(typo, "delightful"), "critical");
assert.equal(getSeverity(typo, "polished"), "warning");
});
test("returns null when severity is null", () => {
const typo = TIER_BASELINES.find((i) => i.key === "typography");
assert.equal(getSeverity(typo, "functional"), null);
});
});
describe("generateTierTestCases", () => {
test("only includes warning/critical items", () => {
const cases = generateTierTestCases("delightful");
assert.ok(cases.length > 0);
for (const tc of cases) {
const item = TIER_BASELINES.find((i) => i.key === tc.baselineKey);
const sev = item.severity.delightful;
assert.ok(sev === "warning" || sev === "critical", `${tc.baselineKey} has severity ${sev}`);
}
});
test("uses correct TC-TIER-NN format", () => {
const cases = generateTierTestCases("polished");
for (const tc of cases) {
assert.match(tc.id, /^TC-TIER-\d{2}$/);
}
});
test("all cases have P0 priority", () => {
const cases = generateTierTestCases("polished");
for (const tc of cases) {
assert.equal(tc.priority, "P0");
}
});
test("cases include required fields", () => {
const cases = generateTierTestCases("delightful");
for (const tc of cases) {
assert.equal(typeof tc.id, "string");
assert.equal(typeof tc.category, "string");
assert.equal(typeof tc.description, "string");
assert.ok(Array.isArray(tc.steps));
assert.equal(typeof tc.expected, "string");
assert.equal(typeof tc.failureImpact, "string");
assert.equal(typeof tc.baselineKey, "string");
assert.equal(typeof tc.label, "string");
}
});
test("returns empty array for invalid tier", () => {
assert.deepEqual(generateTierTestCases("bogus"), []);
});
test("excludes suggestion-only items", () => {
const cases = generateTierTestCases("functional");
for (const tc of cases) {
const item = TIER_BASELINES.find((i) => i.key === tc.baselineKey);
assert.notEqual(item.severity.functional, "suggestion");
}
});
});
describe("getRequiredBaselineKeys", () => {
test("returns Set of warning+critical keys", () => {
const keys = getRequiredBaselineKeys("polished");
assert.ok(keys instanceof Set);
assert.ok(keys.size > 0);
for (const key of keys) {
const item = TIER_BASELINES.find((i) => i.key === key);
const sev = item.severity.polished;
assert.ok(sev === "warning" || sev === "critical");
}
});
test("returns empty Set for invalid tier", () => {
const keys = getRequiredBaselineKeys("bogus");
assert.ok(keys instanceof Set);
assert.equal(keys.size, 0);
});
});
describe("getAllBaselineKeys", () => {
test("returns Set of all keys for tier", () => {
const keys = getAllBaselineKeys("delightful");
assert.ok(keys instanceof Set);
const expected = TIER_BASELINES.filter((i) => i.severity.delightful != null);
assert.equal(keys.size, expected.length);
});
test("includes suggestion-severity keys", () => {
const allKeys = getAllBaselineKeys("functional");
const reqKeys = getRequiredBaselineKeys("functional");
assert.ok(allKeys.size >= reqKeys.size);
});
test("returns empty Set for invalid tier", () => {
assert.equal(getAllBaselineKeys("bogus").size, 0);
});
});
describe("checkBaselineCoverage", () => {
test("matches keywords case-insensitively", () => {
const result = checkBaselineCoverage("TYPOGRAPHY and NAVIGATION present", "delightful");
const coveredKeys = result.covered.map((c) => c.key);
assert.ok(coveredKeys.includes("typography"));
assert.ok(coveredKeys.includes("navigation"));
});
test("classifies covered vs uncovered correctly", () => {
const result = checkBaselineCoverage("has loading spinner and error recovery", "delightful");
const coveredKeys = result.covered.map((c) => c.key);
assert.ok(coveredKeys.includes("loading-states"));
assert.ok(coveredKeys.includes("error-states"));
// items not mentioned should be uncovered
const uncoveredKeys = result.uncovered.map((c) => c.key);
assert.ok(uncoveredKeys.includes("typography"));
});
test("each entry has key, label, severity", () => {
const result = checkBaselineCoverage("typography", "polished");
for (const entry of [...result.covered, ...result.uncovered]) {
assert.equal(typeof entry.key, "string");
assert.equal(typeof entry.label, "string");
assert.ok(entry.severity !== undefined);
}
});
test("returns empty arrays for invalid tier", () => {
const result = checkBaselineCoverage("typography", "bogus");
assert.deepEqual(result.covered, []);
assert.deepEqual(result.uncovered, []);
});
test("all items accounted for (covered + uncovered = total for tier)", () => {
const result = checkBaselineCoverage("", "delightful");
const total = getBaselineForTier("delightful").length;
assert.equal(result.covered.length + result.uncovered.length, total);
// empty text means nothing covered
assert.equal(result.covered.length, 0);
});
});

Sorry, the diff of this file is not supported yet

---
title: "OPC — Your AI Review Team in One Slash Command"
description: "I built a Claude Code skill that dispatches 11 specialist AI agents to review code from different perspectives. Here's what I learned — and an honest comparison with just asking Claude directly."
date: 2026-03-28
draft: false
---
I built [OPC (One Person Company)](https://github.com/iamtouchskyer/opc) — a Claude Code skill that gives you an AI review team. Type `/opc review` and 11 specialists (Security Engineer, PM, New User, DevOps, etc.) review your code in parallel, then a coordinator filters out the noise.
Sounds cool. But is it actually better than just asking Claude to review your code?
## The Honest Test
I ran both approaches on the same codebase — OPC's own repo:
**Single Claude prompt** ("review these files for issues"): **14 findings**. Variable shadowing, DRY violations, missing exit codes, edge cases. Thorough, precise, code-focused.
**OPC** (3 agents: new-user, security, devops): **9 findings**. Fewer code bugs. But it caught 5 things Claude completely missed:
- A new user would run `opc review` in their terminal (not Claude Code) and get confused — no hint it's a skill, not a CLI command
- The install symlink command in README assumes you're in the parent directory — muscle memory says `cd` into the repo first, which breaks it silently
- The postinstall failure message doesn't tell you what a failure looks like
- The Claude Code link goes to a marketing page, not install docs
These aren't code bugs. They're **perspective bugs** — issues you only find when you think like a specific person.
## What I Actually Built
OPC isn't magic. Under the hood it's:
1. **11 markdown files** — each defines a specialist role with expertise areas and anti-patterns ("don't flag missing auth on local tools")
2. **Parallel Claude calls** — 2-5 agents run simultaneously, each with a different system prompt
3. **A coordinator pass** — verifies facts, deduplicates, dismisses false positives
The agents don't talk to each other. There's no "collaboration." The coordinator is the same Claude instance reading all outputs. I'm not going to pretend this is some breakthrough in multi-agent systems.
What it IS: a structured way to get multiple review perspectives without writing the prompt every time. `/opc review` vs. typing "review from security, new user, and devops perspectives" — the former is 10 characters, the latter is a paragraph you'll never write consistently.
## The Parts That Actually Work Well
**Anti-patterns per role.** Each role file says what NOT to flag. The security agent won't flag "no auth" on a local CLI tool. The new-user agent won't suggest hand-holding for a developer tool. This is the single most impactful design choice — it prevents the generic checklist problem that kills most AI review tools.
**Verification gate.** The coordinator doesn't just merge agent outputs. It has explicit checks: "Does this finding have a file:line reference? Does the severity match the actual impact? Did the agent actually read the files in scope?" This catches lazy agent outputs.
**JSON reports.** Every review saves structured JSON to `~/.opc/reports/`. You can track findings over time, compare reviews, or browse them in a web viewer (`npx @touchskyer/opc-viewer`).
## Try It
```bash
npm install -g @touchskyer/opc
# Then in Claude Code:
/opc review
```
Zero dependencies. Just markdown files. Works in 30 seconds.
[GitHub](https://github.com/iamtouchskyer/opc) — star it if you find a bug OPC catches that Claude alone wouldn't.
---
# OPC — 一个斜杠命令召唤你的 AI Review 团队
我做了一个 Claude Code skill 叫 [OPC (One Person Company)](https://github.com/iamtouchskyer/opc)。输入 `/opc review`,11 个 AI 专家(安全工程师、产品经理、新用户、DevOps 等)并行 review 你的代码,然后一个 coordinator 过滤噪音。
听起来不错。但真的比直接问 Claude "帮我 review" 好吗?
## 诚实的对比测试
同一个代码库(OPC 自己的 repo),两种方式:
**直接让 Claude review**:找到了 **14 个问题**。变量 shadowing、DRY 违反、exit code 缺失。细致、精准、聚焦代码层面。
**OPC**(3 个 agent:新用户、安全、DevOps):找到了 **9 个问题**。代码 bug 更少。但抓到了 5 个 Claude 完全看不到的东西:
- 新用户会在 terminal 里直接跑 `opc review`(以为是 CLI 命令),结果只看到帮助信息,不知道要在 Claude Code 里用
- README 里的 symlink 命令假设你在父目录——但正常人 clone 完会 cd 进去,symlink 就断了
- Claude Code 的链接指向营销页,不是安装文档
这些不是代码 bug,是**视角 bug** — 只有当你切换到某个特定角色的思维时才会发现。
## OPC 到底是什么
说白了:
1. **11 个 markdown 文件** — 每个定义一个专家角色,包括专业领域和 anti-patterns("不要在本地工具上标记缺少认证")
2. **并行 Claude 调用** — 2-5 个 agent 同时跑,各有不同 system prompt
3. **Coordinator 验证** — 检查 agent 输出的事实、质疑严重程度、去重、过滤误报
Agent 之间不互相通信。没有真正的"协作"。我不会假装这是什么 multi-agent 突破。
但它解决了一个真实问题:`/opc review` 10 个字符,比每次手写"从安全、新用户、DevOps 角度 review"的 prompt 省事太多。省事 = 会真正用起来。
## 真正有用的设计
**Anti-patterns**:每个角色文件定义了"不要做什么"。安全 agent 不会对本地 CLI 工具标记"缺少认证"。新用户 agent 不会对开发者工具要求"新手引导"。这个设计避免了 AI review 工具最大的问题——generic checklist。
**结构化输出**:每次 review 存 JSON 到 `~/.opc/reports/`,可以用 `npx @touchskyer/opc-viewer` 在浏览器里看。
## 试试
```bash
npm install -g @touchskyer/opc
# 在 Claude Code 里:
/opc review
```
零依赖,30 秒搞定。
[GitHub](https://github.com/iamtouchskyer/opc)

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

# Findings for Run 5 — Core surfaces surfaced during Run 3
**Baseline:** `ext-run-3-done` (to be tagged at end of U3.7)
**Scope:** OPC core bugs / friction points observed while building 5 real
extensions. No patches applied in Run 3 — all deferred to Run 5 per the
"no core modifications" constraint.
## Summary
Run 3 surfaced **no blocking core bugs**. The v0.5.1 extension surface held
up end-to-end for 5 independently-authored extensions covering all 4 hook
types (prompt.append, verdict.append, execute.run, artifact.emit) and 3
node-capability classes (verification@1, design-review@1, execute@1).
The items below are **friction / ergonomics**, not correctness. Each is
actionable with a small, well-scoped patch.
## F1 — `fireVerdictAppend` return value is opaque (side-effect only)
**Where:** `bin/lib/extensions.mjs` — `fireVerdictAppend` writes
`eval-extensions.md` as a side-effect and returns `undefined`.
**Observed during:** U3.6 integration driver v1. First attempt read
`.length` on the return value and crashed. The v1 driver had to be rewritten
to parse `eval-extensions.md` after the call. This is inconsistent with
`firePromptAppend` which returns the injected string directly.
**Suggested Run 5 fix:** Return `{findings, filePath}` so callers can inspect
the merged finding set without re-parsing the markdown. Backward-compat by
leaving `filePath` present and `findings` as a structured array.
**Severity:** 🟡 ergonomic (no correctness issue — docs never promised a
return value).
## F2 — `nodeCapabilities` is required for routing but easy to omit
**Where:** `extensionMatches()` silently matches nothing when `ctx.nodeCapabilities`
is `undefined` or `[]`. Drivers that forget to set it see zero fires across
all hook types, which is indistinguishable from "no extension matched".
**Observed during:** U3.6 driver v1. Spent time debugging apparent
loadExtensions failure before noticing the missing array.
**Suggested Run 5 fix:** When `nodeCapabilities` is absent, emit a single-line
WARN via `stderr` (`[extensions] WARN: ctx.nodeCapabilities not set — no hooks will match`).
Do not throw; the current silent-match-nothing behavior is the correct
production default, just needs an observability hint.
**Severity:** 🔵 informational.
## F3 — Extension-test command has no fixture-dir convention
**Where:** `opc-harness extension-test --ext <path> --hook <name> --context <json>`.
**Observed during:** U3.4/U3.5. Tests that need `flowDir` with seeded files
(`.harness-run3-integration/flow-state.json`, `artifacts/tiny.png`) have to
inline `mkdtemp + writeFileSync + JSON.stringify` in shell, which makes the
tests hard to read and maintain.
**Suggested Run 5 fix:** Add `--fixture-dir <path>` flag that is copied into
a tmp dir and passed as `ctx.flowDir`. Optional `--fixture-describe` that
prints the tree to stderr for debugging.
**Severity:** 🔵 ergonomic.
## F4 — No built-in way to assert "extension X DID fire"
**Where:** The integration driver had to read `eval-extensions.md` body and
grep for category substrings (`h_git_changeset_fires_in_repo`,
`i_session_logex_fires`). This is brittle — a capitalization change or emoji
reorder would break it.
**Suggested Run 5 fix:** Write a machine-readable sidecar
`eval-extensions.json` alongside the markdown, keyed by extension name →
findings array. Integration tests assert against JSON, users read markdown.
**Severity:** 🟡 test-quality.
## F5 — Circuit-breaker state is per-CLI-invocation, not per-flow
**Where:** v0.5.1 fixed cross-command failure merge via JSON sidecar. But
the circuit-breaker counter itself resets on each `node` invocation, so an
extension that trips timeout in `cmdPromptContext` still gets called by
`cmdExtensionVerdict` in the same flow.
**Observed during:** Not hit in Run 3 because no extension timed out
repeatedly. But visual-eval's 60s Python subprocess is a realistic candidate.
**Suggested Run 5 fix:** Persist breaker state in
`.harness/.extension-state.json` keyed by extension name; reload at
`loadExtensions` time and respect "tripped" status across CLI invocations
within the same flow. Reset on flow init.
**Severity:** 🟡 reliability.
## F6 — No lint for `meta.compatibleCapabilities` vs `meta.provides`
**Where:** An extension can declare `provides: ["foo@1"]` and
`compatibleCapabilities: ["execute@1"]` — legal but likely a mistake if the
extension only implements `verdictAppend`. (Session-logex actually had
`execute@1` in its initial compatibleCapabilities; dropped in U3.5r.)
**Suggested Run 5 fix:** `opc-harness extension-test --lint` warns when
`compatibleCapabilities` includes capabilities that don't match the set of
exported hook functions. Not an error (soft overlap is valid), just a
hint.
**Severity:** 🔵 lint-quality.
## F7 — `opc-harness --help` omits extension subcommands (Run 4 finding)
**Where:** `opc-harness --help` banner output.
**Context:** Reviewer A (U4.2r) live-tested the `_starter` README's headline
command `opc-harness extension-test --ext … --all-hooks` against a brew-
installed `opc-harness` on PATH. The brew binary printed the generic usage
banner and exited 0 — no "unknown subcommand" error, no hint. Even switching
to the bundled `node bin/opc-harness.mjs --help`, `grep -i ext` on the help
text returns no matches. The `extension-test` / `prompt-context` /
`extension-verdict` / `extension-artifact` / `config resolve` subcommands are
fully implemented but undiscoverable from `--help`.
**Risk at scale:** Junior devs following the starter README think the
command doesn't exist (or worse, think their extension passed because
exit code is 0). Distribution drift between brew / npm-global / bundled
harnesses compounds the confusion.
**Suggested Run 5 fix:** Add an "Extension commands:" section to the
`--help` banner in `bin/opc-harness.mjs` listing `extension-test`,
`prompt-context`, `extension-verdict`, `extension-artifact`, and
`config resolve` with one-line descriptions. Starter-side mitigation
(in U4.2r README §2) directs users to the bundled binary and flags the
brew/stale-global gotcha until v0.7 ships the help update.
**Severity:** 🟡 discoverability — silent no-op for users on stale binaries.
## Not deferred — fixed in Run 3 itself
These were caught by U3.*r reviewers and fixed in fix-pair commits without
core changes:
- U3.3: CJK-aware stopword filter + CLI-check cache (memex-recall)
- U3.4: basename lockfile match + `--no-renames` + `core.quotepath=false` +
hasGit cache (git-changeset-review)
- U3.5: sentinel-bounded walk + `.logex-nudged` dedup + `.harness*`-prefix
readdir + dropped execute@1 capability + session.jsonl path hint in
message (session-logex)
- U3.6: 9 real assertions + 2-pass in-repo/non-git test + seeded fixtures +
enumerated isolation check (integration driver rev2)
## Recommendation
None of F1–F6 block Run 4 (first third-party-authored extension). F7 is
also non-blocking for Run 4 (starter-side mitigation shipped in U4.2r).
They accumulate as Run 5 polish. The core surface is production-ready as-is.
## Resolution Status (Run 5)
All seven findings addressed during Run 5. Per-item closure:
- **F1** — Resolved in Run 5 — see commit `152ed3e` (`fireVerdictAppend` now
returns `{findings, filePath}`; callers no longer need to re-parse markdown).
- **F2** — Resolved in Run 5 — see commit `152ed3e` (missing
`ctx.nodeCapabilities` now emits a single-line stderr WARN; silent-miss
behavior preserved for production).
- **F3** — Resolved in Run 5 — see commit `0fb3a24` (extension-test command
now resolves fixtures via documented `fixtures/` convention).
- **F4** — Resolved in Run 5 — see commit `23cbeca` (`--assert-fired <id>`
flag added; non-zero exit if the named extension did not fire).
- **F5** — Resolved in Run 5 — see commit `aac291f` (circuit-breaker state
persisted to `.harness/breaker-state.json`; survives CLI re-invocation
within a flow).
- **F6** — Resolved in Run 5 — see commit `0fb3a24` (ext-lint now cross-checks
`meta.compatibleCapabilities` against `meta.provides` and the hook shape).
- **F7** — Resolved in Run 5 — see commit `23cbeca` (`opc-harness --help`
now enumerates extension subcommands under a dedicated `Extensions:` header).
Baseline tag for Run 5 closure: `ext-run-5-done` (see CHANGELOG v0.8 for the
full change list, including the runbook mechanism shipped alongside).
I built an AI review team that fits in one slash command.
/opc review — and 11 specialists (Security, PM, New User, DevOps...) review your code in parallel.
But here's the honest part:
I tested it against just asking Claude directly. Same codebase, same scope.
Single Claude: 14 code bugs found.
OPC (3 agents): 9 code bugs found.
Claude won on quantity.
But OPC caught 5 things Claude completely missed:
A new user would type "opc review" in terminal, not realizing it's a Claude Code skill — zero hint in the help text.
The README's symlink command breaks silently if you cd into the repo first (which everyone does).
The Claude Code link goes to a marketing page instead of install docs.
These aren't code bugs. They're perspective bugs.
You only find them when you think like a specific person — a first-time user, a security auditor, a DevOps engineer checking your npm packaging.
OPC doesn't find MORE bugs. It finds DIFFERENT bugs.
Zero dependencies. Pure markdown. 30 seconds to install.
github.com/iamtouchskyer/opc

Sorry, the diff of this file is not supported yet

Sorry, the diff of this file is not supported yet

# Runbooks
Runbooks are **reusable task recipes** for `/opc loop`. Instead of
decomposing every new task from scratch, the loop-protocol looks for a
runbook whose `match:` patterns cover the incoming task and uses its
`units:` list as the decomposition. Think of them as project-level muscle
memory: "when someone asks to *add a feature*, this is how we do it."
This page is the schema reference and operator guide. Loop-protocol
integration (how runbooks are actually consumed at tick 0) is covered
in `pipeline/loop-protocol.md` — the runbook file here is purely a
specification of the schema and CLI.
---
## Location
Runbooks live in one of the following, checked in order:
1. `--dir <path>` explicit flag to the `runbook` CLI commands
2. `OPC_RUNBOOKS_DIR` environment variable
3. `~/.opc/runbooks/` (default)
Each runbook is a single `.md` file with YAML-lite frontmatter. Non-`.md`
files in the directory are ignored. Loading is non-recursive — flat
directory only.
---
## Schema v1
```yaml
---
version: 1 # REQUIRED — schema version (must be 1)
id: add-feature # REQUIRED — kebab-case slug, unique across dir
title: Add a Feature # REQUIRED — human-readable one-line title
tags: # optional — used for scoring + filtering
- build
- frontend
match: # optional — patterns used by `runbook match`
- add feature # whole-word keyword (case-insensitive)
- "/^implement /i" # /.../FLAGS regex literal
flow: build-verify # optional — which OPC flow template to use
tier: polished # optional — quality tier (functional|polished|delightful)
units: # REQUIRED — non-empty list of unit IDs
- plan
- build
- review
- test-design
- test-execute
protocolRefs: # optional — pipeline protocol filenames
- implementer-prompt.md
createdAt: 2026-04-19 # optional — ISO date (string)
updatedAt: 2026-04-19 # optional — ISO date (string)
---
# Body (markdown)
Everything after the closing `---` is the runbook body. The body is
surfaced by `runbook show <id>` and is available to the orchestrator
as human guidance ("why these units, what to watch out for").
```
### Field rules
| Field | Type | Validation |
|----------------|---------------|---------------------------------------------|
| `version` | number | must be `1` |
| `id` | string | matches `/^[a-z0-9]+(-[a-z0-9]+)*$/` (slug) |
| `title` | string | non-empty |
| `units` | string[] | non-empty, each entry non-empty string |
| `tags` | string[] | optional, array of strings |
| `match` | string[] | optional; regex literals must be parseable; empty regex `//` rejected |
| `flow` | string | optional |
| `tier` | string | optional; must be `functional`, `polished`, or `delightful` |
| `protocolRefs` | string[] | optional |
| `createdAt` | string | optional; must start with `YYYY-MM-DD` |
| `updatedAt` | string | optional; must start with `YYYY-MM-DD` |
Unknown frontmatter keys are preserved on the parsed object but are not
validated. v2 can add fields without breaking v1 readers.
### Match patterns
Two forms are supported:
- **Whole-word keyword** — plain string like `"add feature"`. Matched
case-insensitively with word-boundary enforcement, so `"add"` will
NOT match inside `"address"`. Internal whitespace is flexible —
`"add feature"` matches `"add feature"` (two spaces), `"add\tfeature"`
(tab), or split across lines. Multi-word phrases score higher: a
3-word phrase is 3× the weight of a single word.
- **Regex literal** — a string shaped exactly `/PATTERN/FLAGS`, e.g.
`"/^implement /i"`. Parse errors are caught at load time via
`validateRunbook` — a runbook with a broken regex is rejected with a
stderr WARN and skipped.
> **YAML escaping footgun:** always wrap regex literals in **double
> quotes** in the frontmatter. Backslashes inside double-quoted YAML
> strings pass through to the OPC loader as a single backslash (which
> RegExp then interprets as an escape — e.g. `"/\bword/"` correctly
> compiles to a word-boundary). Single-quoted or unquoted forms have
> different escape rules and have caused silently-broken patterns in
> practice. If `runbook match` doesn't fire on a phrase you expect,
> first check that your regex line is double-quoted.
Tags that appear as whole words in the task also contribute score.
### Scoring
| Signal | Score |
|-------------------------|------------------------------|
| keyword match | 10 × word count |
| regex match | 5 |
| tag whole-word match | 3 |
Tie-breakers (in order): higher total score → more patterns matched →
alphabetical by `id`.
---
## CLI
All output is JSON to stdout. Stderr is reserved for WARN / errors.
Exit codes: `0` success, `1` usage error, `2` not found (show), `3` no
match (match).
### `opc-harness runbook list [--dir <path>]`
Lists all runbooks in the directory, summarized (body omitted).
```json
{
"dir": "/Users/you/.opc/runbooks",
"count": 1,
"runbooks": [
{
"id": "add-feature",
"title": "Add a Feature",
"tags": ["build"],
"match": ["add feature"],
"flow": "build-verify",
"tier": "polished",
"units": ["plan", "build", "review"],
"path": "/Users/you/.opc/runbooks/add-feature.md"
}
]
}
```
### `opc-harness runbook show <id> [--dir <path>]`
Prints the runbook with its body. Exit code `2` if no runbook has that
`id`.
### `opc-harness runbook match <task...> [--dir <path>]`
Scores every runbook against `<task>` and prints the winner. Exit code
`3` if nothing matches.
**Reserved flags.** `--dir` and `--help` are consumed by the command.
Any other `--foo` token is rejected loudly (prevents silent typos like
`--dri` producing empty results). If the literal task text contains
`--flag`-looking tokens, separate with `--`:
```
opc-harness runbook match -- "please use --dir /opt for install"
```
```json
{
"task": "please add feature for login",
"dir": "/Users/you/.opc/runbooks",
"matched": true,
"score": 20,
"patterns": ["add feature"],
"runbook": { "id": "add-feature", "...": "..." }
}
```
---
## Authoring guide
1. **Start small.** A runbook with just `{version, id, title, units, match}`
is valid and useful.
2. **Prefer whole-word keywords over regex** where possible — they are
easier to read, higher-scored, and don't bite you with escape issues.
3. **Multi-word phrases** beat single words. `"add feature"` is more
specific than `"add"` and will outrank it.
4. **Use `tags:`** for cross-cutting signals (`frontend`, `refactor`,
`security`) that may not appear in the task's verb. They contribute
a small boost and document the runbook's domain.
5. **Keep `id` short and stable.** It's the public handle for
`runbook show` and (eventually) for referencing from state files.
6. **The body is human documentation.** The orchestrator may surface it
to the operator or inject it into subagent prompts as context. Write
it for the next person (likely future-you) to read.
7. **Recognized unit IDs.** The schema validator only checks `units`
is `string[]` non-empty — any string passes. The loop-protocol's
default mapping (see `pipeline/loop-protocol.md` §"Standard unit
sequence") recognizes: `spec`, `design`, `plan`, `build`,
`implement`, `review`, `code-review`, `fix`, `test-design`,
`test-execute`, `e2e-verify`, `accept`, `acceptance`, `e2e`,
`audit`. Unrecognized IDs fall through to the build-verify default
silently — pick from this set unless you have a reason not to.
### Try the reference runbook
To use the canonical `add-feature` runbook shipped under
`examples/runbooks/`, symlink it into your user runbooks dir:
```bash
mkdir -p ~/.opc/runbooks
ln -s ~/.claude/skills/opc/examples/runbooks/add-feature.md \
~/.opc/runbooks/add-feature.md
opc-harness runbook match "add a dark-mode toggle" # exit 0 + matched: true
```
---
## Loop-protocol integration
Shipped in U5.11 (v0.8). The loop-protocol's Step 0 invokes
`opc-harness runbook match "<task>"` before plan decomposition. On
exit `0` + `matched: true`, the orchestrator adopts the runbook's
`flow` / `tier` / `units` as the loop plan. On exit `3` it falls
through to fresh decomposition. See `pipeline/loop-protocol.md` §"Step 0
— Runbook Lookup" for the full procedure.
`runbook match` also remains a standalone diagnostic — invoke it
directly to confirm your patterns fire before kicking off a real loop.
---
## Example: a complete runbook
```markdown
---
version: 1
id: add-feature
title: Add a Feature
tags: [build, frontend]
match:
- add feature
- new feature
- "/^implement /i"
flow: build-verify
tier: polished
units:
- plan
- build
- review
- test-design
- test-execute
- acceptance
protocolRefs:
- implementer-prompt.md
- role-evaluator-prompt.md
createdAt: 2026-04-19
updatedAt: 2026-04-19
---
# Add Feature Runbook
Use when the user asks to add a new feature to an existing codebase.
The flow is build-verify because we need explicit test-design +
test-execute separation — the person writing the feature cannot also
be the person verifying it ships.
## Unit rationale
- **plan** — decompose the feature into concrete DoD bullets
- **build** — implementer subagent writes the code
- **review** — 2 independent reviewers (backend + frontend)
- **test-design** — a different subagent designs test cases
- **test-execute** — orchestrator runs them and captures evidence
- **acceptance** — PM/designer final sign-off
```
---
## FAQ
**Q: What happens if two runbooks match?** The higher-scoring one wins;
see the tie-breaker order above. You can always inspect scores with
`runbook match <task>` to see why.
**Q: What happens if no runbook matches?** `runbook match` exits `3`
and the loop-protocol (post-U5.11) falls through to fresh task
decomposition.
**Q: Can I disable runbooks entirely?** Yes — set
`OPC_DISABLE_RUNBOOKS=1` before the harness call. The CLI returns
exit `3` with `disabled: true` in the payload, without scanning
disk. (`/opc loop --no-runbook` is planned CLI sugar that would set
this env var for one invocation, but is not yet wired into arg
parsing as of v0.8 — set the env var directly until then.)
**Q: What happens if a runbook has a typo?** The loader skips it with a
stderr WARN naming the file and the validation error. Other runbooks
in the same directory load normally.
# OPC Extension System Design
**Date**: 2026-04-16
**Status**: Draft
**Scope**: Private extension mechanism for OPC agent pipeline framework
**Horizon**: 3-year architectural target (2 → 20+ extensions)
---
## 0. Context & Motivation
OPC is a digraph-based agent pipeline where build and review are always independent subagents. The harness is a protocol layer — mechanism lives in the harness, policy lives outside.
Some "secret weapons" (design-system contracts, visual linting rules, proprietary style guides) must never enter the open-source core. They are user-specific, context-sensitive, and competitively sensitive. This spec defines a stable extension mechanism that:
1. Lives entirely outside OPC source
2. Plugs into the harness at well-defined call sites
3. Preserves all six OPC core principles without exception
4. Scales from 2 to 20+ extensions without architectural rework
### OPC Core Principles (Invariants)
Every section below is verified against these. Any design that violates them is rejected.
| # | Principle | Short Form |
|---|-----------|------------|
| P1 | The agent that does the work never evaluates it | **Build ≠ Review** |
| P2 | Verdict computed from emoji counts in eval files | **Mechanical Gate** |
| P3 | Review subagents have fresh context | **Independence** |
| P4 | Harness is a protocol layer (~100 lines) | **Lean Core** |
| P5 | All state in `.harness/`, replayable | **File-based State** |
| P6 | Recoverable on agent failure or context exhaustion | **Resilience** |
---
## 1. Directory Structure
Extensions live in the user's home directory, never in OPC source.
```
~/.opc/
├── config.json
└── extensions/
├── design-system/
│ ├── hook.mjs
│ └── prompt.md
└── design-lint/
├── hook.mjs
└── prompt.md
```
**Dotfiles pattern** (recommended): maintain `~/.dotfiles/opc/` in a private repo, symlink to `~/.opc/`. OPC source never sees these files. Private forever.
```bash
ln -s ~/.dotfiles/opc ~/.opc
```
### Path Override (CI & Multi-user)
The `~/.opc/` path must be overridable via (highest priority first):
1. `OPC_EXTENSIONS_DIR` environment variable
2. `--extensions-dir <path>` flag on any harness command
3. `"extensionsDir"` field in `config.json` (machine-level override without env vars)
4. Default: `~/.opc/`
In `lib/extensions.mjs`, `loadExtensions(config)` resolves the path as:
```js
const extensionsDir = process.env.OPC_EXTENSIONS_DIR
|| config.extensionsDir
|| path.join(os.homedir(), '.opc');
```
This enables CI pipelines to point at a repo-local fixture directory without touching the developer's home.
### Global Config: `~/.opc/config.json`
```json
{
"extensionsDir": "/custom/path",
"extensionOrder": ["design-system", "design-lint"],
"requiredExtensions": ["design-system"],
"extensions": {
"design-system": { "enabled": true },
"design-lint": { "enabled": true }
}
}
```
**`requiredExtensions[]`**: If any listed extension is missing or fails `startup.check` at flow init, harness throws `FATAL` and aborts. Non-listed extensions are optional — missing = warn only, failure = warn only.
**Principle check**:
- P4 (Lean Core): config is a plain JSON file, zero runtime magic
- P5 (File-based State): config file is in user home, not in `.harness/` — this is intentional. Config is installation-time state, not flow-time state. Flow-time state (which extensions were applied) is recorded in `handshake.json` (see §3)
---
## 2. Hook Interface
Each `hook.mjs` exports a default object with `meta` and `hooks`:
```js
export default {
meta: {
name: 'design-system', // must match directory name
version: '1.0.0'
// Required/optional status is declared in config.json only — single source of truth.
},
hooks: {
/**
* Returns a string appended to subagent prompt context.
* Called before any subagent dispatch (build + review nodes).
* Return empty string if nothing to add.
*
* Injecting context into a build agent is not a P1 violation — it gives the agent
* more information to do its job correctly. P1 prohibits the same agent from both
* doing work and evaluating it. Extension context injection serves the 'doing work'
* side only.
*/
'prompt.append': async (context) => string,
/**
* Returns finding[] written to ext-findings.md in runDir.
* NEVER passed directly to synthesize — findings enter the
* mechanical gate through the same emoji-count path as
* eval-{role}.md files. Preserves P2 without exception.
*/
'verdict.append': async (context) => finding[],
/**
* Startup health check. Throw = FATAL for required extensions.
* Throw = warn-only for optional extensions.
* Use to verify env vars, file presence, external deps.
*/
'startup.check': async (context) => void
}
}
```
### `context` Object
```js
{
node: string, // e.g. "build", "code-review"
role: string, // e.g. "frontend", "security"
task: string, // acceptance criteria summary (from flow spec)
flowDir: string, // absolute path to .harness/
runDir: string // absolute path to .harness/nodes/{node}/run_{N}/
}
```
### `finding` Type
```js
{
severity: '🔴' | '🟡' | '🔵', // maps directly to mechanical gate emoji counts
category: string, // e.g. "design-system", "color-contrast"
message: string, // human-readable description
file?: string // optional file path for precise attribution
}
```
### Critical Design Constraint: `ext-findings.md`
`verdict.append` findings MUST be serialized to `ext-findings.md` in the same `runDir` as `eval-{role}.md`. `synthesize` reads all `*.md` files in `runDir`. This is the mechanical gate path — no special handling for extension findings.
**Why this matters (P2 compliance)**:
If extension findings were passed directly to `synthesize` as structured data and counted separately, it would create two verdict paths: one mechanical (emoji count from files) and one programmatic (direct count from extensions). That violates P2. By writing to `ext-findings.md`, extensions are indistinguishable from role evaluations at the gate layer.
Example `ext-findings.md`:
```markdown
## Extension Findings: design-system v1.0.0
🔴 [color-token] Button uses hardcoded `#ff0000` instead of `--color-danger` (src/components/Button.tsx:42)
🟡 [spacing-token] Margin 16px should use `--space-4` token (src/components/Card.tsx:18)
🔵 [font-token] Font size matches design system token correctly
```
**Principle check**:
- P1 (Build ≠ Review): `verdict.append` is only called on review nodes, never build nodes
- P2 (Mechanical Gate): findings enter gate via file, not code path
- P5 (File-based State): `ext-findings.md` is in `.harness/`, replayable
---
## 3. Harness Changes
### New File: `lib/extensions.mjs` (~50 lines)
```js
/**
* Scans the resolved extensions directory (OPC_EXTENSIONS_DIR || config.extensionsDir || ~/.opc/),
* validates requiredExtensions from config,
* runs startup.check on each loaded extension.
* Throws FATAL if any required extension is missing or fails startup.check.
* Warns (no throw) for optional extension failures.
*
* Returns an ExtensionRegistry object — pass this to all fire functions.
* Avoids module-level singletons (bad for test isolation, bad for P6 resilience).
*/
export async function loadExtensions(config): Promise<ExtensionRegistry>
/**
* Calls all enabled extensions' prompt.append hooks in extensionOrder (or alphabetical fallback).
* Returns concatenated string (each extension's output separated by \n\n).
* Individual extension failures: required = FATAL, optional = warn + skip.
*/
export async function firePromptAppend(registry, context)
/**
* Calls all enabled extensions' verdict.append hooks.
* Serializes findings[] to ext-findings.md in context.runDir.
* Individual extension failures: required = FATAL, optional = warn + skip.
*/
export async function fireVerdictAppend(registry, context)
```
The orchestrator calls `loadExtensions` once at flow init and passes the registry through:
```js
const registry = await loadExtensions(config);
// ...later, per node dispatch:
await firePromptAppend(registry, context);
// ...later, after eval files written (review nodes only):
await fireVerdictAppend(registry, context);
```
**Why ~50 lines**: `loadExtensions` is file scanning + dynamic import. `firePromptAppend` and `fireVerdictAppend` are map + await + string/file ops. No plugin protocol, no version negotiation — YAGNI for 20 extensions.
### Three Call Sites in Harness
| Call Site | When | Function |
|-----------|------|----------|
| Flow init | After reading config, before any node dispatch | `const registry = await loadExtensions(config)` |
| Before subagent dispatch | Build nodes + review nodes | `await firePromptAppend(registry, context)` |
| After eval files written | Review nodes only | `await fireVerdictAppend(registry, context)` |
**Principle check**:
- P1: `fireVerdictAppend` call site is review nodes only — enforced at the call site, not by convention
- P4: Three call sites, one new file of ~50 lines. Core harness delta is minimal
- P6: Extension failures for optional extensions produce warnings and skip, not crash. Required extension failures are FATAL by design (user opted in)
### New Harness Command: `prompt-context`
```bash
opc-harness prompt-context --node <id> --role <role> --dir <harness-dir>
```
**Output** (JSON to stdout):
```json
{
"append": "## Design System Context\n...\n\n## Design Lint Rules\n...",
"applied": ["design-system", "design-lint"]
}
```
This command is the orchestrator's interface to extension prompt injection. It must be called before dispatching any subagent.
### New Field in `handshake.json`
```json
{
"node": "code-review",
"role": "frontend",
"timestamp": "2026-04-16T10:00:00Z",
"extensionsApplied": ["design-system", "design-lint"]
}
```
`validate-chain` checks: if `config.requiredExtensions` includes `X`, then every handshake in the chain must have `X` in `extensionsApplied`. A chain with a missing required extension is invalid — same severity as a missing eval file.
**Principle check**:
- P5 (File-based State): `extensionsApplied` is recorded in `handshake.json` in `.harness/`, not in memory
- P6 (Resilience): If an orchestrator crashes mid-flow and restarts, `validate-chain` can detect whether extensions were applied before the crash point
---
## 4. Orchestrator Convention
### Prompt Template Updates
`pipeline/implementer-prompt.md` and `pipeline/role-evaluator-prompt.md` MUST include:
```markdown
**Mandatory before dispatch**: Run:
opc-harness prompt-context --node {node} --role {role} --dir .harness
Append the returned `append` string to this prompt verbatim.
Record `applied[]` in handshake field `extensionsApplied`.
```
This is the **soft enforcement layer** — the orchestrator sees the instruction in its prompt template and follows it.
### Two-Layer Enforcement
| Layer | Mechanism | Consequence of Failure |
|-------|-----------|----------------------|
| Soft | Orchestrator reads prompt template → sees mandatory step | Orchestrator skips extension context; likely produces non-compliant output |
| Hard | `validate-chain` rejects handshake chain missing required `extensionsApplied` | Flow marked INVALID; cannot proceed to merge/publish gate |
The two layers are complementary. The soft layer prevents the issue upstream. The hard layer catches it downstream if the soft layer fails (e.g., orchestrator prompt drift, model regression).
**Principle check**:
- P1: Prompt template updates are in role-evaluator-prompt, not implementer-prompt for verdict hooks — the evaluator that runs extension findings is not the one that built the artifact
- P3 (Independence): `prompt-context` command is called fresh per dispatch; no shared state between subagents
---
## 5. Extension Ordering & Conflict Handling
**Ordering**: Extensions fire in the order specified by `extensionOrder` in `config.json`. If `extensionOrder` is absent, extensions fire in alphabetical order (deterministic). Do not rely on JSON object key order — it is not guaranteed stable across serializers.
Example `config.json` with explicit order:
```json
"extensionOrder": ["design-system", "design-lint"]
```
**Conflicts**: Extensions are independent by design. They do not call each other, share state, or produce merged verdicts. If two extensions both flag the same file, both findings appear in `ext-findings.md`. `synthesize` counts emojis — duplicates increase severity signal, which is correct behavior.
**Versioning**: `meta.version` is recorded but not enforced. Semantic versioning is for human operators to track breaking changes in their private extensions. The harness does not validate version compatibility — YAGNI until there's a real cross-extension dependency.
**At 20+ extensions**: The architecture holds. `loadExtensions` is O(n) file scans. `firePromptAppend` and `fireVerdictAppend` are O(n) async calls. If prompt context becomes too large (e.g., 20 extensions each appending 500 tokens), that's a content problem, not an architecture problem — operators should curate `prompt.md` files.
---
## 6. Testing Extensions
### Harness Command: `extension-test`
```bash
# Test a single hook
opc-harness extension-test \
--ext ~/.claude/skills/opc-extension/design-system \
--hook prompt.append \
--context '{"node":"build","role":"frontend","task":"build login page","flowDir":"/tmp/test-harness","runDir":"/tmp/test-harness/nodes/build/run_1"}'
# Test all hooks in sequence
opc-harness extension-test \
--ext ~/.claude/skills/opc-extension/design-system \
--all-hooks \
--context '{"node":"code-review","role":"frontend","task":"review login page","flowDir":"/tmp/test-harness","runDir":"/tmp/test-harness/nodes/code-review/run_1"}'
```
**Output**:
```
[startup.check] ✅ passed (0ms)
[prompt.append] ✅ returned 342 chars
--- output preview ---
## Design System Context
Use design tokens from tokens.ts...
---------------------
[verdict.append] ✅ returned 3 findings
🔴 [color-token] 1 finding
🟡 [spacing-token] 2 findings
```
This enables TDD for extension authors: write `hook.mjs`, run `extension-test`, iterate without a full OPC flow.
**Principle check**:
- P6 (Resilience): Authors can verify extensions in isolation before deploying. Reduces runtime failures.
---
## 7. Installation
Zero magic. Three steps:
```bash
# Step 1: Place files
mkdir -p ~/.claude/skills/opc-extension/design-system
cp hook.mjs prompt.md ~/.claude/skills/opc-extension/design-system/
# Step 2: Declare as required (edit manually)
# ~/.opc/config.json → "requiredExtensions": ["design-system"]
# Step 3: Verify
opc-harness extension-test \
--ext ~/.claude/skills/opc-extension/design-system \
--all-hooks \
--context '{"node":"code-review","role":"frontend","task":"smoke test","flowDir":"/tmp/test-harness","runDir":"/tmp/test-harness/nodes/code-review/run_1"}'
```
**Dotfiles pattern** (recommended for private extensions):
```bash
# In ~/.dotfiles (private repo)
mkdir -p opc/extensions/design-system
cp hook.mjs prompt.md opc/extensions/design-system/
# Symlink
ln -s ~/.dotfiles/opc ~/.opc
```
New machine setup:
```bash
git clone git@github.com:you/dotfiles.git ~/.dotfiles
ln -s ~/.dotfiles/opc ~/.opc
# Done. OPC picks up extensions on next flow init.
```
---
## 8. 3-Year Architectural Assessment
### What Holds
| Concern | Assessment |
|---------|------------|
| 20+ extensions | O(n) scan + O(n) async call. No registry bottleneck. Holds. |
| Extension authoring | `hook.mjs` + `prompt.md` is the complete surface area. No SDK needed. |
| Harness evolution | Three call sites + one 50-line file. Easy to audit and modify. |
| Private forever | `~/.opc/` never touched by OPC updates. Symlink pattern is stable. |
| Replayability | `ext-findings.md` in `.harness/`, `extensionsApplied` in `handshake.json`. Full replay possible. |
| Cross-agent isolation | `prompt-context` is called per-dispatch. No shared extension state between subagents. |
### What Is Explicitly Out of Scope (YAGNI)
- **Extension-to-extension communication**: Not needed. Extensions are independent linters.
- **Hot reload**: Extensions load at flow init. Restarting the flow picks up changes.
- **Remote extension registries**: Private dotfiles is the distribution model.
- **Extension sandboxing**: `hook.mjs` runs in the harness process. Extensions are authored by the operator. Sandboxing is security theater for a single-user OPC deployment.
- **Version negotiation**: `meta.version` is informational. No semver enforcement needed until cross-extension dependencies exist (they don't).
- **Extension UI / management commands**: `opc-harness extension-test` + manual JSON editing is sufficient. A TUI is premature.
---
## 9. Blocker Resolution Summary
| # | Blocker | Solution | Location in Spec |
|---|---------|----------|-----------------|
| 1 | Hook interface undefined | `hook.mjs` default export contract with `meta`, `hooks`, `context`, `finding` types | §2 |
| 2 | No local test mechanism | `opc-harness extension-test --ext ... --hook ... --context` command | §6 |
| 3 | Installation undocumented | Three-step install + dotfiles symlink pattern | §7 |
| 4 | Harness unaware of `~/.opc/` | `lib/extensions.mjs` with `loadExtensions`, three explicit call sites | §3 |
| 5 | No orchestrator convention | Mandatory `prompt-context` step in `implementer-prompt.md` + `role-evaluator-prompt.md` | §4 |
| 6 | No injection verification | `extensionsApplied` in `handshake.json` + `validate-chain` enforcement | §3, §4 |
| 7 | No hook call sites defined | Three call sites: flow init, before subagent dispatch, after eval files written | §3 |
---
## Appendix: Principle Compliance Matrix
| Section | P1 Build≠Review | P2 Mechanical Gate | P3 Independence | P4 Lean Core | P5 File State | P6 Resilience |
|---------|:-:|:-:|:-:|:-:|:-:|:-:|
| §1 Structure | — | — | — | ✅ plain files | ✅ `~/.opc/` | ✅ symlink stable |
| §2 Hook Interface | ✅ `verdict.append` review-only | ✅ `ext-findings.md` via file | ✅ no shared state | ✅ minimal API | ✅ findings in runDir | ✅ throw semantics |
| §3 Harness Changes | ✅ call site enforcement | ✅ file path only | ✅ per-dispatch context | ✅ 50 lines | ✅ handshake.json | ✅ optional warns |
| §4 Orchestrator | ✅ template separation | — | ✅ fresh per dispatch | — | ✅ recorded in handshake | ✅ two-layer check |
| §5 Ordering | — | ✅ duplicates = more signal | ✅ independent | — | — | — |
| §6 Testing | — | — | — | ✅ no test infra needed | — | ✅ pre-deploy verify |
| §7 Installation | — | — | — | ✅ zero magic | — | ✅ new machine = 2 cmds |
---
## §8. Capability Versioning (U1.2, v0.5)
Capability identifiers are versioned to allow breaking schema/behavior changes
without silently breaking downstream nodes.
### §8.1 Identifier format
```
/^[a-z][a-z0-9-]*@[1-9]\d*$/ # canonical: visual-check@1, code-quality@2
/^[a-z][a-z0-9-]*$/ # bare: visual-check → auto-upgrades to @1
```
`N` is a positive decimal integer with no leading zeros (`@1`, `@2`, `@99`,
`@100`). Forms like `@0`, `@01`, `@007` are **not** canonical and pass through
unchanged — they will not match the normalized form `name@1`. This is intentional:
silently treating `@0` as equivalent to `@1` would let a typo erase the version
distinction the system exists to enforce.
**Built-in flow templates** (`bin/lib/flow-templates.mjs`) declare their
`nodeCapabilities` exclusively in canonical `name@N` form. Cold-start init never
emits the bare-name WARN — that signal is reserved for user-installed extensions
or external flow templates that haven't migrated yet.
A **bare** identifier (no `@N`) is auto-upgraded to `@1` at match time. On
first encounter per process, a one-line stderr WARN fires:
```
[opc] WARN: capability 'visual-check' missing version suffix — auto-upgrading to 'visual-check@1'. Declare 'visual-check@1' explicitly to silence this.
```
Subsequent normalizations of the same bare name in the same process are silent.
Both sides of a match (the ext's `meta.provides` and the node's
`nodeCapabilities`) are normalized, so matching is symmetric:
| Ext provides | Node requires | Match |
|----------------|----------------|:-----:|
| `foo@1` | `foo@1` | ✅ |
| `foo` | `foo@1` | ✅ |
| `foo@1` | `foo` | ✅ |
| `foo@1` | `foo@2` | ❌ |
### §8.2 `meta.compatibleCapabilities`
An extension upgrading from `@1` to `@2` can keep firing for legacy-declared
nodes by widening its match surface:
```js
// hook.mjs (shipping visual-check v2)
export const meta = {
name: "visual-check-v2",
provides: ["visual-check@2"],
compatibleCapabilities: ["visual-check@1"], // still match old nodes
description: "Visual consistency check, v2 schema"
};
```
`compatibleCapabilities` is treated identically to `provides` at match time —
both lists are unioned and normalized. Non-array values trigger a load-time
WARN and are coerced to `[]` (same policy as `provides`).
### §8.3 Migration guidance
1. New capability → declare as `name@1` from day one.
2. Breaking change (schema, contract, semantics) → bump to `@2`.
3. Ship ext with `provides: ["name@2"], compatibleCapabilities: ["name@1"]`.
4. Bump all node templates to `name@2` in a follow-up.
5. Remove `compatibleCapabilities` in a later release.
### §8.4 What isn't versioned
The **hook interface** (`promptAppend`, `verdictAppend`, `startupCheck`,
U1.6's `executeRun`, `artifactEmit`) is versioned by the OPC core itself, not
by capability tokens. Extensions opt into new hooks by implementing them; old
hooks continue to work without changes.
## §9. Hook Failure Isolation (U1.3, v0.5)
Extensions are untrusted: they may throw, hang, or return malformed data. The
core enforces three invariants:
1. **Sibling isolation** — one ext's exception/timeout/bad-return never blocks
another ext from running on the same hook call.
2. **Observable failures** — every failure is appended to `registry.failures[]`
as a structured record `{ext, hook, kind, message, at}`. The orchestrator
writes these to `{runDir}/extension-failures.md` so the gate can see them.
Empty file = "no failures this run" (positive signal).
3. **Circuit-breaker** — after `OPC_HOOK_FAILURE_THRESHOLD` (default 3)
consecutive failures, the ext is auto-disabled for the rest of the process.
Subsequent hook calls skip it silently. Set to `0` to **disable the breaker
entirely** (every failure is still recorded, the ext is never auto-disabled).
Note: `0` is the **off switch**, not "trip on first failure" — pick `1` for
that.
### §9.1 Failure kinds
| `kind` | Trigger |
|----------------|----------------------------------------------------------------|
| `throw` | Hook function rejected/threw a non-timeout error |
| `timeout` | Hook exceeded `OPC_HOOK_TIMEOUT_MS` (default 60s). Classified by the `HookTimeoutError` sentinel — never by string-matching `err.message`. |
| `bad-return` | Hook returned wrong type (e.g. non-string from `promptAppend`) |
| `disabled` | Auto-injected when the breaker trips (`hook: "_circuit_breaker"`) |
**Reserved hook names**: any hook name starting with `_` is reserved for OPC
core (currently only `_circuit_breaker`). Extensions MUST NOT export hook
functions with `_`-prefixed names.
### §9.2 Streak semantics
The breaker counts **consecutive** failures, not total. Any successful
invocation resets the streak to 0. This protects against transient flakiness
(e.g. a slow LLM call that occasionally times out) while still tripping on
genuinely broken extensions.
**Concurrency caveat**: "consecutive" is defined per-registry under serial
invocation. Two parallel `firePromptAppend(registry, ...)` calls may interleave
on `_failStreak`, which can delay or accelerate the trip but never corrupts
state. OPC's call pattern is one node at a time, so this is currently a
non-issue; a future per-ext mutex would be required if that changes.
**Manual re-enable**: setting `ext.enabled = true` directly after a trip is a
footgun — the stale `_failStreak` will re-trip the breaker on the very next
single failure. Use the exported `resetExtension(ext)` helper, which clears
both `enabled` and `_failStreak`.
**Bounded log**: `registry.failures[]` is capped at `OPC_HOOK_FAILURE_LOG_CAP`
(default 200) entries. Oldest are dropped FIFO and `registry.failuresDropped`
counts the loss. Long-lived processes therefore cannot exhaust memory or
balloon the report file.
### §9.3 Failure report file
`{runDir}/extension-failures.md` is written by `fireVerdictAppend` (always,
when `runDir` is set) and may be written explicitly via
`writeFailureReport(registry, runDir)` after `firePromptAppend` for prompt-only
paths. Format:
```
# Extension Hook Failures
🟡 my-ext.prompt.append [throw] boom @ 2026-04-18T03:14:15.000Z
🟡 my-ext.prompt.append [timeout] timed out after 60000ms @ ...
🔴 my-ext._circuit_breaker [disabled] circuit-breaker tripped after 3 ... @ ...
```
**Filename rationale**: the file deliberately does **not** start with `eval-`.
The `synthesize` command ingests `eval*.md` as role evaluations and applies
thin-eval / no-code-refs / no-fix / no-reasoning guards that would fire false
positives on every failure-bearing run. `extension-failures.md` is
infrastructure signal, not a role evaluation, and is surfaced through a
separate orchestrator path (gate hook), independent of `synthesize`.
The gate's verdict synthesizer maps eval-file content to verdicts as:
| Eval file content | Synthesize verdict |
|-------------------|--------------------|
| All 🔵 / no findings | `PASS` |
| Any 🟡 (warning) | `ITERATE` |
| Any 🔴 (critical) | `FAIL` |
| Any role BLOCKED | `BLOCKED` |
`extension-failures.md` is **not** ingested by `synthesize`; required
extensions that trip the breaker should be surfaced to the user explicitly via
the gate hook (downstream consumer's responsibility — see U1.6).
## §10. Extension Hook Surface (U1.6, v0.5)
The extension system exposes **five hooks** across three node types. All are
optional; an extension implements only the hooks it needs. Both kebab
(`execute.run`) and camel (`executeRun`) export names are accepted — the
normalizer resolves them to the canonical kebab form before dispatch.
| Hook | Fires during | Args (context) | Return | Failure isolation |
|-----------------|--------------------------|----------------------------------------------------------------|-------------------------------------------|------------------------------------------------------------|
| `startup.check` | extension load | `{}` (empty object — config is not threaded through today) | any (throw = refuse to load) | Load rejected; extension absent from registry |
| `prompt.append` | build / review prompts | `{ node, role, task, flowDir, runDir, devServerUrl, nodeCapabilities }` | `string` (markdown) | Isolated — sibling extensions still fire |
| `verdict.append`| review-node evaluation | same as prompt.append | `Finding[]` (`{severity, category, message}`) | Isolated — findings from siblings still collected |
| `execute.run` | execute-node side effects | same + `role: "executor"` | **ignored** | Isolated — per-extension circuit-breaker on repeated fails |
| `artifact.emit` | execute-node file emission (after `execute.run`) | same as execute.run | `{ name, content }[]` (see below) | Isolated, **per-item** — one bad file doesn't skip the rest |
### `artifact.emit` return contract
Each item `{ name, content }`:
- `name` — must be a **plain basename**. `basename(name) === name` is enforced;
`../escape`, `/abs`, `sub/nested`, empty string, `.`, `..` all rejected with
a stderr WARN and the item skipped.
- `content` — accepts one of:
- `string` (written with default UTF-8 encoding)
- `Buffer` (written as-is)
- Any `ArrayBuffer.isView` value: `Uint8Array`, `DataView`, typed arrays.
Converted losslessly via `Buffer.from(v.buffer, v.byteOffset, v.byteLength)`
before write — zero-copy, honors non-zero `byteOffset` on sliced views.
Files are written atomically to `<runDir>/ext-<ext.name>/<basename>` and
auto-appended to `handshake.artifacts[]` as
`{ type: "ext-artifact", ext, path }`. The handshake merge deduplicates by
`path`, so re-running `extension-artifact` on the same run dir is idempotent.
### Failure semantics
The per-extension circuit-breaker (§9) tracks `_failStreak` across **all**
hooks. Relevant subtleties for execute-node hooks:
- `execute.run`: throw or timeout → `recordFailure`; clean return →
`recordSuccess`. Return value ignored — it's a pure side-effect hook.
- `artifact.emit`: the extension's top-level throw/timeout is a single
failure event. Within a clean return, individual per-item write failures
(bad basename, `EISDIR`, permission, disk-full) each call
`recordFailure`. `recordSuccess` is called **only if every item in the
call succeeded** — persistent per-item I/O errors therefore do trip the
breaker as expected (U1.6r semantics F1 fix-forward).
### CLI surface
`opc-harness extension-artifact --node <id> --dir <harness>` fires
`execute.run` then `artifact.emit` for all extensions whose `meta.provides`
matches the node's `nodeCapabilities`. Stdout JSON shape:
```json
{
"ok": true,
"node": "execute",
"runDir": "/abs/path/.harness/nodes/execute/run_1",
"extensionsApplied": ["visual-check"],
"nodeCapabilities": ["visual-check@1"],
"executeRunCount": 1,
"emitted": [{ "type": "ext-artifact", "ext": "visual-check", "path": "…/screenshot.png" }]
}
```
`nodeCapabilities` is included for symmetry with
`opc-harness extension-verdict` — consumers can diff expected-vs-applied
capabilities uniformly across both hook phases.
1/6
I gave myself an 11-person review team with one slash command.
/opc review — Security Engineer, PM, New User, DevOps, and more review your code in parallel.
Built it as a Claude Code skill. Zero dependencies. Just markdown files.
github.com/iamtouchskyer/opc
2/6
Honest test: I ran both on the same repo.
Single Claude prompt: 14 bugs found
OPC (3 agents): 9 bugs found
Claude won on code bugs. Variable shadowing, DRY violations, exit codes — more thorough in a single pass.
So why bother?
3/6
Because OPC caught 5 things Claude completely missed:
- New user runs "opc review" in terminal, confused it's not a CLI command
- README symlink breaks silently if you cd into the repo first
- Claude Code link goes to marketing page, not install docs
Perspective bugs. Not code bugs.
4/6
How it works:
11 markdown files, each defining a specialist role with anti-patterns ("don't flag missing auth on local tools")
2-5 agents run in parallel with different system prompts
A coordinator verifies facts, questions severity, dismisses false positives
No magic. Structured prompt management.
5/6
The anti-patterns are the key design choice.
Each role knows what NOT to flag. Security agent won't cry about "no auth" on a CLI tool. New-user agent won't demand tutorials in a dev tool.
This prevents the #1 problem with AI review: generic checklist filling.
6/6
npm install -g @touchskyer/opc
Then in Claude Code: /opc review
30 seconds. Zero deps. Finds bugs you wouldn't think to look for.
Star it if it catches something Claude alone wouldn't.
#!/bin/bash
set -e
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
setup_git
# ── Setup: create fake sessions for audit to scan ──
# Audit uses getSessionsBaseDir which hashes git root.
# We'll use --base with a custom dir structure instead.
SESSIONS_BASE="$TMPDIR/fake-opc/sessions"
HASH="abcdef123456"
mkdir -p "$SESSIONS_BASE/$HASH"
# ── Session 1: complete, has skeptic-owner, deep evals, criteria ──
S1="$SESSIONS_BASE/$HASH/session-good"
mkdir -p "$S1/nodes/code-review/run_1"
cat > "$S1/flow-state.json" <<'EOF'
{
"flowTemplate": "build-verify",
"currentNode": null,
"status": "completed",
"tier": "polished",
"totalSteps": 5,
"history": [
{"node": "build", "verdict": "PASS"},
{"node": "code-review", "verdict": "PASS"},
{"node": "gate", "verdict": "PASS"}
]
}
EOF
# Deep eval (>50 lines) with skeptic-owner
python3 -c "
lines = ['# Skeptic Owner Review', '', '## Summary', 'Thorough check.', '']
lines += ['Finding line %d: detail about code quality.' % i for i in range(60)]
lines += ['', '## Verdict', 'PASS']
print('\n'.join(lines))
" > "$S1/nodes/code-review/run_1/eval-skeptic-owner.md"
python3 -c "
lines = ['# Frontend Review', '', '## Summary', 'Looks good.', '']
lines += ['Line %d of frontend analysis with specific code references.' % i for i in range(55)]
lines += ['', '## Verdict', 'PASS']
print('\n'.join(lines))
" > "$S1/nodes/code-review/run_1/eval-frontend.md"
echo "# Acceptance Criteria" > "$S1/acceptance-criteria.md"
# ── Session 2: incomplete, no skeptic-owner, thin evals, skipped node ──
S2="$SESSIONS_BASE/$HASH/session-bad"
mkdir -p "$S2/nodes/code-review/run_1"
cat > "$S2/flow-state.json" <<'EOF'
{
"flowTemplate": "review",
"currentNode": "gate",
"status": "active",
"tier": "functional",
"totalSteps": 2,
"history": [
{"node": "review", "verdict": "PASS", "skipped": true}
]
}
EOF
# Thin eval (< 50 lines), no skeptic-owner
cat > "$S2/nodes/code-review/run_1/eval-backend.md" <<'EOF'
# Backend Review
Looks fine.
## Verdict
PASS
EOF
echo ""
echo "Test: opc-harness audit"
echo "================================================"
echo ""
# ── We need to trick audit into scanning our fake dir ──
# audit uses getSessionsBaseDir(projectDir) which does SHA256(git-root).
# Instead, we'll symlink so the hash matches.
# Actually, audit.mjs scans getSessionsBaseDir which resolves to ~/.opc/sessions/{hash}.
# We can't easily override that, so let's test by passing --base and creating
# a git repo whose hash matches our dir structure.
# Simpler approach: patch HOME so getSessionsBaseDir resolves to our fake dir.
export HOME="$TMPDIR/fake-home"
mkdir -p "$HOME/.opc/sessions"
# Create a project dir with git init, get its hash
PROJ="$TMPDIR/fake-project"
mkdir -p "$PROJ"
cd "$PROJ"
git init -q .
git config user.email "test@test.com"
git config user.name "Test"
echo "x" > x.txt && git add . && git commit -q -m "init"
# Get the hash that audit will compute (must match realpathSync in util.mjs)
REAL_HASH=$(python3 -c "import os,hashlib; print(hashlib.sha256(os.path.realpath('$(git rev-parse --show-toplevel)').encode()).hexdigest()[:12])")
# Create sessions under that hash
mkdir -p "$HOME/.opc/sessions/$REAL_HASH"
cp -r "$S1" "$HOME/.opc/sessions/$REAL_HASH/session-good"
cp -r "$S2" "$HOME/.opc/sessions/$REAL_HASH/session-bad"
# ── Test 1: audit --format json produces valid output ──
echo "1. audit --format json → valid JSON with expected structure"
OUT=$($HARNESS audit --format json --base "$PROJ" 2>/dev/null || true)
if echo "$OUT" | python3 -c "import sys,json; d=json.load(sys.stdin); assert 'sessions' in d and 'aggregate' in d" 2>/dev/null; then
echo " ✅ JSON has sessions + aggregate"
PASS=$((PASS + 1))
else
echo " ❌ Invalid JSON structure"
echo " Output: $(echo "$OUT" | head -5)"
FAIL=$((FAIL + 1))
fi
# ── Test 2: correct session count ──
echo "2. Detects both sessions"
COUNT=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['aggregate']['total_sessions'])" 2>/dev/null)
if [ "$COUNT" = "2" ]; then
echo " ✅ total_sessions=2"
PASS=$((PASS + 1))
else
echo " ❌ total_sessions=$COUNT (expected 2)"
FAIL=$((FAIL + 1))
fi
# ── Test 3: good session has higher conformance than bad ──
echo "3. Good session scores higher than bad session"
SCORES=$(echo "$OUT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
for s in d['sessions']:
print(s['id'], s['conformance_score'])
" 2>/dev/null)
GOOD_SCORE=$(echo "$SCORES" | grep "session-good" | awk '{print $2}')
BAD_SCORE=$(echo "$SCORES" | grep "session-bad" | awk '{print $2}')
if python3 -c "assert float('$GOOD_SCORE') > float('$BAD_SCORE')" 2>/dev/null; then
echo " ✅ good ($GOOD_SCORE) > bad ($BAD_SCORE)"
PASS=$((PASS + 1))
else
echo " ❌ good=$GOOD_SCORE, bad=$BAD_SCORE"
FAIL=$((FAIL + 1))
fi
# ── Test 4: bad session has no_manual_bypass = false (skipped: true in history) ──
echo "4. Skipped node → no_manual_bypass=false"
BYPASS=$(echo "$OUT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
for s in d['sessions']:
if s['id'] == 'session-bad':
print(s['checks']['no_manual_bypass'])
" 2>/dev/null)
if [ "$BYPASS" = "False" ]; then
echo " ✅ no_manual_bypass=False for bad session"
PASS=$((PASS + 1))
else
echo " ❌ no_manual_bypass=$BYPASS (expected False)"
FAIL=$((FAIL + 1))
fi
# ── Test 5: good session has acceptance_criteria_exists = true ──
echo "5. acceptance-criteria.md present → check passes"
AC=$(echo "$OUT" | python3 -c "
import sys, json
d = json.load(sys.stdin)
for s in d['sessions']:
if s['id'] == 'session-good':
print(s['checks']['acceptance_criteria_exists'])
" 2>/dev/null)
if [ "$AC" = "True" ]; then
echo " ✅ acceptance_criteria_exists=True"
PASS=$((PASS + 1))
else
echo " ❌ acceptance_criteria_exists=$AC"
FAIL=$((FAIL + 1))
fi
# ── Test 6: --last 1 returns only 1 session ──
echo "6. --last 1 limits output"
OUT_LAST=$($HARNESS audit --format json --last 1 --base "$PROJ" 2>/dev/null || true)
LAST_COUNT=$(echo "$OUT_LAST" | python3 -c "import sys,json; print(json.load(sys.stdin)['aggregate']['total_sessions'])" 2>/dev/null)
if [ "$LAST_COUNT" = "1" ]; then
echo " ✅ --last 1 returns 1 session"
PASS=$((PASS + 1))
else
echo " ❌ --last 1 returned $LAST_COUNT sessions"
FAIL=$((FAIL + 1))
fi
print_results
#!/bin/bash
set -e
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
setup_git
echo "Test: Output Contracts"
echo "================================================"
echo ""
# ── Test 1: init output has required fields ──
echo "1. init output contract: flow, created, dir"
OUT=$($HARNESS init --flow build-verify --entry build --dir .harness 2>/dev/null)
VALID=$(echo "$OUT" | python3 -c "
import sys,json
d = json.load(sys.stdin)
assert 'flow' in d or 'flowTemplate' in d
assert 'created' in d
assert 'dir' in d
print('ok')
" 2>/dev/null)
if [ "$VALID" = "ok" ]; then
echo " ✅ init output has required fields"
PASS=$((PASS + 1))
else
echo " ❌ init output missing fields"
echo " Output: $(echo "$OUT" | head -5)"
FAIL=$((FAIL + 1))
fi
# ── Test 2: route output contract ──
echo "2. route output contract: next, allowed"
ROUTE=$($HARNESS route --node build --verdict PASS --flow build-verify 2>/dev/null)
VALID2=$(echo "$ROUTE" | python3 -c "
import sys,json
d = json.load(sys.stdin)
assert 'next' in d
print('ok')
" 2>/dev/null)
if [ "$VALID2" = "ok" ]; then
echo " ✅ route output has 'next' field"
PASS=$((PASS + 1))
else
echo " ❌ route output contract broken"
echo " Output: $ROUTE"
FAIL=$((FAIL + 1))
fi
# ── Test 3: transition output contract ──
echo "3. transition output contract: allowed field"
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"}]}
EOF
touch .harness/nodes/build/x
TRANS=$($HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir .harness 2>/dev/null)
VALID3=$(echo "$TRANS" | python3 -c "
import sys,json
d = json.load(sys.stdin)
assert 'allowed' in d
print('ok')
" 2>/dev/null)
if [ "$VALID3" = "ok" ]; then
echo " ✅ transition output has 'allowed' field"
PASS=$((PASS + 1))
else
echo " ❌ transition output contract broken"
echo " Output: $TRANS"
FAIL=$((FAIL + 1))
fi
# ── Test 4: synthesize output contract ──
echo "4. synthesize output contract: roles, totals, verdict, reason"
mkdir -p .harness/nodes/code-review/run_1
cat > .harness/nodes/code-review/run_1/eval-backend.md <<'EOF'
# Backend Review
## Summary
All good.
## Findings
🔵 **Suggestion** — `src/main.ts:1` — Add logging
- **Why**: Helps debugging
- **Fix**: Add console.log
## Verdict
PASS
EOF
mkdir -p "$TMPDIR/src"
printf '%s\n' {1..10} > "$TMPDIR/src/main.ts"
SYNTH=$($HARNESS synthesize .harness --node code-review --run 1 --base "$TMPDIR" --no-strict 2>/dev/null || true)
VALID4=$(echo "$SYNTH" | python3 -c "
import sys,json
d = json.load(sys.stdin)
assert 'roles' in d and isinstance(d['roles'], list)
assert 'totals' in d and 'critical' in d['totals'] and 'warning' in d['totals']
assert 'verdict' in d and d['verdict'] in ('PASS','FAIL','ITERATE','BLOCKED')
assert 'reason' in d
print('ok')
" 2>/dev/null)
if [ "$VALID4" = "ok" ]; then
echo " ✅ synthesize output has roles/totals/verdict/reason"
PASS=$((PASS + 1))
else
echo " ❌ synthesize output contract broken"
echo " Output: $(echo "$SYNTH" | head -5)"
FAIL=$((FAIL + 1))
fi
# ── Test 5: verify output contract ──
echo "5. verify output contract: verdict, findings_count, evidence_complete"
cat > "$TMPDIR/eval-test.md" <<'EOF'
# Test Eval
## Summary
Quick check.
## Findings
🟡 **Warning** — `src/main.ts:1` — Missing validation
- **Why**: Input not sanitized
- **Fix**: Add zod schema
## Verdict
ITERATE
EOF
VERIFY=$($HARNESS verify "$TMPDIR/eval-test.md" --base "$TMPDIR" 2>/dev/null || true)
VALID5=$(echo "$VERIFY" | python3 -c "
import sys,json
d = json.load(sys.stdin)
assert 'verdict' in d
assert 'findings_count' in d
assert 'evidence_complete' in d
print('ok')
" 2>/dev/null)
if [ "$VALID5" = "ok" ]; then
echo " ✅ verify output has verdict/findings_count/evidence_complete"
PASS=$((PASS + 1))
else
echo " ❌ verify output contract broken"
echo " Output: $(echo "$VERIFY" | head -5)"
FAIL=$((FAIL + 1))
fi
# ── Test 6: viz output (non-empty) ──
echo "6. viz output is non-empty"
VIZ=$($HARNESS viz --flow build-verify --dir .harness 2>/dev/null || true)
if [ -n "$VIZ" ]; then
echo " ✅ viz produces output"
PASS=$((PASS + 1))
else
echo " ❌ viz output empty"
FAIL=$((FAIL + 1))
fi
# ── Test 7: all outputs are valid JSON (except viz) ──
echo "7. stderr doesn't leak into stdout for structured commands"
# init, route, transition, synthesize should all be parseable JSON
# We already tested them above — this is a meta-check that none had parse errors
echo " ✅ (covered by tests 1-5 passing)"
PASS=$((PASS + 1))
print_results
#!/usr/bin/env bash
set -euo pipefail
# Test: crash recovery (in_progress timeout) + VALID_LOOP_STATUSES export
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
HARNESS="node $SCRIPT_DIR/bin/opc-harness.mjs"
PASS=0; FAIL=0
check() {
local label="$1" cond="$2"
if eval "$cond"; then
echo " ✅ $label"
PASS=$((PASS + 1))
else
echo " ❌ $label"
FAIL=$((FAIL + 1))
fi
}
TMPD=$(mktemp -d)
trap 'rm -rf "$TMPD"' EXIT
H() { (cd "$TMPD" && $HARNESS "$@" 2>&1); }
setup_loop() {
local reldir="$1" next_unit="$2" tick="${3:-0}" status="${4:-in_progress}" since="${5:-}"
local absdir="$TMPD/$reldir"
mkdir -p "$absdir"
local since_field=""
if [ -n "$since" ]; then
since_field="\"_in_progress_since\": \"$since\","
fi
cat > "$absdir/loop-state.json" << LOOPEOF
{
"tick": $tick,
"unit": "A.1",
"next_unit": "$next_unit",
"status": "$status",
"plan_file": "$absdir/plan.md",
"_written_by": "opc-harness",
"_write_nonce": "test123",
"_last_modified": "2026-01-01T00:00:00.000Z",
$since_field
"_tick_history": [],
"_git_head": null,
"_task_scope": []
}
LOOPEOF
cat > "$absdir/plan.md" << 'PLANEOF'
## Units
- A.1: implement — Build the auth module
- A.2: review — Review auth module
- A.3: implement — Build the dashboard
PLANEOF
}
echo "=== TEST GROUP 1: Stale in_progress auto-recovered ==="
# Set _in_progress_since to 2 hours ago
TWO_HOURS_AGO=$(node -e "console.log(new Date(Date.now() - 2*3600000).toISOString())")
setup_loop "crash1" "A.2" 1 "in_progress" "$TWO_HOURS_AGO"
RESULT=$(H next-tick --dir crash1)
check "returns recovered_from" 'echo "$RESULT" | grep -q "in_progress_timeout"'
check "returns stall reason" 'echo "$RESULT" | grep -q "auto-recovered"'
# Verify state was written to stalled
STATE_STATUS=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$TMPD/crash1/loop-state.json','utf8')).status)")
check "state set to stalled" '[ "$STATE_STATUS" = "stalled" ]'
echo ""
echo "=== TEST GROUP 2: Recent in_progress not recovered ==="
# Set _in_progress_since to 10 minutes ago
TEN_MIN_AGO=$(node -e "console.log(new Date(Date.now() - 10*60000).toISOString())")
setup_loop "crash2" "A.2" 1 "in_progress" "$TEN_MIN_AGO"
RESULT2=$(H next-tick --dir crash2)
check "returns normal skip message" 'echo "$RESULT2" | grep -q "another tick is in progress"'
check "no recovery triggered" '! echo "$RESULT2" | grep -q "in_progress_timeout"'
# Verify state unchanged
STATE_STATUS2=$(node -e "console.log(JSON.parse(require('fs').readFileSync('$TMPD/crash2/loop-state.json','utf8')).status)")
check "state still in_progress" '[ "$STATE_STATUS2" = "in_progress" ]'
echo ""
echo "=== TEST GROUP 3: Custom timeout via env var ==="
FIVE_MIN_AGO=$(node -e "console.log(new Date(Date.now() - 5*60000).toISOString())")
setup_loop "crash3" "A.2" 1 "in_progress" "$FIVE_MIN_AGO"
# Set timeout to 0.05 hours (3 minutes) — 5min old should trigger (floor is 0.1h but 0.05 clamps to 0.1h=6min → too high)
# Use 10 min old instead so it exceeds the 0.1h (6min) floor
TEN_MIN_AGO_3=$(node -e "console.log(new Date(Date.now() - 10*60000).toISOString())")
setup_loop "crash3" "A.2" 1 "in_progress" "$TEN_MIN_AGO_3"
# Timeout clamped to 0.1h (6 min floor); 10 min > 6 min → triggers recovery
RESULT3=$(OPC_TICK_TIMEOUT_HOURS=0.001 H next-tick --dir crash3)
check "custom timeout triggers recovery" 'echo "$RESULT3" | grep -q "in_progress_timeout"'
echo ""
echo "=== TEST GROUP 4: VALID_LOOP_STATUSES export ==="
VSIZE=$(node --input-type=module -e "import { VALID_LOOP_STATUSES } from '$SCRIPT_DIR/bin/lib/util.mjs'; console.log(VALID_LOOP_STATUSES.size)" 2>&1)
check "VALID_LOOP_STATUSES has 5 entries" '[ "$VSIZE" = "5" ]'
TSIZE=$(node --input-type=module -e "import { TERMINAL_LOOP_STATUSES } from '$SCRIPT_DIR/bin/lib/util.mjs'; console.log(TERMINAL_LOOP_STATUSES.size)" 2>&1)
check "TERMINAL_LOOP_STATUSES has 3 entries" '[ "$TSIZE" = "3" ]'
echo ""
echo "=== TEST GROUP 5: TERMINAL_LOOP_STATUSES used in complete-tick ==="
setup_loop "term1" "A.1" 0 "stalled"
echo '{"tests_run": 1}' > "$TMPD/term1/test.json"
RESULT5=$(H complete-tick --unit A.1 --artifacts "$TMPD/term1/test.json" --description "test" --dir term1)
check "stalled loop rejects complete-tick" 'echo "$RESULT5" | grep -q "terminated pipeline"'
echo ""
echo "=== TEST GROUP 6: Tamper detection warning ==="
setup_loop "tamper1" "A.1" 0 "initialized"
# Tamper: set wrong writer
node --input-type=module -e "
import { readFileSync, writeFileSync } from 'fs';
const s = JSON.parse(readFileSync('$TMPD/tamper1/loop-state.json','utf8'));
s._written_by = 'manual-edit';
delete s._write_nonce;
writeFileSync('$TMPD/tamper1/loop-state.json', JSON.stringify(s, null, 2));
"
RESULT6=$(H next-tick --dir tamper1)
check "tamper warning emitted" 'echo "$RESULT6" | grep -q "not written by opc-harness"'
echo ""
echo "=== TEST GROUP 7: Timeout floor clamps tiny values ==="
setup_loop "floor1" "A.1" 0 "in_progress"
# Set _in_progress_since to 10 minutes ago — should NOT trigger with floor of 0.1h (6 min)
TEN_MIN_AGO=$(node -e "console.log(new Date(Date.now() - 10*60000).toISOString())")
node --input-type=module -e "
import { readFileSync, writeFileSync } from 'fs';
const s = JSON.parse(readFileSync('$TMPD/floor1/loop-state.json','utf8'));
s._in_progress_since = '$TEN_MIN_AGO';
writeFileSync('$TMPD/floor1/loop-state.json', JSON.stringify(s, null, 2));
"
# With 0.00001h env var, should be clamped to 0.1h — so 10 min < 6 min = no recovery
# Wait, 10 min > 6 min, so it WOULD recover. Use 3 min instead.
THREE_MIN_AGO=$(node -e "console.log(new Date(Date.now() - 3*60000).toISOString())")
node --input-type=module -e "
import { readFileSync, writeFileSync } from 'fs';
const s = JSON.parse(readFileSync('$TMPD/floor1/loop-state.json','utf8'));
s._in_progress_since = '$THREE_MIN_AGO';
writeFileSync('$TMPD/floor1/loop-state.json', JSON.stringify(s, null, 2));
"
RESULT7=$(OPC_TICK_TIMEOUT_HOURS=0.00001 H next-tick --dir floor1)
check "tiny timeout clamped — no false stall" 'echo "$RESULT7" | grep -q "another tick is in progress"'
echo ""
echo "==========================================="
echo " Results: $PASS passed, $FAIL failed"
echo "==========================================="
[ "$FAIL" -eq 0 ] || exit 1
#!/usr/bin/env bash
set -euo pipefail
# Test: Fix 1 (recon gate) + Fix 3 (evidence timestamp + test runner error)
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
HARNESS="node $SCRIPT_DIR/bin/opc-harness.mjs"
PASS=0; FAIL=0
check() {
local label="$1" cond="$2"
if eval "$cond"; then
echo " ✅ $label"
PASS=$((PASS + 1))
else
echo " ❌ $label"
FAIL=$((FAIL + 1))
fi
}
TMPD=$(mktemp -d)
trap 'rm -rf "$TMPD"' EXIT
H() { (cd "$TMPD" && $HARNESS "$@" 2>&1); }
write_valid_plan() {
local dir="$1"
mkdir -p "$dir"
cat > "$dir/plan.md" << 'EOF'
## Task Scope
- SCOPE-1: Build feature X
## Units
- F1.1: implement — build feature X
verify: npm test
- F1.2: review — review feature X
eval: check quality
EOF
cat > "$dir/acceptance-criteria.md" << 'EOF'
## Acceptance Criteria
- AC-1: Feature X works end-to-end
- AC-2: Tests pass with coverage > 80%
## Verification
- Run `npm test` and confirm all pass
## Quality
- No regressions in existing tests
EOF
}
# ═══════════════════════════════════════════════════════════════════
echo "── Fix 1: Recon Gate ──"
echo " Case 1: --recon with missing file"
DIR="$TMPD/t1"
write_valid_plan "$DIR"
OUT=$(H init-loop --plan "$DIR/plan.md" --recon "$DIR/nonexistent.md" --dir "$DIR" --skip-lint)
check "rejects missing recon file" '[[ "$OUT" == *"recon file not found"* ]]'
echo " Case 2: --recon with too-small file"
DIR="$TMPD/t2"
write_valid_plan "$DIR"
echo "short" > "$DIR/recon.md"
OUT=$(H init-loop --plan "$DIR/plan.md" --recon "$DIR/recon.md" --dir "$DIR" --skip-lint)
check "rejects small recon file" '[[ "$OUT" == *"recon file too small"* ]]'
echo " Case 3: --recon with valid file"
DIR="$TMPD/t3"
write_valid_plan "$DIR"
python3 -c "print('x' * 300)" > "$DIR/recon.md"
OUT=$(H init-loop --plan "$DIR/plan.md" --recon "$DIR/recon.md" --dir "$DIR" --skip-lint)
check "accepts valid recon file" '[[ "$OUT" == *"initialized\":true"* ]]'
echo " Case 4: no --recon flag still works (backward compat)"
DIR="$TMPD/t4"
write_valid_plan "$DIR"
OUT=$(H init-loop --plan "$DIR/plan.md" --dir "$DIR" --skip-lint)
check "no recon flag = init succeeds" '[[ "$OUT" == *"initialized\":true"* ]]'
# ═══════════════════════════════════════════════════════════════════
echo ""
echo "── Fix 3: Evidence Timestamp Gate ──"
setup_loop_state() {
local dir="$1"
mkdir -p "$dir"
# Set _last_modified to NOW so artifacts must be fresh
local ts
ts=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
cat > "$dir/loop-state.json" << EOFSTATE
{
"tick": 1,
"unit": "F1.1",
"description": "implement feature",
"status": "in_progress",
"artifacts": [],
"next_unit": "F1.2",
"blockers": [],
"review_of_previous": "",
"plan_file": "$dir/plan.md",
"units_total": 2,
"unit_ids": ["F1.1", "F1.2"],
"_written_by": "opc-harness/1",
"_plan_hash": "abc123",
"_last_modified": "$ts",
"_git_head": "deadbeef",
"_tick_history": [],
"_max_total_ticks": 6,
"_started_at": "$ts",
"_max_duration_hours": 24,
"_write_nonce": "testnonce1234567",
"_external_validators": {
"pre_commit_hooks": false,
"test_script": "npm test",
"lint_script": null,
"typecheck_script": null
}
}
EOFSTATE
write_valid_plan "$dir"
}
echo " Case 5: stale artifact rejected"
DIR="$TMPD/t5"
setup_loop_state "$DIR"
# Create artifact with OLD mtime (touch -t sets to 2020)
mkdir -p "$DIR"
echo "some output" > "$DIR/output.log"
touch -t 202001010000 "$DIR/output.log"
sleep 1
# Now update _last_modified to be AFTER the artifact
NEW_TS=$(date -u +"%Y-%m-%dT%H:%M:%S.000Z")
sed -i.bak "s/_last_modified.*/_last_modified\": \"$NEW_TS\",/" "$DIR/loop-state.json"
OUT=$(H complete-tick --unit F1.1 --artifacts "$DIR/output.log" --dir "$DIR" 2>&1 || true)
check "rejects stale artifact" '[[ "$OUT" == *"stale"* ]]'
echo " Case 6: fresh artifact accepted (no stale error)"
DIR="$TMPD/t6"
setup_loop_state "$DIR"
# Wait a moment then create artifact (mtime > state._last_modified)
sleep 1
echo "3 tests passed, 0 failed" > "$DIR/output.log"
OUT=$(H complete-tick --unit F1.1 --artifacts "$DIR/output.log" --dir "$DIR" 2>&1 || true)
check "no stale error for fresh artifact" '[[ "$OUT" != *"stale"* ]]'
echo " Case 7: missing test runner output = error (not warning)"
DIR="$TMPD/t7"
setup_loop_state "$DIR"
sleep 1
echo "just some log without test markers" > "$DIR/output.log"
OUT=$(H complete-tick --unit F1.1 --artifacts "$DIR/output.log" --dir "$DIR" 2>&1 || true)
check "test runner missing = error" '[[ "$OUT" == *"must pass tests"* ]]'
check "complete-tick fails" '[[ "$OUT" == *"\"valid\":false"* || "$OUT" == *"errors"* ]]'
# ═══════════════════════════════════════════════════════════════════
echo ""
echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
echo "Results: $PASS passed, $FAIL failed"
[ "$FAIL" -eq 0 ] || exit 1
#!/usr/bin/env bash
set -euo pipefail
# Test: goto maxLoopsPerEdge enforcement
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
HARNESS="node $SCRIPT_DIR/bin/opc-harness.mjs"
PASS=0; FAIL=0
check() {
local label="$1" cond="$2"
if eval "$cond"; then
echo " ✅ $label"
PASS=$((PASS + 1))
else
echo " ❌ $label"
FAIL=$((FAIL + 1))
fi
}
TMPD=$(mktemp -d)
trap 'rm -rf "$TMPD"' EXIT
H() { (cd "$TMPD" && $HARNESS "$@" 2>&1); }
setup_flow() {
local reldir="$1"
local absdir="$TMPD/$reldir"
mkdir -p "$absdir/nodes/build/run_1" "$absdir/nodes/code-review/run_1"
cat > "$absdir/flow-state.json" << EOF
{
"flowTemplate": "build-verify",
"currentNode": "build",
"entryNode": "build",
"status": "in_progress",
"totalSteps": 0,
"history": [{"nodeId": "build", "runId": "run_1", "timestamp": "2026-01-01T00:00:00Z"}],
"edgeCounts": {},
"_written_by": "opc-harness",
"_last_modified": "2026-01-01T00:00:00Z"
}
EOF
}
echo "=== TEST GROUP 1: goto respects maxLoopsPerEdge ==="
setup_flow "run1"
R1=$(H goto code-review --dir run1)
check "first goto succeeds" 'echo "$R1" | grep -q "\"goto\":\"code-review\""'
check "edgeCounts updated" 'grep -q "build→code-review" "$TMPD/run1/flow-state.json"'
R2=$(H goto build --dir run1)
check "goto back to build succeeds" 'echo "$R2" | grep -q "\"goto\":\"build\""'
R3=$(H goto code-review --dir run1)
check "second goto code-review succeeds" 'echo "$R3" | grep -q "\"goto\":\"code-review\""'
H goto build --dir run1 > /dev/null
R5=$(H goto code-review --dir run1)
check "third goto code-review succeeds" 'echo "$R5" | grep -q "\"goto\":\"code-review\""'
# 4th goto code-review from build — should fail (maxLoopsPerEdge=3)
H goto build --dir run1 > /dev/null
R7=$(H goto code-review --dir run1)
check "4th goto code-review blocked" 'echo "$R7" | grep -q "maxLoopsPerEdge"'
echo ""
echo "=== TEST GROUP 2: edgeCounts persisted ==="
setup_flow "run2"
H goto code-review --dir run2 > /dev/null
EDGE_COUNT=$(node -e "const s=JSON.parse(require('fs').readFileSync('$TMPD/run2/flow-state.json','utf8')); console.log(s.edgeCounts['build→code-review'] || 0)")
check "edge count is 1" '[ "$EDGE_COUNT" = "1" ]'
echo ""
echo "==========================================="
echo " Results: $PASS passed, $FAIL failed"
echo "==========================================="
[ "$FAIL" -eq 0 ] || exit 1
#!/bin/bash
# Tests for OUT-1 (pass refuses on ITERATE/FAIL) and OUT-2 (mandatory role enforcement)
source "$(dirname "$0")/test-helpers.sh"
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
setup_tmpdir
setup_git
# ─── Helper: write eval ───
write_good_eval() {
local dir="$1" node="$2" role="$3"
local run_dir
run_dir=$(ls -d "$dir/nodes/$node"/run_* 2>/dev/null | sort -V | tail -1)
[ -z "$run_dir" ] && run_dir="$dir/nodes/$node/run_1" && mkdir -p "$run_dir"
# Generate ≥51 lines with file:line refs and distinct content per role to pass D2
python3 -c "
role='${role}'
lines = [f'# {role} Review — Evaluation Report', '', '## Process']
for i in range(20):
lines.append(f'Reviewed aspect {i} of the {role} domain. Traced code path {i} through handler.')
lines += ['', '## Scope', f'Reviewed src/api/{role}-handler.ts:1-150, src/middleware/{role}-auth.ts:1-80.', '']
lines += ['## Domain Findings', 'LGTM — no findings in scope.', '']
for i in range(15):
lines.append(f'Additional verification note {i}: All checks passed for {role} area {i}.')
lines += ['', '## Threads', 'No open threads.', '', '## VERDICT', 'VERDICT: LGTM']
print('\n'.join(lines))
" > "$run_dir/eval-${role}.md"
}
write_warning_eval() {
local dir="$1" node="$2" role="$3"
local run_dir
run_dir=$(ls -d "$dir/nodes/$node"/run_* 2>/dev/null | sort -V | tail -1)
[ -z "$run_dir" ] && run_dir="$dir/nodes/$node/run_1" && mkdir -p "$run_dir"
cat > "$run_dir/eval-${role}.md" << EVALEOF
# ${role} review
🟡 src/foo.ts:10 — Missing null check
reasoning: Could crash at runtime if input is undefined
fix: Add \`if (!input) return;\` guard
VERDICT: FINDINGS [1]
EVALEOF
}
write_critical_eval() {
local dir="$1" node="$2" role="$3"
local run_dir
run_dir=$(ls -d "$dir/nodes/$node"/run_* 2>/dev/null | sort -V | tail -1)
[ -z "$run_dir" ] && run_dir="$dir/nodes/$node/run_1" && mkdir -p "$run_dir"
cat > "$run_dir/eval-${role}.md" << EVALEOF
# ${role} review
🔴 src/bar.ts:5 — SQL injection vulnerability
reasoning: User input is concatenated into query string
fix: Use parameterized queries
VERDICT: FINDINGS [1]
EVALEOF
}
write_handshake() {
local dir="$1" node="$2" summary="$3" verdict="$4" nodeType="${5:-review}"
local path="$dir/nodes/$node/handshake.json"
mkdir -p "$(dirname "$path")"
local run_dir
run_dir=$(ls -d "$dir/nodes/$node"/run_* 2>/dev/null | sort -V | tail -1)
local artifacts="[]"
if [ -n "$run_dir" ]; then
artifacts=$(ls "$run_dir"/eval-*.md 2>/dev/null | python3 -c "
import sys, json
files = [l.strip() for l in sys.stdin if l.strip()]
print(json.dumps([{'path': f, 'type': 'eval'} for f in files]))
")
fi
cat > "$path" << EOF
{
"nodeId": "$node",
"nodeType": "$nodeType",
"runId": "run_1",
"status": "completed",
"verdict": "$verdict",
"summary": "$summary",
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
"artifacts": $artifacts,
"findings": null
}
EOF
}
# ═══════════════════════════════════════════════════════════════
echo "=== OUT-1: pass refuses on ITERATE/FAIL upstream verdict ==="
# ═══════════════════════════════════════════════════════════════
# Helper: set up full-stack flow to gate-test with review evals
setup_at_gate_test() {
local evalfn="$1" # write_good_eval or write_warning_eval or write_critical_eval
DIR=$(mktemp -d)
cd "$DIR"
$HARNESS init --flow full-stack --entry discuss --dir .harness 2>/dev/null
# discuss → build
write_handshake .harness discuss "Discuss done" "PASS" discussion
$HARNESS transition --from discuss --to build --verdict PASS --flow full-stack --dir .harness 2>/dev/null
# build → code-review
write_handshake .harness build "Build done" "PASS" build
$HARNESS transition --from build --to code-review --verdict PASS --flow full-stack --dir .harness 2>/dev/null
# code-review → test-design (need evals)
write_good_eval .harness code-review senior
write_good_eval .harness code-review tester
write_handshake .harness code-review "Review done" "PASS"
$HARNESS transition --from code-review --to test-design --verdict PASS --flow full-stack --dir .harness 2>/dev/null
# test-design → test-execute
write_good_eval .harness test-design senior
write_good_eval .harness test-design tester
write_handshake .harness test-design "Test design done" "PASS"
$HARNESS transition --from test-design --to test-execute --verdict PASS --flow full-stack --dir .harness 2>/dev/null
# test-execute → gate-test (need evidence)
mkdir -p .harness/nodes/test-execute/run_1
echo "test output" > .harness/nodes/test-execute/run_1/test-results.json
cat > .harness/nodes/test-execute/handshake.json << EOF
{"nodeId":"test-execute","nodeType":"execute","runId":"run_1","status":"completed","verdict":"PASS","summary":"Tests pass","timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","artifacts":[{"type":"test-result","path":"run_1/test-results.json"}]}
EOF
$HARNESS transition --from test-execute --to gate-test --verdict PASS --flow full-stack --dir .harness 2>/dev/null
# Now at gate-test. Write upstream review evals to test-design for synthesize
# (synthesize looks at the latest non-gate node)
# But actually the upstream for gate-test is test-execute (execute), not review.
# We need the upstream to be a review node with evals. Let me use the code-review node.
}
# Test 1: pass allowed when upstream synthesize → PASS
echo "--- 1.1: pass allowed when upstream verdict is PASS ---"
DIR=$(mktemp -d)
cd "$DIR"
# Simpler: use review flow. The gate PASS edge is null (terminal), so pass says "use finalize".
# Instead, directly test the verdict check logic by looking at the error message.
# Actually, let me use a flow file approach.
# Simplest: create a custom flow file for testing.
cat > /tmp/opc-test-guardrail-flow.json << 'FLOWEOF'
{
"opc_compat": ">=0.5",
"nodes": ["review", "gate", "done"],
"edges": {
"review": {"PASS": "gate"},
"gate": {"PASS": "done", "FAIL": "review", "ITERATE": "review"},
"done": {"PASS": null}
},
"limits": {"maxLoopsPerEdge": 3, "maxTotalSteps": 10, "maxNodeReentry": 5},
"nodeTypes": {"review": "review", "gate": "gate", "done": "build"},
"softEvidence": true
}
FLOWEOF
$HARNESS init --flow-file /tmp/opc-test-guardrail-flow.json --entry review --dir .harness 2>/dev/null
write_good_eval .harness review engineer
write_good_eval .harness review skeptic-owner
write_handshake .harness review "Review done" "PASS"
$HARNESS transition --from review --to gate --verdict PASS --flow-file /tmp/opc-test-guardrail-flow.json --dir .harness 2>/dev/null
# Now at gate, upstream PASS evals → pass should work
OUT=$($HARNESS pass --dir .harness 2>/dev/null)
if echo "$OUT" | grep -q '"allowed":true\|"allowed": true'; then
echo " ✅ pass allowed with PASS upstream"; PASS=$((PASS+1))
else
echo " ❌ pass should be allowed with PASS upstream (got: $OUT)"; FAIL=$((FAIL+1))
fi
# Test 2: pass refused when upstream verdict is ITERATE
echo "--- 1.2: pass refused when upstream verdict is ITERATE ---"
DIR=$(mktemp -d)
cd "$DIR"
$HARNESS init --flow-file /tmp/opc-test-guardrail-flow.json --entry review --dir .harness 2>/dev/null
write_warning_eval .harness review engineer
write_good_eval .harness review skeptic-owner
write_handshake .harness review "Review with warnings" "PASS"
$HARNESS transition --from review --to gate --verdict PASS --flow-file /tmp/opc-test-guardrail-flow.json --dir .harness 2>/dev/null
OUT=$($HARNESS pass --dir .harness 2>/dev/null)
if echo "$OUT" | grep -q "Cannot force-pass"; then
echo " ✅ pass refused with ITERATE upstream"; PASS=$((PASS+1))
else
echo " ❌ pass should refuse with ITERATE upstream (got: $OUT)"; FAIL=$((FAIL+1))
fi
# Test 3: pass refused when upstream verdict is FAIL
echo "--- 1.3: pass refused when upstream verdict is FAIL ---"
DIR=$(mktemp -d)
cd "$DIR"
$HARNESS init --flow-file /tmp/opc-test-guardrail-flow.json --entry review --dir .harness 2>/dev/null
write_critical_eval .harness review engineer
write_critical_eval .harness review skeptic-owner
write_handshake .harness review "Review with critical" "PASS"
$HARNESS transition --from review --to gate --verdict PASS --flow-file /tmp/opc-test-guardrail-flow.json --dir .harness 2>/dev/null
OUT=$($HARNESS pass --dir .harness 2>/dev/null)
if echo "$OUT" | grep -q "Cannot force-pass"; then
echo " ✅ pass refused with FAIL upstream"; PASS=$((PASS+1))
else
echo " ❌ pass should refuse with FAIL upstream (got: $OUT)"; FAIL=$((FAIL+1))
fi
# Test 4: skip still works when pass is blocked
echo "--- 1.4: skip works when pass would be blocked ---"
DIR=$(mktemp -d)
cd "$DIR"
$HARNESS init --flow-file /tmp/opc-test-guardrail-flow.json --entry review --dir .harness 2>/dev/null
write_warning_eval .harness review engineer
write_good_eval .harness review skeptic-owner
write_handshake .harness review "Review with warnings" "PASS"
$HARNESS transition --from review --to gate --verdict PASS --flow-file /tmp/opc-test-guardrail-flow.json --dir .harness 2>/dev/null
OUT=$($HARNESS skip --dir .harness 2>/dev/null)
if echo "$OUT" | grep -q '"skipped"\|"next"'; then
echo " ✅ skip works when pass is blocked"; PASS=$((PASS+1))
else
echo " ❌ skip should work when pass is blocked (got: $OUT)"; FAIL=$((FAIL+1))
fi
echo ""
# ═══════════════════════════════════════════════════════════════
echo "=== OUT-2: mandatory role enforcement ==="
# ═══════════════════════════════════════════════════════════════
# Test 5: transition from review with all mandatory roles present
echo "--- 2.1: transition allowed with mandatory roles present ---"
DIR=$(mktemp -d)
cd "$DIR"
$HARNESS init --flow review --entry review --dir .harness 2>/dev/null
write_good_eval .harness review engineer
write_good_eval .harness review skeptic-owner
write_handshake .harness review "Review done" "PASS"
TRANS=$($HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>/dev/null)
if echo "$TRANS" | grep -q '"allowed":true\|"allowed": true'; then
echo " ✅ transition allowed with mandatory roles"; PASS=$((PASS+1))
else
echo " ❌ transition should be allowed (got: $TRANS)"; FAIL=$((FAIL+1))
fi
# Test 6: transition from review missing mandatory role (skeptic-owner)
echo "--- 2.2: transition refused missing mandatory role ---"
DIR=$(mktemp -d)
cd "$DIR"
$HARNESS init --flow review --entry review --dir .harness 2>/dev/null
write_good_eval .harness review engineer
write_good_eval .harness review frontend
write_handshake .harness review "Review done" "PASS"
TRANS=$($HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>/dev/null)
if echo "$TRANS" | grep -q "Missing mandatory role"; then
echo " ✅ transition refused for missing mandatory role"; PASS=$((PASS+1))
else
echo " ❌ transition should refuse (got: $TRANS)"; FAIL=$((FAIL+1))
fi
# Test 7: transition from review with ALL unknown roles — mandatory check skipped (no known role overlap)
echo "--- 2.3: transition allowed with all-unknown roles (no enforcement) ---"
DIR=$(mktemp -d)
cd "$DIR"
$HARNESS init --flow review --entry review --dir .harness 2>/dev/null
write_good_eval .harness review xsenior
write_good_eval .harness review xtester
write_handshake .harness review "Review done" "PASS"
TRANS=$($HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>/dev/null)
if echo "$TRANS" | grep -q '"allowed":true\|"allowed": true'; then
echo " ✅ transition allowed with all-unknown roles (enforcement skipped)"; PASS=$((PASS+1))
else
echo " ❌ transition should be allowed with all-unknown roles (got: $TRANS)"; FAIL=$((FAIL+1))
fi
# Test 7b: transition with MIX of known + unknown roles, missing mandatory → refused
echo "--- 2.3b: transition refused with mix of known/unknown roles missing mandatory ---"
DIR=$(mktemp -d)
cd "$DIR"
$HARNESS init --flow review --entry review --dir .harness 2>/dev/null
write_good_eval .harness review engineer
write_good_eval .harness review custom-role
write_handshake .harness review "Review done" "PASS"
TRANS=$($HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>/dev/null)
if echo "$TRANS" | grep -q "Missing mandatory role"; then
echo " ✅ transition refused — mandatory check active with known+unknown mix"; PASS=$((PASS+1))
else
echo " ❌ transition should refuse (known role present triggers enforcement) (got: $TRANS)"; FAIL=$((FAIL+1))
fi
# Test 8: missingRoles field present in error response
echo "--- 2.4: error includes missingRoles array ---"
DIR=$(mktemp -d)
cd "$DIR"
$HARNESS init --flow review --entry review --dir .harness 2>/dev/null
write_good_eval .harness review engineer
write_good_eval .harness review frontend
write_handshake .harness review "Review done" "PASS"
TRANS=$($HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>/dev/null)
if echo "$TRANS" | grep -q '"missingRoles"'; then
echo " ✅ missingRoles field present"; PASS=$((PASS+1))
else
echo " ❌ missingRoles field should be present (got: $TRANS)"; FAIL=$((FAIL+1))
fi
# ═══════════════════════════════════════════════════════════════
echo "=== OUT-2b: review node must have eval artifacts ==="
# ═══════════════════════════════════════════════════════════════
echo "--- 2.5: transition refused when review node has no eval artifacts ---"
DIR=$(mktemp -d)
cd "$DIR"
$HARNESS init --flow review --entry review --dir .harness 2>/dev/null
# Write handshake with no eval artifacts
mkdir -p .harness/nodes/review
cat > .harness/nodes/review/handshake.json << EOF
{"nodeId":"review","nodeType":"review","runId":"run_1","status":"completed","verdict":"PASS","summary":"No evals","timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)","artifacts":[{"type":"code","path":"src/foo.ts"}]}
EOF
TRANS=$($HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>/dev/null)
if echo "$TRANS" | grep -q "no eval-type artifacts\|eval artifacts"; then
echo " ✅ transition refused when review has no eval artifacts"; PASS=$((PASS+1))
else
echo " ❌ transition should refuse review with no eval artifacts (got: $TRANS)"; FAIL=$((FAIL+1))
fi
# ═══════════════════════════════════════════════════════════════
echo ""
echo "=== CRLF front matter parsing ==="
# ═══════════════════════════════════════════════════════════════
echo "--- 3.1: CRLF line endings in role front matter still detected ---"
DIR=$(mktemp -d)
cd "$DIR"
# Create a role file with CRLF endings to test that mandatory detection works
ROLES_DIR="$REPO_DIR/roles"
# Create a temp mandatory role file with CRLF
TMP_ROLE="$ROLES_DIR/_test-crlf-role.md"
printf -- "---\r\ntags: [review]\r\nmandatory: true\r\n---\r\n\r\n# Test CRLF Role\r\n" > "$TMP_ROLE"
$HARNESS init --flow review --entry review --dir .harness 2>/dev/null
write_good_eval .harness review engineer
write_good_eval .harness review skeptic-owner
# Missing _test-crlf-role → should refuse
write_handshake .harness review "Review done" "PASS"
TRANS=$($HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>/dev/null)
rm -f "$TMP_ROLE"
if echo "$TRANS" | grep -q "Missing mandatory role.*_test-crlf-role\|_test-crlf-role"; then
echo " ✅ CRLF front matter correctly parsed as mandatory"; PASS=$((PASS+1))
else
echo " ❌ CRLF front matter should be parsed (got: $TRANS)"; FAIL=$((FAIL+1))
fi
echo "--- 3.2: malformed front matter doesn't crash ---"
DIR=$(mktemp -d)
cd "$DIR"
ROLES_DIR="$REPO_DIR/roles"
TMP_ROLE="$ROLES_DIR/_test-bad-fm.md"
printf "%s" "---
this is not yaml at all {{{{
" > "$TMP_ROLE"
$HARNESS init --flow review --entry review --dir .harness 2>/dev/null
write_good_eval .harness review engineer
write_good_eval .harness review skeptic-owner
write_handshake .harness review "Review done" "PASS"
TRANS=$($HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>/dev/null)
rm -f "$TMP_ROLE"
# Should not crash — either allowed or refused for legitimate reasons, not an unhandled error
if [ -n "$TRANS" ]; then
echo " ✅ malformed front matter handled without crash"; PASS=$((PASS+1))
else
echo " ❌ malformed front matter caused crash (empty output)"; FAIL=$((FAIL+1))
fi
print_results
#!/bin/bash
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
NODE_BIN="$(command -v node)"
TMP="$(mktemp -d)"
PASS=0
FAIL=0
cleanup() { rm -rf "$TMP"; }
trap cleanup EXIT INT TERM HUP
ok() { echo " ✅ $1"; PASS=$((PASS + 1)); }
fail() { echo " ❌ $1"; FAIL=$((FAIL + 1)); }
assert_contains() {
local haystack="$1" needle="$2" label="$3"
if echo "$haystack" | grep -q "$needle"; then ok "$label"; else fail "$label"; fi
}
assert_not_contains() {
local haystack="$1" needle="$2" label="$3"
if echo "$haystack" | grep -q "$needle"; then fail "$label"; else ok "$label"; fi
}
print_results() {
echo ""
echo "==========================================="
echo " Results: $PASS passed, $FAIL failed"
echo "==========================================="
[ "$FAIL" -eq 0 ] || exit 1
}
echo "Test: install-hooks prereqs"
echo "================================================"
HOME_NO_JQ="$TMP/home-no-jq"
NO_JQ_PATH="$TMP/no-jq-path"
mkdir -p "$HOME_NO_JQ" "$NO_JQ_PATH"
HOME="$HOME_NO_JQ" "$NODE_BIN" "$REPO_ROOT/bin/opc.mjs" install > /dev/null
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
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
HOME_OK="$TMP/home-ok"
mkdir -p "$HOME_OK"
HOME="$HOME_OK" "$NODE_BIN" "$REPO_ROOT/bin/opc.mjs" install > /dev/null
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"
SETTINGS="$HOME_OK/.claude/settings.json"
COMMANDS=$(python3 - "$SETTINGS" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
cmds = []
for group in ("PreCompact", "PostCompact"):
for entry in d.get("hooks", {}).get(group, []):
for hook in entry.get("hooks", []):
cmds.append(hook.get("command", ""))
print("\n".join(cmds))
PY
)
assert_contains "$COMMANDS" "opc-pre-compact.sh" "PreCompact hook registered"
assert_contains "$COMMANDS" "opc-post-compact.sh" "PostCompact hook registered"
assert_not_contains "$COMMANDS" "|| true" "hook failures are not swallowed"
assert_not_contains "$COMMANDS" "2>/dev/null" "hook stderr is not hidden"
print_results
#!/usr/bin/env bash
set -euo pipefail
# Test: file locking in loop-tick and loop-advance
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
HARNESS="node $SCRIPT_DIR/bin/opc-harness.mjs"
PASS=0; FAIL=0
check() {
local label="$1" cond="$2"
if eval "$cond"; then
echo " ✅ $label"
PASS=$((PASS + 1))
else
echo " ❌ $label"
FAIL=$((FAIL + 1))
fi
}
TMPD=$(mktemp -d)
trap 'rm -rf "$TMPD"' EXIT
H() { (cd "$TMPD" && $HARNESS "$@" 2>&1); }
setup_loop() {
local reldir="$1" next_unit="$2" tick="${3:-0}" status="${4:-completed}"
local absdir="$TMPD/$reldir"
mkdir -p "$absdir"
cat > "$absdir/loop-state.json" << LOOPEOF
{
"tick": $tick,
"unit": "A.1",
"next_unit": "$next_unit",
"status": "$status",
"plan_file": "$absdir/plan.md",
"_written_by": "opc-harness",
"_write_nonce": "test123",
"_last_modified": "2026-01-01T00:00:00.000Z",
"_tick_history": [],
"_git_head": null,
"_task_scope": []
}
LOOPEOF
cat > "$absdir/plan.md" << 'PLANEOF'
## Units
- A.1: implement — Build the auth module
- A.2: review — Review auth module
- A.3: implement — Build the dashboard
PLANEOF
}
echo "=== TEST GROUP 1: Lock conflict on complete-tick ==="
setup_loop "lock1" "A.1" 0
echo '{"tests_run": 5, "passed": 5, "exitCode": 0, "_command": "npm test"}' > "$TMPD/lock1/test-result.json"
# Create a lock file simulating another process (use current PID so it looks alive)
cat > "$TMPD/lock1/loop-state.json.lock" << LOCKEOF
{
"pid": $$,
"timestamp": "2026-04-22T00:00:00.000Z",
"command": "complete-tick"
}
LOCKEOF
RESULT=$(H complete-tick --unit A.1 --artifacts "$TMPD/lock1/test-result.json" --description "test" --dir lock1 || true)
check "lock conflict returns error" 'echo "$RESULT" | grep -q "could not acquire lock"'
# Verify state is untouched
check "state not corrupted" 'python3 -c "import json; json.load(open(\"$TMPD/lock1/loop-state.json\"))" 2>/dev/null'
# Clean up lock
rm -f "$TMPD/lock1/loop-state.json.lock"
echo ""
echo "=== TEST GROUP 2: Lock conflict on next-tick ==="
setup_loop "lock2" "A.2" 1
cat > "$TMPD/lock2/loop-state.json.lock" << LOCKEOF
{
"pid": $$,
"timestamp": "2026-04-22T00:00:00.000Z",
"command": "next-tick"
}
LOCKEOF
RESULT2=$(H next-tick --dir lock2 || true)
check "next-tick lock conflict returns error" 'echo "$RESULT2" | grep -q "could not acquire lock"'
rm -f "$TMPD/lock2/loop-state.json.lock"
echo ""
echo "=== TEST GROUP 3: Stale lock (dead PID) is cleaned up ==="
setup_loop "lock3" "A.2" 1
# Use a PID that is almost certainly dead
cat > "$TMPD/lock3/loop-state.json.lock" << LOCKEOF
{
"pid": 999999,
"timestamp": "2026-04-22T00:00:00.000Z",
"command": "old-process"
}
LOCKEOF
RESULT3=$(H next-tick --dir lock3)
check "stale lock recovered — next-tick proceeds" 'echo "$RESULT3" | grep -q "\"ready\":true\|\"ready\": true"'
check "stale lock file removed" '[ ! -f "$TMPD/lock3/loop-state.json.lock" ]'
echo ""
echo "=== TEST GROUP 4: Normal operation acquires and releases lock ==="
setup_loop "lock4" "A.1" 0
echo '{"tests_run": 5, "passed": 5, "exitCode": 0, "_command": "npm test"}' > "$TMPD/lock4/test-result.json"
RESULT4=$(H complete-tick --unit A.1 --artifacts "$TMPD/lock4/test-result.json" --description "Built auth" --dir lock4)
check "complete-tick succeeds with lock" 'echo "$RESULT4" | grep -q "\"completed\":true"'
check "lock released after complete-tick" '[ ! -f "$TMPD/lock4/loop-state.json.lock" ]'
echo ""
echo "==========================================="
echo " Results: $PASS passed, $FAIL failed"
echo "==========================================="
[ "$FAIL" -eq 0 ] || exit 1
#!/bin/bash
set -e
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
# ── Setup: create a minimal session with eval files missing mandatory role ──
mkdir -p .harness/nodes/code-review/run_1
# flow-state.json with code-review as current node
cat > .harness/flow-state.json <<'EOF'
{
"flowTemplate": "build-verify",
"currentNode": "code-review",
"status": "active",
"totalSteps": 3,
"history": []
}
EOF
# Write a valid eval file for frontend role (NOT skeptic-owner)
cat > .harness/nodes/code-review/run_1/eval-frontend.md <<'EOF'
# Frontend Review
## Summary
Code looks reasonable.
## Findings
🟡 **Warning** — `src/app.tsx:42` — Missing error boundary
- **Why**: Uncaught render errors crash the entire app
- **Fix**: Wrap top-level route in `<ErrorBoundary>`
## Verdict
ITERATE — one warning needs addressing.
EOF
echo "Test: Mandatory role enforcement in synthesize"
echo "================================================"
echo ""
# ── Test 1: synthesize detects missing mandatory role ──
echo "1. Missing mandatory role → warning emitted"
OUT=$($HARNESS synthesize .harness --node code-review --run 1 --no-strict 2>/dev/null || true)
if echo "$OUT" | grep -q "mandatory role.*skeptic-owner.*not present"; then
echo " ✅ mandatory role warning detected"
PASS=$((PASS + 1))
else
echo " ❌ mandatory role warning NOT detected"
echo " Output: $OUT"
FAIL=$((FAIL + 1))
fi
# Check mandatoryMissing field in output
if echo "$OUT" | python3 -c "import sys,json; d=json.load(sys.stdin); assert 'skeptic-owner' in d.get('mandatoryMissing',[])" 2>/dev/null; then
echo " ✅ mandatoryMissing contains skeptic-owner"
PASS=$((PASS + 1))
else
echo " ❌ mandatoryMissing field missing or wrong"
FAIL=$((FAIL + 1))
fi
# Check verdict is at least ITERATE (warning bumps it)
VERDICT=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['verdict'])" 2>/dev/null)
if [ "$VERDICT" = "ITERATE" ] || [ "$VERDICT" = "FAIL" ]; then
echo " ✅ verdict=$VERDICT (not PASS)"
PASS=$((PASS + 1))
else
echo " ❌ verdict=$VERDICT (expected ITERATE or FAIL)"
FAIL=$((FAIL + 1))
fi
# ── Test 2: adding skeptic-owner eval → no mandatory warning ──
echo ""
echo "2. With skeptic-owner present → no mandatory warning"
cat > .harness/nodes/code-review/run_1/eval-skeptic-owner.md <<'EOF'
# Skeptic Owner Review
## Summary
Checked the actual consumer path. The orchestrator calls this correctly.
## Findings
🔵 **Suggestion** — `src/app.tsx:1` — Consider adding integration test for error boundary
- **Why**: Unit test alone doesn't prove the boundary catches real render errors
- **Fix**: Add Playwright test that triggers a component throw
Mechanism validated: error boundary renders fallback, no uncaught promise rejection in console.
## Verdict
PASS — mechanism works as designed.
EOF
OUT2=$($HARNESS synthesize .harness --node code-review --run 1 --no-strict 2>/dev/null || true)
if echo "$OUT2" | grep -q "mandatory role.*skeptic-owner"; then
echo " ❌ mandatory warning still present with skeptic-owner eval"
FAIL=$((FAIL + 1))
else
echo " ✅ no mandatory role warning when skeptic-owner present"
PASS=$((PASS + 1))
fi
# mandatoryMissing should be absent
if echo "$OUT2" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('mandatoryMissing') is None" 2>/dev/null; then
echo " ✅ mandatoryMissing is null/absent"
PASS=$((PASS + 1))
else
echo " ❌ mandatoryMissing still populated"
FAIL=$((FAIL + 1))
fi
print_results
#!/bin/bash
set -e
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
setup_git
echo "Test: Negative — Handshake Validation"
echo "================================================"
echo ""
$HARNESS init --flow build-verify --entry build --dir .harness 2>/dev/null >/dev/null
# ── Test 1: missing required fields ──
echo "1. validate rejects handshake missing nodeId"
mkdir -p .harness/nodes/build
cat > .harness/nodes/build/handshake.json <<'EOF'
{"nodeType":"build","runId":"run_1","status":"completed","verdict":"PASS","summary":"ok","timestamp":"2026-01-01T00:00:00.000Z","artifacts":[{"type":"code","path":"x"}]}
EOF
touch .harness/nodes/build/x
VAL=$($HARNESS validate --node build --dir .harness 2>/dev/null || true)
VALID=$(echo "$VAL" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('valid', d.get('passed', 'missing')))" 2>/dev/null)
if [ "$VALID" = "False" ] || [ "$VALID" = "false" ]; then
echo " ✅ missing nodeId rejected"
PASS=$((PASS + 1))
else
echo " ❌ valid=$VALID output=$VAL"
FAIL=$((FAIL + 1))
fi
# ── Test 2: invalid nodeType ──
echo "2. validate rejects invalid nodeType"
cat > .harness/nodes/build/handshake.json <<'EOF'
{"nodeId":"build","nodeType":"banana","runId":"run_1","status":"completed","verdict":"PASS","summary":"ok","timestamp":"2026-01-01T00:00:00.000Z","artifacts":[{"type":"code","path":"x"}]}
EOF
VAL2=$($HARNESS validate --node build --dir .harness 2>/dev/null || true)
VALID2=$(echo "$VAL2" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('valid', d.get('passed', 'missing')))" 2>/dev/null)
if [ "$VALID2" = "False" ] || [ "$VALID2" = "false" ]; then
echo " ✅ invalid nodeType rejected"
PASS=$((PASS + 1))
else
echo " ❌ valid=$VALID2"
FAIL=$((FAIL + 1))
fi
# ── Test 3: artifacts not array ──
echo "3. validate rejects non-array artifacts"
cat > .harness/nodes/build/handshake.json <<'EOF'
{"nodeId":"build","nodeType":"build","runId":"run_1","status":"completed","verdict":"PASS","summary":"ok","timestamp":"2026-01-01T00:00:00.000Z","artifacts":"not-array"}
EOF
VAL3=$($HARNESS validate --node build --dir .harness 2>/dev/null || true)
VALID3=$(echo "$VAL3" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('valid', d.get('passed', 'missing')))" 2>/dev/null)
if [ "$VALID3" = "False" ] || [ "$VALID3" = "false" ]; then
echo " ✅ non-array artifacts rejected"
PASS=$((PASS + 1))
else
echo " ❌ valid=$VALID3"
FAIL=$((FAIL + 1))
fi
# ── Test 4: review node with <2 eval artifacts ──
echo "4. validate rejects review node with only 1 eval"
mkdir -p .harness/nodes/code-review
cat > .harness/nodes/code-review/handshake.json <<'EOF'
{"nodeId":"code-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"}]}
EOF
echo "# Eval A" > .harness/nodes/code-review/eval-a.md
VAL4=$($HARNESS validate --node code-review --dir .harness 2>/dev/null || true)
VALID4=$(echo "$VAL4" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('valid', d.get('passed', 'missing')))" 2>/dev/null)
if [ "$VALID4" = "False" ] || [ "$VALID4" = "false" ]; then
echo " ✅ review with <2 evals rejected"
PASS=$((PASS + 1))
else
echo " ❌ valid=$VALID4"
FAIL=$((FAIL + 1))
fi
print_results
#!/bin/bash
set -e
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
setup_git
echo "Test: Negative — Init Error Paths"
echo "================================================"
echo ""
# ── Test 1: invalid flow template ──
echo "1. init with invalid --flow → error JSON"
OUT=$($HARNESS init --flow nonexistent --entry build --dir .harness 2>/dev/null || true)
if echo "$OUT" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('error') or 'error' in str(d).lower(); print('ok')" 2>/dev/null | grep -q ok; then
echo " ✅ invalid flow rejected with error"
PASS=$((PASS + 1))
else
echo " ❌ output: $OUT"
FAIL=$((FAIL + 1))
fi
# ── Test 2: missing --flow flag ──
echo "2. init without --flow → non-zero exit or error"
OUT2=$($HARNESS init --entry build --dir .harness 2>&1 || true)
if [ -n "$OUT2" ]; then
echo " ✅ missing --flow produces output (error or usage)"
PASS=$((PASS + 1))
else
echo " ❌ no output at all"
FAIL=$((FAIL + 1))
fi
# ── Test 3: invalid entry node ──
echo "3. init with invalid --entry → error"
OUT3=$($HARNESS init --flow build-verify --entry nonexistent --dir .harness2 2>/dev/null || true)
if echo "$OUT3" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('error') or 'error' in str(d).lower() or 'invalid' in str(d).lower(); print('ok')" 2>/dev/null | grep -q ok; then
echo " ✅ invalid entry node rejected"
PASS=$((PASS + 1))
else
# Some implementations silently default — check if it at least ran
if [ -f ".harness2/flow-state.json" ]; then
echo " ⚠️ init succeeded with invalid entry (implementation allows it)"
PASS=$((PASS + 1))
else
echo " ❌ output: $OUT3"
FAIL=$((FAIL + 1))
fi
fi
# ── Test 4: duplicate init (already initialized) ──
echo "4. duplicate init → overwrites or errors gracefully"
$HARNESS init --flow build-verify --entry build --dir .harness3 2>/dev/null >/dev/null || true
OUT4=$($HARNESS init --flow build-verify --entry build --dir .harness3 2>/dev/null || true)
# Should either succeed (overwrite) or error — not crash
if [ -n "$OUT4" ]; then
echo " ✅ duplicate init handled gracefully (no crash)"
PASS=$((PASS + 1))
else
echo " ❌ no output"
FAIL=$((FAIL + 1))
fi
print_results
#!/bin/bash
set -e
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
setup_git
echo "Test: Negative — Synthesize Error Paths"
echo "================================================"
echo ""
$HARNESS init --flow build-verify --entry build --dir .harness 2>/dev/null >/dev/null
# ── Test 1: synthesize with empty eval dir ──
echo "1. synthesize with no eval files → empty output or error"
mkdir -p .harness/nodes/code-review/run_1
SYNTH=$($HARNESS synthesize .harness --node code-review --run 1 --base "$TMPDIR" --no-strict 2>/dev/null || true)
if [ -z "$SYNTH" ]; then
echo " ✅ empty eval dir → no output (graceful)"
PASS=$((PASS + 1))
else
ROLES=$(echo "$SYNTH" | python3 -c "import sys,json; d=json.load(sys.stdin); print(len(d.get('roles',[])))" 2>/dev/null || echo "parse_error")
if [ "$ROLES" = "0" ] || [ "$ROLES" = "parse_error" ]; then
echo " ✅ empty eval dir → 0 roles or error"
PASS=$((PASS + 1))
else
echo " ❌ roles=$ROLES"
FAIL=$((FAIL + 1))
fi
fi
# ── Test 2: eval file without VERDICT line ──
echo "2. eval without VERDICT → graceful handling"
cat > .harness/nodes/code-review/run_1/eval-missing-verdict.md <<'EOF'
# Missing Verdict Eval
## Summary
No verdict line here.
## Findings
Nothing.
EOF
mkdir -p "$TMPDIR/src"
echo "x" > "$TMPDIR/src/main.ts"
SYNTH2=$($HARNESS synthesize .harness --node code-review --run 1 --base "$TMPDIR" --no-strict 2>/dev/null || true)
if [ -z "$SYNTH2" ]; then
echo " ✅ missing VERDICT → no output (graceful)"
PASS=$((PASS + 1))
else
VALID=$(echo "$SYNTH2" | python3 -c "import sys,json; json.load(sys.stdin); print('ok')" 2>/dev/null || echo "no")
if [ "$VALID" = "ok" ]; then
echo " ✅ missing VERDICT handled gracefully (valid JSON)"
PASS=$((PASS + 1))
else
echo " ✅ missing VERDICT → non-JSON error output (acceptable)"
PASS=$((PASS + 1))
fi
fi
# ── Test 3: thin eval (<50 lines) → warning in totals ──
echo "3. thin eval (<50 lines) → warning in totals"
cat > .harness/nodes/code-review/run_1/eval-thin.md <<'EOF'
# Thin Eval
## Summary
Short.
## Findings
🔵 **Suggestion** — `src/main.ts:1` — Add logging
- **Why**: Helps debugging
- **Fix**: Add console.log
## Verdict
PASS
EOF
SYNTH3=$($HARNESS synthesize .harness --node code-review --run 1 --base "$TMPDIR" --no-strict 2>/dev/null || true)
if [ -z "$SYNTH3" ]; then
echo " ❌ no output from synthesize"
FAIL=$((FAIL + 1))
else
WARNINGS=$(echo "$SYNTH3" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('totals',{}).get('warning',0))" 2>/dev/null || echo "0")
if [ "$WARNINGS" -gt 0 ] 2>/dev/null; then
echo " ✅ thin eval produces warning (warnings=$WARNINGS)"
PASS=$((PASS + 1))
else
echo " ❌ warnings=$WARNINGS (expected >0)"
FAIL=$((FAIL + 1))
fi
fi
# ── Test 4: synthesize with nonexistent node ──
echo "4. synthesize nonexistent node → error or empty"
SYNTH4=$($HARNESS synthesize .harness --node nonexistent --run 1 --base "$TMPDIR" --no-strict 2>/dev/null || true)
if [ -z "$SYNTH4" ]; then
echo " ✅ nonexistent node → no output (acceptable)"
PASS=$((PASS + 1))
else
if echo "$SYNTH4" | python3 -c "import sys,json; d=json.load(sys.stdin); assert d.get('error') or len(d.get('roles',[]))==0; print('ok')" 2>/dev/null | grep -q ok; then
echo " ✅ nonexistent node → error or empty roles"
PASS=$((PASS + 1))
else
echo " ✅ nonexistent node → some output (non-crash)"
PASS=$((PASS + 1))
fi
fi
print_results
#!/bin/bash
set -e
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
setup_git
echo "Test: Negative — Transition Error Paths"
echo "================================================"
echo ""
$HARNESS init --flow build-verify --entry build --dir .harness 2>/dev/null >/dev/null
# ── Test 1: transition from wrong currentNode ──
echo "1. transition from code-review when currentNode=build → blocked"
mkdir -p .harness/nodes/code-review
cat > .harness/nodes/code-review/handshake.json <<'EOF'
{"nodeId":"code-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"}]}
EOF
echo "# Eval A" > .harness/nodes/code-review/eval-a.md
echo "# Eval B" > .harness/nodes/code-review/eval-b.md
TRANS=$($HARNESS transition --from code-review --to test-design --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', 'missing'))" 2>/dev/null)
if [ "$ALLOWED" = "False" ]; then
echo " ✅ transition from wrong node blocked"
PASS=$((PASS + 1))
else
echo " ❌ allowed=$ALLOWED output=$TRANS"
FAIL=$((FAIL + 1))
fi
# ── Test 2: transition along invalid edge ──
echo "2. transition build → gate (no direct edge) → blocked"
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:00:00.000Z","artifacts":[{"type":"code","path":"x"}]}
EOF
touch .harness/nodes/build/x
TRANS2=$($HARNESS transition --from build --to gate --verdict PASS --flow build-verify --dir .harness 2>/dev/null || true)
ALLOWED2=$(echo "$TRANS2" | python3 -c "import sys,json; print(json.load(sys.stdin).get('allowed', 'missing'))" 2>/dev/null)
if [ "$ALLOWED2" = "False" ]; then
echo " ✅ invalid edge blocked"
PASS=$((PASS + 1))
else
echo " ❌ allowed=$ALLOWED2 output=$TRANS2"
FAIL=$((FAIL + 1))
fi
# ── Test 3: transition without handshake ──
echo "3. transition without handshake.json → blocked"
rm -rf .harness
$HARNESS init --flow build-verify --entry build --dir .harness 2>/dev/null >/dev/null
TRANS3=$($HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir .harness 2>/dev/null || true)
ALLOWED3=$(echo "$TRANS3" | python3 -c "import sys,json; print(json.load(sys.stdin).get('allowed', 'missing'))" 2>/dev/null)
if [ "$ALLOWED3" = "False" ]; then
echo " ✅ missing handshake blocks transition"
PASS=$((PASS + 1))
else
echo " ❌ allowed=$ALLOWED3 output=$TRANS3"
FAIL=$((FAIL + 1))
fi
# ── Test 4: transition with mismatched verdict ──
echo "4. transition build→code-review with FAIL verdict → blocked (no FAIL edge from 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":"ok","timestamp":"2026-01-01T00:00:00.000Z","artifacts":[{"type":"code","path":"x"}]}
EOF
touch .harness/nodes/build/x
TRANS4=$($HARNESS transition --from build --to code-review --verdict FAIL --flow build-verify --dir .harness 2>/dev/null || true)
ALLOWED4=$(echo "$TRANS4" | python3 -c "import sys,json; print(json.load(sys.stdin).get('allowed', 'missing'))" 2>/dev/null)
if [ "$ALLOWED4" = "False" ]; then
echo " ✅ wrong verdict blocks transition"
PASS=$((PASS + 1))
else
echo " ❌ allowed=$ALLOWED4"
FAIL=$((FAIL + 1))
fi
print_results
#!/bin/bash
# test-preflight.sh — Verify preflight hook lifecycle
# Tests: fireNodePreflight, writeDesignArtifacts, cmdNodePreflight,
# capability routing for design-preflight@1
set -u
REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
cd "$REPO_ROOT" || exit 1
PASS=0
FAIL=0
FAIL_DETAILS=""
fail() {
local msg="$1"
FAIL=$((FAIL + 1))
FAIL_DETAILS="${FAIL_DETAILS} ❌ $msg"$'\n'
}
ok() {
local msg="$1"
PASS=$((PASS + 1))
echo " ✅ $msg"
}
TMP=$(mktemp -d -t opc-preflight-XXXXXX)
cleanup() {
if [ "$FAIL" -eq 0 ]; then
rm -rf "$TMP"
else
echo " ⚠️ TMP preserved for diagnosis: $TMP" >&2
fi
}
trap cleanup EXIT INT TERM HUP
# ── Stage fixtures ──────────────────────────────────────────────
EXT_DIR="$TMP/extensions"
mkdir -p "$EXT_DIR"
cp -R "$REPO_ROOT/test/fixtures/run2-ext/ok-ext" "$EXT_DIR/"
# ── Flow file with design-preflight@1 on build node ─────────────
FLOW_FILE="$TMP/preflight-test.json"
cat > "$FLOW_FILE" <<'EOF'
{
"opc_compat": ">=0.0",
"nodes": ["build", "code-review", "gate"],
"edges": {
"build": { "PASS": "code-review" },
"code-review": { "PASS": "gate" },
"gate": { "PASS": null, "FAIL": "build", "ITERATE": "build" }
},
"limits": { "maxLoopsPerEdge": 3, "maxTotalSteps": 10, "maxNodeReentry": 5 },
"nodeTypes": { "build": "build", "code-review": "review", "gate": "gate" },
"nodeCapabilities": {
"build": ["design-preflight@1", "verification@1"],
"code-review": ["verification@1"]
}
}
EOF
# ── Set up harness dir ──────────────────────────────────────────
HARNESS="$TMP/harness"
OPC_CFG_DIR="$HARNESS/.opc"
mkdir -p "$OPC_CFG_DIR"
cat > "$OPC_CFG_DIR/config.json" <<EOF
{
"extensionsDir": "$EXT_DIR"
}
EOF
cat > "$HARNESS/acceptance-criteria.md" <<'EOF'
# Preflight Test — Acceptance Criteria
- OUT-1: preflight hook fires and writes artifacts
EOF
HARNESS_BIN="node $REPO_ROOT/bin/opc-harness.mjs"
echo "═══ Preflight Hook Tests ═══"
# ── 1. Init flow ────────────────────────────────────────────────
# ── 1. Set up flow state manually (skip cmdInit path-safety check) ──
echo "§1 Setup flow state"
cat > "$HARNESS/flow-state.json" <<EOF
{
"currentNode": "build",
"flow": "preflight-test",
"_flow_file": "$FLOW_FILE",
"totalSteps": 0,
"edgeCounts": {},
"reentryCount": {}
}
EOF
if [ -f "$HARNESS/flow-state.json" ]; then
ok "flow state created with design-preflight@1 on build node"
else
fail "failed to create flow-state.json"
fi
# Create a run dir for the build node
BUILD_RUN="$HARNESS/nodes/build/run_1"
mkdir -p "$BUILD_RUN"
# ── 2. node-preflight fires and writes artifacts ────────────────
echo "§2 Fire node-preflight"
PREFLIGHT_OUT=$(OPC_BREAKER_STATE=disabled $HARNESS_BIN node-preflight --node build --dir "$HARNESS" --flow-file "$FLOW_FILE" 2>/dev/null)
if echo "$PREFLIGHT_OUT" | grep -q '"ok":true'; then
ok "node-preflight returned ok"
else
fail "node-preflight failed: $PREFLIGHT_OUT"
fi
if echo "$PREFLIGHT_OUT" | grep -q '"preflightResults":1'; then
ok "one preflight result collected"
else
fail "expected 1 preflight result: $PREFLIGHT_OUT"
fi
if echo "$PREFLIGHT_OUT" | grep -q '"design"'; then
ok "design artifact type reported"
else
fail "expected design in artifactTypes: $PREFLIGHT_OUT"
fi
# ── 3. Verify design-mode.json written ──────────────────────────
echo "§3 Verify design artifacts"
if [ -f "$HARNESS/design-mode.json" ]; then
ok "design-mode.json exists"
MODE_CONTENT=$(cat "$HARNESS/design-mode.json")
if echo "$MODE_CONTENT" | grep -q '"mode": "auto"'; then
ok "design-mode.json mode=auto (confidence 0.9 > 0.4)"
else
fail "expected mode=auto in design-mode.json: $MODE_CONTENT"
fi
if echo "$MODE_CONTENT" | grep -q '"confidence": 0.9'; then
ok "design-mode.json confidence=0.9"
else
fail "expected confidence=0.9: $MODE_CONTENT"
fi
if echo "$MODE_CONTENT" | grep -q '"source": "inferred"'; then
ok "design-mode.json source=inferred"
else
fail "expected source=inferred: $MODE_CONTENT"
fi
else
fail "design-mode.json not written"
fi
# ── 4. Verify design-selection.json ─────────────────────────────
if [ -f "$HARNESS/design-selection.json" ]; then
ok "design-selection.json exists"
SEL_CONTENT=$(cat "$HARNESS/design-selection.json")
if echo "$SEL_CONTENT" | grep -q '"industry": "test"'; then
ok "design-selection.json industry=test"
else
fail "expected industry=test: $SEL_CONTENT"
fi
else
fail "design-selection.json not written"
fi
# ── 5. Verify design-brief.md ──────────────────────────────────
if [ -f "$HARNESS/design-brief.md" ]; then
ok "design-brief.md exists"
if grep -q "Test brief from ok-ext" "$HARNESS/design-brief.md"; then
ok "design-brief.md content correct"
else
fail "design-brief.md content wrong"
fi
else
fail "design-brief.md not written"
fi
# ── 6. Verify design-tokens.json ───────────────────────────────
if [ -f "$HARNESS/design-tokens.json" ]; then
ok "design-tokens.json exists"
TOKENS_CONTENT=$(cat "$HARNESS/design-tokens.json")
if echo "$TOKENS_CONTENT" | grep -q '"bg": "#FFFFFF"'; then
ok "design-tokens.json has bg token"
else
fail "expected bg token: $TOKENS_CONTENT"
fi
else
fail "design-tokens.json not written"
fi
# ── 7. No preflight on nodes without capability ─────────────────
echo "§4 No-op on non-matching node"
# Use a flow where code-review has only unmatched capabilities
NOOP_FLOW="$TMP/noop-flow.json"
cat > "$NOOP_FLOW" <<'EOF'
{
"opc_compat": ">=0.0",
"nodes": ["build", "code-review", "gate"],
"edges": {
"build": { "PASS": "code-review" },
"code-review": { "PASS": "gate" },
"gate": { "PASS": null, "FAIL": "build", "ITERATE": "build" }
},
"limits": { "maxLoopsPerEdge": 3, "maxTotalSteps": 10, "maxNodeReentry": 5 },
"nodeTypes": { "build": "build", "code-review": "review", "gate": "gate" },
"nodeCapabilities": {
"build": ["design-preflight@1", "verification@1"],
"code-review": ["unrelated-check@1"]
}
}
EOF
NOOP_HARNESS="$TMP/noop-harness"
NOOP_CFG="$NOOP_HARNESS/.opc"
mkdir -p "$NOOP_CFG"
cat > "$NOOP_CFG/config.json" <<EOF
{
"extensionsDir": "$EXT_DIR"
}
EOF
cat > "$NOOP_HARNESS/acceptance-criteria.md" <<'EOF'
# Noop Test
EOF
cat > "$NOOP_HARNESS/flow-state.json" <<EOF
{
"currentNode": "build",
"flow": "noop-flow",
"_flow_file": "$NOOP_FLOW",
"totalSteps": 0,
"edgeCounts": {},
"reentryCount": {}
}
EOF
NOOP_OUT=$(OPC_BREAKER_STATE=disabled $HARNESS_BIN node-preflight --node code-review --dir "$NOOP_HARNESS" --flow-file "$NOOP_FLOW" 2>/dev/null)
if echo "$NOOP_OUT" | grep -q '"preflightResults":0'; then
ok "no preflight on code-review node (no matching capability)"
else
fail "expected 0 preflight results on code-review: $NOOP_OUT"
fi
# ── 8. Extension without preflight hook is silently skipped ──────
echo "§5 Extension without preflight"
# Create a minimal extension with no preflight export
mkdir -p "$EXT_DIR/no-preflight"
cat > "$EXT_DIR/no-preflight/hook.mjs" <<'NEOF'
export const meta = { provides: ["design-preflight@1"] };
export function promptAppend() { return "## no-preflight ext"; }
NEOF
SKIP_OUT=$(OPC_BREAKER_STATE=disabled $HARNESS_BIN node-preflight --node build --dir "$HARNESS" --flow-file "$FLOW_FILE" 2>/dev/null)
if echo "$SKIP_OUT" | grep -q '"ok":true'; then
ok "node-preflight still succeeds with ext that has no preflight hook"
else
fail "node-preflight failed with mixed extensions: $SKIP_OUT"
fi
# ── 9. design-mode.json mode=auto even for low confidence ────────
echo "§6 Low confidence → still mode=auto (confidence controls strictness, not activation)"
# Replace ok-ext with a low-confidence preflight
cat > "$EXT_DIR/ok-ext/hook.mjs" <<'LCEOF'
export const meta = { provides: ["verification@1"] };
export function preflight() {
return { type: "design", confidence: 0.2, reason: "low confidence test" };
}
LCEOF
# Clear old artifacts
rm -f "$HARNESS/design-mode.json"
LOW_OUT=$(OPC_BREAKER_STATE=disabled $HARNESS_BIN node-preflight --node build --dir "$HARNESS" --flow-file "$FLOW_FILE" 2>/dev/null)
if [ -f "$HARNESS/design-mode.json" ]; then
LOW_MODE=$(cat "$HARNESS/design-mode.json")
if echo "$LOW_MODE" | grep -q '"mode": "auto"'; then
ok "design-mode.json mode=auto for confidence 0.2 (auto regardless of confidence)"
else
fail "expected mode=auto for low confidence: $LOW_MODE"
fi
if echo "$LOW_MODE" | grep -q '"confidence": 0.2'; then
ok "confidence preserved at 0.2 for downstream strictness decisions"
else
fail "expected confidence=0.2: $LOW_MODE"
fi
else
fail "design-mode.json not written for low confidence"
fi
# ── 10. design-mode.json mode=explicit for userOverride ──────────
echo "§7 User override → mode=explicit"
cat > "$EXT_DIR/ok-ext/hook.mjs" <<'UOEOF'
export const meta = { provides: ["verification@1"] };
export function preflight() {
return { type: "design", confidence: 0.5, userOverride: true, reason: "user override test" };
}
UOEOF
rm -f "$HARNESS/design-mode.json"
UO_OUT=$(OPC_BREAKER_STATE=disabled $HARNESS_BIN node-preflight --node build --dir "$HARNESS" --flow-file "$FLOW_FILE" 2>/dev/null)
if [ -f "$HARNESS/design-mode.json" ]; then
UO_MODE=$(cat "$HARNESS/design-mode.json")
if echo "$UO_MODE" | grep -q '"mode": "explicit"'; then
ok "design-mode.json mode=explicit for userOverride"
else
fail "expected mode=explicit for userOverride: $UO_MODE"
fi
if echo "$UO_MODE" | grep -q '"source": "user-override"'; then
ok "design-mode.json source=user-override"
else
fail "expected source=user-override: $UO_MODE"
fi
else
fail "design-mode.json not written for userOverride"
fi
# ── 11. design-mode.json mode=off when extension explicitly says off ──
echo "§8 Extension explicit mode=off"
cat > "$EXT_DIR/ok-ext/hook.mjs" <<'OFFEOF'
export const meta = { provides: ["verification@1"] };
export function preflight() {
return { type: "design", mode: "off", confidence: 0.5, reason: "explicit off test" };
}
OFFEOF
rm -f "$HARNESS/design-mode.json"
OFF_OUT=$(OPC_BREAKER_STATE=disabled $HARNESS_BIN node-preflight --node build --dir "$HARNESS" --flow-file "$FLOW_FILE" 2>/dev/null)
if [ -f "$HARNESS/design-mode.json" ]; then
OFF_MODE=$(cat "$HARNESS/design-mode.json")
if echo "$OFF_MODE" | grep -q '"mode": "off"'; then
ok "design-mode.json mode=off when extension explicitly returns mode=off"
else
fail "expected mode=off for explicit off: $OFF_MODE"
fi
else
fail "design-mode.json not written for explicit off"
fi
# ── Summary ─────────────────────────────────────────────────────
echo
echo "═══ Results: $PASS passed, $FAIL failed ═══"
if [ "$FAIL" -gt 0 ]; then
echo "$FAIL_DETAILS"
exit 1
fi
#!/bin/bash
set -e
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
setup_git
echo "Test: Scenario — Cycle Limit Enforcement"
echo "================================================"
echo ""
$HARNESS init --flow build-verify --entry build --dir .harness 2>/dev/null
# ── Helper: advance build→code-review→test-design→test-execute→gate ──
advance_to_gate() {
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"}]}
EOF
touch .harness/nodes/build/x
$HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir .harness 2>/dev/null >/dev/null
mkdir -p .harness/nodes/code-review
cat > .harness/nodes/code-review/handshake.json <<'EOF'
{"nodeId":"code-review","nodeType":"review","runId":"run_1","status":"completed","verdict":"PASS","summary":"ok","timestamp":"2026-01-01T00:02:00.000Z","artifacts":[{"type":"eval","path":"eval-a.md"},{"type":"eval","path":"eval-b.md"}]}
EOF
echo "# Eval A - review findings" > .harness/nodes/code-review/eval-a.md
echo "# Eval B - secondary review" > .harness/nodes/code-review/eval-b.md
$HARNESS transition --from code-review --to test-design --verdict PASS --flow build-verify --dir .harness 2>/dev/null >/dev/null
mkdir -p .harness/nodes/test-design
cat > .harness/nodes/test-design/handshake.json <<'EOF'
{"nodeId":"test-design","nodeType":"review","runId":"run_1","status":"completed","verdict":"PASS","summary":"ok","timestamp":"2026-01-01T00:03:00.000Z","artifacts":[{"type":"eval","path":"eval-a.md"},{"type":"eval","path":"eval-b.md"}]}
EOF
echo "# Eval A - test design findings" > .harness/nodes/test-design/eval-a.md
echo "# Eval B - test design secondary" > .harness/nodes/test-design/eval-b.md
$HARNESS transition --from test-design --to test-execute --verdict PASS --flow build-verify --dir .harness 2>/dev/null >/dev/null
mkdir -p .harness/nodes/test-execute
cat > .harness/nodes/test-execute/handshake.json <<'EOF'
{"nodeId":"test-execute","nodeType":"execute","runId":"run_1","status":"completed","verdict":"PASS","summary":"ok","timestamp":"2026-01-01T00:04:00.000Z","artifacts":[{"type":"test-result","path":"o"}]}
EOF
touch .harness/nodes/test-execute/o
$HARNESS transition --from test-execute --to gate --verdict PASS --flow build-verify --dir .harness 2>/dev/null >/dev/null
}
loopback_gate_to_build() {
mkdir -p .harness/nodes/gate
cat > .harness/nodes/gate/handshake.json <<'EOF'
{"nodeId":"gate","nodeType":"gate","runId":"run_1","status":"completed","verdict":"FAIL","summary":"fail","timestamp":"2026-01-01T00:05:00.000Z","artifacts":[]}
EOF
echo "- fix" > .harness/backlog.md
$HARNESS transition --from gate --to build --verdict FAIL --flow build-verify --dir .harness 2>/dev/null >/dev/null
}
# Loop 1
advance_to_gate
loopback_gate_to_build
# Loop 2
advance_to_gate
loopback_gate_to_build
# Loop 3
advance_to_gate
loopback_gate_to_build
# ── 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"}]}
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)
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)"
PASS=$((PASS + 1))
else
echo " ❌ was allowed: $TRANS"
FAIL=$((FAIL + 1))
fi
# ── Test 2: check reason mentions limit ──
echo "2. Blocked reason mentions edge limit"
REASON=$(echo "$TRANS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('reason',''))" 2>/dev/null)
if echo "$REASON" | grep -qi "edge\|loop\|limit\|max"; then
echo " ✅ reason: $REASON"
PASS=$((PASS + 1))
else
echo " ❌ reason: $REASON"
FAIL=$((FAIL + 1))
fi
print_results
#!/bin/bash
set -e
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
setup_git
echo "Test: Scenario — Escape Hatches (skip, pass, stop, goto)"
echo "================================================"
echo ""
$HARNESS init --flow build-verify --entry build --dir .harness 2>/dev/null
# ── Test 1: skip advances via PASS edge ──
echo "1. skip → advances from build to code-review"
SKIP=$($HARNESS skip --dir .harness --flow build-verify 2>/dev/null)
NODE=$(python3 -c "import json; print(json.load(open('.harness/flow-state.json'))['currentNode'])")
if [ "$NODE" = "code-review" ]; then
echo " ✅ skip moved to code-review"
PASS=$((PASS + 1))
else
echo " ❌ currentNode=$NODE (expected code-review)"
FAIL=$((FAIL + 1))
fi
# ── Test 2: skip writes handshake with skipped flag ──
echo "2. skip writes handshake.json for build"
if [ -f ".harness/nodes/build/handshake.json" ]; then
SKIPPED=$(python3 -c "import json; print(json.load(open('.harness/nodes/build/handshake.json')).get('skipped', False))")
if [ "$SKIPPED" = "True" ]; then
echo " ✅ handshake has skipped=True"
PASS=$((PASS + 1))
else
echo " ❌ handshake missing skipped flag"
FAIL=$((FAIL + 1))
fi
else
echo " ❌ no handshake.json for build"
FAIL=$((FAIL + 1))
fi
# ── Test 3: goto jumps to arbitrary node ──
echo "3. goto test-execute → currentNode=test-execute"
$HARNESS goto test-execute --dir .harness 2>/dev/null >/dev/null || true
NODE2=$(python3 -c "import json; print(json.load(open('.harness/flow-state.json'))['currentNode'])")
if [ "$NODE2" = "test-execute" ]; then
echo " ✅ goto moved to test-execute"
PASS=$((PASS + 1))
else
echo " ❌ currentNode=$NODE2"
FAIL=$((FAIL + 1))
fi
# ── Test 4: stop terminates flow ──
echo "4. stop → status=stopped"
$HARNESS stop --dir .harness 2>/dev/null >/dev/null || true
STATUS=$(python3 -c "import json; print(json.load(open('.harness/flow-state.json'))['status'])")
if [ "$STATUS" = "stopped" ]; then
echo " ✅ status=stopped"
PASS=$((PASS + 1))
else
echo " ❌ status=$STATUS"
FAIL=$((FAIL + 1))
fi
# ── Test 5: pass on gate node ──
echo "5. pass on gate node → advances"
# Re-init for clean gate test
rm -rf .harness
$HARNESS init --flow review --entry review --dir .harness 2>/dev/null
# Skip review to get to gate
$HARNESS skip --dir .harness --flow review 2>/dev/null >/dev/null
NODE3=$(python3 -c "import json; print(json.load(open('.harness/flow-state.json'))['currentNode'])")
if [ "$NODE3" = "gate" ]; then
$HARNESS pass --dir .harness 2>/dev/null >/dev/null || true
$HARNESS finalize --dir .harness 2>/dev/null >/dev/null || true
# After pass+finalize, gate should advance (next=null for review flow → completed)
STATUS2=$(python3 -c "import json; d=json.load(open('.harness/flow-state.json')); print(d.get('status',''))")
if [ "$STATUS2" = "completed" ] || [ "$STATUS2" = "finalized" ]; then
echo " ✅ pass on gate → flow completed"
PASS=$((PASS + 1))
else
echo " ❌ status=$STATUS2 after pass"
FAIL=$((FAIL + 1))
fi
else
echo " ❌ not at gate node (at $NODE3)"
FAIL=$((FAIL + 1))
fi
print_results
#!/bin/bash
set -e
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
setup_git
echo "Test: Scenario — Happy Path (init → build → review → gate PASS)"
echo "================================================"
echo ""
# ── Test 1: init creates flow-state.json ──
echo "1. init --flow build-verify → creates flow-state.json"
$HARNESS init --flow build-verify --entry build --dir .harness 2>/dev/null
if [ -f ".harness/flow-state.json" ]; then
echo " ✅ flow-state.json exists"
PASS=$((PASS + 1))
else
echo " ❌ flow-state.json missing"
FAIL=$((FAIL + 1))
fi
# ── Test 2: currentNode is build after init ──
echo "2. currentNode = build"
NODE=$(python3 -c "import json; print(json.load(open('.harness/flow-state.json'))['currentNode'])")
if [ "$NODE" = "build" ]; then
echo " ✅ currentNode=build"
PASS=$((PASS + 1))
else
echo " ❌ currentNode=$NODE"
FAIL=$((FAIL + 1))
fi
# ── Test 3: route from build with PASS → code-review ──
echo "3. route --node build --verdict PASS → next=code-review"
ROUTE=$($HARNESS route --node build --verdict PASS --flow build-verify 2>/dev/null)
NEXT=$(echo "$ROUTE" | python3 -c "import sys,json; print(json.load(sys.stdin)['next'])")
if [ "$NEXT" = "code-review" ]; then
echo " ✅ next=code-review"
PASS=$((PASS + 1))
else
echo " ❌ next=$NEXT"
FAIL=$((FAIL + 1))
fi
# ── Test 4: transition from build → code-review ──
echo "4. transition build → code-review"
# Need handshake for build node
mkdir -p .harness/nodes/build
cat > .harness/nodes/build/handshake.json <<'EOF'
{
"nodeId": "build",
"nodeType": "build",
"runId": "run_1",
"status": "completed",
"verdict": "PASS",
"summary": "Build completed successfully",
"timestamp": "2026-01-01T00:00:00.000Z",
"artifacts": [{"type":"code","path":"src/app.tsx"}]
}
EOF
mkdir -p .harness/nodes/build/src && touch .harness/nodes/build/src/app.tsx
TRANS=$($HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir .harness 2>/dev/null)
ALLOWED=$(echo "$TRANS" | python3 -c "import sys,json; print(json.load(sys.stdin)['allowed'])")
if [ "$ALLOWED" = "True" ]; then
echo " ✅ transition allowed"
PASS=$((PASS + 1))
else
echo " ❌ transition not allowed: $TRANS"
FAIL=$((FAIL + 1))
fi
# ── Test 5: currentNode updated to code-review ──
echo "5. currentNode updated to code-review after transition"
NODE2=$(python3 -c "import json; print(json.load(open('.harness/flow-state.json'))['currentNode'])")
if [ "$NODE2" = "code-review" ]; then
echo " ✅ currentNode=code-review"
PASS=$((PASS + 1))
else
echo " ❌ currentNode=$NODE2"
FAIL=$((FAIL + 1))
fi
# ── Test 6: full path to gate PASS → next=null (flow complete) ──
echo "6. Full path: code-review → test-design → test-execute → gate PASS → null"
# Transition code-review → test-design
mkdir -p .harness/nodes/code-review
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"}]}
EOF
touch .harness/nodes/code-review/eval-frontend.md
$HARNESS transition --from code-review --to test-design --verdict PASS --flow build-verify --dir .harness 2>/dev/null >/dev/null
# Transition test-design → test-execute
mkdir -p .harness/nodes/test-design
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"}]}
EOF
touch .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
# Transition test-execute → gate
mkdir -p .harness/nodes/test-execute
cat > .harness/nodes/test-execute/handshake.json <<'EOF'
{"nodeId":"test-execute","nodeType":"execute","runId":"run_1","status":"completed","verdict":"PASS","summary":"Tests passed","timestamp":"2026-01-01T00:00:00.000Z","artifacts":[{"type":"test-result","path":"output.txt"}]}
EOF
touch .harness/nodes/test-execute/output.txt
$HARNESS transition --from test-execute --to gate --verdict PASS --flow build-verify --dir .harness 2>/dev/null >/dev/null
# Gate → null (complete)
FINAL=$($HARNESS route --node gate --verdict PASS --flow build-verify 2>/dev/null)
FINAL_NEXT=$(echo "$FINAL" | python3 -c "import sys,json; print(json.load(sys.stdin)['next'])")
if [ "$FINAL_NEXT" = "None" ]; then
echo " ✅ gate PASS → next=None (flow complete)"
PASS=$((PASS + 1))
else
echo " ❌ gate PASS → next=$FINAL_NEXT (expected None)"
FAIL=$((FAIL + 1))
fi
print_results
#!/bin/bash
set -e
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
setup_git
echo "Test: Scenario — Gate FAIL Loopback"
echo "================================================"
echo ""
$HARNESS init --flow build-verify --entry build --dir .harness 2>/dev/null
# Advance to gate
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"}]}
EOF
touch .harness/nodes/build/x
$HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir .harness 2>/dev/null >/dev/null
mkdir -p .harness/nodes/code-review
cat > .harness/nodes/code-review/handshake.json <<'EOF'
{"nodeId":"code-review","nodeType":"review","runId":"run_1","status":"completed","verdict":"PASS","summary":"ok","timestamp":"2026-01-01T00:02:00.000Z","artifacts":[{"type":"eval","path":"eval-a.md"},{"type":"eval","path":"eval-b.md"}]}
EOF
echo "# Eval A" > .harness/nodes/code-review/eval-a.md
echo "# Eval B" > .harness/nodes/code-review/eval-b.md
$HARNESS transition --from code-review --to test-design --verdict PASS --flow build-verify --dir .harness 2>/dev/null >/dev/null
mkdir -p .harness/nodes/test-design
cat > .harness/nodes/test-design/handshake.json <<'EOF'
{"nodeId":"test-design","nodeType":"review","runId":"run_1","status":"completed","verdict":"PASS","summary":"ok","timestamp":"2026-01-01T00:03:00.000Z","artifacts":[{"type":"eval","path":"eval-a.md"},{"type":"eval","path":"eval-b.md"}]}
EOF
echo "# Eval A" > .harness/nodes/test-design/eval-a.md
echo "# Eval B" > .harness/nodes/test-design/eval-b.md
$HARNESS transition --from test-design --to test-execute --verdict PASS --flow build-verify --dir .harness 2>/dev/null >/dev/null
mkdir -p .harness/nodes/test-execute
cat > .harness/nodes/test-execute/handshake.json <<'EOF'
{"nodeId":"test-execute","nodeType":"execute","runId":"run_1","status":"completed","verdict":"PASS","summary":"ok","timestamp":"2026-01-01T00:04:00.000Z","artifacts":[{"type":"test-result","path":"o"}]}
EOF
touch .harness/nodes/test-execute/o
$HARNESS transition --from test-execute --to gate --verdict PASS --flow build-verify --dir .harness 2>/dev/null >/dev/null
# ── Test 1: gate FAIL → routes back to build ──
echo "1. gate FAIL → next=build (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"
PASS=$((PASS + 1))
else
echo " ❌ FAIL → $NEXT"
FAIL=$((FAIL + 1))
fi
# ── Test 2: gate ITERATE → routes back to build ──
echo "2. gate ITERATE → next=build"
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"
PASS=$((PASS + 1))
else
echo " ❌ ITERATE → $NEXT2"
FAIL=$((FAIL + 1))
fi
# ── Test 3: transition with FAIL loopback succeeds ──
echo "3. transition gate → build (FAIL loopback) allowed"
mkdir -p .harness/nodes/gate
cat > .harness/nodes/gate/handshake.json <<'EOF'
{"nodeId":"gate","nodeType":"gate","runId":"run_1","status":"completed","verdict":"FAIL","summary":"critical findings","timestamp":"2026-01-01T00:00:00.000Z","artifacts":[]}
EOF
# Need backlog.md for FAIL/ITERATE transitions
echo "- Fix null reference" > .harness/backlog.md
TRANS=$($HARNESS transition --from gate --to build --verdict FAIL --flow build-verify --dir .harness 2>/dev/null)
ALLOWED=$(echo "$TRANS" | python3 -c "import sys,json; print(json.load(sys.stdin)['allowed'])")
if [ "$ALLOWED" = "True" ]; then
echo " ✅ loopback transition allowed"
PASS=$((PASS + 1))
else
echo " ❌ loopback not allowed: $TRANS"
FAIL=$((FAIL + 1))
fi
# ── Test 4: currentNode is build after loopback ──
echo "4. currentNode = build after loopback"
NODE=$(python3 -c "import json; print(json.load(open('.harness/flow-state.json'))['currentNode'])")
if [ "$NODE" = "build" ]; then
echo " ✅ currentNode=build"
PASS=$((PASS + 1))
else
echo " ❌ currentNode=$NODE"
FAIL=$((FAIL + 1))
fi
# ── Test 5: totalSteps incremented ──
echo "5. totalSteps incremented through transitions"
STEPS=$(python3 -c "import json; print(json.load(open('.harness/flow-state.json'))['totalSteps'])")
if [ "$STEPS" -gt 4 ]; then
echo " ✅ totalSteps=$STEPS (>4)"
PASS=$((PASS + 1))
else
echo " ❌ totalSteps=$STEPS (expected >4)"
FAIL=$((FAIL + 1))
fi
print_results
#!/usr/bin/env bash
set -euo pipefail
# Test: seal + advance commands
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
HARNESS="node $SCRIPT_DIR/bin/opc-harness.mjs"
PASS=0; FAIL=0
check() {
local label="$1" cond="$2"
if eval "$cond"; then
echo " ✅ $label"
PASS=$((PASS + 1))
else
echo " ❌ $label"
FAIL=$((FAIL + 1))
fi
}
TMPD=$(mktemp -d)
trap 'rm -rf "$TMPD"' EXIT
echo "=== TEST GROUP 1: seal — basic artifact scanning ==="
D1="$TMPD/s1"
mkdir -p "$D1/nodes/review/run_1"
echo '{"version":"1.0","flowTemplate":"review","currentNode":"review","entryNode":"review","totalSteps":0,"_written_by":"opc-harness","_write_nonce":"abc","_last_modified":"2025-01-01","history":[],"edgeCounts":{}}' > "$D1/flow-state.json"
# Create eval files
cat > "$D1/nodes/review/run_1/eval-architect.md" << 'EVALEOF'
# Eval: Architecture Review
**ITERATE**
## Findings
- 🔴 Critical issue found
- 🟡 Warning about design
- 🔵 Suggestion for improvement
EVALEOF
cat > "$D1/nodes/review/run_1/eval-engineer.md" << 'EVALEOF'
# Eval: Engineering Review
**PASS**
## Findings
- 🟡 Minor code style issue
EVALEOF
SEAL_OUT=$(cd "$D1" && $HARNESS seal --node review --dir "$D1" 2>/dev/null)
check "seal produces JSON" 'echo "$SEAL_OUT" | python3 -c "import json,sys; json.load(sys.stdin)" 2>/dev/null'
check "seal reports sealed=true" 'echo "$SEAL_OUT" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d[\"sealed\"]==True"'
check "seal finds 2 artifacts" 'echo "$SEAL_OUT" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d[\"artifacts\"]==2, str(d[\"artifacts\"])"'
# Check handshake.json was written
check "handshake.json exists" '[ -f "$D1/nodes/review/handshake.json" ]'
HS=$(cat "$D1/nodes/review/handshake.json")
check "handshake has findings.critical=1" 'echo "$HS" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d[\"findings\"][\"critical\"]==1"'
check "handshake has findings.warning=2" 'echo "$HS" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d[\"findings\"][\"warning\"]==2"'
echo ""
echo "=== TEST GROUP 2: seal — review node warns on < 2 evals ==="
D2="$TMPD/s2"
mkdir -p "$D2/nodes/review/run_1"
echo '{"version":"1.0","flowTemplate":"review","currentNode":"review","entryNode":"review","totalSteps":0,"_written_by":"opc-harness","_write_nonce":"abc","_last_modified":"2025-01-01","history":[],"edgeCounts":{}}' > "$D2/flow-state.json"
echo "# Solo eval" > "$D2/nodes/review/run_1/eval-solo.md"
SEAL_ERR=$(cd "$D2" && $HARNESS seal --node review --dir "$D2" 2>&1 1>/dev/null || true)
check "warns about < 2 evals for review" 'echo "$SEAL_ERR" | grep -q "expected.*2"'
echo ""
echo "=== TEST GROUP 3: seal — no run dirs ==="
D3="$TMPD/s3"
mkdir -p "$D3/nodes/build"
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":{}}' > "$D3/flow-state.json"
SEAL_FAIL=$(cd "$D3" && $HARNESS seal --node build --dir "$D3" 2>/dev/null)
check "seal fails when no run dirs" 'echo "$SEAL_FAIL" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d[\"sealed\"]==False"'
echo ""
echo "=== TEST GROUP 4: advance — error on non-gate ==="
D4="$TMPD/s4"
mkdir -p "$D4/nodes/review"
echo '{"version":"1.0","flowTemplate":"review","currentNode":"review","entryNode":"review","totalSteps":0,"_written_by":"opc-harness","_write_nonce":"abc","_last_modified":"2025-01-01","history":[],"edgeCounts":{}}' > "$D4/flow-state.json"
ADV_OUT=$(cd "$D4" && $HARNESS advance --dir "$D4" 2>/dev/null)
check "advance fails on non-gate node" 'echo "$ADV_OUT" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d[\"advanced\"]==False"'
check "advance error mentions gate" 'echo "$ADV_OUT" | grep -q "gate"'
echo ""
echo "==========================================="
echo " Results: $PASS passed, $FAIL failed"
echo "==========================================="
[ "$FAIL" -eq 0 ] || exit 1
#!/usr/bin/env bash
set -euo pipefail
# Test: session resolution — git-root hashing, legacy fallback, error messages
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
HARNESS="node $SCRIPT_DIR/bin/opc-harness.mjs"
PASS=0; FAIL=0
check() {
local label="$1" cond="$2"
if eval "$cond"; then
echo " ✅ $label"
PASS=$((PASS + 1))
else
echo " ❌ $label"
FAIL=$((FAIL + 1))
fi
}
TMPD=$(mktemp -d)
trap 'rm -rf "$TMPD"' EXIT
echo "=== TEST GROUP 1: git-root hashing — subdirs get same hash ==="
mkdir -p "$TMPD/repo/src/deep"
(cd "$TMPD/repo" && git init -q && git commit --allow-empty -m "init" -q)
HASH_ROOT=$(cd "$TMPD/repo" && node -e "
import { getProjectHash } from '$SCRIPT_DIR/bin/lib/util.mjs';
console.log(getProjectHash());
" 2>&1)
HASH_SUBDIR=$(cd "$TMPD/repo/src/deep" && node -e "
import { getProjectHash } from '$SCRIPT_DIR/bin/lib/util.mjs';
console.log(getProjectHash());
" 2>&1)
check "git root and subdir produce same hash" '[ "$HASH_ROOT" = "$HASH_SUBDIR" ]'
echo ""
echo "=== TEST GROUP 2: non-git dir uses normalized cwd ==="
mkdir -p "$TMPD/nongit/sub"
HASH_NG=$(cd "$TMPD/nongit" && node -e "
import { getProjectHash } from '$SCRIPT_DIR/bin/lib/util.mjs';
console.log(getProjectHash());
" 2>&1)
HASH_NG_SUB=$(cd "$TMPD/nongit/sub" && node -e "
import { getProjectHash } from '$SCRIPT_DIR/bin/lib/util.mjs';
console.log(getProjectHash());
" 2>&1)
check "non-git different dirs get different hashes" '[ "$HASH_NG" != "$HASH_NG_SUB" ]'
echo ""
echo "=== TEST GROUP 3: symlink to git repo gets same hash ==="
ln -s "$TMPD/repo" "$TMPD/repo-link"
HASH_LINK=$(cd "$TMPD/repo-link" && node -e "
import { getProjectHash } from '$SCRIPT_DIR/bin/lib/util.mjs';
console.log(getProjectHash());
" 2>&1)
check "symlink to repo gets same hash as repo" '[ "$HASH_ROOT" = "$HASH_LINK" ]'
echo ""
echo "=== TEST GROUP 4: error message includes diagnostics ==="
ERR_MSG=$(cd "$TMPD/nongit" && $HARNESS transition --from x --to y --verdict PASS --flow review 2>&1 || true)
check "error includes cwd" 'echo "$ERR_MSG" | grep -q "nongit"'
check "error includes hash" 'echo "$ERR_MSG" | grep -q "hash:"'
check "error suggests --dir" 'echo "$ERR_MSG" | grep -q "\-\-dir"'
echo ""
echo "=== TEST GROUP 5: explicit --dir works from any cwd ==="
D5="$TMPD/repo5"
mkdir -p "$D5/.harness/nodes/review"
echo '{"version":"1.0","flowTemplate":"review","currentNode":"review","entryNode":"review","totalSteps":0}' > "$D5/.harness/flow-state.json"
# Run viz from /tmp with explicit --dir pointing to D5
VIZ_OUT=$(cd /tmp && $HARNESS viz --flow review --dir "$D5/.harness" 2>&1)
check "viz with explicit --dir from /tmp works" 'echo "$VIZ_OUT" | grep -q "review\|gate"'
echo ""
echo "=== TEST GROUP 6: legacy session fallback ==="
# Compute legacy hash the same way the old code did (process.cwd() inside Node)
LEGACY_HASH=$(cd "$TMPD/repo" && node --input-type=module -e "
import { createHash } from 'crypto';
console.log(createHash('sha256').update(process.cwd()).digest('hex').slice(0, 12));
")
# If legacy hash differs from git-root hash, create a legacy session
if [ "$LEGACY_HASH" != "$HASH_ROOT" ]; then
LEGACY_BASE="$HOME/.opc/sessions/$LEGACY_HASH"
mkdir -p "$LEGACY_BASE/legacy-sess"
echo '{"version":"1.0","flowTemplate":"review","currentNode":"review"}' > "$LEGACY_BASE/legacy-sess/flow-state.json"
ln -sf "legacy-sess" "$LEGACY_BASE/latest"
FOUND=$(cd "$TMPD/repo" && node --input-type=module -e "
import { getLatestSessionDir } from '$SCRIPT_DIR/bin/lib/util.mjs';
const r = getLatestSessionDir();
console.log(r ? 'found' : 'null');
" 2>&1)
check "legacy session found via fallback" 'echo "$FOUND" | grep -q "found"'
# Clean up legacy session
rm -rf "$LEGACY_BASE"
else
check "legacy hash same as git hash (no fallback needed)" 'true'
fi
echo ""
echo "==========================================="
echo " Results: $PASS passed, $FAIL failed"
echo "==========================================="
[ "$FAIL" -eq 0 ] || exit 1
#!/bin/bash
set -e
source "$(dirname "$0")/test-helpers.sh"
setup_tmpdir
setup_git
echo "Test: Synthesize Golden Snapshots"
echo "================================================"
echo ""
# ── Setup: create session with known eval files ──
SESSION_DIR="$TMPDIR/.harness"
mkdir -p "$SESSION_DIR/nodes/code-review/run_1"
cat > "$SESSION_DIR/flow-state.json" <<'EOF'
{
"flowTemplate": "build-verify",
"currentNode": "gate",
"status": "active",
"tier": "functional",
"totalSteps": 3,
"history": [
{"node": "build", "verdict": "PASS"},
{"node": "code-review", "verdict": "PASS"}
]
}
EOF
# Create a well-formed eval with known severities (must be ≥50 lines to avoid thinEval)
python3 -c "
lines = []
lines.append('# Frontend Review')
lines.append('')
lines.append('## Summary')
lines.append('Found issues in the component rendering path. The application has several problems')
lines.append('that need to be addressed before we can ship this to production safely.')
lines.append('')
lines.append('## Findings')
lines.append('')
lines.append('🔴 **Critical** — \`src/app.tsx:10\` — Unhandled null reference')
lines.append('**Reasoning:** Will crash on first render when data is undefined because the component')
lines.append(' attempts to destructure properties from a null object without any guard clause.')
lines.append(' This affects all users on first page load when the API has not yet responded.')
lines.append('**Fix:** Add null check before accessing \`.items\` — use optional chaining or')
lines.append(' early return pattern.')
lines.append('')
lines.append('🟡 **Warning** — \`src/utils.ts:25\` — No input validation')
lines.append('**Reasoning:** User input flows directly to DOM without sanitization which opens')
lines.append(' the application to XSS attacks. Any user-submitted content could execute')
lines.append(' arbitrary JavaScript in other users browsers.')
lines.append('**Fix:** Use \`DOMPurify.sanitize()\` before insertion into the DOM.')
lines.append('')
lines.append('🔵 **Suggestion** — \`src/index.ts:1\` — Consider barrel exports')
lines.append('**Reasoning:** Multiple imports from the same module are verbose and make refactoring')
lines.append(' harder when files move around. A barrel export centralizes the public API.')
lines.append('**Fix:** Create index.ts with re-exports for all public symbols.')
lines.append('')
lines.append('## Analysis')
lines.append('')
lines.append('The codebase shows signs of rapid development without adequate error handling.')
lines.append('The null reference issue is particularly concerning because it affects the')
lines.append('critical render path. The XSS vulnerability in utils.ts suggests that input')
lines.append('validation was not considered during the initial implementation phase.')
lines.append('')
lines.append('### Recommendations')
lines.append('')
lines.append('1. Add comprehensive null checks throughout the render pipeline')
lines.append('2. Implement a sanitization layer at the form boundary')
lines.append('3. Consider adding TypeScript strict null checks to catch these at compile time')
lines.append('4. Add integration tests that cover the null-data scenario')
lines.append('5. Review all user-input touchpoints for similar XSS vectors')
lines.append('')
lines.append('### Impact Assessment')
lines.append('')
lines.append('The critical finding blocks deployment. The warning should be fixed before')
lines.append('the next release cycle. The suggestion is low priority but improves DX.')
lines.append('')
lines.append('## Verdict')
lines.append('VERDICT: FAIL — critical null reference must be fixed.')
print('\n'.join(lines))
" > "$SESSION_DIR/nodes/code-review/run_1/eval-frontend.md"
# Create source files so file:line refs are valid
mkdir -p "$TMPDIR/src"
printf '%s\n' {1..30} > "$TMPDIR/src/app.tsx"
printf '%s\n' {1..30} > "$TMPDIR/src/utils.ts"
printf '%s\n' {1..5} > "$TMPDIR/src/index.ts"
echo '{"name":"test"}' > "$TMPDIR/package.json"
# ── Test 1: synthesize produces valid JSON ──
echo "1. synthesize → valid JSON output"
OUT=$($HARNESS synthesize "$SESSION_DIR" --node code-review --run 1 --base "$TMPDIR" --no-strict 2>/dev/null || true)
if echo "$OUT" | python3 -c "import sys,json; json.load(sys.stdin)" 2>/dev/null; then
echo " ✅ Valid JSON"
PASS=$((PASS + 1))
else
echo " ❌ Invalid JSON"
echo " Output: $(echo "$OUT" | head -3)"
FAIL=$((FAIL + 1))
fi
# ── Test 2: correct severity counts (per-role, excludes quality gate warnings) ──
echo "2. Correct severity counts (1 critical, 1 warning, 1 suggestion)"
CRIT=$(echo "$OUT" | python3 -c "import sys,json; r=json.load(sys.stdin)['roles'][0]; print(r['critical'])" 2>/dev/null)
WARN=$(echo "$OUT" | python3 -c "import sys,json; r=json.load(sys.stdin)['roles'][0]; print(r['warning'])" 2>/dev/null)
SUGG=$(echo "$OUT" | python3 -c "import sys,json; r=json.load(sys.stdin)['roles'][0]; print(r['suggestion'])" 2>/dev/null)
if [ "$CRIT" = "1" ] && [ "$WARN" = "1" ] && [ "$SUGG" = "1" ]; then
echo " ✅ critical=$CRIT warning=$WARN suggestion=$SUGG"
PASS=$((PASS + 1))
else
echo " ❌ critical=$CRIT warning=$WARN suggestion=$SUGG"
FAIL=$((FAIL + 1))
fi
# ── Test 3: verdict is FAIL when critical present ──
echo "3. Verdict = FAIL when critical findings exist"
VERDICT=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['verdict'])" 2>/dev/null)
if [ "$VERDICT" = "FAIL" ]; then
echo " ✅ verdict=FAIL"
PASS=$((PASS + 1))
else
echo " ❌ verdict=$VERDICT (expected FAIL)"
FAIL=$((FAIL + 1))
fi
# ── Test 4: roles array contains frontend ──
echo "4. Roles array includes frontend"
ROLE=$(echo "$OUT" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['roles'][0]['role'])" 2>/dev/null)
if [ "$ROLE" = "frontend" ]; then
echo " ✅ role=frontend"
PASS=$((PASS + 1))
else
echo " ❌ role=$ROLE"
FAIL=$((FAIL + 1))
fi
# ── Test 5: PASS verdict when only suggestions ──
echo "5. Verdict = PASS when only suggestions"
python3 -c "
lines = []
lines.append('# Frontend Review')
lines.append('')
lines.append('## Summary')
lines.append('Minor suggestions only. The code is well-structured and follows best practices.')
lines.append('No blocking issues found during this review cycle.')
lines.append('')
lines.append('## Findings')
lines.append('')
lines.append('🔵 **Suggestion** — \`src/app.tsx:5\` — Consider memoization')
lines.append('**Reasoning:** Prevents unnecessary re-renders when parent state changes but props')
lines.append(' remain the same. This is a performance optimization that becomes important')
lines.append(' as the component tree grows deeper.')
lines.append('**Fix:** Wrap with React.memo and provide a custom comparison function.')
lines.append('')
lines.append('🔵 **Suggestion** — \`src/app.tsx:10\` — Add aria-label')
lines.append('**Reasoning:** Improves accessibility for screen reader users who cannot see the')
lines.append(' visual context of the button. Without a label the button is announced as')
lines.append(' just button which is not helpful for navigation.')
lines.append('**Fix:** Add aria-label=\"Submit form\" to the button element.')
lines.append('')
lines.append('## Analysis')
lines.append('')
lines.append('Overall the code quality is good. The component follows React conventions')
lines.append('and the file structure is clean. The two suggestions above are nice-to-have')
lines.append('improvements that would make the code slightly better but are not blockers.')
lines.append('')
lines.append('### Code Quality Notes')
lines.append('')
lines.append('- Consistent naming conventions throughout')
lines.append('- Proper use of TypeScript types')
lines.append('- Good separation of concerns between components')
lines.append('- Tests cover the critical paths adequately')
lines.append('- Error boundaries are in place for the main routes')
lines.append('')
lines.append('### Performance Observations')
lines.append('')
lines.append('- Bundle size is within acceptable limits')
lines.append('- No unnecessary re-renders detected in profiling')
lines.append('- Lazy loading is properly configured for route-level splits')
lines.append('- The memoization suggestion above is purely a future-proofing measure')
lines.append('')
lines.append('### Accessibility Audit')
lines.append('')
lines.append('- All interactive elements are keyboard navigable')
lines.append('- Color contrast meets WCAG AA standards')
lines.append('- The aria-label suggestion is the only gap found')
lines.append('- Focus management works correctly on route transitions')
lines.append('')
lines.append('## Verdict')
lines.append('VERDICT: PASS — suggestions only.')
print('\n'.join(lines))
" > "$SESSION_DIR/nodes/code-review/run_1/eval-frontend.md"
# Add skeptic-owner eval to satisfy mandatory role check
python3 -c "
lines = []
lines.append('# Skeptic Owner Review')
lines.append('')
lines.append('## Summary')
lines.append('No concerns from ownership perspective. Code changes are well-scoped')
lines.append('and do not introduce unnecessary complexity or maintenance burden.')
lines.append('')
lines.append('## Findings')
lines.append('')
lines.append('🔵 **Suggestion** — \`src/app.tsx:1\` — Consider adding ownership comment')
lines.append('**Reasoning:** New modules benefit from a brief ownership comment at the top')
lines.append(' to help future maintainers understand who to contact for questions.')
lines.append('**Fix:** Add a comment block with team ownership information.')
lines.append('')
lines.append('## Analysis')
lines.append('')
lines.append('The changes are minimal and well-contained within the component boundary.')
lines.append('No cross-cutting concerns or architectural issues detected. The code')
lines.append('follows established patterns in the codebase and will be easy to maintain.')
lines.append('')
lines.append('### Ownership Assessment')
lines.append('')
lines.append('- Clear module boundaries maintained')
lines.append('- No orphaned code or dead imports')
lines.append('- Dependencies are well-managed and minimal')
lines.append('- Test coverage exists for the critical paths')
lines.append('- No shared state introduced that could cause coupling')
lines.append('')
lines.append('### Maintenance Risk')
lines.append('')
lines.append('- Low complexity score (cyclomatic < 5 per function)')
lines.append('- No external service dependencies added')
lines.append('- Rollback path is straightforward if issues arise')
lines.append('- Feature flag not required for this scope of change')
lines.append('')
lines.append('## Verdict')
lines.append('VERDICT: PASS — no ownership concerns.')
print('\n'.join(lines))
" > "$SESSION_DIR/nodes/code-review/run_1/eval-skeptic-owner.md"
OUT2=$($HARNESS synthesize "$SESSION_DIR" --node code-review --run 1 --base "$TMPDIR" --no-strict 2>/dev/null || true)
VERDICT2=$(echo "$OUT2" | python3 -c "import sys,json; print(json.load(sys.stdin)['verdict'])" 2>/dev/null)
if [ "$VERDICT2" = "PASS" ]; then
echo " ✅ verdict=PASS (suggestions only)"
PASS=$((PASS + 1))
else
echo " ❌ verdict=$VERDICT2 (expected PASS)"
FAIL=$((FAIL + 1))
fi
# ── Test 6: ITERATE verdict when warnings only ──
echo "6. Verdict = ITERATE when warnings present (no critical)"
python3 -c "
lines = []
lines.append('# Frontend Review')
lines.append('')
lines.append('## Summary')
lines.append('Found warnings that should be addressed before shipping.')
lines.append('')
lines.append('## Findings')
lines.append('')
lines.append('🟡 **Warning** — \`src/app.tsx:5\` — Missing error handling')
lines.append('**Reasoning:** Async call without try/catch will silently fail and leave the user')
lines.append(' staring at a loading spinner forever. The promise rejection is swallowed')
lines.append(' by the event loop without any user-visible feedback.')
lines.append('**Fix:** Wrap in try/catch with user notification via toast or error state.')
lines.append('')
lines.append('## Analysis')
lines.append('')
lines.append('The error handling gap is concerning but not critical since the feature')
lines.append('still works in the happy path. Users will only be affected when the API')
lines.append('returns an error or times out, which is an edge case but one that should')
lines.append('be handled gracefully before production deployment.')
lines.append('')
lines.append('### Error Handling Patterns')
lines.append('')
lines.append('The rest of the codebase uses a consistent error boundary pattern but this')
lines.append('particular component bypasses it by making a raw fetch call instead of going')
lines.append('through the shared API client that has retry and error handling built in.')
lines.append('')
lines.append('### Recommendations')
lines.append('')
lines.append('1. Use the shared apiClient.get() instead of raw fetch')
lines.append('2. Add loading and error states to the component')
lines.append('3. Consider adding a timeout to prevent infinite loading')
lines.append('4. Add a retry mechanism for transient failures')
lines.append('5. Log the error for debugging purposes')
lines.append('')
lines.append('### Testing Notes')
lines.append('')
lines.append('- Happy path tests pass')
lines.append('- No error path tests exist for this component')
lines.append('- Integration tests do not cover API failure scenarios')
lines.append('- Consider adding MSW handlers for error responses')
lines.append('')
lines.append('## Verdict')
lines.append('VERDICT: ITERATE — warnings need addressing.')
print('\n'.join(lines))
" > "$SESSION_DIR/nodes/code-review/run_1/eval-frontend.md"
OUT3=$($HARNESS synthesize "$SESSION_DIR" --node code-review --run 1 --base "$TMPDIR" --no-strict 2>/dev/null || true)
VERDICT3=$(echo "$OUT3" | python3 -c "import sys,json; print(json.load(sys.stdin)['verdict'])" 2>/dev/null)
if [ "$VERDICT3" = "ITERATE" ]; then
echo " ✅ verdict=ITERATE"
PASS=$((PASS + 1))
else
echo " ❌ verdict=$VERDICT3 (expected ITERATE)"
FAIL=$((FAIL + 1))
fi
# ── Test 7: BLOCKED verdict ──
echo "7. Verdict = BLOCKED when evaluator says BLOCKED"
python3 -c "
lines = []
lines.append('# Frontend Review')
lines.append('')
lines.append('## Summary')
lines.append('Cannot review — dependency not available. The build environment is broken')
lines.append('and I cannot verify any of the code changes without a working build.')
lines.append('')
lines.append('## Findings')
lines.append('')
lines.append('🔴 **Critical** — \`package.json:1\` — Missing react dependency')
lines.append('**Reasoning:** Build fails entirely because react is listed as a peer dependency')
lines.append(' but is not installed. This blocks all downstream work including testing,')
lines.append(' type checking, and bundle analysis.')
lines.append('**Fix:** Run npm install react react-dom to install the required dependencies.')
lines.append('')
lines.append('## Analysis')
lines.append('')
lines.append('This review is BLOCKED because the project cannot build. Without a working')
lines.append('build, I cannot verify component behavior, run tests, or check for runtime')
lines.append('errors. The missing dependency must be resolved before any meaningful review')
lines.append('can proceed.')
lines.append('')
lines.append('### Environment State')
lines.append('')
lines.append('- npm install fails with ERESOLVE error')
lines.append('- TypeScript compilation fails (cannot find module react)')
lines.append('- Dev server cannot start')
lines.append('- Test suite cannot run')
lines.append('- Linting partially works but misses JSX-specific rules')
lines.append('')
lines.append('### Prerequisites')
lines.append('')
lines.append('1. Fix package.json dependency declarations')
lines.append('2. Run npm install successfully')
lines.append('3. Verify build completes without errors')
lines.append('4. Then re-run this review')
lines.append('')
lines.append('### Impact')
lines.append('')
lines.append('All code review findings would be speculative without a working build.')
lines.append('I refuse to guess at runtime behavior when I cannot verify it.')
lines.append('')
lines.append('## Verdict')
lines.append('VERDICT: BLOCKED — cannot build without dependencies.')
print('\n'.join(lines))
" > "$SESSION_DIR/nodes/code-review/run_1/eval-frontend.md"
OUT4=$($HARNESS synthesize "$SESSION_DIR" --node code-review --run 1 --base "$TMPDIR" --no-strict 2>/dev/null || true)
VERDICT4=$(echo "$OUT4" | python3 -c "import sys,json; print(json.load(sys.stdin)['verdict'])" 2>/dev/null)
if [ "$VERDICT4" = "BLOCKED" ]; then
echo " ✅ verdict=BLOCKED"
PASS=$((PASS + 1))
else
echo " ❌ verdict=$VERDICT4 (expected BLOCKED)"
FAIL=$((FAIL + 1))
fi
print_results
#!/usr/bin/env bash
set -euo pipefail
# Test: transition handles --to null (terminal transitions)
SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
HARNESS="node $SCRIPT_DIR/bin/opc-harness.mjs"
PASS=0; FAIL=0
check() {
local label="$1" cond="$2"
if eval "$cond"; then
echo " ✅ $label"
PASS=$((PASS + 1))
else
echo " ❌ $label"
FAIL=$((FAIL + 1))
fi
}
TMPD=$(mktemp -d)
trap 'rm -rf "$TMPD"' EXIT
write_review_hs() {
local dir="$1" node="$2"
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"
}
echo "=== TEST GROUP 1: --to null delegates to finalize ==="
D1="$TMPD/t1"
mkdir -p "$D1" && cd "$D1"
$HARNESS init --flow review --entry review --dir .harness > /dev/null 2>&1
write_review_hs ".harness" "review"
$HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness > /dev/null 2>&1
RESULT=$(cd "$D1" && $HARNESS transition --from gate --to null --verdict PASS --flow review --dir .harness 2>&1)
check "terminal transition returns finalized" 'echo "$RESULT" | grep -q "finalized"'
echo ""
echo "=== TEST GROUP 2: --to null with invalid edge fails ==="
D2="$TMPD/t2"
mkdir -p "$D2" && cd "$D2"
$HARNESS init --flow review --entry review --dir .harness > /dev/null 2>&1
RESULT2=$(cd "$D2" && $HARNESS transition --from review --to null --verdict PASS --flow review --dir .harness 2>&1)
check "non-terminal node rejects --to null" 'echo "$RESULT2" | grep -q "no terminal edge"'
echo ""
echo "=== TEST GROUP 3: route returns null for terminal ==="
ROUTE_RESULT=$($HARNESS route --node gate --verdict PASS --flow review 2>&1)
check "route returns null for terminal" 'echo "$ROUTE_RESULT" | grep -q "\"next\":null"'
echo ""
echo "==========================================="
echo " Results: $PASS passed, $FAIL failed"
echo "==========================================="
[ "$FAIL" -eq 0 ] || exit 1
+110
-8

@@ -166,9 +166,9 @@ // Evaluation analysis commands: verify, synthesize, tier-baseline

if (runs.length === 0) {
console.error(`No runs found for node '${nodeId}' in ${nodeDir}`);
process.exit(1);
console.log(JSON.stringify({ roles: [], totals: { critical: 0, warning: 0, suggestion: 0 }, verdict: "BLOCKED", reason: `no runs found for node '${nodeId}' in ${nodeDir}` }));
return;
}
targetRunDir = join(nodeDir, runs[0]);
} catch (err) {
console.error(`Cannot read node dir ${nodeDir}: ${err.message}`);
process.exit(1);
console.log(JSON.stringify({ roles: [], totals: { critical: 0, warning: 0, suggestion: 0 }, verdict: "BLOCKED", reason: `cannot read node dir ${nodeDir}: ${err.message}` }));
return;
}

@@ -182,9 +182,9 @@ }

} catch (err) {
console.error(`Cannot read ${targetRunDir}: ${err.message}`);
process.exit(1);
console.log(JSON.stringify({ roles: [], totals: { critical: 0, warning: 0, suggestion: 0 }, verdict: "BLOCKED", reason: `cannot read ${targetRunDir}: ${err.message}` }));
return;
}
if (files.length === 0) {
console.error(`No eval-*.md files in ${targetRunDir}`);
process.exit(1);
console.log(JSON.stringify({ roles: [], totals: { critical: 0, warning: 0, suggestion: 0 }, verdict: "BLOCKED", reason: `no eval-*.md files in ${targetRunDir}` }));
return;
}

@@ -427,2 +427,29 @@ } else {

// ── D1.5: Mandatory role enforcement ──────────────────────────────
// Roles with `mandatory: true` in front matter MUST appear in eval output.
// If missing, emit warning so gate will ITERATE — forcing orchestrator to re-dispatch.
const mandatoryMissing = [];
try {
const rolesDir = new URL("../../roles/", import.meta.url).pathname;
if (existsSync(rolesDir)) {
const roleFiles = readdirSync(rolesDir).filter(f => f.endsWith(".md"));
for (const rf of roleFiles) {
try {
const content = readFileSync(join(rolesDir, rf), "utf8");
// Quick front matter check for mandatory: true
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (fmMatch && /mandatory:\s*true/i.test(fmMatch[1])) {
const roleName = rf.replace(/\.md$/, "");
const found = roles.some(r => r.role === roleName);
if (!found) {
mandatoryMissing.push(roleName);
totals.warning += 1;
thinEvalWarnings.push(`mandatory role '${roleName}' not present in eval output — orchestrator must dispatch this role`);
}
}
} catch { /* unreadable role file */ }
}
}
} catch { /* roles dir resolution failed — skip */ }
// ── D2: Compound eval quality gate ─────────────────────────────

@@ -653,4 +680,78 @@ for (const role of roles) {

// ── Extension rubric sidecar (informational enrichment) ──────────
let rubricScore = undefined;
let convergenceWarning = undefined;
let rubricVersionWarning = undefined;
if (targetRunDir) {
try {
const rubricPath = join(targetRunDir, "ext-design-intelligence", "rubric-verdict.json");
if (existsSync(rubricPath)) {
rubricScore = JSON.parse(readFileSync(rubricPath, "utf8"));
}
} catch { /* graceful — rubric is informational */ }
// ── Fix #2: Version mismatch detection ──
if (rubricScore && rubricScore.version) {
try {
const statePath = join(dir, "flow-state.json");
if (existsSync(statePath)) {
const flowState = JSON.parse(readFileSync(statePath, "utf8"));
if (flowState.extensionVersions) {
const diExt = flowState.extensionVersions.find(e => e.name === "design-intelligence");
if (diExt && diExt.version !== "unknown" && diExt.version !== rubricScore.version) {
rubricVersionWarning = `Rubric version drift: flow pinned ${diExt.version} but current rubric is ${rubricScore.version} — scores may not be comparable across iterations`;
}
}
}
} catch { /* graceful */ }
}
// ── Fix #3: Rubric verdict enforcement (polished+ tier) ──
if (rubricScore && rubricScore.verdict === "FAIL") {
try {
const statePath = join(dir, "flow-state.json");
if (existsSync(statePath)) {
const flowState = JSON.parse(readFileSync(statePath, "utf8"));
const tier = flowState.tier || "functional";
if (tier === "polished" || tier === "delightful") {
if (verdict === "PASS") {
verdict = "ITERATE";
reason = `${reason}; rubric score ${rubricScore.final.toFixed(1)}/5.0 below threshold (FAIL)`;
}
}
}
} catch { /* graceful */ }
}
// ── Fix #4: Convergence detection — max-min across last 3 runs ──
if (rubricScore && nodeId) {
const runFlag = args.indexOf("--run");
const currentRunN = runFlag !== -1 && args[runFlag + 1] ? parseInt(args[runFlag + 1], 10) : null;
const iteration = currentRunN || parseInt((targetRunDir.match(/run_(\d+)$/) || [])[1] || "1", 10);
if (iteration >= 3) {
const recentScores = [rubricScore.final];
for (let i = iteration - 1; i >= Math.max(1, iteration - 2); i--) {
try {
const prevPath = join(dir, "nodes", nodeId, `run_${i}`, "ext-design-intelligence", "rubric-verdict.json");
if (existsSync(prevPath)) {
const prev = JSON.parse(readFileSync(prevPath, "utf8"));
if (prev.final != null) recentScores.push(prev.final);
}
} catch { /* skip */ }
}
if (recentScores.length >= 3) {
const range = Math.max(...recentScores) - Math.min(...recentScores);
if (range < 0.5) {
convergenceWarning = `Rubric score stagnant (range ${range.toFixed(2)} across last ${recentScores.length} runs, scores: ${recentScores.map(s => s.toFixed(1)).join("→")}) — feedback may not be actionable`;
}
}
}
}
}
console.log(JSON.stringify({
roles, totals, verdict, reason, tierCoverage,
rubricScore,
rubricVersionWarning,
convergenceWarning,
thinEvalWarnings: thinEvalWarnings.length > 0 ? thinEvalWarnings : undefined,

@@ -661,2 +762,3 @@ evalQualityGate: qualityFailRoles.length > 0

evaluatorGuidance,
mandatoryMissing: mandatoryMissing.length > 0 ? mandatoryMissing : undefined,
testPlanCoverage: testPlanCoverage || undefined,

@@ -663,0 +765,0 @@ }, null, 2));

+28
-25

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

// Severity / finding detection (skip markdown headings and section labels)
// Reasoning line — accept: "Reasoning: ...", "**Reasoning:** ...", "→ Reasoning: ..."
// NOTE: must check BEFORE severity detection, since severity matches emoji anywhere in line
const reasoningRe = /^(?:→\s*)?(?:\*{0,2})reasoning(?:\*{0,2}):\s*/i;
if (currentFinding && reasoningRe.test(trimmed)) {
currentFinding.reasoning = trimmed.replace(reasoningRe, "").trim();
if (HEDGING_RE.test(trimmed)) {
hedgingDetected.push(`line ${lineNum}: '${trimmed}'`);
}
continue;
}
// Fix line — accept: "→ ...", "Fix: ...", "**Fix:** ...", "→ Fix: ..."
// NOTE: must check BEFORE severity detection, since "→ Fix: ... 🔵 ..." would match severity
const fixMatch = currentFinding && (trimmed.startsWith("→") || /^\*{0,2}fix\*{0,2}:/i.test(trimmed));
if (fixMatch) {
if (trimmed.startsWith("→")) {
currentFinding.fix = trimmed.replace(/^→\s*(?:\*{0,2}fix\*{0,2}:\s*)?/i, "").trim();
} else {
currentFinding.fix = trimmed.replace(/^\*{0,2}fix\*{0,2}:\s*/i, "").trim();
}
if (HEDGING_RE.test(trimmed)) {
hedgingDetected.push(`line ${lineNum}: '${trimmed}'`);
}
continue;
}
// Severity / finding detection (skip markdown headings, tables, and section labels)
const sevMatch = trimmed.match(SEVERITY_RE);
if (sevMatch && !trimmed.startsWith("#")) {
if (sevMatch && !trimmed.startsWith("#") && !trimmed.startsWith("|") && !VERDICT_RE.test(trimmed)) {
const fileMatch = trimmed.match(FILE_REF_RE);

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

// Fix line — accept: "→ ...", "Fix: ...", "**Fix:** ..."
const fixMatch = currentFinding && (trimmed.startsWith("→") || /^\*{0,2}fix\*{0,2}:/i.test(trimmed));
if (fixMatch) {
if (trimmed.startsWith("→")) {
currentFinding.fix = trimmed.slice(1).trim();
} else {
currentFinding.fix = trimmed.replace(/^\*{0,2}fix\*{0,2}:\s*/i, "").trim();
}
if (HEDGING_RE.test(trimmed)) {
hedgingDetected.push(`line ${lineNum}: '${trimmed}'`);
}
continue;
}
// Reasoning line — accept: "Reasoning: ...", "**Reasoning:** ..."
if (currentFinding && /^\*{0,2}reasoning\*{0,2}:/i.test(trimmed)) {
currentFinding.reasoning = trimmed.replace(/^\*{0,2}reasoning\*{0,2}:\s*/i, "").trim();
if (HEDGING_RE.test(trimmed)) {
hedgingDetected.push(`line ${lineNum}: '${trimmed}'`);
}
continue;
}
// Hedging in findings context

@@ -198,0 +201,0 @@ if (

@@ -8,4 +8,4 @@ // ext-commands.mjs — CLI commands for extension system

import { join, resolve } from "path";
import { loadExtensions, firePromptAppend, fireVerdictAppend, fireExecuteRun, fireArtifactEmit, writeFailureReport, saveRegistryCache, normalizeHook, lintCapability, enforceStrictMode, survivingExtensions } from "./extensions.mjs";
import { getFlag } from "./util.mjs";
import { loadExtensions, firePromptAppend, fireVerdictAppend, fireExecuteRun, fireArtifactEmit, fireNodePreflight, writeFailureReport, saveRegistryCache, normalizeHook, lintCapability, enforceStrictMode, survivingExtensions } from "./extensions.mjs";
import { getFlag, atomicWriteSync, resolveDir, resolveDirReadOnly } from "./util.mjs";
import { resolveFlowTemplate } from "./flow-templates.mjs";

@@ -78,3 +78,3 @@ import { parseBypassArgs } from "./bypass-args.mjs";

const role = getFlag(args, "role");
const dir = getFlag(args, "dir", ".harness");
const dir = resolveDirReadOnly(args);

@@ -122,3 +122,3 @@ if (!node || !role) {

handshake.extensionsApplied = survivingExtensions(registry);
writeFileSync(handshakePath, JSON.stringify(handshake, null, 2));
atomicWriteSync(handshakePath, JSON.stringify(handshake, null, 2));
} catch { /* best effort */ }

@@ -311,3 +311,3 @@

const provides = Array.isArray(meta.provides) ? meta.provides : [];
const firingHookPresent = hookNames.some(h => h === "prompt.append" || h === "verdict.append" || h === "execute.run" || h === "artifact.emit");
const firingHookPresent = hookNames.some(h => h === "prompt.append" || h === "verdict.append" || h === "execute.run" || h === "artifact.emit" || h === "preflight");
if (provides.length > 0 && hookNames.length === 0) {

@@ -408,3 +408,3 @@ console.error(

const node = getFlag(args, "node");
const dir = getFlag(args, "dir", ".harness");
const dir = resolveDirReadOnly(args);

@@ -456,3 +456,3 @@ if (!node) {

handshake.extensionsApplied = survivingExtensions(registry);
await writeFile(handshakePath, JSON.stringify(handshake, null, 2));
atomicWriteSync(handshakePath, JSON.stringify(handshake, null, 2));

@@ -484,3 +484,3 @@ console.log(JSON.stringify({ ok: true, node, runDir, extensionsApplied: survivingExtensions(registry), nodeCapabilities }));

const node = getFlag(args, "node");
const dir = getFlag(args, "dir", ".harness");
const dir = resolveDirReadOnly(args);

@@ -543,3 +543,3 @@ if (!node) {

handshake.extensionsApplied = survivingExtensions(registry);
await writeFile(handshakePath, JSON.stringify(handshake, null, 2));
atomicWriteSync(handshakePath, JSON.stringify(handshake, null, 2));

@@ -560,1 +560,74 @@ console.log(JSON.stringify({

}
// ─── node-preflight ─────────────────────────────────────────────
//
// Fires the `preflight` hook on matching extensions BEFORE a build node
// executes. Extension preflight() is a pure function: it receives context
// and returns data. Core writes the artifacts to the session dir.
//
// Usage: opc-harness node-preflight --node <id> --dir <harness-dir>
export async function cmdNodePreflight(args) {
if (args.includes("--help")) {
console.error("Usage: opc-harness node-preflight --node <id> --dir <harness-dir>");
console.error("Fires preflight hook on matching extensions. Writes design artifacts to session dir.");
return;
}
const node = getFlag(args, "node");
const dir = resolveDirReadOnly(args);
if (!node) {
console.error("Usage: opc-harness node-preflight --node <id> --dir <harness-dir>");
process.exit(1);
}
const config = loadOpcConfig(dir);
Object.assign(config, parseBypassArgs(args), { flowDir: dir });
const task = readTaskFromAC(dir);
let registry;
try {
registry = await loadExtensions(config);
} catch (err) {
console.error(err.message);
process.exit(1);
}
const devServerUrl = getFlag(args, "dev-server") || process.env.DEV_SERVER_URL || config.devServerUrl || "";
const nodeCapabilities = readNodeCapabilities(dir, node, args);
const context = {
node,
role: "preflight",
task,
flowDir: resolve(dir),
devServerUrl,
nodeCapabilities,
};
const results = await fireNodePreflight(registry, context);
// Write failure report to the node's latest run dir (if exists)
const nodeDir = resolve(dir, "nodes", node);
const latestRunDir = findLatestRunDir(nodeDir);
if (latestRunDir) {
writeFailureReport(registry, latestRunDir);
}
saveRegistryCache(resolve(dir), registry);
// Report which artifact types were produced
const artifactTypes = results.map(r => r.type).filter(Boolean);
console.log(JSON.stringify({
ok: true,
node,
preflightResults: results.length,
artifactTypes,
extensionsApplied: survivingExtensions(registry),
nodeCapabilities,
}));
enforceStrictMode(registry);
}
// extensions.mjs — OPC Extension System
// Loads user extensions from ~/.opc/extensions/, fires hooks at call sites.
// Loads user extensions from ~/.claude/skills/opc-extension/, fires hooks at call sites.
// No module-level singletons — loadExtensions returns a registry object.
// Deliberate exceptions: _breakerSchemaWarned (one warning per process for schema
// version mismatch) and _bareCapabilityWarnings (deduplicate bare-string warnings
// across fire calls within a single process). Both are intentionally process-scoped.
//

@@ -361,2 +364,3 @@ // ── Activation model (capability contract) ──

if (typeof src.artifactEmit === "function") hooks["artifact.emit"] = src.artifactEmit;
if (typeof src.preflight === "function") hooks["preflight"] = src.preflight;
if (typeof src["prompt.append"] === "function") hooks["prompt.append"] = src["prompt.append"];

@@ -367,2 +371,3 @@ if (typeof src["verdict.append"] === "function") hooks["verdict.append"] = src["verdict.append"];

if (typeof src["artifact.emit"] === "function") hooks["artifact.emit"] = src["artifact.emit"];
if (typeof src["preflight"] === "function") hooks["preflight"] = src["preflight"];

@@ -981,2 +986,126 @@ return { hooks };

// ─── fireNodePreflight ──────────────────────────────────────────
/**
* Call `preflight` on extensions whose `provides` matches context.nodeCapabilities.
* Preflight runs BEFORE a build node. Extensions return data, core writes artifacts.
*
* Each extension returns an object with a `type` field that determines which
* artifact writer is invoked. Currently supported types:
* - "design" → writes design-mode.json, design-selection.json, design-brief.md,
* design-tokens.json to context.flowDir
*
* Returns array of `{ ...result, _ext }` objects (one per extension that fired).
*/
export async function fireNodePreflight(registry, context) {
const results = [];
warnMissingNodeCapsOnce(registry, context);
const requires = context.nodeCapabilities || [];
for (const ext of registry.extensions) {
if (!ext.enabled) continue;
if (!extensionMatches(requires, ext.meta.provides, ext.meta.compatibleCapabilities)) continue;
const fn = ext.hook?.hooks?.["preflight"];
if (typeof fn !== "function") continue;
try {
const result = await withTimeout(
Promise.resolve(fn(context)),
HOOK_TIMEOUT_MS,
`preflight timed out after ${HOOK_TIMEOUT_MS}ms`
);
if (result === undefined || result === null) {
recordSuccess(ext);
continue;
}
if (typeof result !== "object" || Array.isArray(result)) {
console.error(`WARN: extension ${ext.name} preflight returned ${typeof result}, expected object — ignoring`);
recordFailure(registry, ext, "preflight", "bad-return", `returned ${typeof result}, expected object`);
continue;
}
results.push({ ...result, _ext: ext.name });
recordSuccess(ext);
} catch (err) {
console.error(`WARN: extension ${ext.name} preflight failed:`, err.message);
const kind = isHookTimeoutError(err) ? "timeout" : "throw";
recordFailure(registry, ext, "preflight", kind, err.message);
}
}
// Write artifacts per type to the flow (session) directory.
// Isolated: a write failure for one result doesn't block siblings or
// prevent saveBreakerState from running.
if (context.flowDir) {
for (const result of results) {
if (result.type === "design") {
try {
writeDesignArtifacts(result, context.flowDir);
} catch (err) {
console.error(`WARN: writeDesignArtifacts failed for ${result._ext}: ${err.message}`);
}
} else if (result.type) {
console.error(`WARN: unknown preflight result type '${result.type}' from ${result._ext} — skipping artifact write`);
}
}
}
if (registry._flowDir) saveBreakerState(registry._flowDir, registry);
return results;
}
/**
* Write design-related preflight artifacts to the session directory.
* Called by fireNodePreflight for results with type: "design".
*
* Writes up to 4 files:
* - design-mode.json — activation mode + confidence
* - design-selection.json — style inference result
* - design-brief.md — human-readable prompt injection
* - design-tokens.json — machine-readable CSS tokens
*
* Missing optional fields are silently skipped (no partial file writes).
*/
export function writeDesignArtifacts(preflightResult, sessionDir) {
mkdirSync(sessionDir, { recursive: true });
// design-mode.json — always written (contains the activation decision)
// Mode semantics:
// "explicit" — user explicitly set design tokens (userOverride=true)
// "auto" — inferred from task. Confidence controls eval strictness:
// >0.8 strict, 0.4-0.8 moderate, <0.4 general-only
// "off" — only if extension explicitly returns mode="off"
const designMode = {
mode: preflightResult.userOverride ? "explicit"
: (preflightResult.mode === "off" ? "off" : "auto"),
source: preflightResult.userOverride ? "user-override" : "inferred",
confidence: typeof preflightResult.confidence === "number" ? preflightResult.confidence : 0,
reason: preflightResult.reason || "",
userOverride: !!preflightResult.userOverride,
persist: !!preflightResult.persist,
};
atomicWriteSync(join(sessionDir, "design-mode.json"), JSON.stringify(designMode, null, 2) + "\n");
// design-selection.json — the full style inference output
if (preflightResult.selection && typeof preflightResult.selection === "object") {
atomicWriteSync(
join(sessionDir, "design-selection.json"),
JSON.stringify(preflightResult.selection, null, 2) + "\n"
);
}
// design-brief.md — markdown for prompt injection
if (typeof preflightResult.brief === "string" && preflightResult.brief.length > 0) {
atomicWriteSync(join(sessionDir, "design-brief.md"), preflightResult.brief);
}
// design-tokens.json — CSS-consumable token map
if (preflightResult.tokens && typeof preflightResult.tokens === "object") {
atomicWriteSync(
join(sessionDir, "design-tokens.json"),
JSON.stringify(preflightResult.tokens, null, 2) + "\n"
);
}
}
// ─── Failure report ──────────────────────────────────────────────

@@ -983,0 +1112,0 @@

@@ -6,2 +6,3 @@ // Advisory file locking using .lock files with PID + timestamp.

import { readFileSync, writeFileSync, unlinkSync, existsSync } from "fs";
import { randomBytes } from "crypto";

@@ -75,4 +76,6 @@ // Synchronous sleep without spawning a shell process.

// If the file already exists, writeFileSync with flag "wx" throws EEXIST.
const nonce = randomBytes(8).toString("hex");
const lockData = {
pid: process.pid,
nonce,
timestamp: new Date().toISOString(),

@@ -110,6 +113,6 @@ command,

try {
// Only remove if we still own it
// Only remove if we still own it (check pid + nonce to prevent PID-reuse race)
if (existsSync(lockPath)) {
const current = JSON.parse(readFileSync(lockPath, "utf8"));
if (current.pid === process.pid) {
if (current.pid === process.pid && current.nonce === nonce) {
unlinkSync(lockPath);

@@ -116,0 +119,0 @@ }

// Flow core commands: route, init, validate, validateHandshakeData, validate-context
// Depends on: flow-templates.mjs, viz-commands.mjs (getMarker), util.mjs
import { readFileSync, mkdirSync, existsSync } from "fs";
import { readFileSync, mkdirSync, existsSync, readdirSync } from "fs";
import { join, dirname } from "path";

@@ -149,2 +149,7 @@ import { createHash } from "crypto";

registry.bypass = bypassRecord;
// Pin extension versions into flow-state for rubric freeze rule
if (registry.extensions && registry.extensions.length > 0) {
state.extensionVersions = registry.extensions.map(e => ({ name: e.name, version: e.meta?.rubricVersion || e.meta?.version || "unknown" }));
atomicWriteSync(statePath, JSON.stringify(state, null, 2) + "\n");
}
try {

@@ -411,2 +416,168 @@ saveRegistryCache(dir, registry);

// ─── seal ──────────────────────────────────────────────────────
// Auto-scan a node's run directory and generate handshake.json from found artifacts.
export function cmdSeal(args) {
const nodeId = getFlag(args, "node");
const runOverride = getFlag(args, "run");
const dir = resolveDir(args);
if (!nodeId) {
console.error("Usage: opc-harness seal --node <nodeId> [--run <N>] [--dir <path>]");
process.exit(1);
}
// Read flow state for template info
const statePath = join(dir, "flow-state.json");
if (!existsSync(statePath)) {
console.log(JSON.stringify({ sealed: false, error: "flow-state.json not found" }));
return;
}
let state;
try {
state = JSON.parse(readFileSync(statePath, "utf8"));
} catch (err) {
console.log(JSON.stringify({ sealed: false, error: `corrupt flow-state.json: ${err.message}` }));
return;
}
// Resolve template for nodeType lookup
if (state._flow_file) loadFlowFromFile(state._flow_file);
const template = FLOW_TEMPLATES[state.flowTemplate];
if (!template) {
console.log(JSON.stringify({ sealed: false, error: `unknown flow template: ${state.flowTemplate}` }));
return;
}
const nodeType = template.nodeTypes?.[nodeId] || (nodeId.startsWith("gate") ? "gate" : "build");
// Find the latest run dir
const nodeDir = join(dir, "nodes", nodeId);
if (!existsSync(nodeDir)) {
console.log(JSON.stringify({ sealed: false, error: `node dir not found: nodes/${nodeId}` }));
return;
}
let runDir;
if (runOverride) {
runDir = join(nodeDir, `run_${runOverride}`);
} else {
// Find latest run_N
const runs = readdirSync(nodeDir, { withFileTypes: true })
.filter(e => e.isDirectory() && /^run_\d+$/.test(e.name))
.sort((a, b) => {
const na = parseInt(a.name.split("_")[1]);
const nb = parseInt(b.name.split("_")[1]);
return nb - na;
});
if (runs.length === 0) {
console.log(JSON.stringify({ sealed: false, error: `no run_N directories found in nodes/${nodeId}` }));
return;
}
runDir = join(nodeDir, runs[0].name);
}
if (!existsSync(runDir)) {
console.log(JSON.stringify({ sealed: false, error: `run dir not found: ${runDir}` }));
return;
}
const runId = runDir.split("/").pop();
// Scan files and classify artifacts
const files = readdirSync(runDir);
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}` });
}
// 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 */ }
}
// Review node: warn if < 2 eval files
if (nodeType === "review" && evalFiles.length < 2) {
warnings.push(`review node has ${evalFiles.length} eval file(s), expected ≥2 for independent review`);
}
// Build handshake
const handshake = {
nodeId,
nodeType,
runId,
status: "completed",
verdict,
summary: `Sealed ${artifacts.length} artifacts (${evalFiles.length} evals)`,
timestamp: new Date().toISOString(),
artifacts,
findings: null,
};
// 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 (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");
// Validate
const { errors } = validateHandshakeData(handshake, {
checkEvidence: nodeType === "execute",
baseDir: nodeDir,
});
for (const w of warnings) console.error(`⚠️ ${w}`);
console.log(JSON.stringify({
sealed: true,
handshakePath,
artifacts: artifacts.length,
verdict,
validationErrors: errors,
warnings,
}));
}
// ─── validate-context ──────────────────────────────────────────

@@ -413,0 +584,0 @@

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

import { join } from "path";
import { execFileSync } from "child_process";
import { dirname } from "path";
import { fileURLToPath } from "url";
import { FLOW_TEMPLATES, loadFlowFromFile } from "./flow-templates.mjs";

@@ -159,2 +162,51 @@ import { cmdTransition } from "./flow-transition.mjs";

}
// ── OUT-1: Refuse pass when upstream verdict is ITERATE or FAIL ──
// Find the upstream node: the non-gate node that has an edge pointing to this gate
const upstreamId = Object.keys(template.edges).find(n => {
const nt = template.nodeTypes?.[n];
return nt && nt !== "gate" && Object.values(template.edges[n]).includes(current);
});
if (upstreamId) {
const upstreamHandshakePath = join(dir, "nodes", upstreamId, "handshake.json");
if (existsSync(upstreamHandshakePath)) {
try {
const harnessPath = join(dirname(fileURLToPath(import.meta.url)), "..", "opc-harness.mjs");
const synthOutput = execFileSync(
"node",
[harnessPath, "synthesize", "--node", upstreamId, "--dir", dir, "--no-strict"],
{ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }
);
// synthesize may output pretty-printed JSON; extract from first { to last }
const trimmed = synthOutput.trim();
const firstBrace = trimmed.indexOf("{");
const lastBrace = trimmed.lastIndexOf("}");
const synthResult = JSON.parse(trimmed.slice(firstBrace, lastBrace + 1));
const mechVerdict = synthResult.verdict;
if (mechVerdict === "ITERATE" || mechVerdict === "FAIL") {
console.log(JSON.stringify({
error: `Cannot force-pass: upstream verdict is ${mechVerdict}. Use /opc skip instead.`,
allowed: false,
}));
return;
}
} catch (err) {
// synthesize fails when no eval files exist yet — allow pass
// But propagate unexpected errors (e.g. synthesize crashed)
const stderr = err.stderr?.toString() || "";
const stdout = err.stdout?.toString() || "";
const combined = stderr + stdout;
// "no eval" / "no artifact" / "not found" patterns indicate no evals exist — safe to pass
if (!/no eval|no artifact|not found|does not exist|no runs/i.test(combined)) {
console.log(JSON.stringify({
error: `synthesize failed unexpectedly: ${stderr || err.message}`,
allowed: false,
}));
return;
}
// No evals yet — allow pass through
}
}
}
// Delegate to cmdTransition (which has its own locking)

@@ -245,2 +297,3 @@ const transArgs = ["--from", current, "--to", next, "--verdict", "PASS", "--flow", templateName, "--dir", dir];

maxTotalSteps: state.maxTotalSteps ?? template.limits.maxTotalSteps,
maxLoopsPerEdge: state.maxLoopsPerEdge ?? template.limits.maxLoopsPerEdge,
};

@@ -251,2 +304,8 @@ if (state.totalSteps >= limits.maxTotalSteps) {

}
const edgeKey = `${state.currentNode}→${targetNode}`;
const edgeCount = state.edgeCounts?.[edgeKey] || 0;
if (edgeCount >= limits.maxLoopsPerEdge) {
console.log(JSON.stringify({ error: `maxLoopsPerEdge (${limits.maxLoopsPerEdge}) reached for '${edgeKey}' — cannot goto` }));
return;
}
const nodeEntries = state.history.filter(h => h.nodeId === targetNode).length;

@@ -262,2 +321,4 @@ if (nodeEntries >= limits.maxNodeReentry) {

state.totalSteps++;
if (!state.edgeCounts) state.edgeCounts = {};
state.edgeCounts[edgeKey] = (state.edgeCounts[edgeKey] || 0) + 1;
state._written_by = WRITER_SIG;

@@ -264,0 +325,0 @@ state._last_modified = new Date().toISOString();

@@ -9,4 +9,4 @@ // Flow graph definitions — nodes, edges, limits per template

// Harness version — used for opc_compat checking
export const HARNESS_VERSION = "0.9.0";
// Harness compatibility version — minor bumps signal external flow ABI breaks.
export const HARNESS_VERSION = "0.10.0";

@@ -49,2 +49,3 @@ export const FLOW_TEMPLATES = {

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

@@ -87,2 +88,3 @@ },

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

@@ -89,0 +91,0 @@ acceptance: ["visual-consistency-check@1", "user-simulation@1"],

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

import { join, dirname } from "path";
import { fileURLToPath } from "url";
import os from "os";
import { execFileSync } from "child_process";
import { FLOW_TEMPLATES, resolveFlowTemplate, loadFlowFromFile } from "./flow-templates.mjs";

@@ -23,11 +25,30 @@ import { validateHandshakeData } from "./flow-core.mjs";

const from = getFlag(args, "from");
const to = getFlag(args, "to");
const toRaw = getFlag(args, "to");
const verdict = getFlag(args, "verdict");
const dir = resolveDir(args);
if (!from || !to || !verdict) {
console.error("Usage: opc-harness transition --from <node> --to <node> --verdict <V> --flow <template> [--flow-file <path>] --dir <path>");
// Normalize: CLI "--to null" arrives as string "null" — treat as JS null (terminal transition)
const to = toRaw === "null" ? null : toRaw;
if (!from || !verdict) {
console.error("Usage: opc-harness transition --from <node> --to <node|null> --verdict <V> --flow <template> [--flow-file <path>] --dir <path>");
process.exit(1);
}
// Terminal transition (to === null): delegate to finalize
if (to === null) {
// Verify the edge actually goes to null in the template
const resolvedTpl = resolveFlowTemplate(args);
if (!resolvedTpl.error) {
const edges = resolvedTpl.template.edges[from];
if (edges && edges[verdict] === null) {
// Valid terminal edge — run finalize instead
cmdFinalize(args);
return;
}
}
console.log(JSON.stringify({ allowed: false, reason: `no terminal edge '${from}' --${verdict}--> null` }));
return;
}
// Try to load _flow_file from existing state before resolving template

@@ -169,2 +190,65 @@ const statePath = join(dir, "flow-state.json");

// ── OUT-2: Mandatory role enforcement when transitioning from review nodes ──
if (!isGate && fromNodeType === "review") {
const rolesDir = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "roles");
// roles directory is part of the package — if missing, something is very wrong
let roleFiles;
try {
roleFiles = readdirSync(rolesDir).filter(f => f.endsWith(".md"));
} catch (err) {
console.log(JSON.stringify({
allowed: false,
reason: `cannot read roles directory '${rolesDir}': ${err.message} — package may be corrupted`,
}));
return;
}
const mandatoryRoles = [];
for (const rf of roleFiles) {
const rawContent = readFileSync(join(rolesDir, rf), "utf8");
const content = rawContent.replace(/\r\n/g, "\n");
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
if (fmMatch) {
const fm = fmMatch[1];
if (/mandatory:\s*true/i.test(fm)) {
mandatoryRoles.push(rf.replace(/\.md$/, ""));
}
}
}
if (mandatoryRoles.length > 0) {
const fromHandshakePath = join(dir, "nodes", from, "handshake.json");
if (existsSync(fromHandshakePath)) {
const hsData = JSON.parse(readFileSync(fromHandshakePath, "utf8"));
const evalArtifacts = (hsData.artifacts || []).filter(a => a.type === "eval" || a.type === "evaluation");
// Review nodes MUST have eval artifacts
if (evalArtifacts.length === 0) {
console.log(JSON.stringify({
allowed: false,
reason: `review node '${from}' has no eval-type artifacts — review nodes must produce evaluations`,
}));
return;
}
const allKnownRoles = new Set(roleFiles.map(f => f.replace(/\.md$/, "")));
const presentRoles = new Set();
for (const a of evalArtifacts) {
const match = a.path.match(/eval-([^/]+)\.md$/);
if (match) presentRoles.add(match[1]);
}
// Enforce mandatory roles when ANY present role is a known role from roles/ dir
// (skip enforcement only when ALL roles are custom/test — no overlap with roles/ at all)
const hasAnyKnownRole = [...presentRoles].some(r => allKnownRoles.has(r));
if (hasAnyKnownRole) {
const missingRoles = mandatoryRoles.filter(r => !presentRoles.has(r));
if (missingRoles.length > 0) {
console.log(JSON.stringify({
allowed: false,
error: `Missing mandatory role evaluations: [${missingRoles.join(", ")}]. Review node must include all mandatory roles.`,
missingRoles,
}));
return;
}
}
}
}
}
// ── Idempotency guard ──

@@ -402,2 +486,153 @@ if (state.history.length > 0) {

// ─── advance ──────────────────────────────────────────────────
// One-click gate advancement: synthesize upstream → route → transition/finalize.
export function cmdAdvance(args) {
const dir = resolveDir(args);
const statePath = join(dir, "flow-state.json");
if (!existsSync(statePath)) {
console.log(JSON.stringify({ advanced: false, error: "flow-state.json not found" }));
return;
}
let state;
try {
state = JSON.parse(readFileSync(statePath, "utf8"));
} catch (err) {
console.log(JSON.stringify({ advanced: false, error: `corrupt flow-state.json: ${err.message}` }));
return;
}
// Resolve template
if (state._flow_file) loadFlowFromFile(state._flow_file);
const template = FLOW_TEMPLATES[state.flowTemplate];
if (!template) {
console.log(JSON.stringify({ advanced: false, error: `unknown flow template: ${state.flowTemplate}` }));
return;
}
const currentNode = state.currentNode;
const nodeType = template.nodeTypes?.[currentNode] ||
(currentNode === "gate" || currentNode.startsWith("gate-") ? "gate" : null);
if (nodeType !== "gate") {
console.log(JSON.stringify({
advanced: false,
error: `advance only works on gate nodes, current is '${currentNode}' (type: ${nodeType || "unknown"})`,
}));
return;
}
// Find upstream node: last non-gate entry in history
const upstreamEntry = [...state.history].reverse().find(h => {
const nt = template.nodeTypes?.[h.nodeId];
return nt && nt !== "gate";
});
if (!upstreamEntry) {
console.log(JSON.stringify({ advanced: false, error: "cannot find upstream non-gate node in history" }));
return;
}
const upstreamNode = upstreamEntry.nodeId;
// Find the harness binary path (same dir as this module)
const harnessPath = join(dirname(fileURLToPath(import.meta.url)), "..", "opc-harness.mjs");
// Step 1: synthesize
console.error(`[advance] synthesizing ${upstreamNode}...`);
let synthOutput;
try {
synthOutput = execFileSync(
"node",
[harnessPath, "synthesize", "--node", upstreamNode, "--dir", dir],
{ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }
);
} catch (err) {
console.log(JSON.stringify({
advanced: false,
error: `synthesize failed: ${err.stderr || err.message}`,
step: "synthesize",
}));
return;
}
let synthResult;
try {
synthResult = JSON.parse(synthOutput.trim().split("\n").pop());
} catch {
synthResult = {};
}
const verdict = synthResult.verdict || "PASS";
console.error(`[advance] verdict: ${verdict}`);
// Step 2: route
console.error(`[advance] routing ${currentNode} --${verdict}-->...`);
let routeOutput;
try {
const routeArgs = [harnessPath, "route", "--node", currentNode, "--verdict", verdict, "--flow", state.flowTemplate];
if (state._flow_file) routeArgs.push("--flow-file", state._flow_file);
routeArgs.push("--dir", dir);
routeOutput = execFileSync(
"node",
routeArgs,
{ encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] }
);
} catch (err) {
console.log(JSON.stringify({
advanced: false,
error: `route failed: ${err.stderr || err.message}`,
step: "route",
}));
return;
}
let routeResult;
try {
routeResult = JSON.parse(routeOutput.trim());
} catch {
console.log(JSON.stringify({ advanced: false, error: `route output not JSON: ${routeOutput}`, step: "route" }));
return;
}
if (!routeResult.valid) {
console.log(JSON.stringify({ advanced: false, error: `route invalid: ${routeResult.error}`, step: "route" }));
return;
}
const next = routeResult.next;
console.error(`[advance] next: ${next === null ? "null (terminal)" : next}`);
// Step 3: transition (or finalize if terminal)
const toArg = next === null ? "null" : next;
console.error(`[advance] transitioning ${currentNode} → ${toArg}...`);
try {
const transArgs = [harnessPath, "transition", "--from", currentNode, "--to", toArg, "--verdict", verdict, "--flow", state.flowTemplate];
if (state._flow_file) transArgs.push("--flow-file", state._flow_file);
transArgs.push("--dir", dir);
const transOutput = execFileSync(
"node",
transArgs,
{ encoding: "utf8", stdio: ["pipe", "pipe", "inherit"] }
);
let transResult;
try { transResult = JSON.parse(transOutput.trim().split("\n").pop()); } catch { transResult = {}; }
console.log(JSON.stringify({
advanced: true,
verdict,
upstream: upstreamNode,
next,
transition: transResult,
}));
} catch (err) {
console.log(JSON.stringify({
advanced: false,
error: `transition failed: ${err.stderr || err.message}`,
step: "transition",
}));
}
}
// ─── finalize ──────────────────────────────────────────────────

@@ -404,0 +639,0 @@

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

import { getFlag, resolveDir, atomicWriteSync, WRITER_SIG } from "./util.mjs";
import { lockFile } from "./file-lock.mjs";
import { FLOW_TEMPLATES, loadFlowFromFile } from "./flow-templates.mjs";

@@ -114,2 +115,9 @@

const lock = lockFile(statePath, { command: "next-tick" });
if (!lock.acquired) {
console.log(JSON.stringify({ ready: false, terminate: false, reason: "could not acquire lock on loop-state.json", holder: lock.holder }));
return;
}
try {
let state;

@@ -149,2 +157,41 @@ try {

if (state.status === "in_progress") {
// Crash recovery: if in_progress for longer than timeout, auto-stall
const rawTimeout = Number(process.env.OPC_TICK_TIMEOUT_HOURS) || 1;
const timeoutHours = Math.max(rawTimeout, 0.1); // 6-minute floor
const sinceRaw = state._in_progress_since || state._last_modified;
// Validate timestamp: must be parseable, in the past, not older than 30 days
let since = null;
if (sinceRaw) {
const sinceMs = new Date(sinceRaw).getTime();
const now = Date.now();
if (!Number.isNaN(sinceMs) && sinceMs <= now && sinceMs > now - 30 * 24 * 3600000) {
since = sinceRaw;
} else {
// Invalid/future/ancient timestamp — treat as missing, warn
since = state._last_modified; // fallback
}
}
if (since) {
const age = Date.now() - new Date(since).getTime();
const timeoutMs = timeoutHours * 3600000;
if (age > timeoutMs) {
state.status = "stalled";
state._stall_reason = `in_progress for ${(age / 3600000).toFixed(1)}h (timeout: ${timeoutHours}h) — auto-recovered from suspected crash`;
state._written_by = WRITER_SIG;
state._last_modified = new Date().toISOString();
atomicWriteSync(statePath, JSON.stringify(state, null, 2) + "\n");
console.log(JSON.stringify({
ready: false,
terminate: true,
reason: `in_progress stale for ${(age / 3600000).toFixed(1)}h — auto-recovered to stalled. Pipeline terminated due to suspected agent crash.`,
recovered_from: "in_progress_timeout",
stall_reason: state._stall_reason,
status: "stalled",
detail: `Unit was marked in_progress ${(age / 3600000).toFixed(1)}h ago (timeout: ${timeoutHours}h). The agent likely crashed or lost context without completing the tick.`,
hint: "resume with next-tick after investigating why the previous agent session ended, or use reinit-loop to restart the stalled unit",
}));
return;
}
}
// Normal case: still within timeout, skip this cron fire
console.log(JSON.stringify({

@@ -178,2 +225,5 @@ ready: false,

total_ticks: state.tick,
status: "terminated",
detail: `Pipeline consumed all ${maxTicks} allowed ticks (${state.tick} completed) without finishing. This usually means units are being retried too many times.`,
hint: "increase _max_total_ticks in loop-state.json, or decompose remaining units into smaller steps via reinit-loop",
}));

@@ -198,2 +248,5 @@ return;

elapsed_hours: parseFloat(elapsed.toFixed(1)),
status: "terminated",
detail: `Pipeline ran for ${elapsed.toFixed(1)}h exceeding the ${state._max_duration_hours}h wall-clock limit. Current tick: ${state.tick}.`,
hint: "increase _max_duration_hours in loop-state.json if the task genuinely needs more time, or investigate why progress stalled",
}));

@@ -321,2 +374,3 @@ return;

state.status = "in_progress";
state._in_progress_since = new Date().toISOString();
state._written_by = WRITER_SIG;

@@ -371,2 +425,6 @@ state._last_modified = new Date().toISOString();

}));
} finally {
lock.release();
}
}

@@ -377,22 +435,24 @@

function checkStall(state, history, statePath) {
if (history.length >= 2) {
const last2 = history.slice(-2);
if (last2[0].unit === last2[1].unit) {
if (history.length >= 3) {
const last3 = history.slice(-3);
if (last3[0].unit === last3[1].unit && last3[1].unit === last3[2].unit) {
state.status = "stalled";
state.description = `Stalled on unit '${last3[0].unit}' for 3 consecutive ticks`;
state._written_by = WRITER_SIG;
state._last_modified = new Date().toISOString();
atomicWriteSync(statePath, JSON.stringify(state, null, 2) + "\n");
if (history.length >= 3) {
const last3 = history.slice(-3);
// Only stall if same unit AND none of the 3 ticks succeeded
if (last3[0].unit === last3[1].unit && last3[1].unit === last3[2].unit) {
const anySuccess = last3.some(t => t.status === "completed" && t.verdict !== "FAIL");
if (!anySuccess) {
state.status = "stalled";
state.description = `Stalled on unit '${last3[0].unit}' for 3 consecutive ticks`;
state._written_by = WRITER_SIG;
state._last_modified = new Date().toISOString();
atomicWriteSync(statePath, JSON.stringify(state, null, 2) + "\n");
console.log(JSON.stringify({
ready: false,
terminate: true,
reason: `\u26d4 stalled on unit '${last3[0].unit}' for 3 ticks — needs human input`,
stalled_unit: last3[0].unit,
}));
return true;
}
console.log(JSON.stringify({
ready: false,
terminate: true,
reason: `\u26d4 stalled on unit '${last3[0].unit}' for 3 ticks — needs human input`,
stalled_unit: last3[0].unit,
status: "stalled",
detail: `Unit '${last3[0].unit}' has been attempted 3 consecutive times without advancing. The agent may be stuck in a loop or encountering a persistent blocker.`,
hint: "use reinit-loop to decompose the stalled unit into smaller steps, or manually resolve the blocker and restart",
}));
return true;
}

@@ -423,2 +483,5 @@ }

stalled_units: [last6[0].unit, last6[1].unit],
status: "stalled",
detail: `Units '${last6[0].unit}' and '${last6[1].unit}' are oscillating back and forth without convergence. This typically means a review keeps failing the same implementation.`,
hint: "break the cycle by: (1) merging the two units, (2) adding an intermediate unit, or (3) relaxing the review criteria via reinit-loop",
}));

@@ -453,3 +516,3 @@ return true;

`## Project`,
`- Working directory: ${process.cwd()}`,
`- Working directory: ${state.projectDir || process.cwd()}`,
`- Loop directory: ${dir}`,

@@ -480,2 +543,16 @@ "",

// Inject recent technical deltas for cross-tick context
const recentDeltas = (state._tick_history || [])
.filter(t => t.delta)
.slice(-3)
.map(t => `- **${t.unit}**: ${t.delta}`)
.join("\n");
if (recentDeltas) {
parts.push(
`## Recent Technical Decisions`,
recentDeltas,
"",
);
}
parts.push(

@@ -486,3 +563,3 @@ `## Instructions`,

`3. Execute unit ${state.next_unit} using /opc with the ${contextHints.recommended_flow} flow`,
`4. After completion, run: opc-harness complete-tick --unit ${state.next_unit} --artifacts <paths> --description "<summary>"`,
`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`,

@@ -489,0 +566,0 @@ );

@@ -5,2 +5,3 @@ // Shared helpers for loop commands: plan parsing, git detection, hashing

import { readFileSync, existsSync } from "fs";
import { join } from "path";
import { createHash } from "crypto";

@@ -146,5 +147,7 @@ import { execFileSync } from "child_process";

export function getGitHeadHash() {
export function getGitHeadHash(projectDir) {
try {
return execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8", timeout: 5000 }).trim();
const opts = { encoding: "utf8", timeout: 5000 };
if (projectDir) opts.cwd = projectDir;
return execFileSync("git", ["rev-parse", "HEAD"], opts).trim();
} catch {

@@ -155,7 +158,8 @@ return null;

export function detectPreCommitHooks() {
export function detectPreCommitHooks(projectDir) {
const base = projectDir || process.cwd();
const indicators = [
".husky/pre-commit",
".git/hooks/pre-commit",
".pre-commit-config.yaml",
join(base, ".husky/pre-commit"),
join(base, ".git/hooks/pre-commit"),
join(base, ".pre-commit-config.yaml"),
];

@@ -165,10 +169,12 @@ return indicators.some(p => existsSync(p));

export function detectTestScript() {
export function detectTestScript(projectDir) {
try {
const pkg = JSON.parse(readFileSync("package.json", "utf8"));
const pkgPath = projectDir ? join(projectDir, "package.json") : "package.json";
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
const scripts = pkg.scripts || {};
// Return actual command strings (for execution) or false
return {
test: !!scripts.test,
lint: !!scripts.lint || !!scripts.eslint,
typecheck: !!scripts.typecheck || !!scripts["type-check"] || !!scripts.tsc,
test: scripts.test ? `npm run test` : false,
lint: scripts.lint ? `npm run lint` : (scripts.eslint ? `npm run eslint` : false),
typecheck: scripts.typecheck ? `npm run typecheck` : (scripts["type-check"] ? `npm run type-check` : (scripts.tsc ? `npm run tsc` : false)),
};

@@ -175,0 +181,0 @@ } catch {

// Loop init command: init-loop
// Depends on: loop-helpers.mjs, util.mjs
import { readFileSync, existsSync, mkdirSync } from "fs";
import { readFileSync, existsSync, mkdirSync, statSync } from "fs";
import { join, resolve } from "path";

@@ -23,3 +23,56 @@ import { createHash } from "crypto";

const skipLint = args.includes("--skip-lint");
const projectDirRaw = getFlag(args, "project-dir", null);
// Resolve and validate projectDir
let projectDir = null;
if (projectDirRaw) {
projectDir = resolve(projectDirRaw);
if (!existsSync(projectDir)) {
console.log(JSON.stringify({
initialized: false,
errors: [`--project-dir path does not exist: ${projectDir}`],
status: "invalid_config",
detail: `The path '${projectDir}' passed via --project-dir does not exist on the filesystem.`,
hint: "verify the path exists and is accessible, or omit --project-dir to use the current working directory",
}));
return;
}
if (!statSync(projectDir).isDirectory()) {
console.log(JSON.stringify({
initialized: false,
errors: [`--project-dir path is not a directory: ${projectDir}`],
status: "invalid_config",
detail: `The path '${projectDir}' exists but is not a directory.`,
hint: "pass a directory path, not a file path",
}));
return;
}
}
// ── G0: Recon file gate ─────────────────────────────────────────
const reconFile = getFlag(args, "recon", null);
if (reconFile) {
if (!existsSync(reconFile)) {
console.log(JSON.stringify({
initialized: false,
errors: [`recon file not found: ${reconFile} — run codebase reconnaissance before planning`],
status: "missing_recon",
detail: `The recon file '${reconFile}' does not exist. Codebase reconnaissance must be completed before planning.`,
hint: "write a recon summary (directory structure, existing tests, current implementation) to a file and pass its path via --recon",
}));
return;
}
const reconSize = readFileSync(reconFile, "utf8").length;
if (reconSize < 200) {
console.log(JSON.stringify({
initialized: false,
errors: [`recon file too small (${reconSize} chars, need ≥200) — a meaningful recon must describe the existing codebase`],
status: "insufficient_recon",
detail: `Recon file is only ${reconSize} characters (minimum 200). A meaningful recon must describe the existing codebase structure.`,
hint: "include: directory layout, existing tests, relevant source files, what's already implemented",
}));
return;
}
}
if (!existsSync(planFile)) {

@@ -29,2 +82,5 @@ console.log(JSON.stringify({

errors: [`plan file not found: ${planFile}`],
status: "missing_plan",
detail: `Expected plan file at '${planFile}' but it does not exist.`,
hint: "create plan.md with unit definitions (e.g. '- F1.1: implement — description') or pass --plan <path>",
}));

@@ -41,2 +97,5 @@ return;

errors: ["no units found in plan — expected lines like '- F1.1: spec — description'"],
status: "invalid_plan",
detail: "Plan file was found but contains no parseable unit definitions.",
hint: "each unit must match pattern: '- ID: type — description' (e.g. '- F1.1: implement — add login form')",
}));

@@ -70,2 +129,5 @@ return;

errors: ["loop-state.json already exists and is active — use next-tick to advance or delete to restart"],
status: "active_loop_exists",
detail: `An active loop (status: '${existing.status}', tick: ${existing.tick || 0}) already exists in this directory.`,
hint: "run next-tick to continue the existing loop, or delete loop-state.json to start fresh",
}));

@@ -82,2 +144,4 @@ return;

units: units.map(u => `${u.id}: ${u.type}`),
status: "invalid_plan_structure",
detail: `Plan has ${structureErrors.length} structural error(s): implement/build units must be followed by review units.`,
hint: "every implement/build unit must be followed by a review unit before the next implement",

@@ -95,2 +159,4 @@ }));

errors: ["plan.md has no '## Task Scope' section with SCOPE-N items — every plan must declare what the original task requires so the harness can verify coverage at pipeline end"],
status: "missing_scope",
detail: "Plan file lacks a '## Task Scope' section. The harness uses scope items to verify all requirements are covered at pipeline completion.",
hint: "add '## Task Scope' with '- SCOPE-1: ...' items, or pass --skip-scope to bypass",

@@ -116,2 +182,4 @@ }));

errors: lintResult.failures.map(f => `criteria-lint [${f.check}]: ${f.message}`),
status: "criteria_lint_failed",
detail: `acceptance-criteria.md failed ${lintResult.failures.length} lint check(s). The criteria are not mechanically valid.`,
hint: "fix acceptance-criteria.md or pass --skip-lint to bypass",

@@ -158,3 +226,4 @@ }));

_last_modified: new Date().toISOString(),
_git_head: getGitHeadHash(),
_git_head: getGitHeadHash(projectDir),
projectDir: projectDir || undefined,
_tick_history: [],

@@ -177,7 +246,7 @@ _max_total_ticks: units.length * 3,

} else {
console.log(JSON.stringify({ initialized: false, errors: ["--handlers must be a JSON object"] }));
console.log(JSON.stringify({ initialized: false, errors: ["--handlers must be a JSON object"], status: "invalid_config", detail: "--handlers value parsed as JSON but is not an object (got array or primitive).", hint: "pass a JSON object mapping unit types to handler commands, e.g. '{\"implement\":\"skill:build\"}'" }));
return;
}
} catch (e) {
console.log(JSON.stringify({ initialized: false, errors: [`--handlers is not valid JSON: ${e.message}`] }));
console.log(JSON.stringify({ initialized: false, errors: [`--handlers is not valid JSON: ${e.message}`], status: "invalid_config", detail: `--handlers value could not be parsed as JSON: ${e.message}`, hint: "ensure the value is valid JSON, properly quoted for your shell" }));
return;

@@ -191,4 +260,4 @@ }

const hasHooks = detectPreCommitHooks();
const testScripts = detectTestScript();
const hasHooks = detectPreCommitHooks(projectDir);
const testScripts = detectTestScript(projectDir);
state._external_validators = {

@@ -195,0 +264,0 @@ pre_commit_hooks: hasHooks,

// Loop tick completion command: complete-tick
// Depends on: loop-helpers.mjs, util.mjs
import { readFileSync, appendFileSync, existsSync, statSync, writeFileSync } from "fs";
import { join } from "path";
import { readFileSync, appendFileSync, existsSync, statSync, writeFileSync, mkdirSync } from "fs";
import { join, dirname } from "path";
import { execFileSync } from "child_process";
import { parsePlan, hashContent, getGitHeadHash, checkScopeCoverage } from "./loop-helpers.mjs";
import { getFlag, resolveDir, atomicWriteSync, WRITER_SIG } from "./util.mjs";
import { getFlag, resolveDir, atomicWriteSync, WRITER_SIG, TERMINAL_LOOP_STATUSES } from "./util.mjs";
import { lockFile } from "./file-lock.mjs";
import { checkEvalDistinctness, parseEvaluation } from "./eval-parser.mjs";

@@ -19,2 +20,3 @@

const status = getFlag(args, "status", "completed");
const delta = getFlag(args, "delta", "");

@@ -44,2 +46,9 @@ const VALID_TICK_STATUSES = new Set(["completed", "blocked", "failed"]);

const lock = lockFile(statePath, { command: "complete-tick" });
if (!lock.acquired) {
console.log(JSON.stringify({ completed: false, errors: ["could not acquire lock on loop-state.json", lock.holder ? `held by: ${lock.holder.command || lock.holder.pid}` : ""].filter(Boolean) }));
return;
}
try {
let state;

@@ -56,4 +65,11 @@ try {

// Rule 7: terminated pipeline
if (state.status === "pipeline_complete" || state.status === "terminated" || state.status === "stalled") {
console.log(JSON.stringify({ completed: false, errors: [`loop is '${state.status}' — cannot complete ticks on a terminated pipeline`] }));
if (TERMINAL_LOOP_STATUSES.has(state.status)) {
console.log(JSON.stringify({
completed: false,
errors: [`loop is '${state.status}' — cannot complete ticks on a terminated pipeline`],
status: "terminal",
reason: `pipeline is in terminal status '${state.status}'`,
detail: `The loop entered '${state.status}' state and cannot accept new tick completions. This is a permanent state.`,
hint: "re-initialize the loop with init-loop to start a fresh pipeline, or use reinit-loop to decompose stalled units",
}));
return;

@@ -97,7 +113,7 @@ }

if (unitType.startsWith("implement") || unitType.startsWith("build")) {
validateImplementArtifacts(unit, unitType, artifacts, errors, warnings, state);
validateImplementArtifacts(unit, unitType, artifacts, errors, warnings, state, dir);
} else if (unitType.startsWith("review")) {
reviewVerdict = validateReviewArtifacts(unit, artifacts, errors, warnings, state);
} else if (unitType.startsWith("fix")) {
validateFixArtifacts(unit, artifacts, errors, warnings, state);
validateFixArtifacts(unit, artifacts, errors, warnings, state, dir);
} else if (unitType.startsWith("e2e") || unitType.startsWith("accept") || unitType.startsWith("ux-sim")) {

@@ -173,6 +189,6 @@ if (artifacts.length === 0) {

state._last_modified = new Date().toISOString();
state._git_head = getGitHeadHash();
state._git_head = getGitHeadHash(state.projectDir);
if (!Array.isArray(state._tick_history)) state._tick_history = [];
state._tick_history.push({ unit, tick: newTick, status, verdict: reviewVerdict, description: description || undefined });
state._tick_history.push({ unit, tick: newTick, status, verdict: reviewVerdict, description: description || undefined, delta: delta || undefined });

@@ -203,2 +219,3 @@ atomicWriteSync(statePath, JSON.stringify(state, null, 2) + "\n");

state,
delta,
}, warnings);

@@ -216,2 +233,6 @@

}));
} finally {
lock.release();
}
}

@@ -223,2 +244,57 @@

function _runTestScript(cmd, loopDir, tick, projectDir, label = "test") {
const evidenceDir = join(loopDir, "evidence");
mkdirSync(evidenceDir, { recursive: true });
const logPath = join(evidenceDir, `tick-${tick + 1}-${label}.log`);
// Resolve project root: use explicit projectDir, or walk up from loopDir
let projectRoot = projectDir || loopDir;
if (!projectDir) {
let d = loopDir;
while (d !== dirname(d)) {
if (existsSync(join(d, "package.json")) || existsSync(join(d, ".git"))) {
projectRoot = d;
break;
}
d = dirname(d);
}
}
let stdout = "", stderr = "", exitCode = 0, timedOut = false;
try {
stdout = execFileSync("sh", ["-c", cmd], {
cwd: projectRoot,
encoding: "utf8",
timeout: 120_000,
maxBuffer: 5 * 1024 * 1024,
});
} catch (err) {
if (err.killed || err.signal === "SIGTERM") {
timedOut = true;
exitCode = 124; // conventional timeout exit code (like GNU timeout)
} else {
exitCode = err.status || 1;
}
stdout = err.stdout || "";
stderr = err.stderr || "";
}
const log = [
`# Harness-owned test execution`,
`# Command: ${cmd}`,
`# CWD: ${projectRoot}`,
`# Exit code: ${exitCode}`,
timedOut ? `# TIMED OUT after 120s` : "",
`# Timestamp: ${new Date().toISOString()}`,
``,
`--- stdout ---`,
stdout,
`--- stderr ---`,
stderr,
].filter(Boolean).join("\n");
try { writeFileSync(logPath, log); } catch { /* best effort */ }
return { exitCode, logPath, timedOut };
}
function _checkArtifactSize(a, errors) {

@@ -233,3 +309,3 @@ const sz = statSync(a).size;

function validateImplementArtifacts(unit, unitType, artifacts, errors, warnings, state) {
function validateImplementArtifacts(unit, unitType, artifacts, errors, warnings, state, dir) {
if (artifacts.length === 0) {

@@ -294,3 +370,3 @@ errors.push(`implement unit '${unit}' has no artifacts — must have test evidence`);

// Rule 3: atomic commit
const currentHead = getGitHeadHash();
const currentHead = getGitHeadHash(state.projectDir);
if (currentHead && state._git_head && currentHead === state._git_head) {

@@ -307,3 +383,3 @@ errors.push(`git HEAD unchanged since last tick — implement unit must produce a commit`);

try {
const diffStat = execFileSync("git", ["diff", "--name-only", `${state._git_head}..${currentHead}`], { encoding: "utf8", timeout: 5000 }).trim();
const diffStat = execFileSync("git", ["diff", "--name-only", `${state._git_head}..${currentHead}`], { encoding: "utf8", timeout: 5000, cwd: state.projectDir || undefined }).trim();
const changedFiles = diffStat.split("\n").filter(Boolean);

@@ -320,3 +396,3 @@ const substantiveFiles = changedFiles.filter(f => !f.endsWith(".gitkeep") && !f.endsWith(".keep"));

// Rule 9: external validator enforcement
// Rule 9: external validator enforcement — harness-owned test execution
if (state._external_validators) {

@@ -327,17 +403,37 @@ if (!state._external_validators.pre_commit_hooks) {

if (state._external_validators.test_script) {
// Check if any artifact mentions test runner output markers
// Tightened regex: require numeric context to avoid false positives on "password", "failover" etc.
const testRunnerMarkers = /\d+\s*tests?\s*(passed|failed|run)|suites?\s*\d|specs?\s*\d|\d+\s*passing|\d+\s*failing|tests?\s*passed|test result|✓\s*\d|✗\s*\d|✘\s*\d|\d+\s*assertions?/i;
const hasTestEvidence = artifacts.some(a => {
if (!existsSync(a)) return false;
try {
const content = readFileSync(a, "utf8");
return testRunnerMarkers.test(content);
} catch { return false; }
});
if (!hasTestEvidence) {
warnings.push(`test_script '${state._external_validators.test_script}' detected but no artifact contains test runner output — did you run tests?`);
const testResult = _runTestScript(state._external_validators.test_script, dir, state.tick || 0, state.projectDir);
if (testResult.exitCode !== 0) {
const reason = testResult.timedOut ? "TIMED OUT (120s)" : `exit ${testResult.exitCode}`;
errors.push(`test_script '${state._external_validators.test_script}' failed (${reason}) — implement unit must pass tests. Log: ${testResult.logPath}`);
} else {
warnings.push(`test_script passed (exit 0). Log: ${testResult.logPath}`);
}
}
if (state._external_validators.lint_script) {
const lintResult = _runTestScript(state._external_validators.lint_script, dir, state.tick || 0, state.projectDir, "lint");
if (lintResult.exitCode !== 0) {
const reason = lintResult.timedOut ? "TIMED OUT (120s)" : `exit ${lintResult.exitCode}`;
errors.push(`lint_script '${state._external_validators.lint_script}' failed (${reason}) — code must pass lint. Log: ${lintResult.logPath}`);
}
}
if (state._external_validators.typecheck_script) {
const tcResult = _runTestScript(state._external_validators.typecheck_script, dir, state.tick || 0, state.projectDir, "typecheck");
if (tcResult.exitCode !== 0) {
const reason = tcResult.timedOut ? "TIMED OUT (120s)" : `exit ${tcResult.exitCode}`;
errors.push(`typecheck_script '${state._external_validators.typecheck_script}' failed (${reason}) — code must pass type checking. Log: ${tcResult.logPath}`);
}
}
}
// Rule 10: evidence timestamp freshness — artifacts must be newer than tick start
const tickStart = state._last_modified ? new Date(state._last_modified).getTime() : 0;
if (tickStart > 0) {
for (const a of artifacts) {
if (!existsSync(a)) continue;
const mtime = statSync(a).mtimeMs;
if (mtime < tickStart) {
errors.push(`artifact '${a}' is stale (mtime ${new Date(mtime).toISOString()} < tick start ${state._last_modified}) — evidence must be produced during this tick, not reused from a prior run`);
}
}
}
}

@@ -408,3 +504,3 @@

function validateFixArtifacts(unit, artifacts, errors, warnings, state) {
function validateFixArtifacts(unit, artifacts, errors, warnings, state, dir) {
// Rule 5: verify eval file integrity from previous review

@@ -441,6 +537,31 @@ if (state._last_review_evals && typeof state._last_review_evals === "object") {

// Rule 3: fix should also commit
const currentHead = getGitHeadHash();
const currentHead = getGitHeadHash(state.projectDir);
if (currentHead && state._git_head && currentHead === state._git_head) {
errors.push(`git HEAD unchanged — fix unit must produce a commit`);
}
// Rule 9: fix must also pass tests + lint + typecheck
if (state._external_validators) {
if (state._external_validators.test_script) {
const testResult = _runTestScript(state._external_validators.test_script, dir, state.tick || 0, state.projectDir);
if (testResult.exitCode !== 0) {
const reason = testResult.timedOut ? "TIMED OUT (120s)" : `exit ${testResult.exitCode}`;
errors.push(`test_script '${state._external_validators.test_script}' failed (${reason}) — fix unit must pass tests. Log: ${testResult.logPath}`);
}
}
if (state._external_validators.lint_script) {
const lintResult = _runTestScript(state._external_validators.lint_script, dir, state.tick || 0, state.projectDir, "lint");
if (lintResult.exitCode !== 0) {
const reason = lintResult.timedOut ? "TIMED OUT (120s)" : `exit ${lintResult.exitCode}`;
errors.push(`lint_script '${state._external_validators.lint_script}' failed (${reason}) — fix must pass lint. Log: ${lintResult.logPath}`);
}
}
if (state._external_validators.typecheck_script) {
const tcResult = _runTestScript(state._external_validators.typecheck_script, dir, state.tick || 0, state.projectDir, "typecheck");
if (tcResult.exitCode !== 0) {
const reason = tcResult.timedOut ? "TIMED OUT (120s)" : `exit ${tcResult.exitCode}`;
errors.push(`typecheck_script '${state._external_validators.typecheck_script}' failed (${reason}) — fix must pass type checking. Log: ${tcResult.logPath}`);
}
}
}
}

@@ -533,3 +654,3 @@

tick, unit, unitType, status, description,
verdict, artifacts, nextUnit, planFile, allUnits, state,
verdict, artifacts, nextUnit, planFile, allUnits, state, delta,
} = ctx;

@@ -573,2 +694,4 @@

"",
delta ? `## Technical Delta\n${delta}` : "",
"",
`## Recent History`,

@@ -575,0 +698,0 @@ prevTicks || " (first tick)",

// 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 } from "fs";
import { resolve, join } from "path";
import { writeFileSync, renameSync, symlinkSync, unlinkSync, readlinkSync, existsSync, mkdirSync, readdirSync, statSync, rmSync, realpathSync } from "fs";
import { resolve, join, dirname } from "path";
import { createHash, randomBytes } from "crypto";
import { homedir } from "os";
import { execSync } from "child_process";

@@ -34,8 +35,15 @@ // ── CLI flag parsing ────────────────────────────────────────────

} else {
console.error("ERROR: No active session found. Run `opc-harness init` first.");
const cwd = process.cwd();
const hash = getProjectHash(cwd);
const base = getSessionsBaseDir(cwd);
console.error(`ERROR: No active session found for cwd '${cwd}' (hash: ${hash}).`);
console.error(` Looked in: ${base}/`);
console.error(` Tip: use --dir <path> to target an existing session, or run 'opc-harness ls' to list all.`);
process.exit(1);
}
}
const resolved = resolve(raw);
const cwd = process.cwd();
let resolved;
try { resolved = realpathSync(resolve(raw)); } catch { resolved = resolve(raw); }
let cwd;
try { cwd = realpathSync(process.cwd()); } catch { cwd = process.cwd(); }
const opcBase = join(homedir(), ".opc", "sessions");

@@ -50,2 +58,10 @@ // Allow: under cwd OR under ~/.opc/sessions/ (session dirs)

// ── Read-only dir resolution (no path traversal guard) ─────────
// For read-only commands (viz, replay, ext-commands) that need session
// auto-resolve but don't need write-path guards.
export function resolveDirReadOnly(args, fallback = ".harness") {
if (args.includes("--dir")) return getFlag(args, "dir", fallback);
return resolveDir(args, { optional: true }) || fallback;
}
// ── Atomic file write (rename-based) ────────────────────────────

@@ -64,2 +80,5 @@ export function atomicWriteSync(filePath, data) {

export const VALID_LOOP_STATUSES = new Set(["initialized", "in_progress", "pipeline_complete", "terminated", "stalled"]);
export const TERMINAL_LOOP_STATUSES = new Set(["pipeline_complete", "terminated", "stalled"]);
export const WRITER_SIG = "opc-harness";

@@ -72,3 +91,28 @@ export const IDEMPOTENCY_WINDOW_MS = 5000;

/**
* Resolve the canonical project root for hashing.
* 1. Try git root (covers 99% of real usage — subdirs all hash the same)
* 2. Fallback to realpath(cwd) with trailing slash stripped
*/
function getProjectRoot(cwd = process.cwd()) {
try {
const gitRoot = execSync("git rev-parse --show-toplevel", {
cwd, encoding: "utf8", stdio: ["pipe", "pipe", "pipe"],
}).trim();
return realpathSync(gitRoot);
} catch {
// Not a git repo — use normalized cwd
try { return realpathSync(cwd).replace(/\/+$/, ""); } catch { return cwd; }
}
}
export function getProjectHash(cwd = process.cwd()) {
const root = getProjectRoot(cwd);
return createHash("sha256").update(root).digest("hex").slice(0, 12);
}
/**
* Legacy hash: sha256(raw cwd) — used for migration fallback.
*/
function getLegacyProjectHash(cwd = process.cwd()) {
return createHash("sha256").update(cwd).digest("hex").slice(0, 12);

@@ -117,18 +161,21 @@ }

export function getLatestSessionDir(cwd = process.cwd()) {
const base = getSessionsBaseDir(cwd);
const latestLink = join(base, "latest");
try {
const target = readlinkSync(latestLink);
const resolved = resolve(base, target);
// Guard: symlink target must resolve within sessions base dir
if (!resolved.startsWith(base + "/")) return null;
if (existsSync(join(resolved, "flow-state.json"))) return resolved;
// Symlink valid, dir exists, but no flow-state.json — warn
if (existsSync(resolved)) {
console.error(`WARN: latest session dir '${resolved}' exists but has no flow-state.json — ignoring`);
// Try new hash (git-root-based) first, then legacy hash (raw cwd) for migration
for (const hash of [getProjectHash(cwd), getLegacyProjectHash(cwd)]) {
const base = join(homedir(), ".opc", "sessions", hash);
const latestLink = join(base, "latest");
try {
const target = readlinkSync(latestLink);
const resolved = resolve(base, target);
// Guard: symlink target must resolve within sessions base dir
if (!resolved.startsWith(base + "/")) continue;
if (existsSync(join(resolved, "flow-state.json"))) return resolved;
// Symlink valid, dir exists, but no flow-state.json — warn
if (existsSync(resolved)) {
console.error(`WARN: session dir '${resolved}' exists but has no flow-state.json — skipping`);
}
} catch {
// No symlink or unreadable — try next hash
}
return null;
} catch {
return null;
}
return null;
}

@@ -135,0 +182,0 @@

@@ -7,3 +7,3 @@ // Visualization and replay commands: getMarker, cmdViz, cmdReplayData

import { FLOW_TEMPLATES, resolveFlowTemplate, loadFlowFromFile } from "./flow-templates.mjs";
import { getFlag } from "./util.mjs";
import { getFlag, resolveDirReadOnly } from "./util.mjs";

@@ -19,4 +19,4 @@ export function getMarker(nodeId, state) {

export function cmdViz(args) {
// Read-only command: no resolveDir guard needed (viz reads state but never writes)
const dir = getFlag(args, "dir");
// Read-only but needs session auto-resolve so viz works without explicit --dir
const dir = resolveDirReadOnly(args, null);
const jsonOut = args.includes("--json");

@@ -79,4 +79,4 @@

export function cmdReplayData(args) {
// Read-only command: no resolveDir guard needed (reads state + handshakes, never writes)
const dir = getFlag(args, "dir", ".harness");
// Read-only but needs session auto-resolve
const dir = resolveDirReadOnly(args);

@@ -83,0 +83,0 @@ const statePath = join(dir, "flow-state.json");

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

import { cmdReport, cmdDiff } from "./lib/eval-report.mjs";
import { cmdRoute, cmdInit, cmdValidate, cmdValidateContext } from "./lib/flow-core.mjs";
import { cmdTransition, cmdValidateChain, cmdFinalize } from "./lib/flow-transition.mjs";
import { cmdPromptContext, cmdExtensionTest, cmdExtensionVerdict, cmdExtensionArtifact } from "./lib/ext-commands.mjs";
import { cmdRoute, cmdInit, cmdValidate, cmdValidateContext, cmdSeal } from "./lib/flow-core.mjs";
import { cmdTransition, cmdValidateChain, cmdFinalize, cmdAdvance } from "./lib/flow-transition.mjs";
import { cmdPromptContext, cmdExtensionTest, cmdExtensionVerdict, cmdExtensionArtifact, cmdNodePreflight } from "./lib/ext-commands.mjs";
import { cmdConfigResolve } from "./lib/config-layering.mjs";

@@ -24,2 +24,3 @@ import { cmdSkip, cmdPass, cmdStop, cmdGoto, cmdLs } from "./lib/flow-escape.mjs";

import { cmdClean } from "./lib/clean.mjs";
import { cmdAudit } from "./lib/audit.mjs";

@@ -40,2 +41,4 @@ const command = process.argv[2];

case "finalize": cmdFinalize(args); break;
case "seal": cmdSeal(args); break;
case "advance": cmdAdvance(args); break;
case "validate-context": cmdValidateContext(args); break;

@@ -61,2 +64,3 @@ case "viz": cmdViz(args); break;

case "extension-artifact": await cmdExtensionArtifact(args); break;
case "node-preflight": await cmdNodePreflight(args); break;
case "config": await cmdConfigResolve(args); break;

@@ -66,2 +70,3 @@ case "runbook": cmdRunbook(args); break;

case "gc": cmdGc(args); break;
case "audit": cmdAudit(args); break;
default:

@@ -82,2 +87,4 @@ console.log("opc-harness — Mechanical verification for OPC evaluations");

console.log(" finalize [--dir <p>] [--strict] Finalize terminal node");
console.log(" seal --node <id> [--run <N>] [--dir <p>] Auto-generate handshake from artifacts");
console.log(" advance [--dir <p>] One-click gate: synthesize→route→transition");
console.log(" viz --flow <tpl> [--flow-file <p>] [--dir <p>] [--json]");

@@ -122,2 +129,3 @@ console.log(" Visualize flow graph");

console.log(" prompt-context --node <id> --role <role> --dir <p> Fire prompt.append → emit extra prompt context");
console.log(" node-preflight --node <id> --dir <p> Fire preflight → write design artifacts to session dir");
console.log();

@@ -124,0 +132,0 @@ console.log("Loop commands (Layer 2 — zero trust):");

@@ -98,6 +98,20 @@ #!/usr/bin/env node

// Format C: ## Verdict: WORD (inline, no bold)
const m = content.match(/##\s*(?:Overall\s+)?Verdict[:\s]*(?:\n+\s*)?\*{0,2}(\w+)\*{0,2}/i);
return m ? m[1].toUpperCase() : null;
// Format D: ## Overall Verdict\n\n**CONDITIONAL ACCEPT** (multi-word)
const m = content.match(/##\s*(?:Overall\s+)?Verdict[:\s]*(?:\n+\s*)?\*{0,2}([A-Z][A-Za-z *]+?)\*{0,2}\s*(?:—|$)/im)
|| content.match(/##\s*(?:Overall\s+)?Verdict[:\s]*(?:\n+\s*)?\*{0,2}(\w+)\*{0,2}/i);
return m ? normalizeVerdict(m[1].trim()) : null;
}
// --- Normalize verdict strings ---
function normalizeVerdict(raw) {
if (!raw) return null;
const s = raw.toUpperCase().replace(/\s+/g, '_');
if (s === 'FAIL' || s === 'FAILED') return 'FAIL';
if (s.startsWith('CONDITIONAL')) return 'CONDITIONAL';
if (s === 'ITERATE' || s === 'NEEDS_WORK') return 'ITERATE';
if (s === 'LGTM' || s === 'PASS' || s === 'ACCEPTED' || s === 'COMPLETED') return 'PASS';
if (s === 'PASS*') return 'PASS*';
return raw.toUpperCase();
}
// --- Parse counselor name from heading ---

@@ -150,12 +164,54 @@ function parseCounselorName(content) {

// Overall verdict
// Overall verdict — considers tick history, eval verdicts, AND finding counts
const VERDICT_RANK = { FAIL: 0, CONDITIONAL: 1, ITERATE: 2, 'PASS*': 3, PASS: 4 };
function worstVerdict(a, b) {
return (VERDICT_RANK[a] ?? 99) <= (VERDICT_RANK[b] ?? 99) ? a : b;
}
// Collect tick-level verdicts from loop state — deduplicate by unit (latest tick wins)
const tickByUnit = new Map();
for (const t of (loopState?._tick_history || [])) {
const v = normalizeVerdict(t.verdict || t.status || '');
if (v && VERDICT_RANK[v] !== undefined) {
tickByUnit.set(t.unit || t.tick, v); // later tick for same unit overwrites earlier
}
}
const tickVerdicts = [...tickByUnit.values()];
// Collect parsed eval verdicts — R2 overrides R1 per node
const evalVerdictsByNode = new Map();
for (const n of r1Nodes) {
if (n.verdict && VERDICT_RANK[n.verdict] !== undefined) {
evalVerdictsByNode.set(n.nodeId, n.verdict);
}
}
for (const n of r2Nodes) {
if (n.verdict && VERDICT_RANK[n.verdict] !== undefined) {
evalVerdictsByNode.set(n.nodeId, n.verdict); // R2 overrides R1 for same node
}
}
const evalVerdicts = [...evalVerdictsByNode.values()];
function overallVerdict() {
// Start with finding-based verdict
let verdict = 'PASS';
if (!hasR2) {
if (critCount > 0) return 'FAIL';
if (medCount > 0) return 'ITERATE';
return 'PASS';
if (critCount > 0) verdict = 'FAIL';
else if (medCount > 0) verdict = 'ITERATE';
} else {
if (r2NotFixed > 0) verdict = 'FAIL';
else if (r2Partial > 0) verdict = 'PASS*';
}
if (r2NotFixed > 0) return 'FAIL';
if (r2Partial > 0) return 'PASS*';
return 'PASS';
// Incorporate tick history verdicts (loop mode)
for (const tv of tickVerdicts) {
verdict = worstVerdict(verdict, tv);
}
// Incorporate parsed eval verdicts
for (const ev of evalVerdicts) {
verdict = worstVerdict(verdict, ev);
}
return verdict;
}

@@ -170,9 +226,9 @@

function verdictColor(v) {
if (v === 'PASS' || v === 'PASS*') return 'var(--green)';
if (v === 'ITERATE') return 'var(--yellow)';
if (v === 'PASS' || v === 'PASS*' || v === 'LGTM') return 'var(--green)';
if (v === 'ITERATE' || v === 'CONDITIONAL') return 'var(--yellow)';
return 'var(--red)';
}
function verdictBg(v) {
if (v === 'PASS' || v === 'PASS*') return 'rgba(34,197,94,0.12)';
if (v === 'ITERATE') return 'rgba(234,179,8,0.12)';
if (v === 'PASS' || v === 'PASS*' || v === 'LGTM') return 'rgba(34,197,94,0.12)';
if (v === 'ITERATE' || v === 'CONDITIONAL') return 'rgba(234,179,8,0.12)';
return 'rgba(239,68,68,0.12)';

@@ -269,2 +325,3 @@ }

${hasR2 ? renderR2Verdicts() : ''}
${renderTickHistory()}
${renderFooter()}

@@ -402,2 +459,23 @@ </div>

function renderTickHistory() {
const history = loopState?._tick_history || [];
if (!history.length) return '';
return `<div class="section">
<div class="section-title">🕐 Tick History (${history.length} ticks)</div>
<div class="card" style="overflow-x:auto">
<table class="findings-table">
<thead><tr><th>Tick</th><th>Unit</th><th>Verdict</th></tr></thead>
<tbody>${history.map(t => {
const v = normalizeVerdict(t.verdict || t.status || '') || 'UNKNOWN';
return `<tr>
<td>${t.tick ?? '—'}</td>
<td style="font-family:'SF Mono',Menlo,monospace;font-size:.85rem">${esc(t.unit || '—')}</td>
<td><span class="badge" style="background:${verdictBg(v)};color:${verdictColor(v)}">${esc(v)}</span></td>
</tr>`;
}).join('')}</tbody>
</table>
</div>
</div>`;
}
function renderFooter() {

@@ -404,0 +482,0 @@ const totalFindings = allR1Findings.filter(f => f.severity).length;

#!/usr/bin/env node
import { existsSync, mkdirSync, cpSync, rmSync, readdirSync, readFileSync, lstatSync, readlinkSync, realpathSync } from "fs";
import { existsSync, mkdirSync, cpSync, rmSync, readdirSync, readFileSync, writeFileSync, lstatSync, readlinkSync, realpathSync } from "fs";
import { join, dirname } from "path";
import { homedir } from "os";
import { fileURLToPath } from "url";
import { spawnSync } from "child_process";

@@ -11,2 +12,3 @@ const __dirname = dirname(fileURLToPath(import.meta.url));

const skillsDir = join(homedir(), ".claude", "skills", SKILL_NAME);
const srcDir = join(__dirname, "..");

@@ -25,2 +27,15 @@

function validateHookPrereqs(hooksDir) {
for (const file of ["opc-pre-compact.sh", "opc-post-compact.sh"]) {
if (!existsSync(join(hooksDir, file))) {
return `missing hook script: ${join(hooksDir, file)}. Run 'opc install' first.`;
}
}
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 null;
}
switch (command) {

@@ -54,5 +69,59 @@ case "install": {

console.log(` Use /opc in Claude Code to get started.`);
console.log(` Run 'opc install-hooks' to enable compression resilience.`);
break;
}
case "install-hooks": {
const settingsPath = join(homedir(), ".claude", "settings.json");
let settings = {};
if (existsSync(settingsPath)) {
try {
settings = JSON.parse(readFileSync(settingsPath, "utf8"));
} catch (err) {
console.error(`✗ Cannot parse ${settingsPath}: ${err.message}`);
process.exit(1);
}
}
if (!settings.hooks) settings.hooks = {};
const hooksDir = join(skillsDir, "bin", "hooks");
const prereqError = validateHookPrereqs(hooksDir);
if (prereqError) {
console.error(`✗ ${prereqError}`);
process.exit(1);
}
const preCmd = `bash "${join(hooksDir, "opc-pre-compact.sh")}"`;
const postCmd = `bash "${join(hooksDir, "opc-post-compact.sh")}"`;
// 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 }]
});
}
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`);
break;
}
case "uninstall": {

@@ -127,5 +196,6 @@ if (!existsSync(skillsDir)) {

console.log("Usage:");
console.log(" opc install Install skill files to ~/.claude/skills/opc/");
console.log(" opc uninstall Remove skill files (preserves custom roles)");
console.log(" opc version Show version");
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 uninstall Remove skill files (preserves custom roles)");
console.log(" opc version Show version");
console.log();

@@ -132,0 +202,0 @@ console.log("Once installed, use /opc in Claude Code.");

@@ -8,6 +8,8 @@ # OPC Contracts — Stable Interfaces for External Callers

OPC Harness version: read from `HARNESS_VERSION` in `bin/lib/flow-templates.mjs`.
Currently: `0.9.0`.
Currently: `0.10.0`.
External consumers declare compatibility via `opc_compat: ">=0.8"` — see [Flow Templates](#4-custom-flow-templates) below.
`HARNESS_VERSION` is the external-flow compatibility line, not the npm package patch version. For example, `@touchskyer/opc@0.10.2` can expose harness compatibility `0.10.0`; patch releases do not require external flow authors to change `opc_compat`.
External consumers declare compatibility via `opc_compat: ">=0.10"` — see [Flow Templates](#4-custom-flow-templates) below.
---

@@ -237,3 +239,3 @@

{
"opc_compat": ">=0.7", // REQUIRED: minimum harness version
"opc_compat": ">=0.10", // REQUIRED: minimum harness compatibility version
"nodes": ["discover", "build", "review", "gate"],

@@ -240,0 +242,0 @@ "edges": {

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

@@ -22,2 +22,3 @@ "type": "module",

"examples",
"docs",
"CONTRACTS.md",

@@ -24,0 +25,0 @@ "INTEGRATION.md",

@@ -38,2 +38,22 @@ # Executor Protocol

### Step 1b — Dev Server Lifecycle
When executing browser-based evidence capture, ensure a dev server is running:
1. **Check if already running**: `curl -s -o /dev/null -w "%{http_code}" http://localhost:3000` (or project-configured port from `package.json`, `.env`, `vite.config.*`, `next.config.*`)
2. **If not running**: Start it in background and wait for ready:
```bash
npm run dev &
DEV_PID=$!
# Wait for ready (max 30s)
for i in $(seq 1 30); do
curl -s http://localhost:3000 > /dev/null 2>&1 && break
sleep 1
done
```
3. **After evidence capture**: Do NOT kill the server — leave it running for subsequent executor/review ticks in the same flow
4. **Port detection priority**: `PORT` in `.env` → `vite.config.*` server.port → `next.config.*` → default 3000
The implement tick that builds the app SHOULD leave the dev server running in background. The orchestrator SHOULD NOT kill background processes between ticks.
### Step 2 — Read Acceptance Criteria

@@ -151,2 +171,52 @@

## Design Reproduction Mode
When `acceptance-criteria.md` contains a `## Reference` section with `reference_image:`, the executor MUST run design-diff verification instead of (or in addition to) standard scenario execution.
### Detection
```
reference_image: /path/to/ref.png
design_spec: /path/to/spec.json # optional
```
If both fields are present, this is a **design reproduction task**.
### Execution Steps
1. **Find generated artifact** — locate the HTML output in `artifacts/` (e.g., `output.html`)
2. **Screenshot** — convert HTML to PNG using `image-x` html2png:
```bash
python3 ~/.claude/skills/image-x/scripts/html2png.py artifacts/output.html --output artifacts/gen.png
```
3. **VLM design-diff** — the `design-intelligence` extension hook automatically detects `reference_image` in acceptance-criteria.md and runs design-diff mode (ref vs gen). No manual invocation needed.
4. **Read evidence** — check `ext-design-intelligence/design-diff-evidence.json` for structured diffs:
```json
{
"verdict": "ITERATE",
"overall": 2.7,
"diffs": [{"region": "header", "property": "bg", "expected": "#4ac0aa", "actual": "#fff", "severity": "major", "fix": "bg: #4ac0aa"}]
}
```
5. **Write handshake** — include the evidence in the handshake:
```json
{
"verdict": "ITERATE",
"evidence": {
"mode": "design-diff",
"overall": 2.7,
"diffs": [...],
"gen_image": "artifacts/gen.png",
"ref_image": "/path/to/ref.png"
}
}
```
### Gate Consumption
The gate reads `evidence.diffs` from the handshake:
- **PASS**: `overall ≥ 4.0` AND zero major diffs
- **ITERATE**: below threshold — gate injects `evidence.diffs` into the next build prompt so the implementer knows exactly what to fix
- **FAIL**: `overall < 2.0` or 3+ consecutive ITERATE rounds — human intervention needed
## Anti-Patterns

@@ -153,0 +223,0 @@

@@ -113,1 +113,29 @@ # Gate Protocol

- ❌ Dismissing devil's advocate product concerns as "not code-blocking" without tracking them
## Conflict of Interest — Builder as Orchestrator
When the orchestrator also performed the build (same session, same agent):
1. **The orchestrator MUST NOT override gate verdicts.** Specifically:
- ITERATE verdict → orchestrator cannot rationalize warnings as "pre-existing" or "acceptable"
- FAIL verdict → orchestrator cannot downgrade to ITERATE
- Only the USER can override verdicts when conflict-of-interest applies
2. **Detection**: If the current session's build node was executed by the orchestrator (not a subagent in a worktree), conflict-of-interest is assumed.
3. **Escalation**: When conflict-of-interest is detected and verdict is not PASS:
- Show the user: verdict, all findings summary, and the specific warnings
- Ask: "Gate verdict is {VERDICT}. As builder, I have a conflict of interest. Accept findings and iterate, or override? [iterate/override]"
- Do NOT pre-fill the answer or suggest overriding
4. **Audit trail**: Any user override must be logged in progress.md: "⚠️ User override: {verdict} → PASS (conflict-of-interest acknowledged)"
## Skeptic-Owner Authority
When multiple reviewers disagree on verdict, **skeptic-owner's verdict takes precedence**. Skeptic-owner is the user's representative in the pipeline — its job is to verify the output matches what was actually asked for.
Concretely:
- If skeptic-owner says FAIL and others say PASS → treat as FAIL
- If skeptic-owner says ITERATE and others say PASS → treat as ITERATE
- If skeptic-owner says PASS and others say ITERATE → the orchestrator MAY escalate to user, but skeptic-owner's PASS carries more weight than other roles' ITERATE
- The orchestrator MUST NOT dismiss or downgrade skeptic-owner findings under any rationale

@@ -108,2 +108,22 @@ # Loop Protocol — Autonomous Multi-Unit Execution

### Step 0.5 — Codebase Reconnaissance (MANDATORY)
Before decomposing, you MUST explore the existing codebase and write a recon summary. This prevents planning units that duplicate existing work.
**What to capture** (write to `$SESSION_DIR/recon.md`):
- Directory structure of relevant areas
- Existing tests and their coverage
- Already-implemented features related to the task
- Key files that will be touched
**How to pass it:**
```bash
node "$OPC_HARNESS" init-loop \
--plan $SESSION_DIR/plan.md \
--recon $SESSION_DIR/recon.md \
--dir $SESSION_DIR
```
The harness validates: file exists + ≥ 200 chars. If you skip recon, you risk decomposing into units that rebuild what's already there.
### Step 1 — Plan Decomposition

@@ -110,0 +130,0 @@

@@ -63,2 +63,13 @@ # Role Evaluator Subagent Prompt

## Visual & Image Artifact Requirements
When the artifact under review is visual (images, UI screenshots, covers, diagrams, design output):
1. **Quantify every finding** — Don't say "looks smaller." Say "title is 48px vs reference 72px (33% reduction)." Don't say "style differs." Say "background gradient missing: reference has radial-gradient with #2a1845 at top, output is flat #0d1525."
2. **Before/after comparison mandatory** — Load the original (or a sibling from the same series) and diff. Report specific differences with measurements.
3. **Reference overlay** — For each visual finding, cite the reference file path and the specific region/element.
4. **No rationalization** — "Known tradeoff of the approach" is not an acceptable finding disposition. If the output differs from what was asked, report it as a finding regardless of why.
Findings without quantified evidence for visual artifacts are automatically classified as ungrounded and may be rejected by the synthesize gate.
## Design Context Brief (if provided)

@@ -152,5 +163,11 @@ {Design Context Brief — if provided, respect these decisions, do not flag them}

[SEVERITY] file:line — Issue description
→ Suggested fix
reasoning: Why this matters from a {role_name} perspective
fix: Concrete suggested fix (code snippet, config change, or specific action)
**IMPORTANT — Finding format requirements:**
- Every finding MUST have a `reasoning:` line explaining WHY this matters (not just what's wrong)
- Every finding MUST have a `fix:` line OR a `→` line with a concrete suggested fix
- The eval parser mechanically validates these markers — findings without `reasoning:` or `fix:` are flagged as thin evals
- Use exactly `reasoning:` and `fix:` (or `→`) as line prefixes — not `**Why**:`, `**Fix**:`, or other variants
If no issues found: "LGTM — no findings in scope."

@@ -157,0 +174,0 @@ Prioritize: 🔴 first, then 🟡, then 🔵.

+44
-71

@@ -7,28 +7,2 @@ # OPC — One Person Company

## What's Different in v0.8
**Compound eval quality gate (D2).** 11-layer substance check on every eval — thin content, missing code refs, low uniqueness, fabricated references, aspirational claims, change scope coverage, etc. ≥3 layers tripped → hard FAIL (enforce by default); `--no-strict` downgrades to shadow mode. thinEval substance exemption: short evals with complete reasoning/fix/refs are exempt. Evaluator guidance: when D2 triggers, `evaluatorGuidance` output tells the evaluator exactly which layers failed and how to fix.
**Iteration escalation (D3).** Persistent eval warnings across ≥2 iterations auto-escalate to FAIL. No more infinite loops of shallow reviews.
**Task Scope Registry.** Loop mode plans require `## Task Scope` with SCOPE-N items. The harness validates at init and blocks completion if any scope item is uncovered — preventing the #1 failure mode where LLM decomposition silently drops requirements.
**Pipeline E2E lint.** Tasks containing pipeline keywords (cron, webhook, CI/CD) must have an e2e-live-trigger acceptance criterion. Proxy evidence (unit tests) ≠ live evidence.
**Evaluator prompt hardening (D6).** 5 evidence standards baked into the evaluator protocol: cite evidence, address anomalies, no aspirational claims, distinguish root cause vs symptom, cover change scope.
## What's Different in v0.7
**Third-party extension authoring.** `docs/extension-authoring.md` (7800+ words) + `examples/extensions/_starter/` (30-min walkthrough). Hardened via DX litmus: an independent agent built an extension using only the doc + starter.
## What's Different in v0.6
**Digraph engine.** Tasks flow through typed nodes (build → review → gate → ...) with mechanical verdict routing. No more linear pipelines.
**Autonomous loop.** `opc loop` decomposes a feature into units, schedules a durable cron, and runs 8-16 hours unattended — with code-enforced guardrails that survive context compaction.
**Code-enforced, not honor-system.** 29 test suites verify: tamper detection (write nonce), atomic state writes, review independence, oscillation detection, tick limits, scope coverage, compound defense, and JSON crash recovery.
**External validator integration.** Pre-commit hooks, test suites, Playwright E2E, and CI pipelines are formally part of the quality architecture — the agent is supervised by tools it doesn't control.
## How It Works

@@ -52,2 +26,13 @@

### Quality Architecture
![Zero-Trust Quality Architecture](docs/assets/design_philosophy.png)
The system is built on a zero-trust axiom: **every critical output must have an independent verification path.** Four layers:
- **L0 — Zero Trust**: Decision axiom — not code, not prompt. Every critical output needs an independent verification path.
- **L1 — Shape Single Agent**: Intervene during token generation — persona setting, anti-pattern tables, mandatory output structure, scope anchoring, quality gates.
- **L2 — Design Agent Flow**: Multi-agent coordination — separation of concerns, flow topology (parallel review / sequential build), context isolation (file-based handoff, fresh agents, no session reuse).
- **L3 — Deterministic Enforcement**: The only layer that doesn't need LLM compliance — mechanical ops (severity counting, verdict rules, oscillation diff) and hardened verification (file-finding evidence checks, hedging scans, ref validation).
## Quick Start

@@ -95,27 +80,4 @@

## Extensions
## Autonomous Loop
OPC has a capability-routed extension surface. Extensions live in
`~/.claude/skills/opc-extension/<name>/` — each with `ext.json` (capability
declarations) + `hook.mjs` exporting any of `promptAppend` / `verdictAppend`
/ `executeRun` / `artifactEmit` hooks. No fork, no rebuild. Hooks are
sandboxed via per-extension timeouts + circuit breakers, so a broken
third-party extension can't take down the harness.
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`.
Full authoring guide: **[docs/extension-authoring.md](docs/extension-authoring.md)** — zero-OPC-context
quickstart + reference, plus a starter template at `examples/extensions/_starter/`.
## Flow Templates
| Template | Nodes | When |
|----------|-------|------|
| **review** | code-review → gate | PR review, audit, "find problems" |
| **build-verify** | build → code-review → test-design → test-execute → 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" |
## Autonomous Loop (v0.6)
```bash

@@ -134,2 +96,4 @@ /opc loop build the math tutoring app features F1-F4

For well-scoped tasks, the system runs **10+ hours continuously** without intervention.
### Guardrails (code-enforced, not prompt-level)

@@ -151,2 +115,25 @@

## Flow Templates
| Template | Nodes | When |
|----------|-------|------|
| **review** | code-review → gate | PR review, audit, "find problems" |
| **build-verify** | build → code-review → test-design → test-execute → 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
OPC has a capability-routed extension surface. Extensions live in
`~/.claude/skills/opc-extension/<name>/` — each with `ext.json` (capability
declarations) + `hook.mjs` exporting any of `promptAppend` / `verdictAppend`
/ `executeRun` / `artifactEmit` hooks. No fork, no rebuild. Hooks are
sandboxed via per-extension timeouts + circuit breakers, so a broken
third-party extension can't take down the harness.
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`.
Full authoring guide: **[docs/extension-authoring.md](docs/extension-authoring.md)** — zero-OPC-context
quickstart + reference, plus a starter template at `examples/extensions/_starter/`.
## Built-in Roles

@@ -192,23 +179,4 @@

84 test files covering init-loop, complete-tick, next-tick, review independence, JSON crash recovery, compound defense, scope registry, criteria lint, pipeline E2E lint, D2 calibration, and orchestrator-level E2E flow tests.
100+ test files covering init-loop, complete-tick, next-tick, review independence, JSON crash recovery, compound defense, scope registry, criteria lint, pipeline E2E lint, D2 calibration, release packaging, and orchestrator-level E2E flow tests.
## Reproducing benchmarks
OPC ships with an extension system (v0.5, Run 1) so you can plug in additional hooks — visual checks, design-system audits, a11y scans — without forking the skill. The extension loader honors three bypasses so a single harness invocation can ignore locally-configured extensions:
```bash
# Disable every extension for one harness run
OPC_DISABLE_EXTENSIONS=1 node bin/opc-harness.mjs init --flow review --entry review --dir .harness
# Same effect, CLI flag form
node bin/opc-harness.mjs init --flow review --entry review --dir .harness --no-extensions
# Whitelist specific extensions only
node bin/opc-harness.mjs init --flow review --entry review --dir .harness --extensions visual-check,a11y
```
Priority order: `OPC_DISABLE_EXTENSIONS=1` env var > `--no-extensions` CLI flag > `--extensions foo,bar` whitelist > config in `~/.claude/skills/opc-extension/config.json`. See `docs/specs/2026-04-16-opc-extension-system-design.md` for the full contract.
**Note:** `bash test/run-all.sh` runs OPC's own internal test suite, which includes tests that intentionally *load* extensions to exercise the system. Don't set `OPC_DISABLE_EXTENSIONS=1` when running the suite — use the bypasses only on real benchmarking / workflow invocations.
## Requirements

@@ -218,3 +186,4 @@

- Node.js >= 18
- No runtime dependencies, no MCP server, no build step
- Core runtime has no npm dependencies, no MCP server, no build step
- Optional: `jq` for `opc install-hooks` context-compaction hooks

@@ -229,2 +198,6 @@ ## Works better with memex (optional)

## What's New
See [CHANGELOG.md](CHANGELOG.md) for version history.
## Community

@@ -231,0 +204,0 @@

---
tags: [review, verification]
mandatory: true
---

@@ -64,2 +65,18 @@

### D7: Request Compliance — "Did You Do What Was Asked?"
The most common failure mode isn't code — it's **drift from the original request**. The orchestrator interpreted the task, other evaluators assessed "quality," and somewhere the actual user request got lost.
**Core principle**: The user's exact words are the acceptance criteria. Not the orchestrator's paraphrase, not acceptance-criteria.md — the literal message.
**Procedure:**
1. **Quote the original request** — find the user's exact message that triggered this task.
2. **Decompose into atomic checkpoints** — each verifiable claim becomes a checkpoint.
- Explicit: "改成 X" → CP: output contains X
- Implicit: "别的不变" → CP: everything else identical to before
3. **Before/after comparison** — if something was modified, load the original (or siblings in the same series) and diff against the output. Quantify differences: pixel dimensions, text content, hex colors, font sizes.
4. **Zero rationalization** — "known tradeoff," "inherent limitation," "different approach" are not acceptable explanations for failing a checkpoint. If the output doesn't match the request, it's wrong regardless of why.
**Test**: For every checkpoint, provide concrete evidence (measurement, screenshot comparison, text extraction). "Looks correct" without evidence = finding not grounded.
---

@@ -87,6 +104,7 @@

For each design element, pick the 2-3 most relevant dimensions and go deep. Priority:
1. D1 (Silent fallback) + D2 (Enforcement) — highest-impact failures
2. D3 (Integration boundaries) + D5 (E2E trigger) — for multi-component systems
3. D4 (Lifecycle) — for anything that creates persistent state
4. D6 (Consumer mismatch) — if consumers are LLMs or non-expert
1. **D7 (Request compliance) — ALWAYS runs first.** Before any mechanism audit, verify the output matches the user's original request. If it doesn't, nothing else matters.
2. D1 (Silent fallback) + D2 (Enforcement) — highest-impact failures
3. D3 (Integration boundaries) + D5 (E2E trigger) — for multi-component systems
4. D4 (Lifecycle) — for anything that creates persistent state
5. D6 (Consumer mismatch) — if consumers are LLMs or non-expert

@@ -93,0 +111,0 @@ ## Anti-Patterns

---
name: opc
version: 0.10.0
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...]"

@@ -229,2 +229,24 @@ ---

**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.

@@ -311,2 +333,3 @@

- **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.

@@ -510,3 +533,3 @@ - Not every task needs every role. A CSS fix doesn't need Security.

"softEvidence": true,
"opc_compat": ">=0.5",
"opc_compat": ">=0.10",
"contextSchema": {

@@ -525,3 +548,3 @@ "build": {

- `nodeTypes` values must be: `discussion`, `build`, `review`, `execute`, `gate`
- `opc_compat` uses `>=X.Y` semver range (current harness: 0.9.0)
- `opc_compat` uses `>=X.Y` semver range (current harness compatibility: 0.10.0)
- Prototype pollution names (`__proto__`, `constructor`, `prototype`) are rejected

@@ -603,4 +626,9 @@

**Context running low:** Write current state to `$SESSION_DIR/flow-state.json` (already maintained by transition commands). The flow-state.json + handshake files carry all state needed to resume. Tell user to re-invoke — orchestrator will detect flow-state.json and resume.
**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.

@@ -607,0 +635,0 @@

@@ -52,1 +52,12 @@ // Run 2 fixture: ok-ext — clean baseline

}
export function preflight(/* ctx */) {
return {
type: "design",
selection: { industry: "test", archetype: "ok-ext" },
brief: "# Design Brief\n\nTest brief from ok-ext.\n",
tokens: { colors: { bg: "#FFFFFF", text: "#000000" }, typography: {}, shape: {} },
confidence: 0.9,
reason: "test fixture",
};
}

@@ -132,2 +132,19 @@ #!/bin/bash

cat > .harness/nodes/code-review/run_1/eval-skeptic-owner.md <<'SOEOF'
# Skeptic-Owner Evaluation
## Mechanism Audit
🔵 src/config.ts:1 — Config values not validated at startup
→ Add runtime validation with zod schema at boot
Reasoning: Invalid config will cause runtime errors instead of fast startup failure.
## Lifecycle
🔵 src/server.ts:5 — No graceful shutdown handler
→ Add SIGTERM handler that drains connections
Reasoning: Hard shutdown drops in-flight requests during deployment.
## Summary
2 suggestions. No critical or warning issues.
SOEOF
OUT=$($HARNESS synthesize .harness --node code-review --iteration 2 2>/dev/null)

@@ -134,0 +151,0 @@ assert_not_contains "no escalation for clean eval" "$OUT" "persist after"

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

# goto maxNodeReentry (init does NOT add to history; goto test-execute doesn't count for build)
# Need 5 gotos to build to fill history with 5 entries, then 6th is blocked
for i in 1 2 3 4 5; do opc goto build --dir .harness > /dev/null 2>&1; done
# goto maxLoopsPerEdge (self-loop build→build hits edge limit=3 before nodeReentry=5)
# After prior goto test-execute, first goto build = test-execute→build, then build→build starts
for i in 1 2 3 4; do opc goto build --dir .harness > /dev/null 2>&1; done
R=$(opc goto build --dir .harness)
check_json "maxNodeReentry enforced" "'maxNodeReentry' in d.get('error','')" "$R"
check_json "maxLoopsPerEdge enforced" "'maxLoopsPerEdge' in d.get('error','')" "$R"

@@ -85,0 +85,0 @@ # stop

@@ -144,2 +144,19 @@ #!/bin/bash

cat > .harness/nodes/code-review/run_1/eval-skeptic-owner.md <<'SOEOF'
# Skeptic-Owner Evaluation
## Mechanism Audit
🔵 src/config.ts:1 — Config values not validated at startup
→ Add runtime validation with zod schema at boot
Reasoning: Invalid config will cause runtime errors instead of fast startup failure.
## Lifecycle
🔵 src/server.ts:5 — No graceful shutdown handler
→ Add SIGTERM handler that drains connections
Reasoning: Hard shutdown drops in-flight requests during deployment.
## Summary
2 suggestions. No critical or warning issues.
SOEOF
OUT=$($HARNESS synthesize .harness --node code-review --iteration 1)

@@ -225,2 +242,19 @@ assert_field_eq "iteration 1: PASS (no thin, no warnings)" "$OUT" "verdict" '"PASS"'

cat > .harness/nodes/code-review/run_1/eval-skeptic-owner.md <<'SOEOF'
# Skeptic-Owner Evaluation
## Mechanism Audit
🔵 src/config.ts:1 — Config values not validated at startup
→ Add runtime validation with zod schema at boot
Reasoning: Invalid config will cause runtime errors instead of fast startup failure.
## Lifecycle
🔵 src/server.ts:5 — No graceful shutdown handler
→ Add SIGTERM handler that drains connections
Reasoning: Hard shutdown drops in-flight requests during deployment.
## Summary
2 suggestions. No critical or warning issues.
SOEOF
OUT=$($HARNESS synthesize .harness --node code-review --iteration 2)

@@ -227,0 +261,0 @@ assert_field_eq "clean + iteration 2: PASS" "$OUT" "verdict" '"PASS"'

@@ -149,2 +149,19 @@ #!/bin/bash

cat > .harness/nodes/code-review/run_1/eval-skeptic-owner.md <<'SOEOF'
# Skeptic-Owner Evaluation
## Mechanism Audit
🔵 src/config.ts:1 — Config values not validated at startup
→ Add runtime validation with zod schema at boot
Reasoning: Invalid config will cause runtime errors instead of fast startup failure.
## Lifecycle
🔵 src/server.ts:5 — No graceful shutdown handler
→ Add SIGTERM handler that drains connections
Reasoning: Hard shutdown drops in-flight requests during deployment.
## Summary
2 suggestions. No critical or warning issues.
SOEOF
OUT=$($HARNESS synthesize .harness --node code-review)

@@ -250,2 +267,19 @@ assert_field_eq "suggestions only: PASS" "$OUT" "verdict" '"PASS"'

cat > .harness/nodes/code-review/run_1/eval-skeptic-owner.md <<'SOEOF'
# Skeptic-Owner Evaluation
## Mechanism Audit
🔵 src/config.ts:1 — Config values not validated at startup
→ Add runtime validation with zod schema at boot
Reasoning: Invalid config will cause runtime errors instead of fast startup failure.
## Lifecycle
🔵 src/server.ts:5 — No graceful shutdown handler
→ Add SIGTERM handler that drains connections
Reasoning: Hard shutdown drops in-flight requests during deployment.
## Summary
2 suggestions. No critical or warning issues.
SOEOF
OUT=$($HARNESS synthesize .harness --node code-review)

@@ -284,2 +318,19 @@ assert_field_eq "LGTM: PASS" "$OUT" "verdict" '"PASS"'

cat > .harness/nodes/code-review/run_1/eval-skeptic-owner.md <<'SOEOF'
# Skeptic-Owner Evaluation
## Mechanism Audit
🔵 src/config.ts:1 — Config values not validated at startup
→ Add runtime validation with zod schema at boot
Reasoning: Invalid config will cause runtime errors instead of fast startup failure.
## Lifecycle
🔵 src/server.ts:5 — No graceful shutdown handler
→ Add SIGTERM handler that drains connections
Reasoning: Hard shutdown drops in-flight requests during deployment.
## Summary
2 suggestions. No critical or warning issues.
SOEOF
OUT=$($HARNESS synthesize .harness --node code-review)

@@ -286,0 +337,0 @@ # singleHeading(1 heading in 50+ lines) + noCodeRefs(no file:line refs) = 2 layers, threshold is 3

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

write_good_eval .harness review security
write_good_eval .harness review skeptic-owner
write_handshake .harness review "Code review complete" "PASS"

@@ -200,2 +201,4 @@ ROUTE=$($HARNESS route --node review --verdict PASS --flow review)

mv .harness/nodes/review/run_1/eval-tester.md .harness/nodes/review/run_2/eval-tester.md
write_good_eval .harness review skeptic-owner
mv .harness/nodes/review/run_1/eval-skeptic-owner.md .harness/nodes/review/run_2/eval-skeptic-owner.md
write_handshake .harness review "Review round 2" "PASS"

@@ -202,0 +205,0 @@ $HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>/dev/null

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

write_good_eval .harness code-review backend
write_good_eval .harness code-review skeptic-owner
write_handshake .harness code-review "Code review done" "PASS"

@@ -183,0 +184,0 @@ ROUTE=$($HARNESS route --node code-review --verdict PASS --flow build-verify)

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

write_good_eval .harness code-review backend
write_good_eval .harness code-review skeptic-owner
write_handshake .harness code-review "Review done" "PASS"

@@ -132,0 +133,0 @@ $HARNESS transition --from code-review --to test-design --verdict PASS --flow full-stack --dir .harness 2>/dev/null

@@ -171,7 +171,7 @@ #!/bin/bash

rm -rf .h-reentry && $HARNESS init --flow build-verify --dir .h-reentry >/dev/null 2>/dev/null
for i in 1 2 3 4 5; do
for i in 1 2 3; do
$HARNESS goto build --dir .h-reentry >/dev/null
done
OUT=$($HARNESS goto build --dir .h-reentry)
assert_contains "reentry limit" "$OUT" "maxNodeReentry"
assert_contains "edge limit" "$OUT" "maxLoopsPerEdge"

@@ -178,0 +178,0 @@ echo ""

@@ -136,2 +136,18 @@ #!/bin/bash

EVAL
cat > .h-synth2/nodes/code-review/run_1/eval-skeptic-owner.md <<'SOEOF'
# Skeptic-Owner Evaluation
## Mechanism Audit
🔵 src/config.ts:1 — Config values not validated at startup
→ Add runtime validation with zod schema at boot
Reasoning: Invalid config will cause runtime errors instead of fast startup failure.
## Lifecycle
🔵 src/server.ts:5 — No graceful shutdown handler
→ Add SIGTERM handler that drains connections
Reasoning: Hard shutdown drops in-flight requests during deployment.
## Summary
2 suggestions. No critical or warning issues.
SOEOF
OUT=$($HARNESS synthesize .h-synth2 --node code-review)

@@ -155,11 +171,13 @@ assert_contains "PASS verdict" "$OUT" "PASS"

echo ""
echo "--- 7.4: Synthesize no runs found exits nonzero ---"
echo "--- 7.4: Synthesize no runs found → BLOCKED ---"
rm -rf .h-synth4 && mkdir -p .h-synth4/nodes/code-review
assert_exit_nonzero "synth no runs" $HARNESS synthesize .h-synth4 --node code-review
OUT=$($HARNESS synthesize .h-synth4 --node code-review 2>/dev/null)
assert_contains "synth no runs" "$OUT" "BLOCKED"
echo ""
echo "--- 7.5: Synthesize no eval files exits nonzero ---"
echo "--- 7.5: Synthesize no eval files → BLOCKED ---"
rm -rf .h-synth5 && mkdir -p .h-synth5/nodes/code-review/run_1
echo "not an eval" > .h-synth5/nodes/code-review/run_1/readme.txt
assert_exit_nonzero "synth no evals" $HARNESS synthesize .h-synth5 --node code-review
OUT=$($HARNESS synthesize .h-synth5 --node code-review 2>/dev/null)
assert_contains "synth no evals" "$OUT" "BLOCKED"

@@ -240,2 +258,18 @@ echo ""

EVAL
cat > .h-wave/.harness/evaluation-wave-1-skeptic-owner.md << 'SOEOF'
# Skeptic-Owner Evaluation
## Mechanism Audit
🔵 src/config.ts:1 — Config values not validated at startup
→ Add runtime validation with zod schema at boot
Reasoning: Invalid config will cause runtime errors instead of fast startup failure.
## Lifecycle
🔵 src/server.ts:5 — No graceful shutdown handler
→ Add SIGTERM handler that drains connections
Reasoning: Hard shutdown drops in-flight requests during deployment.
## Summary
2 suggestions. No critical or warning issues.
SOEOF
OUT=$($HARNESS synthesize .h-wave --wave 1)

@@ -242,0 +276,0 @@ assert_contains "round filtered" "$OUT" "PASS"

@@ -84,4 +84,9 @@ #!/bin/bash

EVAL
cat > nodes/code-review/run_1/eval-skeptic-owner.md << 'EVAL'
# Skeptic Owner Review
Verified the mechanism is exercised by real consumers.
No trust violations found.
EVAL
cat > nodes/code-review/handshake.json << 'EOF'
{"nodeId":"code-review","nodeType":"review","runId":"run_1","status":"completed","summary":"ok","timestamp":"2024-01-01T00:00:00Z","artifacts":[{"type":"eval","path":"run_1/eval-frontend.md"},{"type":"eval","path":"run_1/eval-backend.md"}],"verdict":null}
{"nodeId":"code-review","nodeType":"review","runId":"run_1","status":"completed","summary":"ok","timestamp":"2024-01-01T00:00:00Z","artifacts":[{"type":"eval","path":"run_1/eval-frontend.md"},{"type":"eval","path":"run_1/eval-backend.md"},{"type":"eval","path":"run_1/eval-skeptic-owner.md"}],"verdict":null}
EOF

@@ -88,0 +93,0 @@ $HARNESS transition --from code-review --to test-design --verdict PASS --flow build-verify --dir . > /dev/null 2>&1

@@ -68,2 +68,17 @@ #!/usr/bin/env bash

cat > "$HOME/.claude/flows/test-cs-compat-current.json" << 'EOF'
{
"nodes": ["a","b"],
"edges": {"a": {"PASS": "b"}, "b": {"PASS": null}},
"limits": {"maxLoopsPerEdge": 3, "maxTotalSteps": 10, "maxNodeReentry": 5},
"opc_compat": ">=0.10"
}
EOF
D=$(mktemp -d)
cd "$D"
OUT=$($HARNESS init --flow test-cs-compat-current --dir . 2>/dev/null)
assert_field_eq "$OUT" "['created']" "True" "2.7b: opc_compat >=0.10 accepted by current harness"
rm -rf "$D"
cd /tmp
# ─────────────────────────────────────────────────────────────────

@@ -275,2 +290,3 @@ echo ""

rm -f "$HOME/.claude/flows/test-cs-compat-high.json"
rm -f "$HOME/.claude/flows/test-cs-compat-current.json"
rm -f "$HOME/.claude/flows/test-cs-malformed.json"

@@ -277,0 +293,0 @@ rm -f "$HOME/.claude/flows/test-cs-noflds.json"

@@ -122,2 +122,18 @@ #!/usr/bin/env bash

"
cat > "$D/nodes/code-review/run_2/eval-skeptic-owner.md" <<'SOEOF'
# Skeptic-Owner Evaluation
## Mechanism Audit
🔵 src/config.ts:1 — Config values not validated at startup
→ Add runtime validation with zod schema at boot
Reasoning: Invalid config will cause runtime errors instead of fast startup failure.
## Lifecycle
🔵 src/server.ts:5 — No graceful shutdown handler
→ Add SIGTERM handler that drains connections
Reasoning: Hard shutdown drops in-flight requests during deployment.
## Summary
2 suggestions. No critical or warning issues.
SOEOF
OUT=$($HARNESS synthesize "$D" --node code-review --run 2 2>/dev/null)

@@ -124,0 +140,0 @@ assert_field_eq "$OUT" "['verdict']" "PASS" "4.4a: --run 2 uses run_2 (PASS verdict)"

@@ -76,2 +76,15 @@ #!/bin/bash

EVAL
cat > "$dir/nodes/$node/run_1/eval-skeptic-owner.md" << 'EVAL'
# Skeptic Owner Review
## D7: Request Compliance
All checkpoints verified.
## Findings
🔵 suggestion — handler.js:30 — add integration test for cleanup path
→ Fix: write test that triggers cleanup and asserts artifact removal
→ Reasoning: cleanup path is untested, silent failure possible
## Verdict
VERDICT: MECHANISMS HOLD — PASS.
EVAL
cat > "$dir/nodes/$node/handshake.json" << HSEOF

@@ -87,3 +100,4 @@ {

{"type": "eval", "path": "run_1/eval-security.md"},
{"type": "eval", "path": "run_1/eval-performance.md"}
{"type": "eval", "path": "run_1/eval-performance.md"},
{"type": "eval", "path": "run_1/eval-skeptic-owner.md"}
],

@@ -90,0 +104,0 @@ "verdict": null

@@ -132,2 +132,19 @@ #!/bin/bash

cat > .harness/nodes/code-review/run_1/eval-skeptic-owner.md <<'SOEOF'
# Skeptic-Owner Evaluation
## Mechanism Audit
🔵 src/config.ts:1 — Config values not validated at startup
→ Add runtime validation with zod schema at boot
Reasoning: Invalid config will cause runtime errors instead of fast startup failure.
## Lifecycle
🔵 src/server.ts:5 — No graceful shutdown handler
→ Add SIGTERM handler that drains connections
Reasoning: Hard shutdown drops in-flight requests during deployment.
## Summary
2 suggestions. No critical or warning issues.
SOEOF
OUT=$($HARNESS synthesize .harness --node code-review 2>/dev/null)

@@ -134,0 +151,0 @@ # Short eval has reasoning + fix + file ref → substance exempt from thinEval

@@ -117,2 +117,18 @@ #!/bin/bash

EVAL
cat > .h-synth2/nodes/code-review/run_1/eval-skeptic-owner.md <<'SOEOF'
# Skeptic-Owner Evaluation
## Mechanism Audit
🔵 src/config.ts:1 — Config values not validated at startup
→ Add runtime validation with zod schema at boot
Reasoning: Invalid config will cause runtime errors instead of fast startup failure.
## Lifecycle
🔵 src/server.ts:5 — No graceful shutdown handler
→ Add SIGTERM handler that drains connections
Reasoning: Hard shutdown drops in-flight requests during deployment.
## Summary
2 suggestions. No critical or warning issues.
SOEOF
OUT=$($HARNESS synthesize .h-synth2 --node code-review 2>/dev/null)

@@ -190,2 +206,18 @@ assert_field_eq "thorough eval PASS" "$OUT" "verdict" "\"PASS\""

EVAL
cat > .h-synth3/nodes/code-review/run_1/eval-skeptic-owner.md <<'SOEOF'
# Skeptic-Owner Evaluation
## Mechanism Audit
🔵 src/config.ts:1 — Config values not validated at startup
→ Add runtime validation with zod schema at boot
Reasoning: Invalid config will cause runtime errors instead of fast startup failure.
## Lifecycle
🔵 src/server.ts:5 — No graceful shutdown handler
→ Add SIGTERM handler that drains connections
Reasoning: Hard shutdown drops in-flight requests during deployment.
## Summary
2 suggestions. No critical or warning issues.
SOEOF
OUT=$($HARNESS synthesize .h-synth3 --node code-review 2>/dev/null)

@@ -258,2 +290,18 @@ assert_field_eq "no tier PASS" "$OUT" "verdict" "\"PASS\""

EVAL
cat > .h-synth4/nodes/code-review/run_1/eval-skeptic-owner.md <<'SOEOF'
# Skeptic-Owner Evaluation
## Mechanism Audit
🔵 src/config.ts:1 — Config values not validated at startup
→ Add runtime validation with zod schema at boot
Reasoning: Invalid config will cause runtime errors instead of fast startup failure.
## Lifecycle
🔵 src/server.ts:5 — No graceful shutdown handler
→ Add SIGTERM handler that drains connections
Reasoning: Hard shutdown drops in-flight requests during deployment.
## Summary
2 suggestions. No critical or warning issues.
SOEOF
OUT=$($HARNESS synthesize .h-synth4 --node code-review 2>/dev/null)

@@ -260,0 +308,0 @@ assert_field_eq "functional PASS" "$OUT" "verdict" "\"PASS\""