@touchskyer/opc
Advanced tools
| import { after, describe, test } from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import { execFileSync } from "node:child_process"; | ||
| import { createHash } from "node:crypto"; | ||
| import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | ||
| import { homedir } from "node:os"; | ||
| import { dirname, join } from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
| const TMPBASE = mkdtempSync(join(homedir(), ".opc", "sessions", "opc-authority-depth-")); | ||
| const HARNESS = join(dirname(fileURLToPath(import.meta.url)), "..", "opc-harness.mjs"); | ||
| const TS0 = "2026-01-01T00:00:00.000Z"; | ||
| after(() => rmSync(TMPBASE, { recursive: true, force: true })); | ||
| function runHarness(command, args) { | ||
| try { | ||
| const out = execFileSync("node", [HARNESS, command, ...args], { | ||
| encoding: "utf8", | ||
| stdio: ["pipe", "pipe", "pipe"], | ||
| }); | ||
| return JSON.parse(out.trim().split("\n").pop()); | ||
| } catch (error) { | ||
| const out = String(error.stdout || "").trim(); | ||
| if (out) return JSON.parse(out.split("\n").pop()); | ||
| return { error: error.message, stderr: String(error.stderr || "") }; | ||
| } | ||
| } | ||
| function writeState(dir, state) { | ||
| mkdirSync(dir, { recursive: true }); | ||
| writeFileSync(join(dir, "flow-state.json"), JSON.stringify({ | ||
| version: "1.0", | ||
| flowTemplate: "build-verify", | ||
| currentNode: "gate", | ||
| entryNode: "test-execute", | ||
| totalSteps: 1, | ||
| maxTotalSteps: 25, | ||
| maxLoopsPerEdge: 3, | ||
| maxNodeReentry: 5, | ||
| history: [{ nodeId: "test-execute", runId: "run_1", timestamp: TS0 }], | ||
| edgeCounts: {}, | ||
| repairEdgeCounts: {}, | ||
| _written_by: "opc-harness", | ||
| _write_nonce: "authority-depth-test", | ||
| _last_modified: TS0, | ||
| ...state, | ||
| }, null, 2)); | ||
| } | ||
| function writeFlowFile(dir, name, flow) { | ||
| const path = join(dir, `${name}.json`); | ||
| writeFileSync(path, JSON.stringify({ opc_compat: ">=0.0", ...flow }, null, 2)); | ||
| return path; | ||
| } | ||
| function sha256(text) { | ||
| return createHash("sha256").update(text).digest("hex"); | ||
| } | ||
| function writeExactAndCanonical(dir, nodeId, runId, exact) { | ||
| const runDir = join(dir, "nodes", nodeId, runId); | ||
| mkdirSync(runDir, { recursive: true }); | ||
| writeFileSync(join(runDir, "handshake.json"), JSON.stringify(exact, null, 2)); | ||
| const canonical = { | ||
| ...exact, | ||
| artifacts: exact.artifacts.map((artifact) => ({ | ||
| ...artifact, | ||
| path: artifact.path.startsWith("../") ? artifact.path.slice(3) : `${runId}/${artifact.path}`, | ||
| })), | ||
| }; | ||
| writeFileSync(join(dir, "nodes", nodeId, "handshake.json"), JSON.stringify(canonical, null, 2)); | ||
| } | ||
| function writeExactHandshake(dir, nodeId, runId, exact) { | ||
| const runDir = join(dir, "nodes", nodeId, runId); | ||
| mkdirSync(runDir, { recursive: true }); | ||
| writeFileSync(join(runDir, "handshake.json"), JSON.stringify(exact, null, 2)); | ||
| } | ||
| function passEval(name) { | ||
| return [ | ||
| `# ${name}`, | ||
| "", | ||
| `${name} independently reviewed the artifact and found no blocker.`, | ||
| `${name} checked authority metadata, artifact references, and verdict routing.`, | ||
| "", | ||
| "VERDICT: PASS FINDINGS[0]", | ||
| "", | ||
| ].join("\n"); | ||
| } | ||
| function treeSnapshot(dir) { | ||
| const out = {}; | ||
| function walk(rel) { | ||
| for (const entry of readdirSync(join(dir, rel), { withFileTypes: true })) { | ||
| const child = rel ? `${rel}/${entry.name}` : entry.name; | ||
| if (entry.isDirectory()) walk(child); | ||
| else if (entry.isFile()) out[child] = readFileSync(join(dir, child), "utf8"); | ||
| } | ||
| } | ||
| walk(""); | ||
| return out; | ||
| } | ||
| describe("deep authority invariants", () => { | ||
| test("validate-chain rejects test-result without deep OPC provenance", () => { | ||
| const dir = join(TMPBASE, "chain-provenance"); | ||
| const runDir = join(dir, "nodes", "test-execute", "run_1"); | ||
| mkdirSync(runDir, { recursive: true }); | ||
| writeFileSync(join(runDir, "test-result.json"), JSON.stringify({ | ||
| checks: [{ id: "smoke", pass: true, total: 1 }], | ||
| summary: { failed: [] }, | ||
| }, null, 2)); | ||
| writeState(dir); | ||
| writeExactAndCanonical(dir, "test-execute", "run_1", { | ||
| nodeId: "test-execute", | ||
| nodeType: "execute", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "execute passed", | ||
| timestamp: TS0, | ||
| artifacts: [{ type: "test-result", path: "test-result.json" }], | ||
| }); | ||
| const result = runHarness("validate-chain", ["--dir", dir]); | ||
| assert.equal(result.valid, false, JSON.stringify(result)); | ||
| assert.match(result.errors.join("\n"), /testCommand provenance|signed provenance ledger/); | ||
| }); | ||
| test("seal rejects current-run canonical that lacks exact-run corroboration", () => { | ||
| const dir = join(TMPBASE, "seal-current-impersonation"); | ||
| const runDir = join(dir, "nodes", "test-execute", "run_1"); | ||
| mkdirSync(runDir, { recursive: true }); | ||
| const resultText = JSON.stringify({ checks: [{ id: "smoke", pass: true, total: 1 }] }, null, 2); | ||
| writeFileSync(join(runDir, "test-command-result.json"), resultText); | ||
| writeState(dir, { currentNode: "test-execute" }); | ||
| writeFileSync(join(dir, "nodes", "test-execute", "handshake.json"), JSON.stringify({ | ||
| nodeId: "test-execute", | ||
| nodeType: "execute", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "forged current canonical", | ||
| timestamp: TS0, | ||
| artifacts: [{ type: "test-result", path: "run_1/test-command-result.json" }], | ||
| testEvidenceProvenance: { | ||
| kind: "opc-test-command", | ||
| sourceNode: "test-design", | ||
| sourceRunId: "run_1", | ||
| commandHash: sha256("npm test"), | ||
| sourcePlanHash: "plan", | ||
| resultHash: sha256(resultText), | ||
| executionActor: "opc-harness:test-command", | ||
| ledger: { kind: "opc-hmac-ledger", recordHash: "forged" }, | ||
| }, | ||
| testEvidencePolicy: { allowVacuousChecks: [] }, | ||
| }, null, 2)); | ||
| const result = runHarness("seal", ["--node", "test-execute", "--dir", dir]); | ||
| assert.equal(result.sealed, false, JSON.stringify(result)); | ||
| assert.match(result.validationErrors.join("\n"), /exact handshake missing|not exact-run projection/); | ||
| }); | ||
| test("validate-chain rejects absolute artifact path authority aliases", () => { | ||
| const dir = join(TMPBASE, "artifact-alias"); | ||
| mkdirSync(dir, { recursive: true }); | ||
| const outside = join(dir, "outside.txt"); | ||
| writeFileSync(outside, "outside artifact\n"); | ||
| writeState(dir, { | ||
| currentNode: "build", | ||
| entryNode: "build", | ||
| history: [{ nodeId: "build", runId: "run_1", timestamp: TS0 }], | ||
| }); | ||
| writeExactAndCanonical(dir, "build", "run_1", { | ||
| nodeId: "build", | ||
| nodeType: "build", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "absolute path artifact", | ||
| timestamp: TS0, | ||
| artifacts: [{ type: "source", path: outside }], | ||
| }); | ||
| const result = runHarness("validate-chain", ["--dir", dir]); | ||
| assert.equal(result.valid, false, JSON.stringify(result)); | ||
| assert.match(result.errors.join("\n"), /must be relative|invalid/); | ||
| }); | ||
| test("review sealed FAIL can structurally advance to gate", () => { | ||
| const dir = join(TMPBASE, "review-fail-to-gate"); | ||
| writeState(dir, { | ||
| flowTemplate: "review", | ||
| currentNode: "review", | ||
| entryNode: "review", | ||
| history: [{ nodeId: "review", runId: "run_1", timestamp: TS0 }], | ||
| }); | ||
| const runDir = join(dir, "nodes", "review", "run_1"); | ||
| mkdirSync(runDir, { recursive: true }); | ||
| writeFileSync(join(runDir, "eval-alpha.md"), passEval("alpha")); | ||
| writeFileSync(join(runDir, "eval-beta.md"), passEval("beta")); | ||
| writeExactAndCanonical(dir, "review", "run_1", { | ||
| nodeId: "review", | ||
| nodeType: "review", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "FAIL", | ||
| summary: "review found blockers", | ||
| timestamp: TS0, | ||
| artifacts: [ | ||
| { type: "eval", path: "eval-alpha.md" }, | ||
| { type: "eval", path: "eval-beta.md" }, | ||
| ], | ||
| }); | ||
| const result = runHarness("transition", [ | ||
| "--from", "review", "--to", "gate", "--verdict", "PASS", "--flow", "review", "--dir", dir, | ||
| ]); | ||
| assert.equal(result.allowed, true, JSON.stringify(result)); | ||
| const state = JSON.parse(readFileSync(join(dir, "flow-state.json"), "utf8")); | ||
| assert.equal(state.currentNode, "gate"); | ||
| }); | ||
| test("seal rejects malformed stale canonical without mutating fixture tree", () => { | ||
| const dir = join(TMPBASE, "stale-malformed-canonical"); | ||
| const run2 = join(dir, "nodes", "build", "run_2"); | ||
| mkdirSync(run2, { recursive: true }); | ||
| writeFileSync(join(run2, "payload.md"), "new payload\n"); | ||
| writeState(dir, { | ||
| currentNode: "build", | ||
| entryNode: "build", | ||
| history: [ | ||
| { nodeId: "build", runId: "run_1", timestamp: TS0 }, | ||
| { nodeId: "build", runId: "run_2", timestamp: "2026-01-01T00:01:00.000Z" }, | ||
| ], | ||
| }); | ||
| writeExactHandshake(dir, "build", "run_1", { | ||
| nodeId: "build", | ||
| nodeType: "build", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "prior exact run exists", | ||
| timestamp: TS0, | ||
| artifacts: [], | ||
| }); | ||
| writeFileSync(join(dir, "nodes", "build", "handshake.json"), JSON.stringify({ runId: "run_1" }, null, 2)); | ||
| const before = treeSnapshot(dir); | ||
| const result = runHarness("seal", ["--node", "build", "--dir", dir]); | ||
| assert.equal(result.sealed, false, JSON.stringify(result)); | ||
| assert.doesNotMatch(result.validationErrors.join("\n"), /exact handshake missing/); | ||
| assert.match(result.validationErrors.join("\n"), /canonical/); | ||
| assert.deepEqual(treeSnapshot(dir), before); | ||
| }); | ||
| test("seal rejects stale canonical projection conflict without mutating fixture tree", () => { | ||
| const dir = join(TMPBASE, "stale-projection-conflict"); | ||
| const run1 = join(dir, "nodes", "build", "run_1"); | ||
| const run2 = join(dir, "nodes", "build", "run_2"); | ||
| mkdirSync(run1, { recursive: true }); | ||
| mkdirSync(run2, { recursive: true }); | ||
| writeFileSync(join(run1, "payload.md"), "prior exact payload\n"); | ||
| writeFileSync(join(run2, "payload.md"), "new payload\n"); | ||
| writeState(dir, { | ||
| currentNode: "build", | ||
| entryNode: "build", | ||
| history: [ | ||
| { nodeId: "build", runId: "run_1", timestamp: TS0 }, | ||
| { nodeId: "build", runId: "run_2", timestamp: "2026-01-01T00:01:00.000Z" }, | ||
| ], | ||
| }); | ||
| writeExactHandshake(dir, "build", "run_1", { | ||
| nodeId: "build", | ||
| nodeType: "build", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "prior exact run", | ||
| timestamp: TS0, | ||
| artifacts: [{ type: "source", path: "payload.md" }], | ||
| }); | ||
| writeFileSync(join(dir, "nodes", "build", "handshake.json"), JSON.stringify({ | ||
| nodeId: "build", | ||
| nodeType: "build", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "stale canonical lies about prior exact", | ||
| timestamp: TS0, | ||
| artifacts: [{ type: "source", path: "run_1/payload.md" }], | ||
| }, null, 2)); | ||
| const before = treeSnapshot(dir); | ||
| const result = runHarness("seal", ["--node", "build", "--dir", dir]); | ||
| assert.equal(result.sealed, false, JSON.stringify(result)); | ||
| assert.match(result.validationErrors.join("\n"), /not exact-run projection/); | ||
| assert.deepEqual(treeSnapshot(dir), before); | ||
| }); | ||
| test("seal rejects orphan stale canonical even when orphan exact exists", () => { | ||
| const dir = join(TMPBASE, "stale-orphan-canonical"); | ||
| const run1 = join(dir, "nodes", "build", "run_1"); | ||
| const run2 = join(dir, "nodes", "build", "run_2"); | ||
| mkdirSync(run1, { recursive: true }); | ||
| mkdirSync(run2, { recursive: true }); | ||
| writeFileSync(join(run1, "payload.md"), "orphan payload\n"); | ||
| writeFileSync(join(run2, "payload.md"), "new payload\n"); | ||
| writeState(dir, { | ||
| currentNode: "build", | ||
| entryNode: "build", | ||
| history: [{ nodeId: "build", runId: "run_2", timestamp: TS0 }], | ||
| }); | ||
| writeExactAndCanonical(dir, "build", "run_1", { | ||
| nodeId: "build", | ||
| nodeType: "build", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "orphan stale run", | ||
| timestamp: TS0, | ||
| artifacts: [{ type: "source", path: "payload.md" }], | ||
| }); | ||
| const before = treeSnapshot(dir); | ||
| const result = runHarness("seal", ["--node", "build", "--dir", dir]); | ||
| assert.equal(result.sealed, false, JSON.stringify(result)); | ||
| assert.match(result.validationErrors.join("\n"), /not recorded in authoritative history/); | ||
| assert.deepEqual(treeSnapshot(dir), before); | ||
| }); | ||
| test("validate-chain rejects dot-slash artifact aliases", () => { | ||
| const dir = join(TMPBASE, "dot-slash-alias"); | ||
| const runDir = join(dir, "nodes", "build", "run_1"); | ||
| mkdirSync(runDir, { recursive: true }); | ||
| writeFileSync(join(runDir, "payload.md"), "exact payload\n"); | ||
| writeFileSync(join(dir, "nodes", "build", "payload.md"), "canonical payload\n"); | ||
| writeState(dir, { | ||
| currentNode: "build", | ||
| entryNode: "build", | ||
| history: [{ nodeId: "build", runId: "run_1", timestamp: TS0 }], | ||
| }); | ||
| writeExactAndCanonical(dir, "build", "run_1", { | ||
| nodeId: "build", | ||
| nodeType: "build", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "dot alias", | ||
| timestamp: TS0, | ||
| artifacts: [{ type: "source", path: "./payload.md" }], | ||
| }); | ||
| const before = treeSnapshot(dir); | ||
| const result = runHarness("validate-chain", ["--dir", dir]); | ||
| assert.equal(result.valid, false, JSON.stringify(result)); | ||
| assert.match(result.errors.join("\n"), /clean relative path|invalid/); | ||
| assert.deepEqual(treeSnapshot(dir), before); | ||
| }); | ||
| test("validate-chain rejects artifact paths pinned to the wrong run_N", () => { | ||
| const dir = join(TMPBASE, "wrong-run-alias"); | ||
| const runDir = join(dir, "nodes", "build", "run_1", "run_2"); | ||
| mkdirSync(runDir, { recursive: true }); | ||
| writeFileSync(join(runDir, "payload.md"), "wrong nested payload\n"); | ||
| writeState(dir, { | ||
| currentNode: "build", | ||
| entryNode: "build", | ||
| history: [{ nodeId: "build", runId: "run_1", timestamp: TS0 }], | ||
| }); | ||
| writeExactAndCanonical(dir, "build", "run_1", { | ||
| nodeId: "build", | ||
| nodeType: "build", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "wrong run path", | ||
| timestamp: TS0, | ||
| artifacts: [{ type: "source", path: "run_2/payload.md" }], | ||
| }); | ||
| const before = treeSnapshot(dir); | ||
| const result = runHarness("validate-chain", ["--dir", dir]); | ||
| assert.equal(result.valid, false, JSON.stringify(result)); | ||
| assert.match(result.errors.join("\n"), /run_N-prefixed|must start with 'run_1\/'/); | ||
| assert.deepEqual(treeSnapshot(dir), before); | ||
| }); | ||
| test("validate-chain rejects traversal structured paths without stack trace", () => { | ||
| const dir = join(TMPBASE, "structured-traversal"); | ||
| const runDir = join(dir, "nodes", "test-execute", "run_1"); | ||
| mkdirSync(runDir, { recursive: true }); | ||
| writeFileSync(join(runDir, "test-result.json"), JSON.stringify({ checks: [] }, null, 2)); | ||
| writeState(dir); | ||
| writeExactAndCanonical(dir, "test-execute", "run_1", { | ||
| nodeId: "test-execute", | ||
| nodeType: "execute", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "bad structured path", | ||
| timestamp: TS0, | ||
| artifacts: [{ type: "test-result", path: "../test-result.json" }], | ||
| }); | ||
| const before = treeSnapshot(dir); | ||
| const result = runHarness("validate-chain", ["--dir", dir]); | ||
| assert.equal(result.valid, false, JSON.stringify(result)); | ||
| assert.equal(result.stderr || "", ""); | ||
| assert.match(result.errors.join("\n"), /traversal|escapes run directory|invalid path/); | ||
| assert.deepEqual(treeSnapshot(dir), before); | ||
| }); | ||
| test("gate transition rejects absolute structured path without stack trace", () => { | ||
| const dir = join(TMPBASE, "gate-absolute-structured"); | ||
| const runDir = join(dir, "nodes", "test-execute", "run_1"); | ||
| mkdirSync(runDir, { recursive: true }); | ||
| const absolute = join(runDir, "test-result.json"); | ||
| writeFileSync(absolute, JSON.stringify({ checks: [] }, null, 2)); | ||
| writeState(dir, { | ||
| currentNode: "gate", | ||
| entryNode: "test-execute", | ||
| history: [ | ||
| { nodeId: "test-execute", runId: "run_1", timestamp: TS0 }, | ||
| { nodeId: "gate", runId: "run_1", timestamp: "2026-01-01T00:01:00.000Z" }, | ||
| ], | ||
| }); | ||
| writeExactAndCanonical(dir, "test-execute", "run_1", { | ||
| nodeId: "test-execute", | ||
| nodeType: "execute", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "absolute structured path", | ||
| timestamp: TS0, | ||
| artifacts: [{ type: "test-result", path: absolute }], | ||
| }); | ||
| writeExactAndCanonical(dir, "gate", "run_1", { | ||
| nodeId: "gate", | ||
| nodeType: "gate", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "ready", | ||
| timestamp: TS0, | ||
| artifacts: [], | ||
| }); | ||
| const before = treeSnapshot(dir); | ||
| const result = runHarness("transition", [ | ||
| "--from", "gate", "--to", "null", "--verdict", "PASS", "--flow", "build-verify", "--dir", dir, | ||
| ]); | ||
| assert.notEqual(result.allowed, true, JSON.stringify(result)); | ||
| assert.notEqual(result.finalized, true, JSON.stringify(result)); | ||
| assert.equal(result.stderr || "", ""); | ||
| assert.match(JSON.stringify(result), /must be relative|fail-closed|invalid/); | ||
| assert.deepEqual(treeSnapshot(dir), before); | ||
| }); | ||
| test("validate-chain allows whitelisted node-level authority artifact", () => { | ||
| const dir = join(TMPBASE, "node-level-control"); | ||
| const runDir = join(dir, "nodes", "build", "run_1"); | ||
| mkdirSync(runDir, { recursive: true }); | ||
| writeFileSync(join(dir, "nodes", "build", "build-brief.md"), "brief\n"); | ||
| writeState(dir, { | ||
| currentNode: "build", | ||
| entryNode: "build", | ||
| history: [{ nodeId: "build", runId: "run_1", timestamp: TS0 }], | ||
| }); | ||
| writeExactAndCanonical(dir, "build", "run_1", { | ||
| nodeId: "build", | ||
| nodeType: "build", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "node artifact", | ||
| timestamp: TS0, | ||
| artifacts: [{ type: "brief", path: "../build-brief.md" }], | ||
| }); | ||
| const result = runHarness("validate-chain", ["--dir", dir]); | ||
| assert.equal(result.valid, true, JSON.stringify(result)); | ||
| }); | ||
| }); |
| import { after, describe, test } from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import { execFileSync } from "node:child_process"; | ||
| import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; | ||
| import { homedir } from "node:os"; | ||
| import { dirname, join } from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
| const TMPBASE = mkdtempSync(join(homedir(), ".opc", "sessions", "opc-projection-")); | ||
| const HARNESS = join(dirname(fileURLToPath(import.meta.url)), "..", "opc-harness.mjs"); | ||
| const TS0 = "2026-01-01T00:00:00.000Z"; | ||
| after(() => rmSync(TMPBASE, { recursive: true, force: true })); | ||
| function runHarness(command, args) { | ||
| try { | ||
| const out = execFileSync("node", [HARNESS, command, ...args], { | ||
| encoding: "utf8", | ||
| stdio: ["pipe", "pipe", "pipe"], | ||
| }); | ||
| return JSON.parse(out.trim().split("\n").pop()); | ||
| } catch (error) { | ||
| const out = String(error.stdout || "").trim(); | ||
| if (out) return JSON.parse(out.split("\n").pop()); | ||
| return { error: error.message, stderr: String(error.stderr || "") }; | ||
| } | ||
| } | ||
| function writeState(dir, state) { | ||
| mkdirSync(dir, { recursive: true }); | ||
| writeFileSync(join(dir, "flow-state.json"), JSON.stringify({ | ||
| version: "1.0", | ||
| flowTemplate: "build-verify", | ||
| currentNode: "build", | ||
| entryNode: "build", | ||
| totalSteps: 1, | ||
| maxTotalSteps: 25, | ||
| maxLoopsPerEdge: 3, | ||
| maxNodeReentry: 5, | ||
| history: [{ nodeId: "build", runId: "run_1", timestamp: TS0 }], | ||
| edgeCounts: {}, | ||
| repairEdgeCounts: {}, | ||
| _written_by: "opc-harness", | ||
| _write_nonce: "projection-test", | ||
| _last_modified: TS0, | ||
| ...state, | ||
| }, null, 2)); | ||
| } | ||
| function writeFlowFile(dir, name, flow) { | ||
| mkdirSync(dir, { recursive: true }); | ||
| const path = join(dir, `${name}.json`); | ||
| writeFileSync(path, JSON.stringify({ opc_compat: ">=0.0", ...flow }, null, 2)); | ||
| return path; | ||
| } | ||
| function passEval(name) { | ||
| return [ | ||
| `# ${name}`, | ||
| "", | ||
| "Reviewed the selected exact run and found no blocking issues.", | ||
| "The evidence is internally consistent for this regression case.", | ||
| "", | ||
| "VERDICT: PASS FINDINGS[0]", | ||
| ].join("\n"); | ||
| } | ||
| function writeBuildRun(dir) { | ||
| const runDir = join(dir, "nodes", "build", "run_1"); | ||
| mkdirSync(runDir, { recursive: true }); | ||
| writeFileSync(join(runDir, "output.md"), "built\n"); | ||
| const exact = { | ||
| nodeId: "build", | ||
| nodeType: "build", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "built", | ||
| timestamp: TS0, | ||
| artifacts: [{ type: "source", path: "output.md" }], | ||
| }; | ||
| writeFileSync(join(runDir, "handshake.json"), JSON.stringify(exact, null, 2)); | ||
| writeFileSync(join(dir, "nodes", "build", "handshake.json"), JSON.stringify({ | ||
| ...exact, | ||
| artifacts: [{ type: "source", path: "run_1/output.md" }], | ||
| }, null, 2)); | ||
| } | ||
| function writeReviewRun(dir, canonicalVerdict = "PASS") { | ||
| const runDir = join(dir, "nodes", "review", "run_1"); | ||
| mkdirSync(runDir, { recursive: true }); | ||
| writeFileSync(join(runDir, "eval-a.md"), passEval("Review A")); | ||
| writeFileSync(join(runDir, "eval-b.md"), passEval("Review B")); | ||
| const exact = { | ||
| nodeId: "review", | ||
| nodeType: "review", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "review passed", | ||
| timestamp: TS0, | ||
| artifacts: [ | ||
| { type: "eval", path: "eval-a.md" }, | ||
| { type: "eval", path: "eval-b.md" }, | ||
| ], | ||
| }; | ||
| writeFileSync(join(runDir, "handshake.json"), JSON.stringify(exact, null, 2)); | ||
| writeFileSync(join(dir, "nodes", "review", "handshake.json"), JSON.stringify({ | ||
| ...exact, | ||
| verdict: canonicalVerdict, | ||
| artifacts: exact.artifacts.map((a) => ({ ...a, path: `run_1/${a.path}` })), | ||
| }, null, 2)); | ||
| } | ||
| describe("canonical projection authority", () => { | ||
| test("validate-chain rejects canonical fields that differ from exact run", () => { | ||
| const dir = join(TMPBASE, "validate-chain"); | ||
| writeState(dir); | ||
| writeBuildRun(dir); | ||
| const path = join(dir, "nodes", "build", "handshake.json"); | ||
| const canonical = JSON.parse(readFileSync(path, "utf8")); | ||
| canonical.verdict = "FAIL"; | ||
| writeFileSync(path, JSON.stringify(canonical, null, 2)); | ||
| const result = runHarness("validate-chain", ["--dir", dir]); | ||
| assert.equal(result.valid, false, JSON.stringify(result)); | ||
| assert.match(result.errors.join("\n"), /not exact-run projection/); | ||
| assert.match(result.errors.join("\n"), /\$\.verdict/); | ||
| }); | ||
| test("gate transition rejects upstream canonical projection mismatch", () => { | ||
| const dir = join(TMPBASE, "gate-transition"); | ||
| const flowFile = writeFlowFile(dir, "projection-flow", { | ||
| nodes: ["review", "gate", "done"], | ||
| edges: { review: { PASS: "gate" }, gate: { PASS: "done" }, done: { PASS: null } }, | ||
| limits: { maxLoopsPerEdge: 3, maxTotalSteps: 10, maxNodeReentry: 5 }, | ||
| nodeTypes: { review: "review", gate: "gate", done: "build" }, | ||
| }); | ||
| writeState(dir, { | ||
| flowTemplate: "projection-flow", | ||
| _flow_file: flowFile, | ||
| currentNode: "gate", | ||
| entryNode: "review", | ||
| history: [ | ||
| { nodeId: "review", runId: "run_1", timestamp: TS0 }, | ||
| { nodeId: "gate", runId: "run_1", timestamp: "2026-01-01T00:00:01.000Z" }, | ||
| ], | ||
| edgeCounts: { "review->gate": 1 }, | ||
| }); | ||
| writeReviewRun(dir, "FAIL"); | ||
| const result = runHarness("transition", [ | ||
| "--from", "gate", "--to", "done", "--verdict", "PASS", "--dir", dir, | ||
| ]); | ||
| assert.equal(result.allowed, false, JSON.stringify(result)); | ||
| assert.match(result.reason, /gate authority check failed/); | ||
| assert.match(result.reason, /not exact-run projection/); | ||
| }); | ||
| test("seal selected loopback run replaces stale canonical", () => { | ||
| const dir = join(TMPBASE, "seal-loopback"); | ||
| writeState(dir, { | ||
| flowTemplate: "review", | ||
| currentNode: "review", | ||
| entryNode: "review", | ||
| totalSteps: 2, | ||
| history: [ | ||
| { nodeId: "review", runId: "run_1", timestamp: TS0 }, | ||
| { nodeId: "gate", runId: "run_1", timestamp: "2026-01-01T00:00:01.000Z" }, | ||
| { nodeId: "review", runId: "run_2", timestamp: "2026-01-01T00:00:02.000Z" }, | ||
| ], | ||
| }); | ||
| writeReviewRun(dir); | ||
| const run2 = join(dir, "nodes", "review", "run_2"); | ||
| mkdirSync(run2, { recursive: true }); | ||
| writeFileSync(join(run2, "eval-a.md"), passEval("Review A")); | ||
| writeFileSync(join(run2, "eval-b.md"), passEval("Review B")); | ||
| const result = runHarness("seal", ["--node", "review", "--dir", dir]); | ||
| assert.equal(result.sealed, true, JSON.stringify(result)); | ||
| const canonical = JSON.parse(readFileSync(join(dir, "nodes", "review", "handshake.json"), "utf8")); | ||
| const exact = JSON.parse(readFileSync(join(run2, "handshake.json"), "utf8")); | ||
| assert.equal(canonical.runId, "run_2"); | ||
| assert.equal(exact.runId, "run_2"); | ||
| assert.ok(canonical.artifacts.every((a) => a.path.startsWith("run_2/"))); | ||
| assert.ok(exact.artifacts.every((a) => !a.path.startsWith("run_2/"))); | ||
| }); | ||
| test("strict finalize rejects exact terminal run missing evidence policy", () => { | ||
| const dir = join(TMPBASE, "finalize-exact"); | ||
| const flowFile = writeFlowFile(dir, "terminal-execute-flow", { | ||
| nodes: ["test-execute"], | ||
| edges: { "test-execute": { PASS: null } }, | ||
| limits: { maxLoopsPerEdge: 3, maxTotalSteps: 10, maxNodeReentry: 5 }, | ||
| nodeTypes: { "test-execute": "execute" }, | ||
| }); | ||
| writeState(dir, { | ||
| flowTemplate: "terminal-execute-flow", | ||
| _flow_file: flowFile, | ||
| currentNode: "test-execute", | ||
| entryNode: "test-execute", | ||
| history: [{ nodeId: "test-execute", runId: "run_1", timestamp: TS0 }], | ||
| }); | ||
| const runDir = join(dir, "nodes", "test-execute", "run_1"); | ||
| mkdirSync(runDir, { recursive: true }); | ||
| writeFileSync(join(runDir, "test-command-result.json"), JSON.stringify({ | ||
| summary: { failed: [] }, | ||
| checks: [{ id: "smoke", pass: true, total: 1 }], | ||
| }, null, 2)); | ||
| const base = { | ||
| nodeId: "test-execute", | ||
| nodeType: "execute", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "terminal execute", | ||
| timestamp: TS0, | ||
| artifacts: [{ type: "test-result", path: "test-command-result.json" }], | ||
| testEvidenceProvenance: { | ||
| kind: "opc-test-command", | ||
| executionActor: "opc-harness:test-command", | ||
| sourceNode: "test-design", | ||
| sourceRunId: "run_1", | ||
| commandHash: "cmd", | ||
| sourcePlanHash: "plan", | ||
| resultHash: "result", | ||
| ledger: { kind: "opc-hmac-ledger", recordHash: "record" }, | ||
| }, | ||
| }; | ||
| writeFileSync(join(runDir, "handshake.json"), JSON.stringify({ ...base, testEvidencePolicy: null }, null, 2)); | ||
| writeFileSync(join(dir, "nodes", "test-execute", "handshake.json"), JSON.stringify({ | ||
| ...base, | ||
| artifacts: [{ type: "test-result", path: "run_1/test-command-result.json" }], | ||
| testEvidencePolicy: { allowVacuousChecks: [] }, | ||
| }, null, 2)); | ||
| const result = runHarness("finalize", ["--strict", "--dir", dir]); | ||
| assert.equal(result.finalized, false, JSON.stringify(result)); | ||
| assert.match(result.error, /testEvidencePolicy must be a non-null object|not exact-run projection/); | ||
| const state = JSON.parse(readFileSync(join(dir, "flow-state.json"), "utf8")); | ||
| assert.notEqual(state.status, "completed"); | ||
| }); | ||
| }); |
| import { after, describe, test } from "node:test"; | ||
| import assert from "node:assert/strict"; | ||
| import { | ||
| existsSync, | ||
| mkdirSync, | ||
| mkdtempSync, | ||
| readFileSync, | ||
| rmSync, | ||
| writeFileSync, | ||
| } from "node:fs"; | ||
| import { homedir } from "node:os"; | ||
| import { dirname, join } from "node:path"; | ||
| import { fileURLToPath } from "node:url"; | ||
| import { execFileSync } from "node:child_process"; | ||
| import { resolveNodeExtensionContext } from "./ext-commands.mjs"; | ||
| import { checkStructuredResults } from "./flow-transition.mjs"; | ||
| import { buildCumulativeFindingsMarkdown } from "./cumulative-findings.mjs"; | ||
| const TMPBASE = mkdtempSync(join(homedir(), ".opc", "sessions", "opc-authority-")); | ||
| const HARNESS = join(dirname(fileURLToPath(import.meta.url)), "..", "opc-harness.mjs"); | ||
| after(() => rmSync(TMPBASE, { recursive: true, force: true })); | ||
| function runHarness(command, args, options = {}) { | ||
| try { | ||
| const output = execFileSync("node", [HARNESS, command, ...args], { | ||
| encoding: "utf8", | ||
| stdio: ["pipe", "pipe", "pipe"], | ||
| env: { ...process.env, ...(options.env || {}) }, | ||
| }); | ||
| return JSON.parse(output.trim().split("\n").pop()); | ||
| } catch (error) { | ||
| const output = String(error.stdout || "").trim(); | ||
| if (output) return JSON.parse(output.split("\n").pop()); | ||
| return { error: error.message, stderr: String(error.stderr || "") }; | ||
| } | ||
| } | ||
| function runHarnessRaw(command, args, options = {}) { | ||
| try { | ||
| return { | ||
| status: 0, | ||
| stdout: execFileSync("node", [HARNESS, command, ...args], { | ||
| encoding: "utf8", | ||
| stdio: ["pipe", "pipe", "pipe"], | ||
| env: { ...process.env, ...(options.env || {}) }, | ||
| }), | ||
| stderr: "", | ||
| }; | ||
| } catch (error) { | ||
| return { | ||
| status: error.status || 1, | ||
| stdout: String(error.stdout || ""), | ||
| stderr: String(error.stderr || ""), | ||
| }; | ||
| } | ||
| } | ||
| function writeState(dir, state) { | ||
| mkdirSync(dir, { recursive: true }); | ||
| writeFileSync(join(dir, "flow-state.json"), JSON.stringify({ | ||
| version: "1.0", | ||
| flowTemplate: "build-verify", | ||
| currentNode: "build", | ||
| entryNode: "build", | ||
| totalSteps: 1, | ||
| maxTotalSteps: 25, | ||
| maxLoopsPerEdge: 3, | ||
| maxNodeReentry: 5, | ||
| history: [{ nodeId: "build", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" }], | ||
| edgeCounts: {}, | ||
| repairEdgeCounts: {}, | ||
| _written_by: "opc-harness", | ||
| _write_nonce: "authority-test", | ||
| _last_modified: "2026-01-01T00:00:00.000Z", | ||
| ...state, | ||
| }, null, 2)); | ||
| } | ||
| function writeBuildHandshake(dir, runId = "run_1") { | ||
| mkdirSync(join(dir, "nodes", "build", runId), { recursive: true }); | ||
| writeFileSync(join(dir, "nodes", "build", runId, "output.md"), "built"); | ||
| const handshake = { | ||
| nodeId: "build", | ||
| nodeType: "build", | ||
| runId, | ||
| status: "completed", | ||
| verdict: null, | ||
| summary: "built", | ||
| timestamp: "2026-01-01T00:00:00.000Z", | ||
| artifacts: [{ type: "source", path: `${runId}/output.md` }], | ||
| }; | ||
| writeFileSync(join(dir, "nodes", "build", runId, "handshake.json"), JSON.stringify({ | ||
| ...handshake, | ||
| artifacts: [{ type: "source", path: "output.md" }], | ||
| }, null, 2)); | ||
| writeFileSync(join(dir, "nodes", "build", "handshake.json"), JSON.stringify(handshake, null, 2)); | ||
| } | ||
| function writeReviewRun(dir, nodeId, runId, options = {}) { | ||
| const runDir = join(dir, "nodes", nodeId, runId); | ||
| mkdirSync(runDir, { recursive: true }); | ||
| writeFileSync(join(runDir, "eval-a.md"), "# Review A\n\nNo blocking findings.\n"); | ||
| writeFileSync(join(runDir, "eval-b.md"), "# Review B\n\nIndependent approval.\n"); | ||
| const handshake = { | ||
| nodeId, | ||
| nodeType: "review", | ||
| runId, | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "review passed", | ||
| timestamp: "2026-01-01T00:00:00.000Z", | ||
| artifacts: [ | ||
| { type: "eval", path: "eval-a.md" }, | ||
| { type: "eval", path: "eval-b.md" }, | ||
| ], | ||
| ...options, | ||
| }; | ||
| writeFileSync(join(runDir, "handshake.json"), JSON.stringify(handshake, null, 2)); | ||
| return handshake; | ||
| } | ||
| function writeTerminalHandshake(dir, nodeId, runId, nodeType = "build") { | ||
| mkdirSync(join(dir, "nodes", nodeId, runId), { recursive: true }); | ||
| const handshake = { | ||
| nodeId, | ||
| nodeType, | ||
| runId, | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "terminal node passed", | ||
| timestamp: "2026-01-01T00:00:00.000Z", | ||
| artifacts: [], | ||
| }; | ||
| writeFileSync(join(dir, "nodes", nodeId, runId, "handshake.json"), JSON.stringify(handshake, null, 2)); | ||
| writeFileSync(join(dir, "nodes", nodeId, "handshake.json"), JSON.stringify(handshake, null, 2)); | ||
| } | ||
| function writeFlowFile(dir, name, flow) { | ||
| mkdirSync(dir, { recursive: true }); | ||
| const path = join(dir, `${name}.json`); | ||
| writeFileSync(path, JSON.stringify({ opc_compat: ">=0.0", ...flow }, null, 2)); | ||
| return path; | ||
| } | ||
| function completeTestPlan() { | ||
| return [ | ||
| "# Test Plan", | ||
| "", | ||
| "Covers unit smoke checks with npm test.", | ||
| "Covers contract schema validation and edge case behavior.", | ||
| "Covers integration workflow and e2e flow behavior.", | ||
| "Covers UI visual screenshot responsive a11y and playwright checks.", | ||
| "Covers tier baseline polish, dark mode, navigation, and favicon checks.", | ||
| "", | ||
| "Run:", | ||
| "npm test", | ||
| "", | ||
| ].join("\n"); | ||
| } | ||
| describe("flow authority regressions", () => { | ||
| test("missing tier is treated as unknown/non-visual", () => { | ||
| const dir = join(TMPBASE, "missing-tier"); | ||
| mkdirSync(dir, { recursive: true }); | ||
| writeFileSync(join(dir, "acceptance-criteria.md"), "# Backend task\n"); | ||
| const flowFile = join(dir, "flow.json"); | ||
| writeFileSync(flowFile, JSON.stringify({ | ||
| opc_compat: ">=0.0", | ||
| nodes: ["build"], | ||
| edges: { build: { PASS: null } }, | ||
| limits: { maxLoopsPerEdge: 3, maxTotalSteps: 10, maxNodeReentry: 5 }, | ||
| nodeTypes: { build: "build" }, | ||
| nodeCapabilities: { build: ["design-intelligence@1"] }, | ||
| })); | ||
| writeState(dir, { | ||
| flowTemplate: "flow", | ||
| _flow_file: flowFile, | ||
| tier: undefined, | ||
| totalSteps: 0, | ||
| history: [], | ||
| }); | ||
| const context = resolveNodeExtensionContext(dir, "build", ["--dir", dir]); | ||
| assert.equal(context.tier, null); | ||
| assert.equal(context.visualEvaluationRequired, false); | ||
| assert.equal(context.nodeCapabilitiesResolved, true); | ||
| }); | ||
| test("seal uses the state-selected run, not filesystem latest", () => { | ||
| const dir = join(TMPBASE, "seal-selected-run"); | ||
| writeState(dir); | ||
| writeBuildHandshake(dir, "run_1"); | ||
| mkdirSync(join(dir, "nodes", "build", "run_2"), { recursive: true }); | ||
| writeFileSync(join(dir, "nodes", "build", "run_2", "rogue.md"), "rogue"); | ||
| const sealed = runHarness("seal", ["--node", "build", "--dir", dir]); | ||
| assert.equal(sealed.sealed, true, JSON.stringify(sealed)); | ||
| const handshake = JSON.parse(readFileSync(join(dir, "nodes", "build", "handshake.json"), "utf8")); | ||
| assert.equal(handshake.runId, "run_1"); | ||
| assert.ok(!handshake.artifacts.some((artifact) => artifact.path.includes("run_2"))); | ||
| const rogue = runHarness("seal", ["--node", "build", "--run", "2", "--dir", dir]); | ||
| assert.equal(rogue.sealed, false, JSON.stringify(rogue)); | ||
| assert.match(rogue.error, /does not match selected run/); | ||
| }); | ||
| test("seal validation failure leaves canonical handshake unchanged", () => { | ||
| const dir = join(TMPBASE, "seal-no-mutation"); | ||
| writeState(dir); | ||
| mkdirSync(join(dir, "nodes", "build", "run_1"), { recursive: true }); | ||
| writeFileSync(join(dir, "nodes", "build", "run_1", "broken.json"), "{broken"); | ||
| writeBuildHandshake(dir, "run_1"); | ||
| const handshakePath = join(dir, "nodes", "build", "handshake.json"); | ||
| const before = readFileSync(handshakePath, "utf8"); | ||
| const sealed = runHarness("seal", ["--node", "build", "--dir", dir]); | ||
| assert.equal(sealed.sealed, false, JSON.stringify(sealed)); | ||
| assert.match(sealed.validationErrors.join("\n"), /broken\.json/); | ||
| assert.equal(readFileSync(handshakePath, "utf8"), before); | ||
| }); | ||
| test("seal refuses to overwrite malformed canonical handshake", () => { | ||
| const dir = join(TMPBASE, "seal-malformed-canonical"); | ||
| writeState(dir); | ||
| mkdirSync(join(dir, "nodes", "build", "run_1"), { recursive: true }); | ||
| writeFileSync(join(dir, "nodes", "build", "run_1", "output.md"), "built"); | ||
| writeFileSync(join(dir, "nodes", "build", "run_1", "handshake.json"), JSON.stringify({ | ||
| nodeId: "build", | ||
| nodeType: "build", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: null, | ||
| summary: "selected run", | ||
| timestamp: "2026-01-01T00:00:00.000Z", | ||
| artifacts: [{ type: "source", path: "output.md" }], | ||
| }, null, 2)); | ||
| const handshakePath = join(dir, "nodes", "build", "handshake.json"); | ||
| writeFileSync(handshakePath, "{broken"); | ||
| const sealed = runHarness("seal", ["--node", "build", "--dir", dir]); | ||
| assert.equal(sealed.sealed, false, JSON.stringify(sealed)); | ||
| assert.match(sealed.validationErrors.join("\n"), /canonical handshake\.json parse error/); | ||
| assert.equal(readFileSync(handshakePath, "utf8"), "{broken"); | ||
| }); | ||
| test("stopped flows reject mutating commands", () => { | ||
| const dir = join(TMPBASE, "stopped"); | ||
| writeState(dir, { status: "stopped" }); | ||
| writeBuildHandshake(dir, "run_1"); | ||
| assert.match(runHarness("skip", ["--dir", dir]).error, /flow is stopped/); | ||
| assert.match(runHarness("goto", ["code-review", "--dir", dir]).error, /flow is stopped/); | ||
| assert.match(runHarness("seal", ["--node", "build", "--dir", dir]).error, /flow is stopped/); | ||
| assert.match( | ||
| runHarness("transition", [ | ||
| "--from", "build", "--to", "code-review", "--verdict", "PASS", "--dir", dir, | ||
| ]).reason, | ||
| /flow is stopped/, | ||
| ); | ||
| }); | ||
| test("stopped flows reject extension lifecycle writes and stop is idempotent", () => { | ||
| const dir = join(TMPBASE, "stopped-extensions"); | ||
| writeState(dir, { status: "stopped", stoppedAt: "2026-01-01T00:00:00.000Z" }); | ||
| writeBuildHandshake(dir, "run_1"); | ||
| const statePath = join(dir, "flow-state.json"); | ||
| const before = readFileSync(statePath, "utf8"); | ||
| assert.match(runHarness("prompt-context", ["--node", "build", "--role", "tester", "--dir", dir]).stderr, /flow is stopped/); | ||
| assert.match(runHarness("extension-verdict", ["--node", "build", "--dir", dir]).stderr, /flow is stopped/); | ||
| assert.match(runHarness("extension-artifact", ["--node", "build", "--dir", dir]).stderr, /flow is stopped/); | ||
| assert.match(runHarness("node-preflight", ["--node", "build", "--dir", dir]).stderr, /flow is stopped/); | ||
| const stoppedAgain = runHarness("stop", ["--dir", dir]); | ||
| assert.equal(stoppedAgain.alreadyStopped, true, JSON.stringify(stoppedAgain)); | ||
| assert.equal(readFileSync(statePath, "utf8"), before); | ||
| }); | ||
| test("stopped extension commands do not load extension top-level or startup code", () => { | ||
| const dir = join(TMPBASE, "stopped-extension-load"); | ||
| const extDir = join(dir, "exts"); | ||
| const markerDir = join(dir, "markers"); | ||
| const ext = join(extDir, "marker-ext"); | ||
| writeState(dir, { status: "stopped", stoppedAt: "2026-01-01T00:00:00.000Z" }); | ||
| writeBuildHandshake(dir, "run_1"); | ||
| mkdirSync(ext, { recursive: true }); | ||
| mkdirSync(markerDir, { recursive: true }); | ||
| writeFileSync(join(ext, "hook.mjs"), ` | ||
| import { writeFileSync } from "node:fs"; | ||
| import { join } from "node:path"; | ||
| if (process.env.OPC_MARKER_DIR) writeFileSync(join(process.env.OPC_MARKER_DIR, "top-level"), "loaded"); | ||
| export default { | ||
| meta: { name: "marker-ext", provides: ["design-system-injection@1"] }, | ||
| startup: { | ||
| check() { | ||
| writeFileSync(join(process.env.OPC_MARKER_DIR, "startup"), "checked"); | ||
| return { ok: true }; | ||
| } | ||
| }, | ||
| hooks: { "prompt.append": () => "marker" } | ||
| }; | ||
| `); | ||
| const result = runHarness("prompt-context", ["--node", "build", "--role", "tester", "--dir", dir], { | ||
| env: { OPC_EXTENSIONS_DIR: extDir, OPC_MARKER_DIR: markerDir }, | ||
| }); | ||
| assert.match(result.stderr, /flow is stopped/); | ||
| assert.equal(existsSync(join(markerDir, "top-level")), false); | ||
| assert.equal(existsSync(join(markerDir, "startup")), false); | ||
| }); | ||
| test("seal explicit empty --run fails before rewriting canonical bytes", () => { | ||
| const dir = join(TMPBASE, "seal-empty-run"); | ||
| writeState(dir); | ||
| writeBuildHandshake(dir, "run_1"); | ||
| const handshakePath = join(dir, "nodes", "build", "handshake.json"); | ||
| const before = readFileSync(handshakePath, "utf8"); | ||
| const eqEmpty = runHarness("seal", ["--node", "build", "--run=", "--dir", dir]); | ||
| const trailing = runHarness("seal", ["--node", "build", "--dir", dir, "--run"]); | ||
| assert.equal(eqEmpty.sealed, false, JSON.stringify(eqEmpty)); | ||
| assert.match(eqEmpty.error, /--run must be a positive numeric ordinal/); | ||
| assert.equal(trailing.sealed, false, JSON.stringify(trailing)); | ||
| assert.match(trailing.error, /--run must be a positive numeric ordinal/); | ||
| assert.equal(readFileSync(handshakePath, "utf8"), before); | ||
| }); | ||
| test("seal rejects parsed but invalid canonical handshake roots without rewriting", () => { | ||
| for (const [name, content, pattern] of [ | ||
| ["null", "null\n", /root must be a non-null object/], | ||
| ["array", "[]\n", /root must be a non-null object/], | ||
| ["scalar", "\"x\"\n", /root must be a non-null object/], | ||
| ["empty-object", "{}\n", /nodeId.*expected 'build'/], | ||
| ["partial-object", JSON.stringify({ nodeId: "build", runId: "run_1", status: "completed", artifacts: [] }), /missing or empty required field: nodeType|missing or empty required field: summary|missing or empty required field: timestamp/], | ||
| ["wrong-run", JSON.stringify({ nodeId: "build", nodeType: "build", runId: "run_2", status: "completed", artifacts: [] }), /runId.*expected 'run_1'/], | ||
| ]) { | ||
| const dir = join(TMPBASE, `seal-invalid-canonical-${name}`); | ||
| writeState(dir); | ||
| mkdirSync(join(dir, "nodes", "build", "run_1"), { recursive: true }); | ||
| writeFileSync(join(dir, "nodes", "build", "run_1", "output.md"), "built"); | ||
| const handshakePath = join(dir, "nodes", "build", "handshake.json"); | ||
| writeFileSync(handshakePath, content); | ||
| const result = runHarness("seal", ["--node", "build", "--dir", dir]); | ||
| assert.equal(result.sealed, false, `${name}: ${JSON.stringify(result)}`); | ||
| assert.match(result.validationErrors.join("\n"), pattern, name); | ||
| assert.equal(readFileSync(handshakePath, "utf8"), content, name); | ||
| } | ||
| }); | ||
| test("state-backed commands reject explicit flow identity mismatch", () => { | ||
| const dir = join(TMPBASE, "flow-identity"); | ||
| writeState(dir); | ||
| writeBuildHandshake(dir, "run_1"); | ||
| const skip = runHarness("skip", ["--flow", "quick", "--dir", dir]); | ||
| assert.match(skip.error, /persisted flow identity.*build-verify.*quick/); | ||
| const chain = runHarness("validate-chain", ["--flow", "quick", "--dir", dir]); | ||
| assert.equal(chain.valid, false, JSON.stringify(chain)); | ||
| assert.match(chain.errors.join("\n"), /persisted flow identity.*build-verify.*quick/); | ||
| }); | ||
| test("viz refuses explicit graph substitution when state has persisted flow identity", () => { | ||
| const dir = join(TMPBASE, "viz-flow-identity"); | ||
| writeState(dir); | ||
| const result = runHarnessRaw("viz", ["--flow", "quick", "--dir", dir]); | ||
| assert.notEqual(result.status, 0); | ||
| assert.match(result.stderr, /persisted flow identity.*build-verify.*quick/); | ||
| }); | ||
| test("validate and validate-chain reject canonical handshake run mismatch", () => { | ||
| const dir = join(TMPBASE, "validate-run-mismatch"); | ||
| writeState(dir); | ||
| mkdirSync(join(dir, "nodes", "build", "run_1"), { recursive: true }); | ||
| writeFileSync(join(dir, "nodes", "build", "run_1", "output.md"), "built"); | ||
| writeFileSync(join(dir, "nodes", "build", "run_1", "handshake.json"), JSON.stringify({ | ||
| nodeId: "build", | ||
| nodeType: "build", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: null, | ||
| summary: "selected run", | ||
| timestamp: "2026-01-01T00:00:00.000Z", | ||
| artifacts: [{ type: "source", path: "output.md" }], | ||
| }, null, 2)); | ||
| writeFileSync(join(dir, "nodes", "build", "handshake.json"), JSON.stringify({ | ||
| nodeId: "build", | ||
| nodeType: "build", | ||
| runId: "run_2", | ||
| status: "completed", | ||
| verdict: null, | ||
| summary: "wrong run", | ||
| timestamp: "2026-01-01T00:00:00.000Z", | ||
| artifacts: [{ type: "source", path: "run_1/output.md" }], | ||
| }, null, 2)); | ||
| const positional = runHarness("validate", [join(dir, "nodes", "build", "handshake.json")]); | ||
| const defaultValidate = runHarness("validate", ["--dir", dir]); | ||
| const chain = runHarness("validate-chain", ["--dir", dir]); | ||
| assert.equal(positional.valid, false, JSON.stringify(positional)); | ||
| assert.match(positional.errors.join("\n"), /runId.*expected 'run_1'/); | ||
| assert.equal(defaultValidate.valid, false, JSON.stringify(defaultValidate)); | ||
| assert.match(defaultValidate.errors.join("\n"), /runId.*expected 'run_1'/); | ||
| assert.equal(chain.valid, false, JSON.stringify(chain)); | ||
| assert.match(chain.errors.join("\n"), /runId.*expected 'run_1'/); | ||
| }); | ||
| test("positional run-scoped validate cannot self-authorize a non-selected run", () => { | ||
| const dir = join(TMPBASE, "validate-positional-run-scope"); | ||
| writeState(dir); | ||
| const run2 = join(dir, "nodes", "build", "run_2"); | ||
| mkdirSync(run2, { recursive: true }); | ||
| writeFileSync(join(run2, "handshake.json"), JSON.stringify({ | ||
| nodeId: "build", | ||
| nodeType: "build", | ||
| runId: "run_2", | ||
| status: "completed", | ||
| verdict: null, | ||
| summary: "valid but not selected", | ||
| timestamp: "2026-01-01T00:00:00.000Z", | ||
| artifacts: [], | ||
| }, null, 2)); | ||
| const result = runHarness("validate", [join(run2, "handshake.json")]); | ||
| assert.equal(result.valid, false, JSON.stringify(result)); | ||
| assert.match(result.errors.join("\n"), /path run is 'run_2', expected 'run_1'/); | ||
| }); | ||
| test("pass synthesizes authoritative upstream run instead of rogue latest run", () => { | ||
| const dir = join(TMPBASE, "pass-exact-upstream"); | ||
| const flowFile = join(dir, "authority-pass-flow.json"); | ||
| mkdirSync(dir, { recursive: true }); | ||
| writeFileSync(flowFile, JSON.stringify({ | ||
| opc_compat: ">=0.0", | ||
| nodes: ["review", "gate", "done"], | ||
| edges: { review: { PASS: "gate" }, gate: { PASS: "done" }, done: { PASS: null } }, | ||
| limits: { maxLoopsPerEdge: 3, maxTotalSteps: 10, maxNodeReentry: 5 }, | ||
| nodeTypes: { review: "review", gate: "gate", done: "build" }, | ||
| })); | ||
| writeState(dir, { | ||
| flowTemplate: "authority-pass-flow", | ||
| _flow_file: flowFile, | ||
| currentNode: "gate", | ||
| entryNode: "review", | ||
| history: [ | ||
| { nodeId: "review", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" }, | ||
| { nodeId: "gate", runId: "run_1", timestamp: "2026-01-01T00:00:01.000Z" }, | ||
| ], | ||
| edgeCounts: { "review→gate": 1 }, | ||
| }); | ||
| const run1 = join(dir, "nodes", "review", "run_1"); | ||
| const run2 = join(dir, "nodes", "review", "run_2"); | ||
| mkdirSync(run1, { recursive: true }); | ||
| mkdirSync(run2, { recursive: true }); | ||
| const passEval = (title, noun) => [ | ||
| `# ${title}`, | ||
| "", | ||
| "## Evidence Scope", | ||
| ...Array.from({ length: 24 }, (_, i) => `${noun} scope check ${i + 1}: reviewed selected run_1 evidence for the force-pass guard.`), | ||
| "", | ||
| "## Authority Notes", | ||
| ...Array.from({ length: 24 }, (_, i) => `${noun} authority note ${i + 1}: rogue run_2 content is intentionally outside the accepted source run.`), | ||
| "", | ||
| "## Verdict", | ||
| ...Array.from({ length: 8 }, (_, i) => `${noun} closing note ${i + 1}: no blocking issue found in authoritative run_1.`), | ||
| "VERDICT: PASS FINDINGS[0]", | ||
| ].join("\n"); | ||
| writeFileSync(join(run1, "eval-skeptic-owner.md"), passEval("Skeptic Owner", "ownership")); | ||
| writeFileSync(join(run1, "eval-peer.md"), passEval("Peer", "peer")); | ||
| writeFileSync(join(run2, "eval-a.md"), "# A\n\n🔴 Bug — file.js:1 — rogue\n→ fix\nReasoning: rogue\nVERDICT: FAIL FINDINGS[1]\n"); | ||
| const reviewHandshake = { | ||
| nodeId: "review", | ||
| nodeType: "review", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "authoritative run", | ||
| timestamp: "2026-01-01T00:00:00.000Z", | ||
| artifacts: [ | ||
| { type: "eval", path: "run_1/eval-skeptic-owner.md" }, | ||
| { type: "eval", path: "run_1/eval-peer.md" }, | ||
| ], | ||
| }; | ||
| writeFileSync(join(run1, "handshake.json"), JSON.stringify({ | ||
| ...reviewHandshake, | ||
| artifacts: [ | ||
| { type: "eval", path: "eval-skeptic-owner.md" }, | ||
| { type: "eval", path: "eval-peer.md" }, | ||
| ], | ||
| }, null, 2)); | ||
| writeFileSync(join(dir, "nodes", "review", "handshake.json"), JSON.stringify(reviewHandshake, null, 2)); | ||
| mkdirSync(join(dir, "nodes", "gate"), { recursive: true }); | ||
| writeFileSync(join(dir, "nodes", "gate", "handshake.json"), JSON.stringify({ | ||
| nodeId: "gate", | ||
| nodeType: "gate", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "gate", | ||
| timestamp: "2026-01-01T00:00:01.000Z", | ||
| artifacts: [], | ||
| }, null, 2)); | ||
| const result = runHarness("pass", ["--dir", dir]); | ||
| assert.equal(result.allowed, true, JSON.stringify(result)); | ||
| const state = JSON.parse(readFileSync(join(dir, "flow-state.json"), "utf8")); | ||
| assert.equal(state.currentNode, "done"); | ||
| }); | ||
| test("historical evidence fallback is bound to the history runId", () => { | ||
| const dir = join(TMPBASE, "history-run-authority"); | ||
| const rogueRun = join(dir, "nodes", "review", "run_2"); | ||
| mkdirSync(rogueRun, { recursive: true }); | ||
| writeFileSync(join(rogueRun, "handshake.json"), JSON.stringify({ | ||
| nodeId: "review", | ||
| nodeType: "review", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "forged latest run", | ||
| timestamp: "2026-01-01T00:00:00.000Z", | ||
| artifacts: [], | ||
| })); | ||
| const state = { | ||
| history: [ | ||
| { nodeId: "review", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" }, | ||
| { nodeId: "gate", runId: "run_1", timestamp: "2026-01-01T00:00:01.000Z" }, | ||
| ], | ||
| }; | ||
| const template = { | ||
| nodeTypes: { review: "review", gate: "gate" }, | ||
| requiredTestCommandEvidence: false, | ||
| }; | ||
| const reasons = checkStructuredResults(dir, state, template, "gate"); | ||
| assert.ok(reasons.some((reason) => /missing handshake for node 'review' run 'run_1'/.test(reason)), reasons.join("\n")); | ||
| }); | ||
| test("validate-chain binds required extension provenance to the history run", () => { | ||
| const dir = join(TMPBASE, "validate-chain-extension-history-run"); | ||
| const flowFile = writeFlowFile(dir, "authority-extension-flow", { | ||
| nodes: ["code-review", "gate"], | ||
| edges: { "code-review": { PASS: "gate" }, gate: { PASS: null } }, | ||
| limits: { maxLoopsPerEdge: 3, maxTotalSteps: 10, maxNodeReentry: 5 }, | ||
| nodeTypes: { "code-review": "review", gate: "gate" }, | ||
| nodeCapabilities: { "code-review": ["code-quality-check@1"] }, | ||
| }); | ||
| mkdirSync(join(dir, ".opc"), { recursive: true }); | ||
| writeFileSync(join(dir, ".opc", "config.json"), JSON.stringify({ requiredExtensions: ["req-ext"] })); | ||
| writeState(dir, { | ||
| flowTemplate: "authority-extension-flow", | ||
| _flow_file: flowFile, | ||
| currentNode: "gate", | ||
| entryNode: "code-review", | ||
| history: [ | ||
| { nodeId: "code-review", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" }, | ||
| { nodeId: "gate", runId: "run_1", timestamp: "2026-01-01T00:00:01.000Z" }, | ||
| ], | ||
| edgeCounts: { "code-review→gate": 1 }, | ||
| }); | ||
| writeReviewRun(dir, "code-review", "run_1", { extensionsApplied: ["req-ext"] }); | ||
| const rogueRun = join(dir, "nodes", "code-review", "run_2"); | ||
| mkdirSync(rogueRun, { recursive: true }); | ||
| writeFileSync(join(rogueRun, "eval-extensions.json"), JSON.stringify({ | ||
| version: 1, | ||
| extensionsApplied: ["req-ext"], | ||
| findings: [], | ||
| }, null, 2)); | ||
| const result = runHarness("validate-chain", ["--dir", dir]); | ||
| assert.equal(result.valid, false, JSON.stringify(result)); | ||
| assert.match(result.errors.join("\n"), /eval-extensions\.json not found in run_1/); | ||
| }); | ||
| test("strict finalize validates history exact runs instead of node-level canonical latest", () => { | ||
| const dir = join(TMPBASE, "strict-finalize-history-run"); | ||
| const flowFile = writeFlowFile(dir, "strict-finalize-flow", { | ||
| nodes: ["review", "done"], | ||
| edges: { review: { PASS: "done" }, done: { PASS: null } }, | ||
| limits: { maxLoopsPerEdge: 3, maxTotalSteps: 10, maxNodeReentry: 5 }, | ||
| nodeTypes: { review: "review", done: "build" }, | ||
| }); | ||
| writeState(dir, { | ||
| flowTemplate: "strict-finalize-flow", | ||
| _flow_file: flowFile, | ||
| currentNode: "done", | ||
| entryNode: "review", | ||
| history: [ | ||
| { nodeId: "review", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" }, | ||
| { nodeId: "done", runId: "run_1", timestamp: "2026-01-01T00:00:01.000Z" }, | ||
| ], | ||
| edgeCounts: { "review→done": 1 }, | ||
| }); | ||
| const canonicalReview = { | ||
| nodeId: "review", | ||
| nodeType: "review", | ||
| runId: "run_2", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "wrong canonical run", | ||
| timestamp: "2026-01-01T00:00:00.000Z", | ||
| artifacts: [], | ||
| }; | ||
| mkdirSync(join(dir, "nodes", "review"), { recursive: true }); | ||
| writeFileSync(join(dir, "nodes", "review", "handshake.json"), JSON.stringify(canonicalReview, null, 2)); | ||
| writeTerminalHandshake(dir, "done", "run_1"); | ||
| const stateBefore = readFileSync(join(dir, "flow-state.json"), "utf8"); | ||
| const result = runHarness("finalize", ["--strict", "--dir", dir]); | ||
| assert.equal(result.finalized, false, JSON.stringify(result)); | ||
| assert.match(result.error, /missing handshake.*run_1|expected 'run_1'/); | ||
| assert.equal(readFileSync(join(dir, "flow-state.json"), "utf8"), stateBefore); | ||
| }); | ||
| test("structured evidence rejects canonical handshake runId mismatch", () => { | ||
| const dir = join(TMPBASE, "canonical-run-mismatch"); | ||
| const nodeDir = join(dir, "nodes", "test-execute"); | ||
| mkdirSync(join(nodeDir, "run_1"), { recursive: true }); | ||
| writeFileSync(join(nodeDir, "run_1", "result.json"), "{}"); | ||
| writeFileSync(join(nodeDir, "run_1", "handshake.json"), JSON.stringify({ | ||
| nodeId: "test-execute", | ||
| nodeType: "execute", | ||
| runId: "run_2", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "newer run", | ||
| timestamp: "2026-01-01T00:00:00.000Z", | ||
| artifacts: [{ type: "test-result", path: "result.json" }], | ||
| })); | ||
| const state = { | ||
| history: [ | ||
| { nodeId: "test-execute", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" }, | ||
| { nodeId: "gate", runId: "run_1", timestamp: "2026-01-01T00:00:01.000Z" }, | ||
| ], | ||
| }; | ||
| const template = { | ||
| nodeTypes: { "test-execute": "execute", gate: "gate" }, | ||
| requiredTestCommandEvidence: false, | ||
| }; | ||
| const reasons = checkStructuredResults(dir, state, template, "gate"); | ||
| assert.ok(reasons.some((reason) => /expected 'run_1'/.test(reason)), reasons.join("\n")); | ||
| }); | ||
| test("test-design plan gate uses the state-selected run, not filesystem latest", () => { | ||
| const dir = join(TMPBASE, "test-plan-selected-run"); | ||
| writeState(dir, { | ||
| currentNode: "test-design", | ||
| entryNode: "test-design", | ||
| history: [{ nodeId: "test-design", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" }], | ||
| }); | ||
| const selectedRun = join(dir, "nodes", "test-design", "run_1"); | ||
| const rogueRun = join(dir, "nodes", "test-design", "run_2"); | ||
| mkdirSync(selectedRun, { recursive: true }); | ||
| mkdirSync(rogueRun, { recursive: true }); | ||
| writeFileSync(join(selectedRun, "eval-a.md"), "# Eval A\n"); | ||
| writeFileSync(join(selectedRun, "eval-b.md"), "# Eval B\n"); | ||
| writeFileSync(join(rogueRun, "test-plan.md"), completeTestPlan()); | ||
| writeFileSync(join(dir, "nodes", "test-design", "handshake.json"), JSON.stringify({ | ||
| nodeId: "test-design", | ||
| nodeType: "review", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "selected run has evals but no test plan", | ||
| timestamp: "2026-01-01T00:00:00.000Z", | ||
| artifacts: [ | ||
| { type: "eval", path: "run_1/eval-a.md" }, | ||
| { type: "eval", path: "run_1/eval-b.md" }, | ||
| ], | ||
| }, null, 2)); | ||
| writeFileSync(join(selectedRun, "handshake.json"), JSON.stringify({ | ||
| nodeId: "test-design", | ||
| nodeType: "review", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "selected run has evals but no test plan", | ||
| timestamp: "2026-01-01T00:00:00.000Z", | ||
| artifacts: [ | ||
| { type: "eval", path: "eval-a.md" }, | ||
| { type: "eval", path: "eval-b.md" }, | ||
| ], | ||
| }, null, 2)); | ||
| const result = runHarness("transition", [ | ||
| "--from", "test-design", "--to", "test-execute", "--verdict", "PASS", "--dir", dir, | ||
| ]); | ||
| assert.equal(result.allowed, false, JSON.stringify(result)); | ||
| assert.match(result.reason, /test-design test-plan\.md missing/); | ||
| assert.equal(existsSync(join(dir, "nodes", "test-execute", "run_1")), false); | ||
| }); | ||
| test("unbound node-level testCommand is rejected before target run or shell marker", () => { | ||
| const dir = join(TMPBASE, "unbound-test-command"); | ||
| const marker = join(dir, "shell-marker"); | ||
| writeState(dir, { | ||
| currentNode: "test-design", | ||
| entryNode: "test-design", | ||
| history: [{ nodeId: "test-design", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" }], | ||
| }); | ||
| const selectedRun = join(dir, "nodes", "test-design", "run_1"); | ||
| mkdirSync(selectedRun, { recursive: true }); | ||
| writeFileSync(join(selectedRun, "eval-a.md"), "# Eval A\n"); | ||
| writeFileSync(join(selectedRun, "eval-b.md"), "# Eval B\n"); | ||
| writeFileSync(join(selectedRun, "test-plan.md"), completeTestPlan()); | ||
| writeFileSync(join(dir, "nodes", "test-design", "test-execution.json"), JSON.stringify({ | ||
| testCommand: `touch ${marker}`, | ||
| })); | ||
| writeFileSync(join(dir, "nodes", "test-design", "handshake.json"), JSON.stringify({ | ||
| nodeId: "test-design", | ||
| nodeType: "review", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "selected run", | ||
| timestamp: "2026-01-01T00:00:00.000Z", | ||
| artifacts: [ | ||
| { type: "eval", path: "run_1/eval-a.md" }, | ||
| { type: "eval", path: "run_1/eval-b.md" }, | ||
| { type: "test-plan", path: "run_1/test-plan.md" }, | ||
| ], | ||
| }, null, 2)); | ||
| writeFileSync(join(selectedRun, "handshake.json"), JSON.stringify({ | ||
| nodeId: "test-design", | ||
| nodeType: "review", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "selected run", | ||
| timestamp: "2026-01-01T00:00:00.000Z", | ||
| artifacts: [ | ||
| { type: "eval", path: "eval-a.md" }, | ||
| { type: "eval", path: "eval-b.md" }, | ||
| { type: "test-plan", path: "test-plan.md" }, | ||
| ], | ||
| }, null, 2)); | ||
| const beforeState = readFileSync(join(dir, "flow-state.json"), "utf8"); | ||
| const result = runHarness("transition", [ | ||
| "--from", "test-design", "--to", "test-execute", "--verdict", "PASS", "--dir", dir, | ||
| ]); | ||
| assert.equal(result.allowed, false, JSON.stringify(result)); | ||
| assert.match(result.reason, /testCommand source binding failed/); | ||
| assert.equal(readFileSync(join(dir, "flow-state.json"), "utf8"), beforeState); | ||
| assert.equal(existsSync(join(dir, "nodes", "test-execute", "run_1")), false); | ||
| assert.equal(existsSync(marker), false); | ||
| }); | ||
| test("pass rejects malformed upstream history runId instead of synthesizing latest run", () => { | ||
| const dir = join(TMPBASE, "pass-malformed-history-run"); | ||
| const flowFile = writeFlowFile(dir, "pass-malformed-history-flow", { | ||
| nodes: ["review", "gate", "done"], | ||
| edges: { review: { PASS: "gate" }, gate: { PASS: "done" }, done: { PASS: null } }, | ||
| limits: { maxLoopsPerEdge: 3, maxTotalSteps: 10, maxNodeReentry: 5 }, | ||
| nodeTypes: { review: "review", gate: "gate", done: "build" }, | ||
| }); | ||
| writeState(dir, { | ||
| flowTemplate: "pass-malformed-history-flow", | ||
| _flow_file: flowFile, | ||
| currentNode: "gate", | ||
| entryNode: "review", | ||
| history: [ | ||
| { nodeId: "review", runId: "bad", timestamp: "2026-01-01T00:00:00.000Z" }, | ||
| { nodeId: "gate", runId: "run_1", timestamp: "2026-01-01T00:00:01.000Z" }, | ||
| ], | ||
| edgeCounts: { "review→gate": 1 }, | ||
| }); | ||
| writeReviewRun(dir, "review", "run_2"); | ||
| const stateBefore = readFileSync(join(dir, "flow-state.json"), "utf8"); | ||
| const result = runHarness("pass", ["--dir", dir]); | ||
| assert.equal(result.allowed, false, JSON.stringify(result)); | ||
| assert.match(result.error, /missing or invalid runId/); | ||
| assert.equal(readFileSync(join(dir, "flow-state.json"), "utf8"), stateBefore); | ||
| assert.equal(existsSync(join(dir, "nodes", "done")), false); | ||
| }); | ||
| test("advance rejects missing upstream history runId instead of synthesizing latest run", () => { | ||
| const dir = join(TMPBASE, "advance-missing-history-run"); | ||
| const flowFile = writeFlowFile(dir, "advance-missing-history-flow", { | ||
| nodes: ["review", "gate", "done"], | ||
| edges: { review: { PASS: "gate" }, gate: { PASS: "done" }, done: { PASS: null } }, | ||
| limits: { maxLoopsPerEdge: 3, maxTotalSteps: 10, maxNodeReentry: 5 }, | ||
| nodeTypes: { review: "review", gate: "gate", done: "build" }, | ||
| }); | ||
| writeState(dir, { | ||
| flowTemplate: "advance-missing-history-flow", | ||
| _flow_file: flowFile, | ||
| currentNode: "gate", | ||
| entryNode: "review", | ||
| history: [ | ||
| { nodeId: "review", timestamp: "2026-01-01T00:00:00.000Z" }, | ||
| { nodeId: "gate", runId: "run_1", timestamp: "2026-01-01T00:00:01.000Z" }, | ||
| ], | ||
| edgeCounts: { "review→gate": 1 }, | ||
| }); | ||
| writeReviewRun(dir, "review", "run_2"); | ||
| const stateBefore = readFileSync(join(dir, "flow-state.json"), "utf8"); | ||
| const result = runHarness("advance", ["--dir", dir]); | ||
| assert.equal(result.advanced, false, JSON.stringify(result)); | ||
| assert.equal(result.step, "synthesize"); | ||
| assert.match(result.error, /missing or invalid/); | ||
| assert.equal(readFileSync(join(dir, "flow-state.json"), "utf8"), stateBefore); | ||
| assert.equal(existsSync(join(dir, "nodes", "done")), false); | ||
| }); | ||
| test("validate fails closed when session state is malformed", () => { | ||
| const dir = join(TMPBASE, "validate-corrupt-session-authority"); | ||
| mkdirSync(join(dir, "nodes", "build", "run_2"), { recursive: true }); | ||
| writeFileSync(join(dir, "flow-state.json"), "{not-json"); | ||
| const hsPath = join(dir, "nodes", "build", "run_2", "handshake.json"); | ||
| writeFileSync(hsPath, JSON.stringify({ | ||
| nodeId: "build", | ||
| nodeType: "build", | ||
| runId: "run_2", | ||
| status: "completed", | ||
| verdict: null, | ||
| summary: "standalone-looking rogue evidence", | ||
| timestamp: "2026-01-01T00:00:00.000Z", | ||
| artifacts: [], | ||
| }, null, 2)); | ||
| const result = runHarness("validate", [hsPath]); | ||
| assert.equal(result.valid, false, JSON.stringify(result)); | ||
| assert.match(result.errors.join("\n"), /cannot parse flow-state\.json/); | ||
| }); | ||
| test("positional validate checks the whole session authority", () => { | ||
| const dir = join(TMPBASE, "validate-whole-session-authority"); | ||
| writeState(dir, { | ||
| currentNode: "build", | ||
| history: [ | ||
| { nodeId: "build", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" }, | ||
| { nodeId: "review", runId: "bad", timestamp: "2026-01-01T00:00:01.000Z" }, | ||
| ], | ||
| }); | ||
| writeBuildHandshake(dir, "run_1"); | ||
| const result = runHarness("validate", [join(dir, "nodes", "build", "run_1", "handshake.json")]); | ||
| assert.equal(result.valid, false, JSON.stringify(result)); | ||
| assert.match(result.errors.join("\n"), /history\[1\]\.runId missing or invalid/); | ||
| }); | ||
| test("default validate cannot use canonical when exact selected run is missing", () => { | ||
| const dir = join(TMPBASE, "validate-missing-exact-run"); | ||
| writeState(dir); | ||
| mkdirSync(join(dir, "nodes", "build"), { recursive: true }); | ||
| writeFileSync(join(dir, "nodes", "build", "output.md"), "canonical output"); | ||
| writeFileSync(join(dir, "nodes", "build", "handshake.json"), JSON.stringify({ | ||
| nodeId: "build", | ||
| nodeType: "build", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: null, | ||
| summary: "canonical impersonation", | ||
| timestamp: "2026-01-01T00:00:00.000Z", | ||
| artifacts: [{ type: "source", path: "output.md" }], | ||
| }, null, 2)); | ||
| const result = runHarness("validate", ["--dir", dir]); | ||
| assert.equal(result.valid, false, JSON.stringify(result)); | ||
| assert.match(result.errors.join("\n"), /missing exact selected-run handshake/); | ||
| }); | ||
| test("validate-chain rejects repeated-node canonical projection conflict", () => { | ||
| const dir = join(TMPBASE, "validate-chain-canonical-projection"); | ||
| writeState(dir, { | ||
| currentNode: "gate", | ||
| entryNode: "review", | ||
| history: [ | ||
| { nodeId: "review", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" }, | ||
| { nodeId: "gate", runId: "run_1", timestamp: "2026-01-01T00:00:01.000Z" }, | ||
| { nodeId: "review", runId: "run_2", timestamp: "2026-01-01T00:00:02.000Z" }, | ||
| { nodeId: "gate", runId: "run_2", timestamp: "2026-01-01T00:00:03.000Z" }, | ||
| ], | ||
| edgeCounts: { "review→gate": 2, "gate→review": 1 }, | ||
| }); | ||
| writeReviewRun(dir, "review", "run_1"); | ||
| writeReviewRun(dir, "review", "run_2"); | ||
| for (const runId of ["run_1", "run_2"]) { | ||
| mkdirSync(join(dir, "nodes", "gate", runId), { recursive: true }); | ||
| writeFileSync(join(dir, "nodes", "gate", runId, "handshake.json"), JSON.stringify({ | ||
| nodeId: "gate", | ||
| nodeType: "gate", | ||
| runId, | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "gate", | ||
| timestamp: "2026-01-01T00:00:03.000Z", | ||
| artifacts: [], | ||
| }, null, 2)); | ||
| } | ||
| writeFileSync(join(dir, "nodes", "review", "handshake.json"), JSON.stringify({ | ||
| nodeId: "review", | ||
| nodeType: "review", | ||
| runId: "run_3", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "rogue canonical", | ||
| timestamp: "2026-01-01T00:00:04.000Z", | ||
| artifacts: [], | ||
| }, null, 2)); | ||
| const result = runHarness("validate-chain", ["--dir", dir]); | ||
| assert.equal(result.valid, false, JSON.stringify(result)); | ||
| assert.match(result.errors.join("\n"), /canonical .*runId is 'run_3', expected 'run_2'/); | ||
| }); | ||
| test("validate-chain rejects partial canonical projection schema", () => { | ||
| const dir = join(TMPBASE, "validate-chain-partial-canonical"); | ||
| writeState(dir, { | ||
| currentNode: "gate", | ||
| entryNode: "review", | ||
| history: [ | ||
| { nodeId: "review", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" }, | ||
| { nodeId: "gate", runId: "run_1", timestamp: "2026-01-01T00:00:01.000Z" }, | ||
| ], | ||
| edgeCounts: { "review→gate": 1 }, | ||
| }); | ||
| writeReviewRun(dir, "review", "run_1"); | ||
| mkdirSync(join(dir, "nodes", "gate", "run_1"), { recursive: true }); | ||
| writeFileSync(join(dir, "nodes", "gate", "run_1", "handshake.json"), JSON.stringify({ | ||
| nodeId: "gate", | ||
| nodeType: "gate", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "gate", | ||
| timestamp: "2026-01-01T00:00:01.000Z", | ||
| artifacts: [], | ||
| }, null, 2)); | ||
| writeFileSync(join(dir, "nodes", "review", "handshake.json"), JSON.stringify({ | ||
| nodeId: "review", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| artifacts: [], | ||
| }, null, 2)); | ||
| const result = runHarness("validate-chain", ["--dir", dir]); | ||
| assert.equal(result.valid, false, JSON.stringify(result)); | ||
| assert.match(result.errors.join("\n"), /canonical .*missing or empty required field: nodeType/); | ||
| }); | ||
| test("strict finalize rejects canonical conflict before writing completed state", () => { | ||
| const dir = join(TMPBASE, "strict-finalize-canonical-projection"); | ||
| const flowFile = writeFlowFile(dir, "strict-finalize-canonical-flow", { | ||
| nodes: ["review", "done"], | ||
| edges: { review: { PASS: "done" }, done: { PASS: null } }, | ||
| limits: { maxLoopsPerEdge: 3, maxTotalSteps: 10, maxNodeReentry: 5 }, | ||
| nodeTypes: { review: "review", done: "build" }, | ||
| }); | ||
| writeState(dir, { | ||
| flowTemplate: "strict-finalize-canonical-flow", | ||
| _flow_file: flowFile, | ||
| currentNode: "done", | ||
| entryNode: "review", | ||
| history: [ | ||
| { nodeId: "review", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" }, | ||
| { nodeId: "done", runId: "run_1", timestamp: "2026-01-01T00:00:01.000Z" }, | ||
| ], | ||
| edgeCounts: { "review→done": 1 }, | ||
| }); | ||
| writeReviewRun(dir, "review", "run_1"); | ||
| writeFileSync(join(dir, "nodes", "review", "handshake.json"), JSON.stringify({ | ||
| nodeId: "review", | ||
| nodeType: "review", | ||
| runId: "run_2", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "rogue canonical", | ||
| timestamp: "2026-01-01T00:00:02.000Z", | ||
| artifacts: [], | ||
| }, null, 2)); | ||
| writeTerminalHandshake(dir, "done", "run_1"); | ||
| const before = readFileSync(join(dir, "flow-state.json"), "utf8"); | ||
| const result = runHarness("finalize", ["--strict", "--dir", dir]); | ||
| assert.equal(result.finalized, false, JSON.stringify(result)); | ||
| assert.match(result.error, /expected 'run_1'/); | ||
| assert.equal(readFileSync(join(dir, "flow-state.json"), "utf8"), before); | ||
| }); | ||
| test("strict finalize rejects terminal partial test provenance before completion", () => { | ||
| const dir = join(TMPBASE, "strict-finalize-terminal-provenance"); | ||
| const flowFile = writeFlowFile(dir, "strict-terminal-provenance-flow", { | ||
| nodes: ["test-execute"], | ||
| edges: { "test-execute": { PASS: null } }, | ||
| limits: { maxLoopsPerEdge: 3, maxTotalSteps: 10, maxNodeReentry: 5 }, | ||
| nodeTypes: { "test-execute": "execute" }, | ||
| }); | ||
| writeState(dir, { | ||
| flowTemplate: "strict-terminal-provenance-flow", | ||
| _flow_file: flowFile, | ||
| currentNode: "test-execute", | ||
| entryNode: "test-execute", | ||
| history: [{ nodeId: "test-execute", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" }], | ||
| }); | ||
| const runDir = join(dir, "nodes", "test-execute", "run_1"); | ||
| mkdirSync(runDir, { recursive: true }); | ||
| writeFileSync(join(runDir, "test-command-result.json"), JSON.stringify({ | ||
| checks: [{ id: "smoke", pass: true, total: 1 }], | ||
| provenance: { kind: "opc-test-command", executionActor: "opc-harness:test-command" }, | ||
| }, null, 2)); | ||
| const handshake = { | ||
| nodeId: "test-execute", | ||
| nodeType: "execute", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "partial provenance", | ||
| timestamp: "2026-01-01T00:00:00.000Z", | ||
| artifacts: [{ type: "test-result", path: "test-command-result.json" }], | ||
| testEvidenceProvenance: { | ||
| kind: "opc-test-command", | ||
| sourceNode: "test-design", | ||
| commandHash: "abc", | ||
| sourcePlanHash: "def", | ||
| resultHash: "ghi", | ||
| executionActor: "opc-harness:test-command", | ||
| }, | ||
| }; | ||
| writeFileSync(join(runDir, "handshake.json"), JSON.stringify(handshake, null, 2)); | ||
| writeFileSync(join(dir, "nodes", "test-execute", "handshake.json"), JSON.stringify({ | ||
| ...handshake, | ||
| artifacts: [{ type: "test-result", path: "run_1/test-command-result.json" }], | ||
| }, null, 2)); | ||
| const before = readFileSync(join(dir, "flow-state.json"), "utf8"); | ||
| const result = runHarness("finalize", ["--strict", "--flow-file", flowFile, "--dir", dir]); | ||
| assert.equal(result.finalized, false, JSON.stringify(result)); | ||
| assert.match(result.error, /testEvidenceProvenance\.sourceRunId/); | ||
| assert.equal(readFileSync(join(dir, "flow-state.json"), "utf8"), before); | ||
| }); | ||
| test("hotfix retest rejects missing historical test-design run before shell spawn", () => { | ||
| const dir = join(TMPBASE, "hotfix-missing-test-design-run"); | ||
| const marker = join(dir, "hotfix-marker"); | ||
| const flowFile = writeFlowFile(dir, "hotfix-retest-flow", { | ||
| nodes: ["test-design", "test-execute", "hotfix"], | ||
| edges: { | ||
| "test-design": { PASS: "test-execute" }, | ||
| "test-execute": { ITERATE: "hotfix", PASS: null }, | ||
| hotfix: { PASS: "test-execute" }, | ||
| }, | ||
| limits: { maxLoopsPerEdge: 3, maxTotalSteps: 10, maxNodeReentry: 5 }, | ||
| nodeTypes: { "test-design": "review", "test-execute": "execute", hotfix: "build" }, | ||
| }); | ||
| writeState(dir, { | ||
| flowTemplate: "hotfix-retest-flow", | ||
| _flow_file: flowFile, | ||
| currentNode: "hotfix", | ||
| entryNode: "test-design", | ||
| history: [ | ||
| { nodeId: "test-design", timestamp: "2026-01-01T00:00:00.000Z" }, | ||
| { nodeId: "test-execute", runId: "run_1", timestamp: "2026-01-01T00:00:01.000Z" }, | ||
| { nodeId: "hotfix", runId: "run_1", timestamp: "2026-01-01T00:00:02.000Z" }, | ||
| ], | ||
| edgeCounts: { "test-design→test-execute": 1, "test-execute→hotfix": 1 }, | ||
| }); | ||
| mkdirSync(join(dir, "nodes", "hotfix", "run_1"), { recursive: true }); | ||
| writeFileSync(join(dir, "nodes", "hotfix", "run_1", "fix.md"), "fix"); | ||
| const hotfixHandshake = { | ||
| nodeId: "hotfix", | ||
| nodeType: "build", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "fix", | ||
| timestamp: "2026-01-01T00:00:02.000Z", | ||
| artifacts: [{ type: "source", path: "run_1/fix.md" }], | ||
| }; | ||
| writeFileSync(join(dir, "nodes", "hotfix", "run_1", "handshake.json"), JSON.stringify({ | ||
| ...hotfixHandshake, | ||
| artifacts: [{ type: "source", path: "fix.md" }], | ||
| }, null, 2)); | ||
| writeFileSync(join(dir, "nodes", "hotfix", "handshake.json"), JSON.stringify(hotfixHandshake, null, 2)); | ||
| mkdirSync(join(dir, "nodes", "test-design", "run_2"), { recursive: true }); | ||
| writeFileSync(join(dir, "nodes", "test-design", "run_2", "test-execution.json"), JSON.stringify({ | ||
| runId: "run_2", | ||
| testCommand: `touch ${marker}`, | ||
| })); | ||
| const before = readFileSync(join(dir, "flow-state.json"), "utf8"); | ||
| const result = runHarness("transition", [ | ||
| "--from", "hotfix", "--to", "test-execute", "--verdict", "PASS", "--flow-file", flowFile, "--dir", dir, | ||
| ]); | ||
| assert.equal(result.allowed, false, JSON.stringify(result)); | ||
| assert.match(result.reason, /testCommand source binding failed/); | ||
| assert.equal(existsSync(marker), false); | ||
| assert.equal(existsSync(join(dir, "nodes", "test-execute", "run_2")), false); | ||
| assert.equal(readFileSync(join(dir, "flow-state.json"), "utf8"), before); | ||
| }); | ||
| test("hotfix retest rejects malformed newest test-design history instead of older run", () => { | ||
| const dir = join(TMPBASE, "hotfix-malformed-newest-test-design"); | ||
| const marker = join(dir, "hotfix-newest-marker"); | ||
| const flowFile = writeFlowFile(dir, "hotfix-newest-flow", { | ||
| nodes: ["test-design", "test-execute", "hotfix"], | ||
| edges: { | ||
| "test-design": { PASS: "test-execute" }, | ||
| "test-execute": { ITERATE: "hotfix", PASS: null }, | ||
| hotfix: { PASS: "test-execute" }, | ||
| }, | ||
| limits: { maxLoopsPerEdge: 3, maxTotalSteps: 10, maxNodeReentry: 5 }, | ||
| nodeTypes: { "test-design": "review", "test-execute": "execute", hotfix: "build" }, | ||
| }); | ||
| writeState(dir, { | ||
| flowTemplate: "hotfix-newest-flow", | ||
| _flow_file: flowFile, | ||
| currentNode: "hotfix", | ||
| entryNode: "test-design", | ||
| history: [ | ||
| { nodeId: "test-design", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" }, | ||
| { nodeId: "test-execute", runId: "run_1", timestamp: "2026-01-01T00:00:01.000Z" }, | ||
| { nodeId: "test-design", runId: "bad", timestamp: "2026-01-01T00:00:02.000Z" }, | ||
| { nodeId: "hotfix", runId: "run_1", timestamp: "2026-01-01T00:00:03.000Z" }, | ||
| ], | ||
| edgeCounts: { "test-design→test-execute": 1, "test-execute→test-design": 1, "test-design→hotfix": 1 }, | ||
| }); | ||
| const tdRun = join(dir, "nodes", "test-design", "run_1"); | ||
| mkdirSync(tdRun, { recursive: true }); | ||
| writeFileSync(join(tdRun, "test-plan.md"), completeTestPlan()); | ||
| writeFileSync(join(tdRun, "test-execution.json"), JSON.stringify({ | ||
| runId: "run_1", | ||
| testCommand: `touch ${marker}`, | ||
| })); | ||
| writeReviewRun(dir, "test-design", "run_1", { | ||
| artifacts: [ | ||
| { type: "eval", path: "eval-a.md" }, | ||
| { type: "eval", path: "eval-b.md" }, | ||
| { type: "test-plan", path: "test-plan.md" }, | ||
| ], | ||
| }); | ||
| mkdirSync(join(dir, "nodes", "test-execute", "run_1"), { recursive: true }); | ||
| writeFileSync(join(dir, "nodes", "test-execute", "run_1", "handshake.json"), JSON.stringify({ | ||
| nodeId: "test-execute", | ||
| nodeType: "execute", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "ITERATE", | ||
| summary: "needs hotfix", | ||
| timestamp: "2026-01-01T00:00:01.000Z", | ||
| artifacts: [{ type: "cli-output", path: "log.txt" }], | ||
| }, null, 2)); | ||
| mkdirSync(join(dir, "nodes", "hotfix", "run_1"), { recursive: true }); | ||
| writeFileSync(join(dir, "nodes", "hotfix", "run_1", "handshake.json"), JSON.stringify({ | ||
| nodeId: "hotfix", | ||
| nodeType: "build", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "fix", | ||
| timestamp: "2026-01-01T00:00:03.000Z", | ||
| artifacts: [], | ||
| }, null, 2)); | ||
| const before = readFileSync(join(dir, "flow-state.json"), "utf8"); | ||
| const result = runHarness("transition", [ | ||
| "--from", "hotfix", "--to", "test-execute", "--verdict", "PASS", "--flow-file", flowFile, "--dir", dir, | ||
| ]); | ||
| assert.equal(result.allowed, false, JSON.stringify(result)); | ||
| assert.match(result.reason, /history runId is missing or invalid/); | ||
| assert.equal(existsSync(marker), false); | ||
| assert.equal(existsSync(join(dir, "nodes", "test-execute", "run_2")), false); | ||
| assert.equal(readFileSync(join(dir, "flow-state.json"), "utf8"), before); | ||
| }); | ||
| test("gate backlog rejects unbound canonical warnings before mutation", () => { | ||
| const dir = join(TMPBASE, "backlog-exact-upstream"); | ||
| const flowFile = writeFlowFile(dir, "backlog-exact-flow", { | ||
| nodes: ["review", "gate"], | ||
| edges: { review: { PASS: "gate" }, gate: { PASS: null, ITERATE: "review" } }, | ||
| limits: { maxLoopsPerEdge: 3, maxTotalSteps: 10, maxNodeReentry: 5 }, | ||
| nodeTypes: { review: "review", gate: "gate" }, | ||
| }); | ||
| writeState(dir, { | ||
| flowTemplate: "backlog-exact-flow", | ||
| _flow_file: flowFile, | ||
| currentNode: "gate", | ||
| entryNode: "review", | ||
| history: [ | ||
| { nodeId: "review", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" }, | ||
| { nodeId: "gate", runId: "run_1", timestamp: "2026-01-01T00:00:01.000Z" }, | ||
| ], | ||
| edgeCounts: { "review→gate": 1 }, | ||
| }); | ||
| writeReviewRun(dir, "review", "run_1", { findings: { critical: 0, warning: 0, suggestion: 0 } }); | ||
| writeFileSync(join(dir, "nodes", "review", "handshake.json"), JSON.stringify({ | ||
| nodeId: "review", | ||
| nodeType: "review", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "rogue warning mirror", | ||
| timestamp: "2026-01-01T00:00:02.000Z", | ||
| artifacts: [ | ||
| { type: "eval", path: "run_1/eval-a.md" }, | ||
| { type: "eval", path: "run_1/eval-b.md" }, | ||
| ], | ||
| findings: { critical: 0, warning: 1, suggestion: 0 }, | ||
| }, null, 2)); | ||
| mkdirSync(join(dir, "nodes", "gate"), { recursive: true }); | ||
| writeFileSync(join(dir, "nodes", "gate", "handshake.json"), JSON.stringify({ | ||
| nodeId: "gate", | ||
| nodeType: "gate", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "ITERATE", | ||
| summary: "gate", | ||
| timestamp: "2026-01-01T00:00:01.000Z", | ||
| artifacts: [], | ||
| }, null, 2)); | ||
| const result = runHarness("transition", [ | ||
| "--from", "gate", "--to", "review", "--verdict", "ITERATE", "--flow-file", flowFile, "--dir", dir, | ||
| ]); | ||
| assert.equal(result.allowed, false, JSON.stringify(result)); | ||
| assert.match(result.reason, /not exact-run projection/); | ||
| }); | ||
| test("cumulative findings keeps orphan run findings out of reviewer context", () => { | ||
| const dir = join(TMPBASE, "cumulative-orphan-runs"); | ||
| writeState(dir, { | ||
| currentNode: "code-review", | ||
| entryNode: "build", | ||
| history: [ | ||
| { nodeId: "build", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" }, | ||
| { nodeId: "code-review", runId: "run_1", timestamp: "2026-01-01T00:00:01.000Z" }, | ||
| ], | ||
| }); | ||
| writeBuildHandshake(dir, "run_1"); | ||
| writeReviewRun(dir, "code-review", "run_1"); | ||
| const orphan = join(dir, "nodes", "code-review", "run_2"); | ||
| mkdirSync(orphan, { recursive: true }); | ||
| writeFileSync(join(orphan, "eval-orphan.md"), [ | ||
| "# Orphan", | ||
| "", | ||
| "🔴 Orphan-only critical — rogue.js:1", | ||
| "→ Do not inject this into reviewer context.", | ||
| "Reasoning: This run is not state-selected.", | ||
| ].join("\n")); | ||
| const markdown = buildCumulativeFindingsMarkdown(dir, JSON.parse(readFileSync(join(dir, "flow-state.json"), "utf8"))); | ||
| assert.match(markdown, /Forensic Orphan Runs/); | ||
| assert.match(markdown, /code-review\/run_2/); | ||
| assert.doesNotMatch(markdown, /Orphan-only critical/); | ||
| }); | ||
| test("cumulative findings keeps orphan fixesApplied out of reviewer context", () => { | ||
| const dir = join(TMPBASE, "cumulative-orphan-fixes"); | ||
| writeState(dir, { | ||
| currentNode: "code-review", | ||
| entryNode: "build", | ||
| history: [ | ||
| { nodeId: "build", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" }, | ||
| { nodeId: "code-review", runId: "run_1", timestamp: "2026-01-01T00:00:01.000Z" }, | ||
| ], | ||
| }); | ||
| writeBuildHandshake(dir, "run_1"); | ||
| writeReviewRun(dir, "code-review", "run_1"); | ||
| mkdirSync(join(dir, "nodes", "code-review", "run_2"), { recursive: true }); | ||
| writeFileSync(join(dir, "nodes", "code-review", "run_2", "handshake.json"), JSON.stringify({ | ||
| nodeId: "code-review", | ||
| nodeType: "review", | ||
| runId: "run_2", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "orphan retry", | ||
| timestamp: "2026-01-01T00:00:02.000Z", | ||
| artifacts: [], | ||
| fixesApplied: ["Orphan-only fix must not enter reviewer prompt"], | ||
| }, null, 2)); | ||
| const markdown = buildCumulativeFindingsMarkdown(dir, JSON.parse(readFileSync(join(dir, "flow-state.json"), "utf8"))); | ||
| assert.match(markdown, /Forensic Orphan Runs/); | ||
| assert.match(markdown, /code-review\/run_2/); | ||
| assert.doesNotMatch(markdown, /Orphan-only fix/); | ||
| }); | ||
| test("seal rejects partial testEvidenceProvenance without rewriting canonical bytes", () => { | ||
| const dir = join(TMPBASE, "seal-partial-test-provenance"); | ||
| writeState(dir, { | ||
| flowTemplate: "build-verify", | ||
| currentNode: "test-execute", | ||
| entryNode: "test-execute", | ||
| history: [{ nodeId: "test-execute", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" }], | ||
| }); | ||
| const nodeDir = join(dir, "nodes", "test-execute"); | ||
| const runDir = join(nodeDir, "run_1"); | ||
| mkdirSync(runDir, { recursive: true }); | ||
| writeFileSync(join(runDir, "test-command-result.json"), JSON.stringify({ | ||
| checks: [{ id: "smoke", pass: true, total: 1 }], | ||
| provenance: { kind: "opc-test-command", executionActor: "opc-harness:test-command" }, | ||
| }, null, 2)); | ||
| const handshakePath = join(nodeDir, "handshake.json"); | ||
| const canonical = { | ||
| nodeId: "test-execute", | ||
| nodeType: "execute", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "partial provenance", | ||
| timestamp: "2026-01-01T00:00:00.000Z", | ||
| artifacts: [{ type: "test-result", path: "run_1/test-command-result.json" }], | ||
| testEvidenceProvenance: { | ||
| kind: "opc-test-command", | ||
| executionActor: "opc-harness:test-command", | ||
| }, | ||
| }; | ||
| writeFileSync(handshakePath, JSON.stringify(canonical, null, 2)); | ||
| const before = readFileSync(handshakePath, "utf8"); | ||
| const result = runHarness("seal", ["--node", "test-execute", "--dir", dir]); | ||
| assert.equal(result.sealed, false, JSON.stringify(result)); | ||
| assert.match(result.validationErrors.join("\n"), /testEvidenceProvenance\.commandHash missing/); | ||
| assert.equal(readFileSync(handshakePath, "utf8"), before); | ||
| }); | ||
| test("seal requires sourceRunId and non-null policy in test evidence provenance", () => { | ||
| const dir = join(TMPBASE, "seal-source-run-required"); | ||
| writeState(dir, { | ||
| flowTemplate: "build-verify", | ||
| currentNode: "test-execute", | ||
| entryNode: "test-execute", | ||
| history: [{ nodeId: "test-execute", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" }], | ||
| }); | ||
| const nodeDir = join(dir, "nodes", "test-execute"); | ||
| const runDir = join(nodeDir, "run_1"); | ||
| mkdirSync(runDir, { recursive: true }); | ||
| writeFileSync(join(runDir, "test-command-result.json"), JSON.stringify({ | ||
| checks: [{ id: "smoke", pass: true, total: 1 }], | ||
| provenance: { | ||
| kind: "opc-test-command", | ||
| sourceNode: "test-design", | ||
| commandHash: "cmd", | ||
| sourcePlanHash: "plan", | ||
| executionActor: "opc-harness:test-command", | ||
| }, | ||
| }, null, 2)); | ||
| const handshakePath = join(nodeDir, "handshake.json"); | ||
| writeFileSync(handshakePath, JSON.stringify({ | ||
| nodeId: "test-execute", | ||
| nodeType: "execute", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "missing source run", | ||
| timestamp: "2026-01-01T00:00:00.000Z", | ||
| artifacts: [{ type: "test-result", path: "run_1/test-command-result.json" }], | ||
| testEvidenceProvenance: { | ||
| kind: "opc-test-command", | ||
| sourceNode: "test-design", | ||
| commandHash: "cmd", | ||
| sourcePlanHash: "plan", | ||
| resultHash: "result", | ||
| executionActor: "opc-harness:test-command", | ||
| ledger: { kind: "opc-hmac-ledger", recordHash: "missing" }, | ||
| }, | ||
| testEvidencePolicy: null, | ||
| }, null, 2)); | ||
| const before = readFileSync(handshakePath, "utf8"); | ||
| const result = runHarness("seal", ["--node", "test-execute", "--dir", dir]); | ||
| assert.equal(result.sealed, false, JSON.stringify(result)); | ||
| assert.match(result.validationErrors.join("\n"), /sourceRunId/); | ||
| assert.match(result.validationErrors.join("\n"), /testEvidencePolicy/); | ||
| assert.equal(readFileSync(handshakePath, "utf8"), before); | ||
| }); | ||
| test("direct gate FAIL rejects invalid upstream authority before mutation", () => { | ||
| const dir = join(TMPBASE, "direct-gate-fail-authority"); | ||
| const flowFile = writeFlowFile(dir, "direct-gate-fail-flow", { | ||
| nodes: ["review", "gate"], | ||
| edges: { review: { PASS: "gate" }, gate: { FAIL: "review", PASS: null } }, | ||
| limits: { maxLoopsPerEdge: 3, maxTotalSteps: 10, maxNodeReentry: 5 }, | ||
| nodeTypes: { review: "review", gate: "gate" }, | ||
| }); | ||
| writeState(dir, { | ||
| flowTemplate: "direct-gate-fail-flow", | ||
| _flow_file: flowFile, | ||
| currentNode: "gate", | ||
| entryNode: "review", | ||
| history: [ | ||
| { nodeId: "review", timestamp: "2026-01-01T00:00:00.000Z" }, | ||
| { nodeId: "gate", runId: "run_1", timestamp: "2026-01-01T00:00:01.000Z" }, | ||
| ], | ||
| edgeCounts: { "review→gate": 1 }, | ||
| repairEdgeCounts: {}, | ||
| }); | ||
| writeReviewRun(dir, "review", "run_2", { verdict: "FAIL", findings: { critical: 1, warning: 0, suggestion: 0 } }); | ||
| const before = readFileSync(join(dir, "flow-state.json"), "utf8"); | ||
| const result = runHarness("transition", [ | ||
| "--from", "gate", "--to", "review", "--verdict", "FAIL", "--flow-file", flowFile, "--dir", dir, | ||
| ]); | ||
| assert.equal(result.allowed, false, JSON.stringify(result)); | ||
| assert.match(result.reason, /gate authority check failed/); | ||
| assert.equal(readFileSync(join(dir, "flow-state.json"), "utf8"), before); | ||
| assert.equal(existsSync(join(dir, "nodes", "review", "run_3")), false); | ||
| }); | ||
| test("direct gate FAIL rejects partial canonical projection before mutation", () => { | ||
| const dir = join(TMPBASE, "direct-gate-partial-canonical"); | ||
| const flowFile = writeFlowFile(dir, "direct-gate-partial-flow", { | ||
| nodes: ["review", "gate"], | ||
| edges: { review: { PASS: "gate" }, gate: { FAIL: "review", PASS: null } }, | ||
| limits: { maxLoopsPerEdge: 3, maxTotalSteps: 10, maxNodeReentry: 5 }, | ||
| nodeTypes: { review: "review", gate: "gate" }, | ||
| }); | ||
| writeState(dir, { | ||
| flowTemplate: "direct-gate-partial-flow", | ||
| _flow_file: flowFile, | ||
| currentNode: "gate", | ||
| entryNode: "review", | ||
| history: [ | ||
| { nodeId: "review", runId: "run_1", timestamp: "2026-01-01T00:00:00.000Z" }, | ||
| { nodeId: "gate", runId: "run_1", timestamp: "2026-01-01T00:00:01.000Z" }, | ||
| ], | ||
| edgeCounts: { "review→gate": 1 }, | ||
| repairEdgeCounts: {}, | ||
| }); | ||
| writeReviewRun(dir, "review", "run_1", { verdict: "FAIL", findings: { critical: 1, warning: 0, suggestion: 0 } }); | ||
| mkdirSync(join(dir, "nodes", "gate", "run_1"), { recursive: true }); | ||
| writeFileSync(join(dir, "nodes", "gate", "run_1", "handshake.json"), JSON.stringify({ | ||
| nodeId: "gate", | ||
| nodeType: "gate", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "FAIL", | ||
| summary: "gate", | ||
| timestamp: "2026-01-01T00:00:01.000Z", | ||
| artifacts: [], | ||
| }, null, 2)); | ||
| writeFileSync(join(dir, "nodes", "review", "handshake.json"), JSON.stringify({ | ||
| nodeId: "review", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| artifacts: [], | ||
| }, null, 2)); | ||
| const before = readFileSync(join(dir, "flow-state.json"), "utf8"); | ||
| const result = runHarness("transition", [ | ||
| "--from", "gate", "--to", "review", "--verdict", "FAIL", "--flow-file", flowFile, "--dir", dir, | ||
| ]); | ||
| assert.equal(result.allowed, false, JSON.stringify(result)); | ||
| assert.match(result.reason, /gate authority check failed/); | ||
| assert.equal(readFileSync(join(dir, "flow-state.json"), "utf8"), before); | ||
| assert.equal(existsSync(join(dir, "nodes", "review", "run_2")), false); | ||
| }); | ||
| }); |
| const REPAIR_VERDICTS = new Set(["FAIL", "ITERATE"]); | ||
| function resolveLimits(state, template) { | ||
| const limits = {}; | ||
| for (const name of ["maxTotalSteps", "maxLoopsPerEdge", "maxNodeReentry"]) { | ||
| limits[name] = Object.hasOwn(state, name) | ||
| ? state[name] | ||
| : template.limits?.[name]; | ||
| } | ||
| for (const [name, value] of Object.entries(limits)) { | ||
| if (!Number.isInteger(value) || value <= 0) { | ||
| return { error: `${name} must be a positive integer` }; | ||
| } | ||
| } | ||
| return limits; | ||
| } | ||
| function readCount(map, edgeKey, label) { | ||
| if (!map || typeof map !== "object" || Array.isArray(map)) { | ||
| return { error: `${label} is invalid` }; | ||
| } | ||
| const count = map[edgeKey] ?? 0; | ||
| if (!Number.isInteger(count) || count < 0) { | ||
| return { error: `${label} count is invalid for edge '${edgeKey}'` }; | ||
| } | ||
| return { count }; | ||
| } | ||
| function validateCountMap(map, label) { | ||
| if (!map || typeof map !== "object" || Array.isArray(map)) { | ||
| return { error: `${label} is invalid` }; | ||
| } | ||
| for (const edgeKey of Object.keys(map)) { | ||
| const result = readCount(map, edgeKey, label); | ||
| if (result.error) return result; | ||
| } | ||
| return {}; | ||
| } | ||
| export function isRepairVerdict(verdict) { | ||
| return REPAIR_VERDICTS.has(String(verdict || "").toUpperCase()); | ||
| } | ||
| export function repairEdgeCount(state, edgeKey) { | ||
| if (Object.hasOwn(state, "repairEdgeCounts")) { | ||
| return readCount(state.repairEdgeCounts, edgeKey, "repairEdgeCounts"); | ||
| } | ||
| return readCount(state.edgeCounts ?? {}, edgeKey, "edgeCounts"); | ||
| } | ||
| export function seedRepairEdgeCounts(state, template) { | ||
| if (Object.hasOwn(state, "repairEdgeCounts")) { | ||
| const existing = state.repairEdgeCounts; | ||
| if (!existing || typeof existing !== "object" || Array.isArray(existing)) { | ||
| throw new Error("repairEdgeCounts is invalid"); | ||
| } | ||
| for (const edgeKey of Object.keys(existing)) { | ||
| const result = readCount(existing, edgeKey, "repairEdgeCounts"); | ||
| if (result.error) throw new Error(result.error); | ||
| } | ||
| return existing; | ||
| } | ||
| const seeded = {}; | ||
| for (const [from, edges] of Object.entries(template.edges || {})) { | ||
| for (const [verdict, to] of Object.entries(edges || {})) { | ||
| if (!isRepairVerdict(verdict) || to === null) continue; | ||
| const edgeKey = `${from}→${to}`; | ||
| const legacy = readCount(state.edgeCounts ?? {}, edgeKey, "edgeCounts"); | ||
| if (legacy.error) throw new Error(legacy.error); | ||
| seeded[edgeKey] = legacy.count; | ||
| } | ||
| } | ||
| state.repairEdgeCounts = seeded; | ||
| return seeded; | ||
| } | ||
| export function evaluateFlowBudget({ state, template, from, to, verdict }) { | ||
| if (!state || typeof state !== "object" || Array.isArray(state)) { | ||
| return { allowed: false, reason: "state is invalid" }; | ||
| } | ||
| const limits = resolveLimits(state, template); | ||
| if (limits.error) return { allowed: false, reason: limits.error }; | ||
| if (!Number.isInteger(state.totalSteps) || state.totalSteps < 0) { | ||
| return { allowed: false, reason: "totalSteps must be a non-negative integer" }; | ||
| } | ||
| if (!Array.isArray(state.history)) { | ||
| return { allowed: false, reason: "history is invalid" }; | ||
| } | ||
| const traversalMap = validateCountMap(state.edgeCounts, "edgeCounts"); | ||
| if (traversalMap.error) return { allowed: false, reason: traversalMap.error }; | ||
| if (Object.hasOwn(state, "repairEdgeCounts")) { | ||
| const repairMap = validateCountMap(state.repairEdgeCounts, "repairEdgeCounts"); | ||
| if (repairMap.error) return { allowed: false, reason: repairMap.error }; | ||
| } | ||
| if (to === null) return { allowed: true, terminal: true }; | ||
| if (state.totalSteps >= limits.maxTotalSteps) { | ||
| return { allowed: false, reason: `maxTotalSteps (${limits.maxTotalSteps}) reached` }; | ||
| } | ||
| const edgeKey = `${from}→${to}`; | ||
| const edgeCount = state.edgeCounts[edgeKey] ?? 0; | ||
| let repairCount = 0; | ||
| if (isRepairVerdict(verdict)) { | ||
| const repairCounts = seedRepairEdgeCounts(state, template); | ||
| repairCount = repairCounts[edgeKey] ?? 0; | ||
| if (repairCount >= limits.maxLoopsPerEdge) { | ||
| return { | ||
| allowed: false, | ||
| reason: `maxLoopsPerEdge (${limits.maxLoopsPerEdge}) reached for repair edge '${edgeKey}'`, | ||
| edgeKey, | ||
| repairCount, | ||
| }; | ||
| } | ||
| } | ||
| const nodeEntries = state.history.filter((entry) => entry.nodeId === to).length; | ||
| if (nodeEntries >= limits.maxNodeReentry) { | ||
| return { | ||
| allowed: false, | ||
| reason: `maxNodeReentry (${limits.maxNodeReentry}) reached for node '${to}'`, | ||
| }; | ||
| } | ||
| return { allowed: true, edgeKey, edgeCount, repairCount }; | ||
| } | ||
| export function nodeHasBudgetedExit({ state, template, node }) { | ||
| const edges = Object.entries(template.edges?.[node] || {}); | ||
| const reasons = []; | ||
| for (const [verdict, to] of edges) { | ||
| if (verdict === "PASS" && to === null) { | ||
| return { available: true, terminal: true }; | ||
| } | ||
| if (to === null) continue; | ||
| const result = evaluateFlowBudget({ state, template, from: node, to, verdict }); | ||
| if (result.allowed) return { available: true, verdict, to }; | ||
| reasons.push(result.reason); | ||
| } | ||
| return { available: false, reasons }; | ||
| } |
| import { existsSync, readFileSync } from "fs"; | ||
| import { isAbsolute, join } from "path"; | ||
| import { resolveCurrentRun } from "./runaway-guard.mjs"; | ||
| export function isRunId(value) { | ||
| return typeof value === "string" && /^run_\d+$/.test(value); | ||
| } | ||
| export function isPlainObject(value) { | ||
| return value !== null && typeof value === "object" && !Array.isArray(value); | ||
| } | ||
| export function parseHandshakeFile(path) { | ||
| let data; | ||
| try { | ||
| data = JSON.parse(readFileSync(path, "utf8")); | ||
| } catch (error) { | ||
| return { error: `${path}: corrupt/parse error: ${error.message}` }; | ||
| } | ||
| if (!isPlainObject(data)) { | ||
| return { error: `${path}: root must be a non-null object` }; | ||
| } | ||
| return { data }; | ||
| } | ||
| export function handshakeIdentityErrors(data, nodeId, runId) { | ||
| const errors = []; | ||
| if (data.nodeId !== nodeId) { | ||
| errors.push(`nodeId is '${data.nodeId}', expected '${nodeId}'`); | ||
| } | ||
| if (data.runId !== runId) { | ||
| errors.push(`runId is '${data.runId}', expected '${runId}'`); | ||
| } | ||
| if (typeof data.status !== "string" || data.status.length === 0) { | ||
| errors.push("status missing or invalid"); | ||
| } | ||
| return errors; | ||
| } | ||
| export function resolveExactRunHandshake(dir, nodeId, runId) { | ||
| if (!isRunId(runId)) { | ||
| return { path: null, data: null, error: `invalid runId '${runId}' for node '${nodeId}'` }; | ||
| } | ||
| const nodeDir = join(dir, "nodes", nodeId); | ||
| const runPath = join(nodeDir, runId, "handshake.json"); | ||
| if (!existsSync(runPath)) return { path: runPath, data: null, error: null, missing: true }; | ||
| const parsed = parseHandshakeFile(runPath); | ||
| if (parsed.error) return { path: runPath, data: null, error: parsed.error }; | ||
| const errors = handshakeIdentityErrors(parsed.data, nodeId, runId); | ||
| return errors.length > 0 | ||
| ? { path: runPath, data: parsed.data, error: `${runPath}: ${errors.join("; ")}` } | ||
| : { path: runPath, data: parsed.data, error: null }; | ||
| } | ||
| export function sessionAuthorityErrors(state) { | ||
| const errors = []; | ||
| if (!isPlainObject(state)) return ["flow-state.json must contain an object"]; | ||
| if (typeof state.currentNode !== "string" || state.currentNode.length === 0) { | ||
| errors.push("flow-state.json currentNode missing or invalid"); | ||
| } | ||
| if (!Array.isArray(state.history)) { | ||
| errors.push("flow-state.json history must be an array"); | ||
| return errors; | ||
| } | ||
| state.history.forEach((entry, i) => { | ||
| const nodeId = entry?.nodeId || entry?.node; | ||
| const runId = entry?.runId || entry?.run; | ||
| if (typeof nodeId !== "string" || nodeId.length === 0) { | ||
| errors.push(`flow-state.json history[${i}].nodeId missing or invalid`); | ||
| } | ||
| if (!isRunId(runId)) { | ||
| errors.push(`flow-state.json history[${i}].runId missing or invalid`); | ||
| } | ||
| }); | ||
| const current = resolveCurrentRun(state); | ||
| if (!current) errors.push(`cannot resolve current run for '${state.currentNode || "unknown"}'`); | ||
| return errors; | ||
| } | ||
| export function readSessionAuthority(dir) { | ||
| const statePath = join(dir, "flow-state.json"); | ||
| if (!existsSync(statePath)) return { exists: false, state: null, error: null }; | ||
| let state; | ||
| try { | ||
| state = JSON.parse(readFileSync(statePath, "utf8")); | ||
| } catch (error) { | ||
| return { exists: true, state: null, error: `cannot parse flow-state.json: ${error.message}` }; | ||
| } | ||
| const errors = sessionAuthorityErrors(state); | ||
| if (errors.length > 0) return { exists: true, state: null, error: errors.join("; ") }; | ||
| return { exists: true, state, error: null }; | ||
| } | ||
| export function authoritativeEntries(state, { includeCurrent = true } = {}) { | ||
| const entries = []; | ||
| const seen = new Set(); | ||
| const add = (entry) => { | ||
| const nodeId = entry?.nodeId || entry?.node; | ||
| const runId = entry?.runId || entry?.run; | ||
| if (typeof nodeId !== "string" || nodeId.length === 0 || !isRunId(runId)) return false; | ||
| const key = `${nodeId}\0${runId}`; | ||
| if (seen.has(key)) return true; | ||
| entries.push({ nodeId, runId }); | ||
| seen.add(key); | ||
| return true; | ||
| }; | ||
| if (state?.entryNode) add({ nodeId: state.entryNode, runId: "run_1" }); | ||
| for (const entry of state?.history || []) add(entry); | ||
| if (includeCurrent) { | ||
| const current = resolveCurrentRun(state); | ||
| if (current) add({ nodeId: state.currentNode, runId: current.runId }); | ||
| } | ||
| return entries; | ||
| } | ||
| export function latestAuthoritativeRunByNode(state) { | ||
| const map = new Map(); | ||
| for (const entry of authoritativeEntries(state)) map.set(entry.nodeId, entry.runId); | ||
| return map; | ||
| } | ||
| export function expectedRunForNode(state, nodeId) { | ||
| return latestAuthoritativeRunByNode(state).get(nodeId) || null; | ||
| } | ||
| export function canonicalHandshakePath(dir, nodeId) { | ||
| return join(dir, "nodes", nodeId, "handshake.json"); | ||
| } | ||
| const NODE_LEVEL_ARTIFACTS = new Set([ | ||
| "build-brief.md", | ||
| "test-plan.md", | ||
| "test-execution.json", | ||
| ]); | ||
| function stableJson(value) { | ||
| if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; | ||
| if (!isPlainObject(value)) return JSON.stringify(value); | ||
| const keys = Object.keys(value).sort(); | ||
| return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`; | ||
| } | ||
| function canonicalArtifactPath(path, runId, context = {}) { | ||
| if (typeof path !== "string") return path; | ||
| let normalized = path; | ||
| while (normalized.startsWith("../")) normalized = normalized.slice(3); | ||
| if (/^run_\d+\//.test(normalized)) return normalized; | ||
| if (NODE_LEVEL_ARTIFACTS.has(normalized)) { | ||
| const nodePath = context.nodeDir ? join(context.nodeDir, normalized) : null; | ||
| const runPath = context.runDir ? join(context.runDir, normalized) : null; | ||
| if (nodePath && existsSync(nodePath) && (!runPath || !existsSync(runPath))) return normalized; | ||
| } | ||
| if (normalized.startsWith("/") || normalized.startsWith("./")) return normalized; | ||
| return `${runId}/${normalized}`; | ||
| } | ||
| function artifactPathSegments(path) { | ||
| return String(path).split(/[\\/]+/); | ||
| } | ||
| function validateExactArtifactPath(path, runId) { | ||
| if (typeof path !== "string" || path.length === 0) return "path missing or invalid"; | ||
| if (path.includes("\0") || isAbsolute(path) || path.startsWith("./")) return `path '${path}' must be a clean relative path`; | ||
| const segments = artifactPathSegments(path); | ||
| if (segments.includes("..")) { | ||
| if (path.startsWith("../") && NODE_LEVEL_ARTIFACTS.has(path.slice(3)) && segments.length === 2) return null; | ||
| return `path '${path}' contains traversal`; | ||
| } | ||
| if (/^run_\d+\//.test(path)) return `exact artifact path '${path}' must be run-relative, not run_N-prefixed`; | ||
| return null; | ||
| } | ||
| function validateCanonicalArtifactPath(path, runId) { | ||
| if (typeof path !== "string" || path.length === 0) return "path missing or invalid"; | ||
| if (path.includes("\0") || isAbsolute(path) || path.startsWith("./")) return `path '${path}' must be a clean relative path`; | ||
| const segments = artifactPathSegments(path); | ||
| if (segments.includes("..")) return `path '${path}' contains traversal`; | ||
| if (NODE_LEVEL_ARTIFACTS.has(path)) return null; | ||
| const prefix = `${runId}/`; | ||
| if (!path.startsWith(prefix)) return `canonical artifact path '${path}' must start with '${prefix}'`; | ||
| const rest = path.slice(prefix.length); | ||
| if (!rest || rest.startsWith("./") || artifactPathSegments(rest).includes("..")) { | ||
| return `canonical artifact path '${path}' has invalid run-relative suffix`; | ||
| } | ||
| if (/^run_\d+\//.test(rest)) return `canonical artifact path '${path}' has duplicate run_N prefix`; | ||
| return null; | ||
| } | ||
| function artifactPathErrors(handshake, runId, kind) { | ||
| const errors = []; | ||
| if (!Array.isArray(handshake?.artifacts)) return errors; | ||
| const validator = kind === "canonical" ? validateCanonicalArtifactPath : validateExactArtifactPath; | ||
| for (const [index, artifact] of handshake.artifacts.entries()) { | ||
| const error = validator(artifact?.path, runId); | ||
| if (error) errors.push(`${kind} artifacts[${index}]: ${error}`); | ||
| } | ||
| return errors; | ||
| } | ||
| function normalizeProjection(value, runId, key = "", context = {}) { | ||
| if (Array.isArray(value)) { | ||
| const items = value.map((item) => normalizeProjection(item, runId, "", context)); | ||
| return key === "artifacts" ? items.sort((a, b) => stableJson(a).localeCompare(stableJson(b))) : items; | ||
| } | ||
| if (!isPlainObject(value)) return value; | ||
| const out = {}; | ||
| for (const k of Object.keys(value).sort()) { | ||
| out[k] = normalizeProjection(value[k], runId, k, context); | ||
| } | ||
| if (key === "" && Array.isArray(out.artifacts)) { | ||
| out.artifacts = normalizeProjection(out.artifacts, runId, "artifacts", context); | ||
| } | ||
| if (Object.hasOwn(out, "path")) out.path = canonicalArtifactPath(out.path, runId, context); | ||
| return out; | ||
| } | ||
| function firstProjectionDiff(canonical, exact, path = "$") { | ||
| if (stableJson(canonical) === stableJson(exact)) return null; | ||
| if (Array.isArray(canonical) || Array.isArray(exact)) { | ||
| if (!Array.isArray(canonical) || !Array.isArray(exact)) return `${path}: type mismatch`; | ||
| if (canonical.length !== exact.length) return `${path}: canonical length ${canonical.length} != exact length ${exact.length}`; | ||
| for (let i = 0; i < canonical.length; i++) { | ||
| const diff = firstProjectionDiff(canonical[i], exact[i], `${path}[${i}]`); | ||
| if (diff) return diff; | ||
| } | ||
| return `${path}: array mismatch`; | ||
| } | ||
| if (!isPlainObject(canonical) || !isPlainObject(exact)) { | ||
| return `${path}: canonical ${JSON.stringify(canonical)} != exact ${JSON.stringify(exact)}`; | ||
| } | ||
| const keys = [...new Set([...Object.keys(canonical), ...Object.keys(exact)])].sort(); | ||
| for (const key of keys) { | ||
| if (!Object.hasOwn(canonical, key)) return `${path}.${key}: missing from canonical`; | ||
| if (!Object.hasOwn(exact, key)) return `${path}.${key}: missing from exact`; | ||
| const diff = firstProjectionDiff(canonical[key], exact[key], `${path}.${key}`); | ||
| if (diff) return diff; | ||
| } | ||
| return `${path}: object mismatch`; | ||
| } | ||
| export function canonicalProjectionErrors(dir, state, validateCanonical = null, options = {}) { | ||
| const errors = []; | ||
| const entries = options.entries | ||
| ? new Map(options.entries.map((entry) => [entry.nodeId, entry.runId])) | ||
| : latestAuthoritativeRunByNode(state); | ||
| for (const [nodeId, runId] of entries) { | ||
| const path = canonicalHandshakePath(dir, nodeId); | ||
| if (!existsSync(path)) continue; | ||
| const exact = resolveExactRunHandshake(dir, nodeId, runId); | ||
| if (exact.error) { | ||
| errors.push(`${nodeId}/${runId}: exact ${exact.error}`); | ||
| continue; | ||
| } | ||
| if (exact.missing || !exact.path || !existsSync(exact.path)) { | ||
| errors.push(`${nodeId}/${runId}: exact handshake missing at ${exact.path}`); | ||
| continue; | ||
| } | ||
| const parsed = parseHandshakeFile(path); | ||
| if (parsed.error) { | ||
| errors.push(`${nodeId}/${runId}: canonical ${parsed.error}`); | ||
| continue; | ||
| } | ||
| const identityErrors = handshakeIdentityErrors(parsed.data, nodeId, runId); | ||
| if (identityErrors.length > 0) { | ||
| errors.push(`${nodeId}/${runId}: canonical ${path}: ${identityErrors.join("; ")}`); | ||
| } | ||
| if (validateCanonical) { | ||
| const schemaErrors = validateCanonical(parsed.data, path, nodeId, runId); | ||
| errors.push(...schemaErrors.map((error) => `${nodeId}/${runId}: canonical ${error}`)); | ||
| } | ||
| errors.push(...artifactPathErrors(parsed.data, runId, "canonical").map((error) => `${nodeId}/${runId}: ${error}`)); | ||
| errors.push(...artifactPathErrors(exact.data, runId, "exact").map((error) => `${nodeId}/${runId}: ${error}`)); | ||
| const nodeDir = join(dir, "nodes", nodeId); | ||
| const context = { nodeDir, runDir: join(nodeDir, runId) }; | ||
| const canonicalProjection = normalizeProjection(parsed.data, runId, "", context); | ||
| const exactProjection = normalizeProjection(exact.data, runId, "", context); | ||
| const diff = firstProjectionDiff(canonicalProjection, exactProjection); | ||
| if (diff) { | ||
| errors.push(`${nodeId}/${runId}: canonical ${path} is not exact-run projection of ${exact.path}: ${diff}`); | ||
| } | ||
| } | ||
| return errors; | ||
| } |
| export function stoppedFlowError(state, command) { | ||
| if (state?.status !== "stopped") return null; | ||
| return `flow is stopped - ${command} cannot mutate state`; | ||
| } | ||
| export function assertFlowMutable(state, command) { | ||
| const error = stoppedFlowError(state, command); | ||
| if (error) throw new Error(error); | ||
| } |
| import assert from "node:assert/strict"; | ||
| import { after, describe, test } from "node:test"; | ||
| import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; | ||
| import { homedir, tmpdir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { cmdSynthesize } from "./eval-commands.mjs"; | ||
| import { buildCumulativeFindingsMarkdown } from "./cumulative-findings.mjs"; | ||
| import { loadTestCommandSpec } from "./test-command-execution.mjs"; | ||
| import { collectTestDesignPlanReasons } from "./test-plan-gate.mjs"; | ||
| import { compareRunIds, parseRunOrdinal } from "./run-id.mjs"; | ||
| import { cmdUxVerdict } from "./ux-verdict.mjs"; | ||
| const TMPBASE = mkdtempSync(join(tmpdir(), "opc-run-id-selection-")); | ||
| const UXBASE = mkdtempSync(join(homedir(), ".opc", "sessions", "opc-run-id-selection-")); | ||
| after(() => { | ||
| rmSync(TMPBASE, { recursive: true, force: true }); | ||
| rmSync(UXBASE, { recursive: true, force: true }); | ||
| }); | ||
| function runDir(session, node, runId) { | ||
| const dir = join(session, "nodes", node, runId); | ||
| mkdirSync(dir, { recursive: true }); | ||
| return dir; | ||
| } | ||
| function captureJson(fn) { | ||
| const output = []; | ||
| const original = console.log; | ||
| console.log = (...args) => output.push(args.join(" ")); | ||
| try { | ||
| fn(); | ||
| } finally { | ||
| console.log = original; | ||
| } | ||
| return JSON.parse(output.at(-1)); | ||
| } | ||
| function completeTestPlan() { | ||
| return [ | ||
| "# Test Plan", | ||
| "## Unit / Smoke", | ||
| "node unit.mjs", | ||
| "unit assertion one", | ||
| "unit assertion two", | ||
| "## Contract / Edge Case", | ||
| "node contract.mjs", | ||
| "boundary assertion one", | ||
| "boundary assertion two", | ||
| "## Integration / E2E Flow", | ||
| "node integration.mjs", | ||
| "workflow assertion one", | ||
| "workflow assertion two", | ||
| "## UI / Visual / A11y", | ||
| "node visual.mjs", | ||
| "accessibility assertion one", | ||
| "accessibility assertion two", | ||
| "## Tier Baseline / Polish", | ||
| "node tier.mjs", | ||
| "baseline assertion one", | ||
| "baseline assertion two", | ||
| "", | ||
| ].join("\n"); | ||
| } | ||
| describe("exact run_N selection", () => { | ||
| test("run ordinals parse and compare without Number coercion", () => { | ||
| assert.equal(parseRunOrdinal("run_9007199254740993"), 9007199254740993n); | ||
| assert.equal(parseRunOrdinal("9007199254740992"), 9007199254740992n); | ||
| assert.equal(parseRunOrdinal("run_bad"), null); | ||
| assert.equal(compareRunIds("run_9007199254740992", "run_9007199254740993"), -1); | ||
| assert.ok(compareRunIds("invalid-a", "invalid-b") < 0); | ||
| }); | ||
| test("synthesize selects the exact latest run beyond MAX_SAFE_INTEGER", () => { | ||
| const session = join(TMPBASE, "synthesize"); | ||
| const oldRun = runDir(session, "review", "run_9007199254740992"); | ||
| const newRun = runDir(session, "review", "run_9007199254740993"); | ||
| writeFileSync(join(oldRun, "eval-owner.md"), "# Review\n\nNo findings.\n"); | ||
| writeFileSync(join(newRun, "eval-owner.md"), "# Review\n\n[CRITICAL] src/new.mjs:1 — newest run finding\n"); | ||
| const result = captureJson(() => cmdSynthesize([session, "--node", "review"])); | ||
| assert.equal(result.totals.critical, 1, JSON.stringify(result)); | ||
| }); | ||
| test("test command loading selects the exact latest run", () => { | ||
| const session = join(TMPBASE, "test-command"); | ||
| const oldRun = runDir(session, "test-design", "run_9007199254740992"); | ||
| const newRun = runDir(session, "test-design", "run_9007199254740993"); | ||
| writeFileSync(join(oldRun, "handshake.json"), JSON.stringify({ testCommand: "node old.mjs" })); | ||
| writeFileSync(join(newRun, "handshake.json"), JSON.stringify({ testCommand: "node new.mjs" })); | ||
| assert.equal(loadTestCommandSpec(session, "test-design")?.testCommand, "node new.mjs"); | ||
| }); | ||
| test("test plan gate selects the exact latest run", () => { | ||
| const session = join(TMPBASE, "test-plan"); | ||
| const oldRun = runDir(session, "test-design", "run_9007199254740992"); | ||
| const newRun = runDir(session, "test-design", "run_9007199254740993"); | ||
| writeFileSync(join(oldRun, "test-plan.md"), completeTestPlan()); | ||
| writeFileSync(join(newRun, "test-plan.md"), "# Test Plan\n\nNo executable cases yet.\n"); | ||
| const reasons = collectTestDesignPlanReasons(session, "test-design"); | ||
| assert.ok(reasons.some((reason) => reason.includes("missing layers")), reasons.join("\n")); | ||
| }); | ||
| test("cumulative findings preserve exact numeric run order", () => { | ||
| const session = join(TMPBASE, "cumulative"); | ||
| runDir(session, "review", "run_9007199254740993"); | ||
| runDir(session, "review", "run_9007199254740992"); | ||
| const markdown = buildCumulativeFindingsMarkdown(session, { | ||
| entryNode: "review", | ||
| currentNode: "review", | ||
| history: [], | ||
| }); | ||
| assert.ok( | ||
| markdown.indexOf("run_9007199254740992") < markdown.indexOf("run_9007199254740993"), | ||
| markdown, | ||
| ); | ||
| }); | ||
| test("UX verdict reads the exact preceding run", () => { | ||
| const session = join(UXBASE, "ux-verdict"); | ||
| const priorRunId = "run_9007199254740992"; | ||
| const currentRunId = "run_9007199254740993"; | ||
| const prior = runDir(session, "ux-simulation", priorRunId); | ||
| const current = runDir(session, "ux-simulation", currentRunId); | ||
| writeFileSync(join(session, "flow-state.json"), JSON.stringify({ tier: "polished" })); | ||
| writeFileSync(join(prior, "ux-verdict.json"), JSON.stringify({ | ||
| verdict: "PASS", | ||
| uxResult: { flagDetails: [], redFlags: { critical: 0, warning: 0, suggestion: 0 } }, | ||
| })); | ||
| writeFileSync(join(current, "observer-new-user.md"), [ | ||
| "# Observer Report", | ||
| "", | ||
| "```json", | ||
| JSON.stringify({ | ||
| persona: "new-user", | ||
| tier: "polished", | ||
| red_flags: [], | ||
| trust_signals: { present: [], absent: [] }, | ||
| friction_points: [], | ||
| tier_fit: "at-tier", | ||
| reasoning: "I found the experience detailed, coherent, and trustworthy throughout the complete workflow.", | ||
| }, null, 2), | ||
| "```", | ||
| "", | ||
| ].join("\n")); | ||
| const result = captureJson(() => cmdUxVerdict([ | ||
| "--dir", session, | ||
| "--run", currentRunId.slice("run_".length), | ||
| ])); | ||
| assert.equal(result.uxResult.delta?.vs_run, priorRunId, JSON.stringify(result)); | ||
| }); | ||
| test("rubric convergence reads the two exact preceding runs", () => { | ||
| const session = join(TMPBASE, "convergence"); | ||
| const node = "review"; | ||
| const current = runDir(session, node, "run_9007199254740993"); | ||
| const prior = runDir(session, node, "run_9007199254740992"); | ||
| const prior2 = runDir(session, node, "run_9007199254740991"); | ||
| const wrongPrior = runDir(session, node, "run_9007199254740990"); | ||
| for (const dir of [current, prior, prior2, wrongPrior]) { | ||
| mkdirSync(join(dir, "ext-design-intelligence"), { recursive: true }); | ||
| writeFileSync(join(dir, "eval-owner.md"), "# Review\n\nNo findings.\n"); | ||
| } | ||
| writeFileSync(join(current, "ext-design-intelligence", "rubric-verdict.json"), JSON.stringify({ final: 4.1, verdict: "PASS" })); | ||
| writeFileSync(join(prior, "ext-design-intelligence", "rubric-verdict.json"), JSON.stringify({ final: 1.0, verdict: "FAIL" })); | ||
| writeFileSync(join(prior2, "ext-design-intelligence", "rubric-verdict.json"), JSON.stringify({ final: 4.2, verdict: "PASS" })); | ||
| writeFileSync(join(wrongPrior, "ext-design-intelligence", "rubric-verdict.json"), JSON.stringify({ final: 4.3, verdict: "PASS" })); | ||
| const result = captureJson(() => cmdSynthesize([ | ||
| session, | ||
| "--node", node, | ||
| "--run", "9007199254740993", | ||
| ])); | ||
| assert.equal(result.convergenceWarning, undefined, JSON.stringify(result)); | ||
| }); | ||
| }); |
| export function parseRunOrdinal(value) { | ||
| const match = /^(?:run_)?(\d+)$/.exec(String(value ?? "")); | ||
| return match ? BigInt(match[1]) : null; | ||
| } | ||
| export function compareRunIds(left, right) { | ||
| const leftOrdinal = parseRunOrdinal(left); | ||
| const rightOrdinal = parseRunOrdinal(right); | ||
| if (leftOrdinal === null || rightOrdinal === null) { | ||
| return String(left).localeCompare(String(right)); | ||
| } | ||
| return leftOrdinal === rightOrdinal ? 0 : leftOrdinal < rightOrdinal ? -1 : 1; | ||
| } |
| #!/bin/bash | ||
| set -u | ||
| ROOT="$(cd "$(dirname "$0")/.." && pwd)" | ||
| TMP="$(mktemp -d)" | ||
| trap 'rm -rf "$TMP"' EXIT | ||
| PASS=0 | ||
| FAIL=0 | ||
| ok() { | ||
| echo " PASS $1" | ||
| PASS=$((PASS + 1)) | ||
| } | ||
| fail() { | ||
| echo " FAIL $1" | ||
| FAIL=$((FAIL + 1)) | ||
| } | ||
| expect_abort() { | ||
| local name="$1" | ||
| shift | ||
| if "$@" >/dev/null 2>&1; then | ||
| fail "$name did not abort" | ||
| else | ||
| ok "$name aborted" | ||
| fi | ||
| } | ||
| expect_finishes_with_open_stdin() { | ||
| local name="$1" | ||
| shift | ||
| local fifo="$TMP/stdin-fifo-$$" | ||
| mkfifo "$fifo" | ||
| node -e 'const fs=require("fs"); fs.openSync(process.argv[1],"w"); setTimeout(()=>{},90000)' "$fifo" & | ||
| local writer=$! | ||
| "$@" < "$fifo" >/dev/null 2>&1 & | ||
| local pid=$! | ||
| child_done() { | ||
| local stat | ||
| stat=$(ps -o stat= -p "$pid" 2>/dev/null | tr -d ' ' || true) | ||
| [ -z "$stat" ] || [[ "$stat" == Z* ]] | ||
| } | ||
| local deadline=$((SECONDS + 90)) | ||
| while [ "$SECONDS" -lt "$deadline" ]; do | ||
| if child_done; then | ||
| wait "$pid" | ||
| kill "$writer" 2>/dev/null || true | ||
| wait "$writer" 2>/dev/null || true | ||
| rm -f "$fifo" | ||
| ok "$name" | ||
| return | ||
| fi | ||
| sleep 1 | ||
| done | ||
| if child_done; then | ||
| wait "$pid" | ||
| kill "$writer" 2>/dev/null || true | ||
| wait "$writer" 2>/dev/null || true | ||
| rm -f "$fifo" | ||
| ok "$name" | ||
| return | ||
| fi | ||
| kill -TERM "-$pid" 2>/dev/null || kill -TERM "$pid" 2>/dev/null || true | ||
| sleep 1 | ||
| kill -KILL "-$pid" 2>/dev/null || kill -KILL "$pid" 2>/dev/null || true | ||
| wait "$pid" 2>/dev/null || true | ||
| kill "$writer" 2>/dev/null || true | ||
| wait "$writer" 2>/dev/null || true | ||
| rm -f "$fifo" | ||
| fail "$name hung with open stdin" | ||
| } | ||
| echo "=== Shell cleanup fault contracts ===" | ||
| HARNESS_NAME=".harness-bypass-chain-cleanup-$$" | ||
| expect_abort "bypass-chain fault injection" \ | ||
| env OPC_TEST_HARNESS_NAME="$HARNESS_NAME" OPC_TEST_ABORT_AFTER_HARNESS_INIT=1 \ | ||
| bash "$ROOT/test/test-bypass-chain.sh" | ||
| if [ ! -e "$ROOT/$HARNESS_NAME" ]; then | ||
| ok "bypass-chain removes repo-local harness" | ||
| else | ||
| fail "bypass-chain left repo-local harness" | ||
| rm -rf "$ROOT/$HARNESS_NAME" | ||
| fi | ||
| FLOW_HOME="$TMP/flow-home" | ||
| FLOW_HOME_PROBE="$TMP/flow-home-probe" | ||
| mkdir -p "$FLOW_HOME/.claude/flows" | ||
| printf '%s\n' 'user idea-factory sentinel' > "$FLOW_HOME/.claude/flows/idea-factory.json" | ||
| expect_abort "flow-part3 fault injection" \ | ||
| env OPC_TEST_HOME_OVERRIDE="$FLOW_HOME" OPC_TEST_HOME_PROBE="$FLOW_HOME_PROBE" \ | ||
| OPC_TEST_ABORT_AFTER_FLOW_FIXTURE=1 bash "$ROOT/test/test-flow-part3.sh" | ||
| if [ "$(cat "$FLOW_HOME_PROBE" 2>/dev/null)" = "$FLOW_HOME" ]; then | ||
| ok "flow-part3 used the injected fixture HOME" | ||
| else | ||
| fail "flow-part3 did not use the injected fixture HOME" | ||
| fi | ||
| if [ "$(cat "$FLOW_HOME/.claude/flows/idea-factory.json" 2>/dev/null)" = "user idea-factory sentinel" ]; then | ||
| ok "flow-part3 restores pre-existing flow fixture" | ||
| else | ||
| fail "flow-part3 overwrote pre-existing flow fixture" | ||
| fi | ||
| COVERAGE_HOME="$TMP/coverage-home" | ||
| COVERAGE_HOME_PROBE="$TMP/coverage-home-probe" | ||
| mkdir -p "$COVERAGE_HOME/.claude/flows" | ||
| printf '%s\n' 'user coverage idea sentinel' > "$COVERAGE_HOME/.claude/flows/idea-factory.json" | ||
| printf '%s\n' 'user context sentinel' > "$COVERAGE_HOME/.claude/flows/test-ctx-flow.json" | ||
| expect_abort "coverage-part1 fault injection" \ | ||
| env OPC_TEST_HOME_OVERRIDE="$COVERAGE_HOME" OPC_TEST_HOME_PROBE="$COVERAGE_HOME_PROBE" \ | ||
| OPC_TEST_ABORT_AFTER_CONTEXT_FIXTURE=1 bash "$ROOT/test/test-coverage-part1.sh" | ||
| if [ "$(cat "$COVERAGE_HOME_PROBE" 2>/dev/null)" = "$COVERAGE_HOME" ]; then | ||
| ok "coverage-part1 used the injected fixture HOME" | ||
| else | ||
| fail "coverage-part1 did not use the injected fixture HOME" | ||
| fi | ||
| if [ "$(cat "$COVERAGE_HOME/.claude/flows/idea-factory.json" 2>/dev/null)" = "user coverage idea sentinel" ]; then | ||
| ok "coverage-part1 restores idea-factory fixture" | ||
| else | ||
| fail "coverage-part1 overwrote idea-factory fixture" | ||
| fi | ||
| if [ "$(cat "$COVERAGE_HOME/.claude/flows/test-ctx-flow.json" 2>/dev/null)" = "user context sentinel" ]; then | ||
| ok "coverage-part1 restores context fixture" | ||
| else | ||
| fail "coverage-part1 overwrote context fixture" | ||
| fi | ||
| expect_finishes_with_open_stdin "gaps-part1 does not hang when stdin stays open" \ | ||
| bash "$ROOT/test/test-gaps-part1.sh" | ||
| if grep -q 'bash "$f" < /dev/null' "$ROOT/test/run-all.sh"; then | ||
| ok "run-all closes child stdin" | ||
| else | ||
| fail "run-all does not close child stdin" | ||
| fi | ||
| echo "" | ||
| echo "Results: $PASS passed, $FAIL failed" | ||
| [ "$FAIL" -eq 0 ] |
@@ -5,2 +5,4 @@ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs"; | ||
| import { parseStructuredFindings, structuredSeverityName } from "./structured-findings.mjs"; | ||
| import { compareRunIds } from "./run-id.mjs"; | ||
| import { authoritativeEntries } from "./flow-evidence.mjs"; | ||
@@ -16,5 +18,6 @@ const OUT = "cumulative-findings.md"; | ||
| try { | ||
| return readdirSync(nodeDir) | ||
| .filter((name) => /^run_\d+$/.test(name)) | ||
| .sort((a, b) => Number(a.slice(4)) - Number(b.slice(4))); | ||
| return readdirSync(nodeDir, { withFileTypes: true }) | ||
| .filter((entry) => entry.isDirectory() && /^run_\d+$/.test(entry.name)) | ||
| .map((entry) => entry.name) | ||
| .sort(compareRunIds); | ||
| } catch { return []; } | ||
@@ -68,4 +71,4 @@ } | ||
| const nodeDir = join(dir, "nodes", nodeId); | ||
| const runDir = runId ? join(nodeDir, runId) : join(nodeDir, listRunDirs(nodeDir).at(-1) || ""); | ||
| const handshake = readJson(join(nodeDir, "handshake.json")); | ||
| const runDir = runId ? join(nodeDir, runId) : ""; | ||
| const handshake = runId ? readJson(join(nodeDir, "handshake.json")) : null; | ||
| const runHandshake = readJson(join(runDir, "handshake.json")); | ||
@@ -76,42 +79,29 @@ return { nodeId, runId, nodeDir, runDir, handshake, runHandshake }; | ||
| function orderedEntries(dir, state) { | ||
| const nodes = []; | ||
| const seenNodes = new Set(); | ||
| const addNode = (entry) => { | ||
| const nodeId = entry?.nodeId || entry?.node; | ||
| if (!nodeId || seenNodes.has(nodeId)) return; | ||
| seenNodes.add(nodeId); | ||
| nodes.push(nodeId); | ||
| }; | ||
| addNode({ nodeId: state?.entryNode }); | ||
| for (const entry of state?.history || []) addNode(entry); | ||
| addNode({ nodeId: state?.currentNode }); | ||
| return authoritativeEntries(state); | ||
| } | ||
| function orphanEntries(dir, state) { | ||
| const authoritative = new Set(orderedEntries(dir, state).map((entry) => `${entry.nodeId}\0${entry.runId}`)); | ||
| const nodesDir = join(dir, "nodes"); | ||
| if (!existsSync(nodesDir)) return nodes.map((nodeId) => ({ nodeId })); | ||
| if (!existsSync(nodesDir)) return []; | ||
| const orphans = []; | ||
| for (const nodeId of readdirSync(nodesDir).sort()) { | ||
| const nodeDir = join(nodesDir, nodeId); | ||
| if (statSync(nodeDir).isDirectory()) addNode({ nodeId }); | ||
| if (!statSync(nodeDir).isDirectory()) continue; | ||
| for (const runId of listRunDirs(nodeDir)) { | ||
| if (!authoritative.has(`${nodeId}\0${runId}`)) orphans.push({ nodeId, runId }); | ||
| } | ||
| } | ||
| return nodes.flatMap((nodeId) => { | ||
| const runIds = listRunDirs(join(nodesDir, nodeId)); | ||
| return runIds.length ? runIds.map((runId) => ({ nodeId, runId })) : [{ nodeId }]; | ||
| }); | ||
| return orphans; | ||
| } | ||
| export function collectExecutionFixes(dir) { | ||
| export function collectExecutionFixes(dir, state) { | ||
| const fixes = []; | ||
| const nodesDir = join(dir, "nodes"); | ||
| if (!existsSync(nodesDir)) return fixes; | ||
| for (const nodeId of readdirSync(nodesDir).sort()) { | ||
| const nodeDir = join(nodesDir, nodeId); | ||
| if (!statSync(nodeDir).isDirectory()) continue; | ||
| for (const raw of fixArrays(readJson(join(nodeDir, "handshake.json")))) { | ||
| for (const entry of orderedEntries(dir, state)) { | ||
| const { nodeId, runId } = entry; | ||
| const hs = readJson(join(dir, "nodes", nodeId, runId, "handshake.json")); | ||
| for (const raw of fixArrays(hs)) { | ||
| const text = fixText(raw); | ||
| if (text) fixes.push({ nodeId, runId: null, text }); | ||
| if (text) fixes.push({ nodeId, runId, text }); | ||
| } | ||
| for (const runId of listRunDirs(nodeDir)) { | ||
| for (const raw of fixArrays(readJson(join(nodeDir, runId, "handshake.json")))) { | ||
| const text = fixText(raw); | ||
| if (text) fixes.push({ nodeId, runId, text }); | ||
| } | ||
| } | ||
| } | ||
@@ -124,3 +114,3 @@ return fixes; | ||
| lines.push(`## ${nodeId}${runId ? ` / ${runId}` : ""}`); | ||
| const hs = handshake || runHandshake; | ||
| const hs = runHandshake || handshake; | ||
| if (hs) lines.push(`- Status: ${hs.status || "unknown"}${hs.verdict ? `, verdict: ${hs.verdict}` : ""}`); | ||
@@ -155,3 +145,10 @@ if (existsSync(join(nodeDir, "extension-context.md"))) { | ||
| for (const entry of orderedEntries(dir, state)) appendNode(lines, readRunSummary(dir, entry)); | ||
| const fixes = collectExecutionFixes(dir); | ||
| const orphans = orphanEntries(dir, state); | ||
| if (orphans.length > 0) { | ||
| lines.push("## Forensic Orphan Runs"); | ||
| lines.push("These runs are present on disk but are not selected by flow-state/history."); | ||
| for (const entry of orphans) lines.push(`- ${entry.nodeId}/${entry.runId}`); | ||
| lines.push(""); | ||
| } | ||
| const fixes = collectExecutionFixes(dir, state); | ||
| if (fixes.length) { | ||
@@ -158,0 +155,0 @@ lines.push("## Fixes Applied During Execution"); |
@@ -8,5 +8,6 @@ // Evaluation analysis commands: verify, synthesize, tier-baseline | ||
| import { parseEvaluation } from "./eval-parser.mjs"; | ||
| import { getFlag, resolveDir } from "./util.mjs"; | ||
| import { getFlag, hasFlag, resolveDir } from "./util.mjs"; | ||
| import { checkBaselineCoverage, generateTierTestCases, VALID_TIERS, TEST_LAYERS, TEST_LAYER_KEYWORDS, TEST_LAYER_LABELS } from "./tier-baselines.mjs"; | ||
| import { anchorIssues as collectAnchorIssues } from "./test-plan-gate.mjs"; | ||
| import { compareRunIds, parseRunOrdinal } from "./run-id.mjs"; | ||
@@ -216,16 +217,23 @@ /** | ||
| const runFlag = args.indexOf("--run"); | ||
| const runProvided = hasFlag(args, "run"); | ||
| if (runFlag !== -1 && args[runFlag + 1]) { | ||
| targetRunDir = join(dir, "nodes", nodeId, `run_${args[runFlag + 1]}`); | ||
| if (runProvided) { | ||
| const runValue = getFlag(args, "run", ""); | ||
| if (!/^[1-9]\d*$/.test(runValue)) { | ||
| console.log(JSON.stringify({ | ||
| roles: [], | ||
| totals: { critical: 0, warning: 0, suggestion: 0 }, | ||
| verdict: "BLOCKED", | ||
| reason: "--run must be a positive numeric ordinal", | ||
| })); | ||
| return; | ||
| } | ||
| targetRunDir = join(dir, "nodes", nodeId, `run_${runValue}`); | ||
| } else { | ||
| const nodeDir = join(dir, "nodes", nodeId); | ||
| try { | ||
| const runs = readdirSync(nodeDir) | ||
| .filter((d) => d.startsWith("run_")) | ||
| .sort((a, b) => { | ||
| const na = parseInt(a.replace("run_", ""), 10); | ||
| const nb = parseInt(b.replace("run_", ""), 10); | ||
| return nb - na; | ||
| }); | ||
| const runs = readdirSync(nodeDir, { withFileTypes: true }) | ||
| .filter((entry) => entry.isDirectory() && /^run_\d+$/.test(entry.name)) | ||
| .map((entry) => entry.name) | ||
| .sort((a, b) => compareRunIds(b, a)); | ||
| if (runs.length === 0) { | ||
@@ -848,9 +856,9 @@ console.log(JSON.stringify({ roles: [], totals: { critical: 0, warning: 0, suggestion: 0 }, verdict: "BLOCKED", reason: `no runs found for node '${nodeId}' in ${nodeDir}` })); | ||
| 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 explicitRun = runFlag !== -1 ? parseRunOrdinal(args[runFlag + 1]) : null; | ||
| const iteration = explicitRun ?? parseRunOrdinal(targetRunDir.match(/run_(\d+)$/)?.[1]) ?? 1n; | ||
| if (iteration >= 3n) { | ||
| const recentScores = [rubricScore.final]; | ||
| for (let i = iteration - 1; i >= Math.max(1, iteration - 2); i--) { | ||
| for (const previous of [iteration - 1n, iteration - 2n]) { | ||
| try { | ||
| const prevPath = join(dir, "nodes", nodeId, `run_${i}`, "ext-design-intelligence", "rubric-verdict.json"); | ||
| const prevPath = join(dir, "nodes", nodeId, `run_${previous}`, "ext-design-intelligence", "rubric-verdict.json"); | ||
| if (existsSync(prevPath)) { | ||
@@ -857,0 +865,0 @@ const prev = JSON.parse(readFileSync(prevPath, "utf8")); |
+299
-148
@@ -5,6 +5,5 @@ // ext-commands.mjs — CLI commands for extension system | ||
| import { readFileSync, writeFileSync, existsSync, readdirSync, cpSync, mkdtempSync, rmSync, lstatSync, statSync, realpathSync, mkdirSync, copyFileSync } from "fs"; | ||
| import { readFile, writeFile } from "fs/promises"; | ||
| import { tmpdir } from "os"; | ||
| import { join, resolve } from "path"; | ||
| import { loadExtensions, firePromptAppend, fireVerdictAppend, fireExecuteRun, fireArtifactEmit, fireNodePreflight, writeFailureReport, saveRegistryCache, normalizeHook, lintCapability, enforceStrictMode, survivingExtensions } from "./extensions.mjs"; | ||
| import { basename, join, resolve } from "path"; | ||
| import { loadExtensions, firePromptAppend, fireVerdictAppend, fireExecuteRun, fireArtifactEmit, fireNodePreflight, readFailureReportState, writeFailureReport, saveRegistryCache, normalizeHook, lintCapability, enforceStrictMode, participatingExtensions } from "./extensions.mjs"; | ||
| import { getFlag, atomicWriteSync, resolveDir, resolveDirReadOnly } from "./util.mjs"; | ||
@@ -15,2 +14,5 @@ import { resolveFlowTemplate } from "./flow-templates.mjs"; | ||
| import { readCumulativeFindingsAppend } from "./cumulative-findings.mjs"; | ||
| import { compareRunIds } from "./run-id.mjs"; | ||
| import { resolveCurrentRun } from "./runaway-guard.mjs"; | ||
| import { assertFlowMutable } from "./flow-state-guard.mjs"; | ||
@@ -49,3 +51,3 @@ // ─── Shared helpers ────────────────────────────────────────────── | ||
| .map(e => e.name) | ||
| .sort((a, b) => parseInt(b.replace("run_", ""), 10) - parseInt(a.replace("run_", ""), 10)); | ||
| .sort((a, b) => compareRunIds(b, a)); | ||
| return runDirs.length > 0 ? join(nodeDir, runDirs[0]) : null; | ||
@@ -55,2 +57,143 @@ } catch { return null; } | ||
| export function resolveSelectedRunDir(dir, node, command = "extension lifecycle") { | ||
| let state; | ||
| try { | ||
| state = JSON.parse(readFileSync(resolve(dir, "flow-state.json"), "utf8")); | ||
| } catch (error) { | ||
| throw new Error(`cannot resolve selected run: flow-state.json parse error: ${error.message}`); | ||
| } | ||
| assertFlowMutable(state, command); | ||
| if (state.currentNode !== node) { | ||
| throw new Error(`cannot resolve selected run: current node is '${state.currentNode}', not '${node}'`); | ||
| } | ||
| const selected = resolveCurrentRun(state); | ||
| if (!selected) { | ||
| throw new Error(`cannot resolve selected run for '${node}'`); | ||
| } | ||
| const runDir = resolve(dir, "nodes", node, selected.runId); | ||
| if (!existsSync(runDir) || !lstatSync(runDir).isDirectory()) { | ||
| throw new Error(`selected run directory not found: nodes/${node}/${selected.runId}`); | ||
| } | ||
| return runDir; | ||
| } | ||
| function readOptionalHandshake(handshakePath) { | ||
| if (!existsSync(handshakePath)) return {}; | ||
| let handshake; | ||
| try { | ||
| handshake = JSON.parse(readFileSync(handshakePath, "utf8")); | ||
| } catch (error) { | ||
| throw new Error(`handshake.json parse error: ${error.message}`); | ||
| } | ||
| if (!handshake || typeof handshake !== "object" || Array.isArray(handshake)) { | ||
| throw new Error("handshake.json schema error: root must be an object"); | ||
| } | ||
| return handshake; | ||
| } | ||
| function validatePromptProvenanceInputs(runDir, context) { | ||
| const runId = basename(runDir); | ||
| const nodeId = context?.nodeId; | ||
| const sidecarPath = join(runDir, "prompt-extensions.json"); | ||
| if (existsSync(sidecarPath)) { | ||
| let sidecar; | ||
| try { | ||
| sidecar = JSON.parse(readFileSync(sidecarPath, "utf8")); | ||
| } catch (error) { | ||
| throw new Error(`prompt-extensions.json parse error: ${error.message}`); | ||
| } | ||
| if (!sidecar || typeof sidecar !== "object" || Array.isArray(sidecar) || sidecar.version !== 1) { | ||
| throw new Error("prompt-extensions.json schema/version error"); | ||
| } | ||
| if (sidecar.nodeId !== nodeId || sidecar.runId !== runId) { | ||
| throw new Error("prompt-extensions.json provenance does not match selected run"); | ||
| } | ||
| if (!Array.isArray(sidecar.extensionsApplied) || sidecar.extensionsApplied.some((name) => typeof name !== "string" || !name)) { | ||
| throw new Error("prompt-extensions.json extensionsApplied must be an array of names"); | ||
| } | ||
| } | ||
| const handshake = readOptionalHandshake(join(runDir, "handshake.json")); | ||
| if (handshake.nodeId && handshake.nodeId !== nodeId) { | ||
| throw new Error(`run handshake nodeId '${handshake.nodeId}' does not match '${nodeId}'`); | ||
| } | ||
| if (handshake.runId && handshake.runId !== runId) { | ||
| throw new Error(`run handshake runId '${handshake.runId}' does not match '${runId}'`); | ||
| } | ||
| return handshake; | ||
| } | ||
| function prevalidateLifecycleEvidence(runDir, context, { prompt = false } = {}) { | ||
| readFailureReportState(runDir); | ||
| return prompt | ||
| ? validatePromptProvenanceInputs(runDir, context) | ||
| : readOptionalHandshake(join(runDir, "handshake.json")); | ||
| } | ||
| export function writePromptExtensionProvenance(runDir, context, extensionsApplied) { | ||
| const runId = basename(runDir); | ||
| const nodeId = context?.nodeId; | ||
| if (typeof nodeId !== "string" || !nodeId || !/^run_\d+$/.test(runId)) { | ||
| throw new Error("cannot write prompt extension provenance without valid nodeId and runId"); | ||
| } | ||
| if (!Array.isArray(extensionsApplied) || extensionsApplied.some((name) => typeof name !== "string" || !name)) { | ||
| throw new Error("cannot write prompt extension provenance: extensionsApplied must be an array of names"); | ||
| } | ||
| const sidecarPath = join(runDir, "prompt-extensions.json"); | ||
| const handshakePath = join(runDir, "handshake.json"); | ||
| const handshake = validatePromptProvenanceInputs(runDir, context); | ||
| if (extensionsApplied.length === 0 && Object.keys(handshake).length === 0) return null; | ||
| const sidecar = { | ||
| version: 1, | ||
| generatedAt: new Date().toISOString(), | ||
| nodeId, | ||
| runId, | ||
| extensionsApplied: extensionsApplied.slice(), | ||
| }; | ||
| atomicWriteSync(sidecarPath, JSON.stringify(sidecar, null, 2) + "\n"); | ||
| atomicWriteSync(handshakePath, JSON.stringify({ | ||
| ...handshake, | ||
| nodeId, | ||
| ...(context.nodeType ? { nodeType: context.nodeType } : {}), | ||
| runId, | ||
| extensionsApplied: extensionsApplied.slice(), | ||
| }, null, 2) + "\n"); | ||
| return sidecarPath; | ||
| } | ||
| export function collectPromptExtensionProvenanceErrors(runDir, expected) { | ||
| const runId = expected?.runId || basename(runDir); | ||
| const sidecarPath = join(runDir, "prompt-extensions.json"); | ||
| if (!existsSync(sidecarPath)) return [`${runId}/prompt-extensions.json not found`]; | ||
| let sidecar; | ||
| try { | ||
| sidecar = JSON.parse(readFileSync(sidecarPath, "utf8")); | ||
| } catch (error) { | ||
| return [`${runId}/prompt-extensions.json parse error: ${error.message}`]; | ||
| } | ||
| const errors = []; | ||
| if (!sidecar || typeof sidecar !== "object" || Array.isArray(sidecar) || sidecar.version !== 1) { | ||
| errors.push(`${runId}/prompt-extensions.json schema/version invalid`); | ||
| return errors; | ||
| } | ||
| if (sidecar.nodeId !== expected.nodeId) { | ||
| errors.push(`${runId}/prompt-extensions.json nodeId '${sidecar.nodeId}' does not match '${expected.nodeId}'`); | ||
| } | ||
| if (sidecar.runId !== runId) { | ||
| errors.push(`${runId}/prompt-extensions.json runId '${sidecar.runId}' does not match '${runId}'`); | ||
| } | ||
| if (!Array.isArray(sidecar.extensionsApplied) || sidecar.extensionsApplied.some((name) => typeof name !== "string" || !name)) { | ||
| errors.push(`${runId}/prompt-extensions.json extensionsApplied missing or invalid`); | ||
| } else { | ||
| const claimed = expected.extensionsApplied || []; | ||
| if (claimed.length !== sidecar.extensionsApplied.length || claimed.some((name) => !sidecar.extensionsApplied.includes(name))) { | ||
| errors.push(`${runId}/prompt-extensions.json extensionsApplied does not match handshake claim`); | ||
| } | ||
| } | ||
| return errors; | ||
| } | ||
| /** | ||
@@ -72,20 +215,64 @@ * Read flow-state.json + resolved flow template, return the current node's | ||
| function readNodeCapabilities(dir, node, args) { | ||
| try { | ||
| const statePath = resolve(dir, "flow-state.json"); | ||
| let state = null; | ||
| if (existsSync(statePath)) { | ||
| try { state = JSON.parse(readFileSync(statePath, "utf8")); } catch { /* state corrupt — treat as absent */ } | ||
| let state = null; | ||
| const statePath = resolve(dir, "flow-state.json"); | ||
| if (existsSync(statePath)) { | ||
| try { | ||
| state = JSON.parse(readFileSync(statePath, "utf8")); | ||
| } catch (error) { | ||
| throw new Error(`flow-state.json parse error: ${error.message}`); | ||
| } | ||
| const { template } = resolveFlowTemplate(args, state); | ||
| const templateResolved = !!template; | ||
| const nodeResolved = templateResolved && Array.isArray(template.nodes) && template.nodes.includes(node); | ||
| if (!nodeResolved) return { caps: [], templateResolved: false }; | ||
| if (!template.nodeCapabilities) return { caps: [], templateResolved: true }; | ||
| const caps = template.nodeCapabilities[node]; | ||
| return { caps: Array.isArray(caps) ? caps : [], templateResolved: true }; | ||
| } catch { | ||
| return { caps: [], templateResolved: false }; | ||
| if (!state || typeof state !== "object" || Array.isArray(state)) { | ||
| throw new Error("flow-state.json schema error: root must be an object"); | ||
| } | ||
| } | ||
| const tier = typeof state?.tier === "string" ? state.tier : null; | ||
| const visualEvaluationRequired = tier === "polished" || tier === "delightful"; | ||
| const resolved = resolveFlowTemplate(args, state); | ||
| if (resolved.error) throw new Error(resolved.error); | ||
| const { template } = resolved; | ||
| const templateResolved = !!template; | ||
| const nodeResolved = templateResolved && Array.isArray(template.nodes) && template.nodes.includes(node); | ||
| const nodeType = nodeResolved ? template.nodeTypes?.[node] || null : null; | ||
| if (!nodeResolved) { | ||
| return { caps: [], templateResolved: false, nodeType, tier, visualEvaluationRequired }; | ||
| } | ||
| const caps = template.nodeCapabilities?.[node]; | ||
| return { | ||
| caps: Array.isArray(caps) ? caps : [], | ||
| templateResolved: true, | ||
| nodeType, | ||
| tier, | ||
| visualEvaluationRequired, | ||
| }; | ||
| } | ||
| export function resolveNodeExtensionContext(dir, node, args = [], overrides = {}) { | ||
| const { | ||
| caps: nodeCapabilities, | ||
| templateResolved, | ||
| nodeType, | ||
| tier, | ||
| visualEvaluationRequired, | ||
| } = readNodeCapabilities(dir, node, args); | ||
| const task = Object.hasOwn(overrides, "task") ? overrides.task : readTaskFromAC(dir); | ||
| const context = { | ||
| node, | ||
| nodeId: node, | ||
| nodeType, | ||
| tier, | ||
| role: overrides.role ?? null, | ||
| task, | ||
| taskDescription: task, | ||
| visualEvaluationRequired, | ||
| flowDir: resolve(dir), | ||
| cwd: overrides.cwd ?? process.cwd(), | ||
| devServerUrl: overrides.devServerUrl ?? "", | ||
| nodeCapabilities, | ||
| nodeCapabilitiesResolved: templateResolved, | ||
| }; | ||
| if (overrides.runDir) context.runDir = overrides.runDir; | ||
| return context; | ||
| } | ||
| // ─── prompt-context ────────────────────────────────────────────── | ||
@@ -112,3 +299,20 @@ | ||
| const task = readTaskFromAC(dir); | ||
| const runDir = resolveSelectedRunDir(dir, node, "prompt-context"); | ||
| const devServerUrl = getFlag(args, "dev-server") || process.env.DEV_SERVER_URL || config.devServerUrl || ""; | ||
| const context = resolveNodeExtensionContext(dir, node, args, { | ||
| role, | ||
| task, | ||
| runDir, | ||
| devServerUrl, | ||
| }); | ||
| const { nodeCapabilities } = context; | ||
| try { | ||
| prevalidateLifecycleEvidence(runDir, context, { prompt: true }); | ||
| } catch (error) { | ||
| console.error(error.message); | ||
| process.exit(1); | ||
| return; | ||
| } | ||
| let registry; | ||
@@ -122,35 +326,2 @@ try { | ||
| const devServerUrl = getFlag(args, "dev-server") || process.env.DEV_SERVER_URL || config.devServerUrl || ""; | ||
| const { caps: nodeCapabilities, templateResolved } = readNodeCapabilities(dir, node, args); | ||
| // Resolve nodeType from flow-state.json + template | ||
| let nodeType = null; | ||
| try { | ||
| const stPath = join(resolve(dir), "flow-state.json"); | ||
| if (existsSync(stPath)) { | ||
| const st = JSON.parse(readFileSync(stPath, "utf8")); | ||
| const tplName = st.flowTemplate; | ||
| if (tplName) { | ||
| const { FLOW_TEMPLATES } = await import("./flow-templates.mjs"); | ||
| const tpl = FLOW_TEMPLATES[tplName]; | ||
| if (tpl?.nodeTypes) nodeType = tpl.nodeTypes[node] || null; | ||
| } | ||
| } | ||
| } catch { /* best effort */ } | ||
| const context = { | ||
| node, | ||
| nodeId: node, | ||
| nodeType, | ||
| role, | ||
| task, | ||
| taskDescription: task, | ||
| flowDir: resolve(dir), | ||
| runDir: resolve(dir), | ||
| cwd: process.cwd(), | ||
| devServerUrl, | ||
| nodeCapabilities, | ||
| nodeCapabilitiesResolved: templateResolved, | ||
| }; | ||
| const extensionAppend = await firePromptAppend(registry, context); | ||
@@ -161,24 +332,11 @@ const append = [ | ||
| ].filter(Boolean).join("\n\n"); | ||
| const applied = participatingExtensions(registry, context, ["prompt.append"]); | ||
| // Stamp extensionsApplied into this node's latest run handshake (if run dir exists) | ||
| const nodeDir = resolve(dir, "nodes", node); | ||
| const latestRunDir = findLatestRunDir(nodeDir); | ||
| if (latestRunDir) { | ||
| try { | ||
| const handshakePath = join(latestRunDir, 'handshake.json'); | ||
| let handshake = {}; | ||
| try { handshake = JSON.parse(readFileSync(handshakePath, 'utf8')); } catch { /* no handshake yet */ } | ||
| handshake.extensionsApplied = survivingExtensions(registry); | ||
| atomicWriteSync(handshakePath, JSON.stringify(handshake, null, 2)); | ||
| } catch { /* best effort */ } | ||
| // G2 fix: persist prompt-phase failures (e.g. slow-ext timeout) so | ||
| // operators see them in extension-failures.md instead of just stderr. | ||
| // writeFailureReport now read-merges, so this won't clobber prior phases. | ||
| writeFailureReport(registry, latestRunDir); | ||
| } | ||
| // Failure evidence is canonical and must remain parseable before participant | ||
| // provenance can be stamped into the selected run. | ||
| writeFailureReport(registry, runDir); | ||
| writePromptExtensionProvenance(runDir, context, applied); | ||
| saveRegistryCache(resolve(dir), registry); | ||
| console.log(JSON.stringify({ append, applied: registry.applied, nodeCapabilities })); | ||
| console.log(JSON.stringify({ append, applied, nodeCapabilities })); | ||
@@ -455,3 +613,3 @@ // Strict mode: after isolation work is done, exit non-zero if any failures. | ||
| console.error("Usage: opc-harness extension-verdict --node <id> --dir <harness-dir>"); | ||
| console.error("Loads extensions, fires verdict.append, writes eval-extensions.md to latest run dir."); | ||
| console.error("Loads extensions, fires verdict.append, writes eval-extensions.md to the state-selected run dir."); | ||
| return; | ||
@@ -471,3 +629,25 @@ } | ||
| const task = readTaskFromAC(dir); | ||
| const runDir = resolveSelectedRunDir(dir, node, "extension-verdict"); | ||
| const devServerUrl = getFlag(args, "dev-server") || process.env.DEV_SERVER_URL || config.devServerUrl || ""; | ||
| const context = resolveNodeExtensionContext(dir, node, args, { | ||
| role: "evaluator", | ||
| task, | ||
| runDir, | ||
| devServerUrl, | ||
| }); | ||
| const { nodeCapabilities } = context; | ||
| // Existing canonical provenance is a trust boundary. Missing is allowed; | ||
| // malformed bytes fail closed and are never replaced with a fresh object. | ||
| const handshakePath = join(runDir, "handshake.json"); | ||
| let handshake; | ||
| try { | ||
| handshake = prevalidateLifecycleEvidence(runDir, context); | ||
| } catch (error) { | ||
| console.error(error.message); | ||
| process.exit(1); | ||
| return; | ||
| } | ||
| let registry; | ||
@@ -481,34 +661,10 @@ try { | ||
| const runDir = findLatestRunDir(resolve(dir, "nodes", node)); | ||
| if (!runDir) { | ||
| console.error(`No run directories found for node '${node}' in ${resolve(dir, "nodes", node)}`); | ||
| process.exit(1); | ||
| } | ||
| const devServerUrl = getFlag(args, "dev-server") || process.env.DEV_SERVER_URL || config.devServerUrl || ""; | ||
| const { caps: nodeCapabilities, templateResolved } = readNodeCapabilities(dir, node, args); | ||
| const context = { | ||
| node, | ||
| role: "evaluator", | ||
| task, | ||
| flowDir: resolve(dir), | ||
| runDir, | ||
| devServerUrl, | ||
| nodeCapabilities, | ||
| nodeCapabilitiesResolved: templateResolved, | ||
| }; | ||
| await fireVerdictAppend(registry, context); | ||
| // Stamp extensionsApplied into the run dir's handshake.json | ||
| const handshakePath = join(runDir, 'handshake.json'); | ||
| let handshake = {}; | ||
| try { | ||
| handshake = JSON.parse(await readFile(handshakePath, 'utf8')); | ||
| } catch { /* no handshake yet, start fresh */ } | ||
| handshake.extensionsApplied = survivingExtensions(registry); | ||
| const appliedExtensions = participatingExtensions(registry, context, ["verdict.append"]); | ||
| handshake.extensionsApplied = appliedExtensions; | ||
| atomicWriteSync(handshakePath, JSON.stringify(handshake, null, 2)); | ||
| console.log(JSON.stringify({ ok: true, node, runDir, extensionsApplied: survivingExtensions(registry), nodeCapabilities })); | ||
| console.log(JSON.stringify({ ok: true, node, runDir, extensionsApplied: appliedExtensions, nodeCapabilities })); | ||
@@ -548,3 +704,25 @@ // Strict mode: after eval-extensions.md and writeFailureReport have run | ||
| const task = readTaskFromAC(dir); | ||
| const runDir = resolveSelectedRunDir(dir, node, "extension-artifact"); | ||
| const devServerUrl = getFlag(args, "dev-server") || process.env.DEV_SERVER_URL || config.devServerUrl || ""; | ||
| const context = resolveNodeExtensionContext(dir, node, args, { | ||
| role: "executor", | ||
| task, | ||
| runDir, | ||
| devServerUrl, | ||
| }); | ||
| const { nodeCapabilities } = context; | ||
| // Read before running side-effectful hooks so malformed canonical provenance | ||
| // cannot be laundered by a later handshake rewrite. | ||
| const handshakePath = join(runDir, "handshake.json"); | ||
| let handshake; | ||
| try { | ||
| handshake = prevalidateLifecycleEvidence(runDir, context); | ||
| } catch (error) { | ||
| console.error(error.message); | ||
| process.exit(1); | ||
| return; | ||
| } | ||
| let registry; | ||
@@ -558,22 +736,2 @@ try { | ||
| const runDir = findLatestRunDir(resolve(dir, "nodes", node)); | ||
| if (!runDir) { | ||
| console.error(`No run directories found for node '${node}' in ${resolve(dir, "nodes", node)}`); | ||
| process.exit(1); | ||
| } | ||
| const devServerUrl = getFlag(args, "dev-server") || process.env.DEV_SERVER_URL || config.devServerUrl || ""; | ||
| const { caps: nodeCapabilities, templateResolved } = readNodeCapabilities(dir, node, args); | ||
| const context = { | ||
| node, | ||
| role: "executor", | ||
| task, | ||
| flowDir: resolve(dir), | ||
| runDir, | ||
| devServerUrl, | ||
| nodeCapabilities, | ||
| nodeCapabilitiesResolved: templateResolved, | ||
| }; | ||
| const executeResults = await fireExecuteRun(registry, context); | ||
@@ -588,7 +746,2 @@ const emitted = await fireArtifactEmit(registry, context); | ||
| // Merge ext-artifact entries into handshake.artifacts[] (dedup by path) | ||
| const handshakePath = join(runDir, 'handshake.json'); | ||
| let handshake = {}; | ||
| try { | ||
| handshake = JSON.parse(await readFile(handshakePath, 'utf8')); | ||
| } catch { /* no handshake yet */ } | ||
| if (!Array.isArray(handshake.artifacts)) handshake.artifacts = []; | ||
@@ -599,3 +752,4 @@ const seen = new Set(handshake.artifacts.map(a => (a && a.path) || null).filter(Boolean)); | ||
| } | ||
| handshake.extensionsApplied = survivingExtensions(registry); | ||
| const appliedExtensions = participatingExtensions(registry, context, ["execute.run", "artifact.emit"]); | ||
| handshake.extensionsApplied = appliedExtensions; | ||
| atomicWriteSync(handshakePath, JSON.stringify(handshake, null, 2)); | ||
@@ -607,3 +761,3 @@ | ||
| runDir, | ||
| extensionsApplied: survivingExtensions(registry), | ||
| extensionsApplied: appliedExtensions, | ||
| nodeCapabilities, | ||
@@ -645,3 +799,20 @@ executeRunCount: executeResults.length, | ||
| const task = readTaskFromAC(dir); | ||
| const { caps: nodeCapabilities, templateResolved } = readNodeCapabilities(dir, node, args); | ||
| const devServerUrl = getFlag(args, "dev-server") || process.env.DEV_SERVER_URL || config.devServerUrl || ""; | ||
| let runDir; | ||
| let context; | ||
| try { | ||
| runDir = resolveSelectedRunDir(dir, node, "node-preflight"); | ||
| context = resolveNodeExtensionContext(dir, node, args, { | ||
| role: "preflight", | ||
| task, | ||
| runDir, | ||
| devServerUrl, | ||
| }); | ||
| prevalidateLifecycleEvidence(runDir, context); | ||
| } catch (error) { | ||
| console.error(error.message); | ||
| process.exit(1); | ||
| return; | ||
| } | ||
| const { nodeCapabilities } = context; | ||
@@ -670,25 +841,5 @@ if (nodeCapabilities.length > 0 && !task.trim()) { | ||
| const devServerUrl = getFlag(args, "dev-server") || process.env.DEV_SERVER_URL || config.devServerUrl || ""; | ||
| const context = { | ||
| node, | ||
| nodeId: node, | ||
| role: "preflight", | ||
| task, | ||
| taskDescription: task, | ||
| flowDir: resolve(dir), | ||
| cwd: process.cwd(), | ||
| devServerUrl, | ||
| nodeCapabilities, | ||
| nodeCapabilitiesResolved: templateResolved, | ||
| }; | ||
| 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); | ||
| } | ||
| writeFailureReport(registry, runDir); | ||
@@ -705,3 +856,3 @@ saveRegistryCache(resolve(dir), registry); | ||
| artifactTypes, | ||
| extensionsApplied: survivingExtensions(registry), | ||
| extensionsApplied: participatingExtensions(registry, context, ["preflight"]), | ||
| nodeCapabilities, | ||
@@ -708,0 +859,0 @@ })); |
+94
-31
@@ -96,2 +96,3 @@ // extensions.mjs — OPC Extension System | ||
| function recordFailure(registry, ext, hook, kind, message) { | ||
| ext._successfulHooks?.delete(hook); | ||
| const entry = { | ||
@@ -120,6 +121,14 @@ ext: ext.name, | ||
| function recordSuccess(ext) { | ||
| function beginHookInvocation(registry, hook) { | ||
| for (const ext of registry?.extensions || []) { | ||
| ext?._successfulHooks?.delete(hook); | ||
| } | ||
| } | ||
| function recordSuccess(ext, hook) { | ||
| // Any successful invocation resets the consecutive-failure streak. | ||
| // The breaker only trips on N-in-a-row, not N-total. | ||
| if (ext._failStreak) ext._failStreak = 0; | ||
| if (!(ext._successfulHooks instanceof Set)) ext._successfulHooks = new Set(); | ||
| ext._successfulHooks.add(hook); | ||
| } | ||
@@ -764,2 +773,3 @@ | ||
| const parts = []; | ||
| beginHookInvocation(registry, "prompt.append"); | ||
| warnMissingNodeCapsOnce(registry, context); | ||
@@ -782,3 +792,3 @@ const requires = context.nodeCapabilities || []; | ||
| if (result === undefined || result === null || result === "") { | ||
| recordSuccess(ext); | ||
| recordSuccess(ext, "prompt.append"); | ||
| continue; | ||
@@ -792,3 +802,3 @@ } | ||
| parts.push(result); | ||
| recordSuccess(ext); | ||
| recordSuccess(ext, "prompt.append"); | ||
| } catch (err) { | ||
@@ -822,4 +832,24 @@ console.error(`WARN: extension ${ext.name} prompt.append failed:`, err.message); | ||
| */ | ||
| export function readVerdictExtensionState(runDir) { | ||
| const jsonPath = join(runDir, "eval-extensions.json"); | ||
| if (!existsSync(jsonPath)) return null; | ||
| let prior; | ||
| try { | ||
| prior = JSON.parse(readFileSync(jsonPath, "utf8")); | ||
| } catch (error) { | ||
| throw new Error(`eval-extensions.json parse error: ${error.message}`); | ||
| } | ||
| if (!prior || typeof prior !== "object" || Array.isArray(prior)) { | ||
| throw new Error("eval-extensions.json schema error: root must be an object"); | ||
| } | ||
| return prior; | ||
| } | ||
| export async function fireVerdictAppend(registry, context) { | ||
| const allFindings = []; | ||
| const jsonPath = context.runDir | ||
| ? join(context.runDir, "eval-extensions.json") | ||
| : null; | ||
| if (context.runDir) readVerdictExtensionState(context.runDir); | ||
| beginHookInvocation(registry, "verdict.append"); | ||
| warnMissingNodeCapsOnce(registry, context); | ||
@@ -842,3 +872,3 @@ const requires = context.nodeCapabilities || []; | ||
| if (findings === undefined || findings === null) { | ||
| recordSuccess(ext); | ||
| recordSuccess(ext, "verdict.append"); | ||
| continue; | ||
@@ -855,3 +885,3 @@ } | ||
| } | ||
| recordSuccess(ext); | ||
| recordSuccess(ext, "verdict.append"); | ||
| } catch (err) { | ||
@@ -880,3 +910,2 @@ console.error(`WARN: extension ${ext.name} verdict.append failed:`, err.message); | ||
| // consumers (clean schema). Don't rename either without a v2 bump. | ||
| const jsonPath = join(context.runDir, "eval-extensions.json"); | ||
| const jsonDoc = { | ||
@@ -889,2 +918,7 @@ version: 1, | ||
| })), | ||
| extensionsApplied: participatingExtensions( | ||
| registry, | ||
| context, | ||
| ["verdict.append"] | ||
| ), | ||
| findings: allFindings.map((f) => ({ | ||
@@ -957,2 +991,3 @@ extension: f._ext, | ||
| const results = []; | ||
| beginHookInvocation(registry, "execute.run"); | ||
| warnMissingNodeCapsOnce(registry, context); | ||
@@ -975,3 +1010,3 @@ const requires = context.nodeCapabilities || []; | ||
| results.push({ ext: ext.name, result }); | ||
| recordSuccess(ext); | ||
| recordSuccess(ext, "execute.run"); | ||
| } catch (err) { | ||
@@ -1002,2 +1037,3 @@ console.error(`WARN: extension ${ext.name} execute.run failed:`, err.message); | ||
| const emitted = []; | ||
| beginHookInvocation(registry, "artifact.emit"); | ||
| warnMissingNodeCapsOnce(registry, context); | ||
@@ -1023,3 +1059,3 @@ const requires = context.nodeCapabilities || []; | ||
| ); | ||
| if (items === undefined || items === null) { recordSuccess(ext); continue; } | ||
| if (items === undefined || items === null) { recordSuccess(ext, "artifact.emit"); continue; } | ||
| if (!Array.isArray(items)) { | ||
@@ -1078,3 +1114,3 @@ console.error(`WARN: extension ${ext.name} artifact.emit returned ${typeof items}, expected array — ignoring`); | ||
| // write failures (U1.6r semantics F1 fix-forward). | ||
| if (!anyItemFailed) recordSuccess(ext); | ||
| if (!anyItemFailed) recordSuccess(ext, "artifact.emit"); | ||
| } | ||
@@ -1100,2 +1136,3 @@ if (registry._flowDir) saveBreakerState(registry._flowDir, registry); | ||
| const results = []; | ||
| beginHookInvocation(registry, "preflight"); | ||
| warnMissingNodeCapsOnce(registry, context); | ||
@@ -1118,3 +1155,3 @@ const requires = context.nodeCapabilities || []; | ||
| if (result === undefined || result === null) { | ||
| recordSuccess(ext); | ||
| recordSuccess(ext, "preflight"); | ||
| continue; | ||
@@ -1128,3 +1165,3 @@ } | ||
| results.push({ ...result, _ext: ext.name }); | ||
| recordSuccess(ext); | ||
| recordSuccess(ext, "preflight"); | ||
| } catch (err) { | ||
@@ -1234,2 +1271,31 @@ console.error(`WARN: extension ${ext.name} preflight failed:`, err.message); | ||
| */ | ||
| export function readFailureReportState(runDir) { | ||
| const sidecarPath = join(runDir, "extension-failures.json"); | ||
| if (!existsSync(sidecarPath)) return { entries: [], droppedTotal: 0 }; | ||
| let data; | ||
| try { | ||
| data = JSON.parse(readFileSync(sidecarPath, "utf8")); | ||
| } catch (error) { | ||
| throw new Error(`extension-failures.json parse error: ${error.message}`); | ||
| } | ||
| if (!data || typeof data !== "object" || Array.isArray(data) || !Array.isArray(data.failures)) { | ||
| throw new Error("extension-failures.json schema error: failures must be an array"); | ||
| } | ||
| if (!Number.isSafeInteger(data.droppedTotal) || data.droppedTotal < 0) { | ||
| throw new Error("extension-failures.json schema error: droppedTotal must be a non-negative safe integer"); | ||
| } | ||
| for (const [index, entry] of data.failures.entries()) { | ||
| if (!entry || typeof entry !== "object" || Array.isArray(entry)) { | ||
| throw new Error(`extension-failures.json schema error: failures[${index}] must be an object`); | ||
| } | ||
| for (const field of ["ext", "hook", "kind", "message", "at"]) { | ||
| if (typeof entry[field] !== "string" || entry[field].length === 0) { | ||
| throw new Error(`extension-failures.json schema error: failures[${index}].${field} must be a non-empty string`); | ||
| } | ||
| } | ||
| } | ||
| return { entries: data.failures, droppedTotal: data.droppedTotal }; | ||
| } | ||
| export function writeFailureReport(registry, runDir) { | ||
@@ -1242,22 +1308,6 @@ if (!runDir) return; | ||
| // U2.8c: Cross-command merge (G3) via JSON sidecar. | ||
| // | ||
| // Previous attempt parsed the markdown via regex; that was fragile (missing | ||
| // /u flag for emoji, ambiguous ext.hook split on dots) and silently | ||
| // degenerated to overwrite. The structurally correct fix is to keep the | ||
| // canonical record in a machine-readable JSON sidecar and render the | ||
| // markdown view from JSON. Parser/writer skew becomes impossible. | ||
| // | ||
| // Each CLI invocation reads the sidecar, unions with this run's | ||
| // registry.failures (dedup on ext|hook|kind|message), then writes BOTH | ||
| // sidecar + markdown atomically. | ||
| let priorEntries = []; | ||
| let priorDropped = 0; | ||
| if (existsSync(sidecarPath)) { | ||
| try { | ||
| const data = JSON.parse(readFileSync(sidecarPath, "utf8")); | ||
| if (Array.isArray(data.failures)) priorEntries = data.failures; | ||
| if (typeof data.droppedTotal === "number") priorDropped = data.droppedTotal; | ||
| } catch { /* corrupt sidecar = treat as empty, will be overwritten */ } | ||
| } | ||
| // Parse in a pure helper so lifecycle commands can fail closed before hooks. | ||
| const prior = readFailureReportState(runDir); | ||
| const priorEntries = prior.entries; | ||
| const priorDropped = prior.droppedTotal; | ||
@@ -1318,2 +1368,15 @@ // U2.8e (#2): use JSON.stringify on a tuple instead of `|`-joined string — | ||
| export function participatingExtensions(registry, context, hookNames) { | ||
| if (!registry || !Array.isArray(registry.extensions)) return []; | ||
| const requires = context?.nodeCapabilities || []; | ||
| const hooks = Array.isArray(hookNames) ? hookNames : []; | ||
| if (hooks.length === 0) return []; | ||
| return registry.extensions | ||
| .filter((ext) => ext && ext.enabled !== false) | ||
| .filter((ext) => extensionMatches(requires, ext.meta.provides, ext.meta.compatibleCapabilities)) | ||
| .filter((ext) => hooks.some((hookName) => ext._successfulHooks?.has(hookName))) | ||
| .map((ext) => ext.name); | ||
| } | ||
| // ─── Strict mode (CI enforcement) ──────────────────────────────── | ||
@@ -1320,0 +1383,0 @@ // OPC_STRICT_EXTENSIONS=1 turns recorded extension hook failures into a |
@@ -5,2 +5,5 @@ // flow-core-consistency.test.mjs — consistency, validators, routing | ||
| import assert from "node:assert/strict"; | ||
| import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; | ||
| import { homedir } from "node:os"; | ||
| import { join } from "node:path"; | ||
| import { validateHandshakeData, RULE_VALIDATORS, cmdRoute } from "./flow-core.mjs"; | ||
@@ -204,3 +207,3 @@ import { validHandshake } from "./flow-core.test-helpers.mjs"; | ||
| describe("cmdRoute", () => { | ||
| let logOutput, errOutput, exitCode; | ||
| let logOutput, errOutput, exitCode, routeDir; | ||
| let origLog, origErr, origExit; | ||
@@ -212,2 +215,5 @@ | ||
| exitCode = null; | ||
| const base = join(homedir(), ".opc", "sessions"); | ||
| mkdirSync(base, { recursive: true }); | ||
| routeDir = mkdtempSync(join(base, "opc-route-empty-")); | ||
| origLog = console.log; | ||
@@ -225,2 +231,3 @@ origErr = console.error; | ||
| process.exit = origExit; | ||
| rmSync(routeDir, { recursive: true, force: true }); | ||
| }); | ||
@@ -239,3 +246,3 @@ | ||
| test("node not in flow → valid=false", () => { | ||
| cmdRoute(["--node", "nonexistent", "--verdict", "PASS", "--flow", "review"]); | ||
| cmdRoute(["--node", "nonexistent", "--verdict", "PASS", "--flow", "review", "--dir", routeDir]); | ||
| const out = JSON.parse(logOutput[0]); | ||
@@ -247,3 +254,3 @@ assert.equal(out.valid, false); | ||
| test("valid route → valid=true with next node", () => { | ||
| cmdRoute(["--node", "review", "--verdict", "PASS", "--flow", "review"]); | ||
| cmdRoute(["--node", "review", "--verdict", "PASS", "--flow", "review", "--dir", routeDir]); | ||
| const out = JSON.parse(logOutput[0]); | ||
@@ -254,4 +261,19 @@ assert.equal(out.valid, true); | ||
| test("existing falsey non-object flow state fails closed", () => { | ||
| const base = join(homedir(), ".opc", "sessions"); | ||
| mkdirSync(base, { recursive: true }); | ||
| const dir = mkdtempSync(join(base, "opc-route-state-")); | ||
| try { | ||
| writeFileSync(join(dir, "flow-state.json"), "0"); | ||
| cmdRoute(["--node", "review", "--verdict", "PASS", "--flow", "review", "--dir", dir]); | ||
| const out = JSON.parse(logOutput[0]); | ||
| assert.equal(out.valid, false); | ||
| assert.match(out.error, /flow-state\.json.*object/); | ||
| } finally { | ||
| rmSync(dir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| test("no edge for verdict → valid=false", () => { | ||
| cmdRoute(["--node", "review", "--verdict", "BLOCKED", "--flow", "review"]); | ||
| cmdRoute(["--node", "review", "--verdict", "BLOCKED", "--flow", "review", "--dir", routeDir]); | ||
| const out = JSON.parse(logOutput[0]); | ||
@@ -258,0 +280,0 @@ assert.equal(out.valid, false); |
+460
-96
@@ -13,2 +13,3 @@ // Flow core commands: route, init, validate, validateHandshakeData, validate-context | ||
| symlinkSync, | ||
| lstatSync, | ||
| } from "fs"; | ||
@@ -22,3 +23,3 @@ import { join, dirname, resolve, basename } from "path"; | ||
| import { | ||
| getFlag, resolveDir, atomicWriteSync, createSessionDir, getProjectRoot, getSessionsBaseDir, | ||
| getFlag, hasFlag, resolveDir, atomicWriteSync, createSessionDir, getProjectRoot, getSessionsBaseDir, | ||
| VALID_NODE_TYPES, VALID_STATUSES, VALID_VERDICTS, EVIDENCE_TYPES, | ||
@@ -37,4 +38,11 @@ WRITER_SIG, | ||
| import { parseBypassArgs } from "./bypass-args.mjs"; | ||
| import { readTaskFromAC, findLatestRunDir } from "./ext-commands.mjs"; | ||
| import { collectTestResultReasons } from "./test-result-gate.mjs"; | ||
| import { | ||
| collectPromptExtensionProvenanceErrors, | ||
| readTaskFromAC, | ||
| resolveNodeExtensionContext, | ||
| } from "./ext-commands.mjs"; | ||
| import { | ||
| collectTestEvidenceProvenanceReasons, | ||
| collectTestResultReasons, | ||
| } from "./test-result-gate.mjs"; | ||
| import { loadTestCommandSpec, testCommandHash } from "./test-command-execution.mjs"; | ||
@@ -45,5 +53,18 @@ import { | ||
| registryPath, | ||
| resolveCurrentRun, | ||
| writeSessionRegistry, | ||
| } from "./runaway-guard.mjs"; | ||
| import { lockFile } from "./file-lock.mjs"; | ||
| import { evaluateFlowBudget } from "./flow-budget.mjs"; | ||
| import { parseRunOrdinal } from "./run-id.mjs"; | ||
| import { stoppedFlowError } from "./flow-state-guard.mjs"; | ||
| import { | ||
| expectedRunForNode, | ||
| isRunId, | ||
| isPlainObject, | ||
| readSessionAuthority, | ||
| resolveExactRunHandshake, | ||
| authoritativeEntries, | ||
| canonicalProjectionErrors, | ||
| } from "./flow-evidence.mjs"; | ||
@@ -70,6 +91,15 @@ // ─── route ────────────────────────────────────────────────────── | ||
| const statePath = join(stateDir, "flow-state.json"); | ||
| try { | ||
| state = JSON.parse(readFileSync(statePath, "utf8")); | ||
| if (state._flow_file) loadFlowFromFile(state._flow_file); | ||
| } catch { /* no/corrupt state file — resolve from args alone */ } | ||
| if (existsSync(statePath)) { | ||
| try { | ||
| state = JSON.parse(readFileSync(statePath, "utf8")); | ||
| if (!state || typeof state !== "object" || Array.isArray(state)) { | ||
| console.log(JSON.stringify({ next: null, valid: false, error: "corrupt flow-state.json: expected an object" })); | ||
| return; | ||
| } | ||
| if (state._flow_file) loadFlowFromFile(state._flow_file); | ||
| } catch (error) { | ||
| console.log(JSON.stringify({ next: null, valid: false, error: `corrupt flow-state.json: ${error.message}` })); | ||
| return; | ||
| } | ||
| } | ||
| } | ||
@@ -95,2 +125,11 @@ | ||
| const next = nodeEdges[verdict]; | ||
| if (state) { | ||
| const budget = evaluateFlowBudget({ state, template, from: node, to: next, verdict }); | ||
| if (!budget.allowed) { | ||
| console.log(JSON.stringify({ next: null, valid: false, error: budget.reason })); | ||
| return; | ||
| } | ||
| } | ||
| // Read autoMode from the state loaded above | ||
@@ -102,3 +141,3 @@ let autoReminder; | ||
| console.log(JSON.stringify({ next: nodeEdges[verdict], valid: true, ...(autoReminder ? { reminder: autoReminder } : {}) })); | ||
| console.log(JSON.stringify({ next, valid: true, ...(autoReminder ? { reminder: autoReminder } : {}) })); | ||
| } | ||
@@ -136,2 +175,7 @@ | ||
| } | ||
| const stopped = stoppedFlowError(state, "record-commit"); | ||
| if (stopped) { | ||
| console.log(JSON.stringify({ recorded: false, error: stopped })); | ||
| return; | ||
| } | ||
@@ -341,2 +385,3 @@ const root = (typeof state.projectRoot === "string" && state.projectRoot) ? state.projectRoot : getProjectRoot(); | ||
| edgeCounts: {}, | ||
| repairEdgeCounts: {}, | ||
| projectRoot, | ||
@@ -348,6 +393,6 @@ // Git floor at flow start + commits the flow produces. changeScope diffs | ||
| bypassMode: bypassRecord, | ||
| flowStartedAt, | ||
| autoMode: autoMode || undefined, | ||
| ...(autoMode ? { | ||
| _claudeSessionId: claudeSessionId, | ||
| flowStartedAt, | ||
| autoRepairCounts: {}, | ||
@@ -458,19 +503,12 @@ } : {}), | ||
| preflightNode = firstBriefOrBuild || entryNode; | ||
| const preflightCaps = template.nodeCapabilities?.[preflightNode] || []; | ||
| const preflightTask = readTaskFromAC(dir); | ||
| const preflightCtx = resolveNodeExtensionContext(dir, preflightNode, args, { | ||
| role: "preflight", | ||
| task: preflightTask, | ||
| devServerUrl: process.env.DEV_SERVER_URL || "", | ||
| }); | ||
| const preflightCaps = preflightCtx.nodeCapabilities; | ||
| if (preflightCaps.length > 0 && preflightTask.trim()) { | ||
| const preflightRegistry = await loadExtensions(bypassCfg); | ||
| const preflightCtx = { | ||
| node: preflightNode, | ||
| nodeId: preflightNode, | ||
| nodeType: template.nodeTypes?.[preflightNode] || null, | ||
| role: "preflight", | ||
| task: preflightTask, | ||
| taskDescription: preflightTask, | ||
| flowDir: resolve(dir), | ||
| cwd: process.cwd(), | ||
| devServerUrl: process.env.DEV_SERVER_URL || "", | ||
| nodeCapabilities: preflightCaps, | ||
| }; | ||
| preflightResult = await fireNodePreflight(preflightRegistry, preflightCtx); | ||
@@ -621,7 +659,7 @@ if (preflightResult?.length) preflightStatus = { node: preflightNode, status: "ok" }; | ||
| // cannot pass validation on a loopback without listing what changed. | ||
| const runNum = parseInt(String(data.runId).replace(/^run_/, ""), 10); | ||
| if (Number.isFinite(runNum) && runNum > 1) { | ||
| const runOrdinal = parseRunOrdinal(data.runId); | ||
| if (runOrdinal !== null && runOrdinal > 1n) { | ||
| const deltaResult = runBriefLint(briefText, { tier: lintTier, hasPriorFindings: true }); | ||
| if (deltaResult.failures.some(f => f.check === "iteration-delta")) { | ||
| errors.push("brief re-entered after gate loopback (run_" + runNum + ") but has no '## Iteration Delta' section — list specific changes from prior findings"); | ||
| errors.push("brief re-entered after gate loopback (" + data.runId + ") but has no '## Iteration Delta' section — list specific changes from prior findings"); | ||
| } | ||
@@ -739,9 +777,28 @@ } | ||
| function selectedRunHandshakeForNodePath(direct) { | ||
| if (basename(direct) !== "handshake.json") return null; | ||
| const nodeDir = dirname(direct); | ||
| const harnessDir = dirname(dirname(nodeDir)); | ||
| const statePath = join(harnessDir, "flow-state.json"); | ||
| if (!existsSync(statePath)) return null; | ||
| let state; | ||
| try { | ||
| state = JSON.parse(readFileSync(statePath, "utf8")); | ||
| } catch { | ||
| return null; | ||
| } | ||
| if (state.currentNode !== basename(nodeDir)) return null; | ||
| const selected = resolveCurrentRun(state); | ||
| if (!selected) return null; | ||
| const selectedPath = join(nodeDir, selected.runId, "handshake.json"); | ||
| return existsSync(selectedPath) ? selectedPath : null; | ||
| } | ||
| function resolveHandshakeForValidate(file) { | ||
| const direct = resolve(file); | ||
| if (existsSync(direct)) return direct; | ||
| if (basename(direct) !== "handshake.json") return direct; | ||
| const latestRun = findLatestRunDir(dirname(direct)); | ||
| const fallback = latestRun ? join(latestRun, "handshake.json") : null; | ||
| return fallback && existsSync(fallback) ? fallback : direct; | ||
| if (!existsSync(direct)) { | ||
| const selected = selectedRunHandshakeForNodePath(direct); | ||
| if (selected) return selected; | ||
| } | ||
| return direct; | ||
| } | ||
@@ -755,2 +812,11 @@ | ||
| function handshakeBaseDir(file, data = null) { | ||
| const dir = dirname(resolve(file)); | ||
| if (!/^run_\d+$/.test(basename(dir))) return dir; | ||
| const artifacts = Array.isArray(data?.artifacts) ? data.artifacts : []; | ||
| return artifacts.some((artifact) => | ||
| typeof artifact?.path === "string" && !/^run_\d+\//.test(artifact.path) | ||
| ) ? dir : dirname(dir); | ||
| } | ||
| function firstPositionalArg(args) { | ||
@@ -783,2 +849,9 @@ for (let i = 0; i < args.length; i++) { | ||
| } | ||
| const currentRun = resolveCurrentRun(state); | ||
| if (currentRun) { | ||
| const exact = resolveExactRunHandshake(dir, state.currentNode, currentRun.runId); | ||
| if (exact.error) return { error: exact.error }; | ||
| if (!exact.missing && exact.path && existsSync(exact.path)) return { file: exact.path }; | ||
| return { error: `missing exact selected-run handshake for node '${state.currentNode}' run '${currentRun.runId}'` }; | ||
| } | ||
| return { | ||
@@ -789,2 +862,46 @@ file: resolveHandshakeForValidate(join(dir, "nodes", state.currentNode, "handshake.json")), | ||
| function sessionExactEvidenceErrors(dir, state) { | ||
| const errors = []; | ||
| for (const entry of authoritativeEntries(state, { includeCurrent: true })) { | ||
| const exact = resolveExactRunHandshake(dir, entry.nodeId, entry.runId); | ||
| if (exact.error) errors.push(exact.error); | ||
| else if (exact.missing || !existsSync(exact.path)) { | ||
| errors.push(`missing exact selected/history handshake for node '${entry.nodeId}' run '${entry.runId}'`); | ||
| } | ||
| } | ||
| return errors; | ||
| } | ||
| function nodeAndRunFromHandshakePath(file) { | ||
| const abs = resolve(file); | ||
| const parent = dirname(abs); | ||
| const maybeRun = basename(parent); | ||
| if (/^run_\d+$/.test(maybeRun)) { | ||
| return { nodeId: basename(dirname(parent)), pathRunId: maybeRun }; | ||
| } | ||
| return { nodeId: basename(parent), pathRunId: null }; | ||
| } | ||
| function stateBackedValidateIdentityErrors(file, data) { | ||
| if (!isPlainObject(data)) return []; | ||
| const harnessDir = harnessDirForHandshake(file); | ||
| const authority = readSessionAuthority(harnessDir); | ||
| if (!authority.exists) return []; | ||
| if (authority.error) return [authority.error]; | ||
| const state = authority.state; | ||
| const { nodeId, pathRunId } = nodeAndRunFromHandshakePath(file); | ||
| const expectedRun = expectedRunForNode(state, nodeId); | ||
| const errors = sessionExactEvidenceErrors(harnessDir, state); | ||
| if (!expectedRun) { | ||
| errors.push(`no authoritative selected/history run for node '${nodeId}'`); | ||
| return errors; | ||
| } | ||
| if (pathRunId && pathRunId !== expectedRun) { | ||
| errors.push(`handshake path run is '${pathRunId}', expected '${expectedRun}'`); | ||
| } | ||
| if (data.nodeId !== nodeId) errors.push(`handshake nodeId is '${data.nodeId}', expected '${nodeId}'`); | ||
| if (data.runId !== expectedRun) errors.push(`handshake runId is '${data.runId}', expected '${expectedRun}'`); | ||
| return errors; | ||
| } | ||
| export function cmdValidate(args) { | ||
@@ -814,7 +931,14 @@ const inputFile = firstPositionalArg(args); | ||
| let tier = null; | ||
| let authorityState = null; | ||
| let authorityTemplate = null; | ||
| try { | ||
| const harnessDir = harnessDirForHandshake(file); | ||
| const statePath = join(harnessDir, "flow-state.json"); | ||
| if (existsSync(statePath)) { | ||
| const state = JSON.parse(readFileSync(statePath, "utf8")); | ||
| const authority = readSessionAuthority(harnessDir); | ||
| if (authority.exists && authority.error) { | ||
| console.log(JSON.stringify({ valid: false, errors: [authority.error] })); | ||
| return; | ||
| } | ||
| if (authority.exists) { | ||
| const state = authority.state; | ||
| authorityState = state; | ||
| // Auto-restore flow template from _flow_file if needed | ||
@@ -826,2 +950,3 @@ if (state._flow_file) { | ||
| const tmpl = FLOW_TEMPLATES[state.flowTemplate]; | ||
| authorityTemplate = tmpl || null; | ||
| if (tmpl && tmpl.softEvidence) soft = true; | ||
@@ -831,3 +956,6 @@ } | ||
| } | ||
| } catch { /* flow-state.json unreadable — treat as strict */ } | ||
| } catch (error) { | ||
| console.log(JSON.stringify({ valid: false, errors: [`state-backed validation failed: ${error.message}`] })); | ||
| return; | ||
| } | ||
@@ -837,5 +965,20 @@ const { errors, warnings } = validateHandshakeData(data, { | ||
| softEvidence: soft, | ||
| baseDir: dirname(file), | ||
| baseDir: handshakeBaseDir(file, data), | ||
| tier, | ||
| }); | ||
| if (data?.testEvidenceProvenance != null) { | ||
| errors.push(...collectTestEvidenceProvenanceReasons(data)); | ||
| } | ||
| errors.push(...stateBackedValidateIdentityErrors(file, data)); | ||
| if (authorityState) { | ||
| const harnessDir = harnessDirForHandshake(file); | ||
| errors.push(...canonicalProjectionErrors(harnessDir, authorityState, (canonical, path, nodeId, runId) => | ||
| canonicalHandshakeErrors(canonical, nodeId, runId, { | ||
| nodeType: authorityTemplate?.nodeTypes?.[nodeId], | ||
| softEvidence: soft, | ||
| baseDir: handshakeBaseDir(path, canonical), | ||
| tier, | ||
| }) | ||
| )); | ||
| } | ||
@@ -855,3 +998,5 @@ for (const w of warnings) { | ||
| if (!sourceNode) return {}; | ||
| const spec = loadTestCommandSpec(dir, sourceNode); | ||
| const sourceRunId = handshake?.testEvidenceProvenance?.sourceRunId; | ||
| if (!/^run_\d+$/.test(sourceRunId || "")) return {}; | ||
| const spec = loadTestCommandSpec(dir, sourceNode, sourceRunId); | ||
| if (!spec) return {}; | ||
@@ -865,3 +1010,3 @@ return { | ||
| function collectFilesRecursive(root, prefix = "") { | ||
| export function collectFilesRecursive(root, prefix = "") { | ||
| const out = []; | ||
@@ -882,5 +1027,6 @@ let entries = []; | ||
| function classifyArtifact(relPath, nodeType) { | ||
| export function classifyArtifact(relPath, nodeType) { | ||
| const name = basename(relPath); | ||
| const lower = name.toLowerCase(); | ||
| if (lower === "handshake.json" || lower === "flow-state.json") return null; | ||
| if (lower === "build-brief.md") return "brief"; | ||
@@ -895,2 +1041,4 @@ if (lower === "test-plan.md") return "test-plan"; | ||
| if (/^(.*-)?lint-result\.json$/i.test(name) || /^(.*-)?report\.json$/i.test(name) || /^(.*-)?result\.json$/i.test(name)) return "report"; | ||
| // Every non-reserved JSON file inside run_N is a machine-readable report artifact. | ||
| if (lower.endsWith(".json")) return "report"; | ||
| if (/\.(ts|tsx|js|jsx|css|html|mjs|cjs)$/i.test(name)) return "source"; | ||
@@ -901,2 +1049,14 @@ if (lower.endsWith(".md") || lower.endsWith(".txt")) return "source"; | ||
| export function scanNodeArtifacts(nodeDir, runDir, nodeType) { | ||
| const runId = basename(runDir); | ||
| const files = collectFilesRecursive(runDir).map((file) => `${runId}/${file}`); | ||
| for (const nodeLevel of ["build-brief.md", "test-plan.md", "test-execution.json"]) { | ||
| if (existsSync(join(nodeDir, nodeLevel))) files.push(nodeLevel); | ||
| } | ||
| return files | ||
| .sort() | ||
| .map((path) => ({ type: classifyArtifact(path, nodeType), path })) | ||
| .filter((artifact) => artifact.type); | ||
| } | ||
| function normalizeEvalVerdict(raw) { | ||
@@ -933,5 +1093,32 @@ const text = String(raw || "").toUpperCase(); | ||
| function readJsonFile(path) { | ||
| try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; } | ||
| if (!existsSync(path)) return null; | ||
| return JSON.parse(readFileSync(path, "utf8")); | ||
| } | ||
| function canonicalHandshakeErrors(data, nodeId, runId, options = {}) { | ||
| if (!isPlainObject(data)) return ["canonical handshake.json root must be a non-null object"]; | ||
| const errors = []; | ||
| const validation = validateHandshakeData(data, { | ||
| checkEvidence: true, | ||
| softEvidence: !!options.softEvidence, | ||
| baseDir: options.baseDir, | ||
| tier: options.tier, | ||
| }); | ||
| errors.push(...validation.errors.map((error) => `canonical handshake.json: ${error}`)); | ||
| if (data.nodeId !== nodeId) errors.push(`canonical handshake.json nodeId is '${data.nodeId}', expected '${nodeId}'`); | ||
| if (data.runId !== runId) errors.push(`canonical handshake.json runId is '${data.runId}', expected '${runId}'`); | ||
| if (options.nodeType && data.nodeType !== options.nodeType) { | ||
| errors.push(`canonical handshake.json nodeType is '${data.nodeType}', expected '${options.nodeType}'`); | ||
| } | ||
| if (typeof data.status !== "string" || data.status.length === 0) { | ||
| errors.push("canonical handshake.json status missing or invalid"); | ||
| } | ||
| if (!Array.isArray(data.artifacts)) errors.push("canonical handshake.json artifacts must be an array"); | ||
| if (data.testEvidenceProvenance != null) { | ||
| errors.push(...collectTestEvidenceProvenanceReasons(data) | ||
| .map((error) => `canonical handshake.json: ${error}`)); | ||
| } | ||
| return errors; | ||
| } | ||
| function preserveHarnessTestEvidence(target, existing) { | ||
@@ -952,5 +1139,54 @@ const prov = existing?.testEvidenceProvenance; | ||
| const NODE_LEVEL_SEAL_ARTIFACTS = new Set([ | ||
| "build-brief.md", | ||
| "test-plan.md", | ||
| "test-execution.json", | ||
| ]); | ||
| function exactArtifactPathFromCanonical(path, runId) { | ||
| if (typeof path !== "string") return path; | ||
| if (path.startsWith(`${runId}/`)) return path.slice(runId.length + 1); | ||
| if (NODE_LEVEL_SEAL_ARTIFACTS.has(path)) return `../${path}`; | ||
| return path; | ||
| } | ||
| function exactHandshakeFromCanonical(handshake, runId) { | ||
| const exact = JSON.parse(JSON.stringify(handshake)); | ||
| if (Array.isArray(exact.artifacts)) { | ||
| exact.artifacts = exact.artifacts.map((artifact) => ({ | ||
| ...artifact, | ||
| path: exactArtifactPathFromCanonical(artifact.path, runId), | ||
| })); | ||
| } | ||
| return exact; | ||
| } | ||
| function isPriorRun(priorRunId, currentRunId) { | ||
| const prior = parseRunOrdinal(priorRunId); | ||
| const current = parseRunOrdinal(currentRunId); | ||
| return prior !== null && current !== null && prior < current; | ||
| } | ||
| function staleCanonicalAuthorityErrors(dir, state, template, nodeId, runId) { | ||
| const authorized = Array.isArray(state.history) && | ||
| state.history.some((entry) => (entry?.nodeId || entry?.node) === nodeId && (entry?.runId || entry?.run) === runId); | ||
| if (!authorized) return [`stale canonical ${nodeId}/${runId} is not recorded in authoritative history`]; | ||
| const exact = resolveExactRunHandshake(dir, nodeId, runId); | ||
| if (exact.error) return [`stale canonical ${nodeId}/${runId} exact ${exact.error}`]; | ||
| if (exact.missing || !existsSync(exact.path || "")) { | ||
| return [`stale canonical ${nodeId}/${runId} exact handshake missing at ${exact.path}`]; | ||
| } | ||
| return canonicalProjectionErrors(dir, state, (canonical, path, projectionNodeId, projectionRunId) => | ||
| canonicalHandshakeErrors(canonical, projectionNodeId, projectionRunId, { | ||
| nodeType: template.nodeTypes?.[projectionNodeId], | ||
| softEvidence: !!template.softEvidence, | ||
| baseDir: handshakeBaseDir(path, canonical), | ||
| tier: state.tier && VALID_TIERS.has(state.tier) ? state.tier : null, | ||
| }), { entries: [{ nodeId, runId }] }); | ||
| } | ||
| export function cmdSeal(args) { | ||
| const nodeId = getFlag(args, "node"); | ||
| const runOverride = getFlag(args, "run"); | ||
| const runOverrideProvided = hasFlag(args, "run"); | ||
| const runOverride = getFlag(args, "run", ""); | ||
| const dir = resolveDir(args); | ||
@@ -977,10 +1213,38 @@ | ||
| } | ||
| const stopped = stoppedFlowError(state, "seal"); | ||
| if (stopped) { | ||
| console.log(JSON.stringify({ sealed: false, error: stopped })); | ||
| return; | ||
| } | ||
| if (state.currentNode !== nodeId) { | ||
| console.log(JSON.stringify({ | ||
| sealed: false, | ||
| error: `cannot seal node '${nodeId}': current node is '${state.currentNode}'`, | ||
| })); | ||
| return; | ||
| } | ||
| const selectedRun = resolveCurrentRun(state); | ||
| if (!selectedRun) { | ||
| console.log(JSON.stringify({ sealed: false, error: `cannot resolve selected run for '${nodeId}'` })); | ||
| return; | ||
| } | ||
| if (runOverrideProvided && !/^[1-9]\d*$/.test(runOverride)) { | ||
| console.log(JSON.stringify({ sealed: false, error: "--run must be a positive numeric ordinal" })); | ||
| return; | ||
| } | ||
| if (runOverrideProvided && `run_${runOverride}` !== selectedRun.runId) { | ||
| console.log(JSON.stringify({ | ||
| sealed: false, | ||
| error: `--run ${runOverride} does not match selected run '${selectedRun.runId}'`, | ||
| })); | ||
| 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}` })); | ||
| const resolved = resolveFlowTemplate(args, state); | ||
| if (resolved.error) { | ||
| console.log(JSON.stringify({ sealed: false, error: resolved.error })); | ||
| return; | ||
| } | ||
| const { template } = resolved; | ||
@@ -996,22 +1260,5 @@ const nodeType = template.nodeTypes?.[nodeId] || (nodeId.startsWith("gate") ? "gate" : "build"); | ||
| 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); | ||
| } | ||
| const runDir = join(nodeDir, selectedRun.runId); | ||
| if (!existsSync(runDir)) { | ||
| if (!existsSync(runDir) || !lstatSync(runDir).isDirectory()) { | ||
| console.log(JSON.stringify({ sealed: false, error: `run dir not found: ${runDir}` })); | ||
@@ -1023,18 +1270,78 @@ return; | ||
| const handshakePath = join(nodeDir, "handshake.json"); | ||
| const existingHandshake = readJsonFile(handshakePath); | ||
| let existingHandshake; | ||
| const canonicalExists = existsSync(handshakePath); | ||
| try { | ||
| existingHandshake = canonicalExists ? readJsonFile(handshakePath) : null; | ||
| } catch (error) { | ||
| console.log(JSON.stringify({ | ||
| sealed: false, | ||
| handshakePath, | ||
| artifacts: 0, | ||
| verdict: null, | ||
| validationErrors: [`canonical handshake.json parse error — fail-closed: ${error.message}`], | ||
| warnings: [], | ||
| })); | ||
| return; | ||
| } | ||
| const staleCanonical = canonicalExists && isPlainObject(existingHandshake) && | ||
| isRunId(existingHandshake.runId) && isPriorRun(existingHandshake.runId, runId); | ||
| if (staleCanonical) { | ||
| const canonicalErrors = staleCanonicalAuthorityErrors(dir, state, template, nodeId, existingHandshake.runId); | ||
| if (canonicalErrors.length > 0) { | ||
| console.log(JSON.stringify({ | ||
| sealed: false, | ||
| handshakePath, | ||
| artifacts: 0, | ||
| verdict: null, | ||
| validationErrors: canonicalErrors, | ||
| warnings: [], | ||
| })); | ||
| return; | ||
| } | ||
| } | ||
| if (canonicalExists && !staleCanonical) { | ||
| const canonicalErrors = canonicalHandshakeErrors(existingHandshake, nodeId, runId, { | ||
| nodeType, | ||
| softEvidence: !!template.softEvidence, | ||
| baseDir: handshakeBaseDir(handshakePath, existingHandshake), | ||
| tier: state.tier && VALID_TIERS.has(state.tier) ? state.tier : null, | ||
| }); | ||
| canonicalErrors.push(...canonicalProjectionErrors(dir, state, (canonical, path, projectionNodeId, projectionRunId) => | ||
| canonicalHandshakeErrors(canonical, projectionNodeId, projectionRunId, { | ||
| nodeType: template.nodeTypes?.[projectionNodeId], | ||
| softEvidence: !!template.softEvidence, | ||
| baseDir: handshakeBaseDir(path, canonical), | ||
| tier: state.tier && VALID_TIERS.has(state.tier) ? state.tier : null, | ||
| }), { entries: [{ nodeId, runId }] })); | ||
| if (canonicalErrors.length > 0) { | ||
| console.log(JSON.stringify({ | ||
| sealed: false, | ||
| handshakePath, | ||
| artifacts: 0, | ||
| verdict: null, | ||
| validationErrors: canonicalErrors, | ||
| warnings: [], | ||
| })); | ||
| return; | ||
| } | ||
| } | ||
| const selectedRunHandshakePath = join(runDir, "handshake.json"); | ||
| let selectedRunHandshake = null; | ||
| let selectedRunHandshakeError = null; | ||
| if (existsSync(selectedRunHandshakePath)) { | ||
| try { | ||
| selectedRunHandshake = JSON.parse(readFileSync(selectedRunHandshakePath, "utf8")); | ||
| if (!selectedRunHandshake || typeof selectedRunHandshake !== "object" || Array.isArray(selectedRunHandshake)) { | ||
| selectedRunHandshakeError = `${runId}/handshake.json schema error: root must be an object`; | ||
| selectedRunHandshake = null; | ||
| } | ||
| } catch (error) { | ||
| selectedRunHandshakeError = `${runId}/handshake.json parse error: ${error.message}`; | ||
| } | ||
| } | ||
| // Scan files and classify artifacts | ||
| const files = collectFilesRecursive(runDir).map(f => `${runId}/${f}`); | ||
| for (const nodeLevel of ["build-brief.md", "test-plan.md", "test-execution.json"]) { | ||
| if (existsSync(join(nodeDir, nodeLevel))) files.push(nodeLevel); | ||
| } | ||
| const artifacts = []; | ||
| const artifacts = scanNodeArtifacts(nodeDir, runDir, nodeType); | ||
| const warnings = []; | ||
| for (const f of files.sort()) { | ||
| const type = classifyArtifact(f, nodeType); | ||
| if (!type) continue; | ||
| artifacts.push({ type, path: f }); | ||
| } | ||
| // Infer verdict from eval files | ||
@@ -1063,4 +1370,14 @@ const evalFiles = artifacts.filter(a => a.type === "eval"); | ||
| if (nodeType === "execute") preserveHarnessTestEvidence(handshake, existingHandshake); | ||
| const selectedParticipants = selectedRunHandshake?.extensionsApplied; | ||
| if ( | ||
| Array.isArray(selectedParticipants) && | ||
| selectedParticipants.every((name) => typeof name === "string" && name.length > 0) | ||
| ) { | ||
| handshake.extensionsApplied = selectedParticipants.slice(); | ||
| } | ||
| if (nodeType === "execute" && existingHandshake?.runId === runId) { | ||
| preserveHarnessTestEvidence(handshake, existingHandshake); | ||
| } | ||
| const { critical, warning, suggestion } = inferred.findings; | ||
@@ -1071,6 +1388,4 @@ if (critical + warning + suggestion > 0) { | ||
| // Write handshake | ||
| atomicWriteSync(handshakePath, JSON.stringify(handshake, null, 2) + "\n"); | ||
| // Validate | ||
| // Validate before sealing. Invalid machine-readable evidence must not rewrite | ||
| // the canonical handshake. | ||
| const { errors } = validateHandshakeData(handshake, { | ||
@@ -1080,28 +1395,77 @@ checkEvidence: nodeType === "execute", | ||
| }); | ||
| const exactHandshake = exactHandshakeFromCanonical(handshake, runId); | ||
| const exactValidation = validateHandshakeData(exactHandshake, { | ||
| checkEvidence: nodeType === "execute", | ||
| baseDir: runDir, | ||
| }); | ||
| errors.push(...exactValidation.errors.map((error) => `selected run handshake: ${error}`)); | ||
| if (selectedRunHandshakeError) errors.push(selectedRunHandshakeError); | ||
| if ( | ||
| (nodeType === "brief" || nodeType === "build") && | ||
| Array.isArray(handshake.extensionsApplied) && | ||
| handshake.extensionsApplied.length > 0 | ||
| ) { | ||
| errors.push(...collectPromptExtensionProvenanceErrors(runDir, { | ||
| nodeId, | ||
| runId, | ||
| extensionsApplied: handshake.extensionsApplied, | ||
| })); | ||
| } | ||
| const parsedJsonArtifacts = new Map(); | ||
| for (const art of artifacts) { | ||
| if (!/\.json$/i.test(art.path)) continue; | ||
| try { | ||
| const text = readFileSync(join(nodeDir, art.path), "utf8"); | ||
| parsedJsonArtifacts.set(art.path, { text, data: JSON.parse(text) }); | ||
| } catch (error) { | ||
| errors.push(`artifact ${art.path} is not valid JSON — fail-closed: ${error.message}`); | ||
| } | ||
| } | ||
| if (nodeType === "execute") { | ||
| const evidenceContext = testEvidenceContext(dir, handshake); | ||
| const testResultArtifacts = artifacts.filter((art) => | ||
| art.type === "test-result" && /\.json$/i.test(art.path || "")); | ||
| if (handshake.testEvidenceProvenance && testResultArtifacts.length === 0) { | ||
| errors.push("testEvidenceProvenance requires a test-result JSON artifact"); | ||
| } | ||
| for (const art of artifacts) { | ||
| if (art.type !== "test-result" || !/\.json$/i.test(art.path)) continue; | ||
| try { | ||
| const text = readFileSync(join(nodeDir, art.path), "utf8"); | ||
| const data = JSON.parse(text); | ||
| errors.push(...collectTestResultReasons(data, { | ||
| handshake, | ||
| nodeId, | ||
| runId: handshake.runId, | ||
| artifact: art, | ||
| artifactHash: createHash("sha256").update(text).digest("hex"), | ||
| sessionDir: dir, | ||
| ...evidenceContext, | ||
| })); | ||
| } catch { | ||
| errors.push(`artifact ${art.path} unreadable — fail-closed`); | ||
| } | ||
| const parsed = parsedJsonArtifacts.get(art.path); | ||
| if (!parsed) continue; | ||
| errors.push(...collectTestEvidenceProvenanceReasons(handshake, { | ||
| sessionDir: dir, | ||
| artifact: art, | ||
| artifactHash: createHash("sha256").update(parsed.text).digest("hex"), | ||
| })); | ||
| errors.push(...collectTestResultReasons(parsed.data, { | ||
| handshake, | ||
| nodeId, | ||
| runId: handshake.runId, | ||
| artifact: art, | ||
| artifactHash: createHash("sha256").update(parsed.text).digest("hex"), | ||
| sessionDir: dir, | ||
| ...evidenceContext, | ||
| })); | ||
| } | ||
| } | ||
| if (errors.length > 0) { | ||
| for (const w of warnings) console.error(`⚠️ ${w}`); | ||
| console.log(JSON.stringify({ | ||
| sealed: false, | ||
| handshakePath, | ||
| artifacts: artifacts.length, | ||
| verdict, | ||
| validationErrors: errors, | ||
| warnings, | ||
| })); | ||
| return; | ||
| } | ||
| atomicWriteSync(selectedRunHandshakePath, JSON.stringify(exactHandshake, null, 2) + "\n"); | ||
| atomicWriteSync(handshakePath, JSON.stringify(handshake, null, 2) + "\n"); | ||
| for (const w of warnings) console.error(`⚠️ ${w}`); | ||
| console.log(JSON.stringify({ | ||
| sealed: true, | ||
| sealed: errors.length === 0, | ||
| handshakePath, | ||
@@ -1108,0 +1472,0 @@ artifacts: artifacts.length, |
+117
-54
@@ -9,4 +9,4 @@ // Flow escape hatches + listing: skip, pass, stop, goto, ls | ||
| import { fileURLToPath } from "url"; | ||
| import { FLOW_TEMPLATES, loadFlowFromFile } from "./flow-templates.mjs"; | ||
| import { cmdTransition } from "./flow-transition.mjs"; | ||
| import { resolveFlowTemplate, loadFlowFromFile } from "./flow-templates.mjs"; | ||
| import { allocateNextRunId, cmdTransition, reserveRunDirectory } from "./flow-transition.mjs"; | ||
| import { | ||
@@ -18,2 +18,6 @@ getFlag, resolveDir, atomicWriteSync, getSessionsBaseDir, | ||
| import { resolveCallerIdentity, checkOwnership } from "./driver-owner.mjs"; | ||
| import { evaluateFlowBudget, nodeHasBudgetedExit } from "./flow-budget.mjs"; | ||
| import { resolveCurrentRun } from "./runaway-guard.mjs"; | ||
| import { stoppedFlowError } from "./flow-state-guard.mjs"; | ||
| import { resolveExactRunHandshake } from "./flow-evidence.mjs"; | ||
@@ -43,3 +47,2 @@ // ── Shared state loader ── | ||
| const dir = resolveDir(args); | ||
| const flow = getFlag(args, "flow"); | ||
| const statePath = join(dir, "flow-state.json"); | ||
@@ -57,5 +60,7 @@ | ||
| const { state, statePath: sp } = loaded; | ||
| const templateName = flow || state.flowTemplate; | ||
| const template = Object.hasOwn(FLOW_TEMPLATES, templateName) ? FLOW_TEMPLATES[templateName] : null; | ||
| if (!template) { console.log(JSON.stringify({ error: `unknown flow: ${templateName}` })); return; } | ||
| const stopped = stoppedFlowError(state, "skip"); | ||
| if (stopped) { console.log(JSON.stringify({ error: stopped })); return; } | ||
| const resolved = resolveFlowTemplate(args, state); | ||
| if (resolved.error) { console.log(JSON.stringify({ error: resolved.error })); return; } | ||
| const { template } = resolved; | ||
@@ -74,23 +79,15 @@ const current = state.currentNode; | ||
| // ── Cycle limit checks (mirror cmdTransition) ── | ||
| const limits = { | ||
| maxTotalSteps: state.maxTotalSteps ?? template.limits.maxTotalSteps, | ||
| maxLoopsPerEdge: state.maxLoopsPerEdge ?? template.limits.maxLoopsPerEdge, | ||
| maxNodeReentry: state.maxNodeReentry ?? template.limits.maxNodeReentry, | ||
| }; | ||
| if (state.totalSteps >= limits.maxTotalSteps) { | ||
| console.log(JSON.stringify({ error: `maxTotalSteps (${limits.maxTotalSteps}) reached — cannot skip` })); | ||
| const sourceRun = resolveCurrentRun(state); | ||
| if (!sourceRun) { | ||
| console.log(JSON.stringify({ error: `cannot resolve current run for '${current}'` })); | ||
| return; | ||
| } | ||
| // ── Cycle limit checks (mirror cmdTransition) ── | ||
| const edgeKey = `${current}\u2192${next}`; | ||
| const edgeCount = state.edgeCounts[edgeKey] || 0; | ||
| if (edgeCount >= limits.maxLoopsPerEdge) { | ||
| console.log(JSON.stringify({ error: `maxLoopsPerEdge (${limits.maxLoopsPerEdge}) reached for '${edgeKey}' — cannot skip` })); | ||
| const budget = evaluateFlowBudget({ state, template, from: current, to: next, verdict: "PASS" }); | ||
| if (!budget.allowed) { | ||
| console.log(JSON.stringify({ error: `${budget.reason} — cannot skip` })); | ||
| return; | ||
| } | ||
| const nodeEntries = state.history.filter(h => h.nodeId === next).length; | ||
| if (nodeEntries >= limits.maxNodeReentry) { | ||
| console.log(JSON.stringify({ error: `maxNodeReentry (${limits.maxNodeReentry}) reached for '${next}' — cannot skip` })); | ||
| return; | ||
| } | ||
| // ── maxSkips: prevent skipping through entire flow ── | ||
@@ -104,2 +101,16 @@ const maxSkips = template.limits.maxSkips ?? 2; | ||
| let targetRunEntries = []; | ||
| try { | ||
| targetRunEntries = readdirSync(join(dir, "nodes", next), { withFileTypes: true }); | ||
| } catch { /* target node has no run directory yet */ } | ||
| const runId = allocateNextRunId(state.history, targetRunEntries, next); | ||
| try { | ||
| reserveRunDirectory(dir, next, runId); | ||
| } catch (error) { | ||
| console.log(JSON.stringify({ | ||
| error: `cannot reserve run directory 'nodes/${next}/${runId}': ${error.message}`, | ||
| })); | ||
| return; | ||
| } | ||
| // Write a skip handshake so pre-transition won't block | ||
@@ -111,3 +122,3 @@ const nodeDir = join(dir, "nodes", current); | ||
| nodeType: template.nodeTypes?.[current] || "execute", | ||
| runId: `run_${(state.history.filter(h => h.nodeId === current).length || 0) + 1}`, | ||
| runId: sourceRun.runId, | ||
| status: "completed", | ||
@@ -122,7 +133,6 @@ verdict: null, | ||
| const runId = `run_${state.history.filter(h => h.nodeId === next).length + 1}`; | ||
| state.history.push({ nodeId: next, runId, timestamp: new Date().toISOString(), skipped: true }); | ||
| state.currentNode = next; | ||
| state.totalSteps++; | ||
| state.edgeCounts[edgeKey] = (state.edgeCounts[edgeKey] || 0) + 1; | ||
| state.edgeCounts[edgeKey] = budget.edgeCount + 1; | ||
| state._written_by = WRITER_SIG; | ||
@@ -132,3 +142,2 @@ state._last_modified = new Date().toISOString(); | ||
| atomicWriteSync(sp, JSON.stringify(state, null, 2) + "\n"); | ||
| mkdirSync(join(dir, "nodes", next, runId), { recursive: true }); | ||
@@ -149,5 +158,7 @@ console.log(JSON.stringify({ skipped: current, next, runId })); | ||
| const { state } = loaded; | ||
| const templateName = state.flowTemplate; | ||
| const template = Object.hasOwn(FLOW_TEMPLATES, templateName) ? FLOW_TEMPLATES[templateName] : null; | ||
| if (!template) { console.log(JSON.stringify({ error: `unknown flow: ${templateName}` })); return; } | ||
| const stopped = stoppedFlowError(state, "pass"); | ||
| if (stopped) { console.log(JSON.stringify({ error: stopped })); return; } | ||
| const resolved = resolveFlowTemplate(args, state); | ||
| if (resolved.error) { console.log(JSON.stringify({ error: resolved.error })); return; } | ||
| const { template, name: templateName } = resolved; | ||
@@ -180,9 +191,33 @@ const current = state.currentNode; | ||
| if (upstreamId) { | ||
| const upstreamHandshakePath = join(dir, "nodes", upstreamId, "handshake.json"); | ||
| if (existsSync(upstreamHandshakePath)) { | ||
| const upstreamEntries = (state.history || []).filter(h => h?.nodeId === upstreamId); | ||
| const upstreamEntry = upstreamEntries.length > 0 | ||
| ? upstreamEntries[upstreamEntries.length - 1] | ||
| : (upstreamId === state.entryNode ? { nodeId: upstreamId, runId: "run_1", legacyInitial: true } : null); | ||
| const runMatch = /^run_(\d+)$/.exec(upstreamEntry?.runId || ""); | ||
| if (!runMatch) { | ||
| console.log(JSON.stringify({ | ||
| error: `Cannot force-pass: upstream history for '${upstreamId}' has missing or invalid runId.`, | ||
| allowed: false, | ||
| })); | ||
| return; | ||
| } | ||
| const upstreamExact = resolveExactRunHandshake(dir, upstreamId, upstreamEntry.runId); | ||
| if (upstreamExact.error) { | ||
| console.log(JSON.stringify({ error: `Cannot force-pass: ${upstreamExact.error}`, allowed: false })); | ||
| return; | ||
| } | ||
| if (upstreamExact.missing || !upstreamExact.path || !existsSync(upstreamExact.path)) { | ||
| console.log(JSON.stringify({ | ||
| error: `Cannot force-pass: upstream evidence missing for '${upstreamId}' ${upstreamEntry.runId}.`, | ||
| allowed: false, | ||
| })); | ||
| return; | ||
| } | ||
| if (existsSync(upstreamExact.path)) { | ||
| try { | ||
| const harnessPath = join(dirname(fileURLToPath(import.meta.url)), "..", "opc-harness.mjs"); | ||
| const synthArgs = [harnessPath, "synthesize", "--node", upstreamId, "--dir", dir, "--no-strict", "--run", runMatch[1]]; | ||
| const synthOutput = execFileSync( | ||
| "node", | ||
| [harnessPath, "synthesize", "--node", upstreamId, "--dir", dir, "--no-strict"], | ||
| synthArgs, | ||
| { encoding: "utf8", stdio: ["pipe", "pipe", "pipe"] } | ||
@@ -250,2 +285,11 @@ ); | ||
| } | ||
| if (state.status === "stopped") { | ||
| console.log(JSON.stringify({ | ||
| stopped: true, | ||
| alreadyStopped: true, | ||
| currentNode: state.currentNode, | ||
| totalSteps: state.totalSteps, | ||
| })); | ||
| return; | ||
| } | ||
@@ -296,38 +340,58 @@ state.status = "stopped"; | ||
| const { state, statePath: sp } = loaded; | ||
| const template = Object.hasOwn(FLOW_TEMPLATES, state.flowTemplate) ? FLOW_TEMPLATES[state.flowTemplate] : null; | ||
| if (!template) { console.log(JSON.stringify({ error: `unknown flow: ${state.flowTemplate}` })); return; } | ||
| const stopped = stoppedFlowError(state, "goto"); | ||
| if (stopped) { console.log(JSON.stringify({ error: stopped })); return; } | ||
| const resolved = resolveFlowTemplate(args, state); | ||
| if (resolved.error) { console.log(JSON.stringify({ error: resolved.error })); return; } | ||
| const { template, name: flowName } = resolved; | ||
| if (!template.nodes.includes(targetNode)) { | ||
| console.log(JSON.stringify({ error: `'${targetNode}' is not a node in flow '${state.flowTemplate}'` })); | ||
| console.log(JSON.stringify({ error: `'${targetNode}' is not a node in flow '${flowName}'` })); | ||
| return; | ||
| } | ||
| // Check node reentry limit | ||
| const limits = { | ||
| maxNodeReentry: state.maxNodeReentry ?? template.limits.maxNodeReentry, | ||
| maxTotalSteps: state.maxTotalSteps ?? template.limits.maxTotalSteps, | ||
| maxLoopsPerEdge: state.maxLoopsPerEdge ?? template.limits.maxLoopsPerEdge, | ||
| }; | ||
| if (state.totalSteps >= limits.maxTotalSteps) { | ||
| console.log(JSON.stringify({ error: `maxTotalSteps (${limits.maxTotalSteps}) reached — cannot goto` })); | ||
| const edgeKey = `${state.currentNode}→${targetNode}`; | ||
| const gotoBudget = evaluateFlowBudget({ | ||
| state, | ||
| template, | ||
| from: state.currentNode, | ||
| to: targetNode, | ||
| verdict: "GOTO", | ||
| }); | ||
| if (!gotoBudget.allowed) { | ||
| console.log(JSON.stringify({ error: `${gotoBudget.reason} — cannot goto` })); | ||
| return; | ||
| } | ||
| 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` })); | ||
| const projectedState = { | ||
| ...state, | ||
| totalSteps: state.totalSteps + 1, | ||
| history: [...state.history, { nodeId: targetNode }], | ||
| }; | ||
| const exit = nodeHasBudgetedExit({ state: projectedState, template, node: targetNode }); | ||
| if (!exit.available) { | ||
| console.log(JSON.stringify({ | ||
| error: `goto target '${targetNode}' has no budgeted exit`, | ||
| reasons: exit.reasons, | ||
| })); | ||
| return; | ||
| } | ||
| const nodeEntries = state.history.filter(h => h.nodeId === targetNode).length; | ||
| if (nodeEntries >= limits.maxNodeReentry) { | ||
| console.log(JSON.stringify({ error: `maxNodeReentry (${limits.maxNodeReentry}) reached for '${targetNode}'` })); | ||
| let targetRunEntries = []; | ||
| try { | ||
| targetRunEntries = readdirSync(join(dir, "nodes", targetNode), { withFileTypes: true }); | ||
| } catch { /* target node has no run directory yet */ } | ||
| const runId = allocateNextRunId(state.history, targetRunEntries, targetNode); | ||
| try { | ||
| reserveRunDirectory(dir, targetNode, runId); | ||
| } catch (error) { | ||
| console.log(JSON.stringify({ | ||
| error: `cannot reserve run directory 'nodes/${targetNode}/${runId}': ${error.message}`, | ||
| })); | ||
| return; | ||
| } | ||
| const runId = `run_${nodeEntries + 1}`; | ||
| state.history.push({ nodeId: targetNode, runId, timestamp: new Date().toISOString(), goto: true }); | ||
| state.currentNode = targetNode; | ||
| state.totalSteps++; | ||
| if (!state.edgeCounts) state.edgeCounts = {}; | ||
| state.edgeCounts[edgeKey] = (state.edgeCounts[edgeKey] || 0) + 1; | ||
| state.edgeCounts[edgeKey] = gotoBudget.edgeCount + 1; | ||
| state._written_by = WRITER_SIG; | ||
@@ -337,3 +401,2 @@ state._last_modified = new Date().toISOString(); | ||
| atomicWriteSync(sp, JSON.stringify(state, null, 2) + "\n"); | ||
| mkdirSync(join(dir, "nodes", targetNode, runId), { recursive: true }); | ||
@@ -340,0 +403,0 @@ console.log(JSON.stringify({ goto: targetNode, runId, totalSteps: state.totalSteps })); |
@@ -492,3 +492,44 @@ // Flow graph definitions — nodes, edges, limits per template | ||
| // Priority 1: explicit --flow-file | ||
| if (state && typeof state === "object") { | ||
| const persistedName = typeof state.flowTemplate === "string" && state.flowTemplate.trim() | ||
| ? state.flowTemplate | ||
| : null; | ||
| const persistedFile = typeof state._flow_file === "string" && state._flow_file.trim() | ||
| ? resolve(state._flow_file) | ||
| : null; | ||
| if (flowName && flowName !== persistedName) { | ||
| return { | ||
| error: `persisted flow identity '${persistedName ?? "<missing>"}' does not match explicit --flow '${flowName}'`, | ||
| }; | ||
| } | ||
| if (flowFile) { | ||
| const explicitFile = resolve(flowFile); | ||
| if (!persistedFile || explicitFile !== persistedFile) { | ||
| return { | ||
| error: `persisted flow identity '${persistedFile ?? persistedName ?? "<missing>"}' does not match explicit --flow-file '${explicitFile}'`, | ||
| }; | ||
| } | ||
| } | ||
| if (persistedFile) { | ||
| if (!persistedName) { | ||
| return { error: `persisted flow identity is missing flowTemplate for '${persistedFile}'` }; | ||
| } | ||
| const result = loadFlowFromFile(persistedFile); | ||
| if (result.error) return { error: `persisted flow identity '${persistedFile}' is unavailable: ${result.error}` }; | ||
| if (result.name !== persistedName) { | ||
| return { | ||
| error: `persisted flow identity mismatch: flowTemplate '${persistedName}' does not match _flow_file '${result.name}'`, | ||
| }; | ||
| } | ||
| return { template: result.template, name: result.name }; | ||
| } | ||
| if (!persistedName) return { error: "persisted flow identity is missing flowTemplate" }; | ||
| const template = Object.hasOwn(FLOW_TEMPLATES, persistedName) ? FLOW_TEMPLATES[persistedName] : null; | ||
| if (!template) return { error: `unknown flow template: ${persistedName}` }; | ||
| return { template, name: persistedName }; | ||
| } | ||
| if (flowFile) { | ||
@@ -500,19 +541,6 @@ const result = loadFlowFromFile(flowFile); | ||
| // Priority 2: _flow_file from persisted state | ||
| if (state && state._flow_file) { | ||
| const result = loadFlowFromFile(state._flow_file); | ||
| if (result.error) { | ||
| // File disappeared — fall through to name lookup | ||
| console.error(`⚠️ _flow_file '${state._flow_file}' failed: ${result.error} — falling back to template name`); | ||
| } else { | ||
| return { template: result.template, name: result.name }; | ||
| } | ||
| } | ||
| // Priority 3: lookup by name — explicit --flow, else persisted state.flowTemplate | ||
| const resolvedName = flowName || (state && state.flowTemplate); | ||
| if (!resolvedName) return { error: "no --flow or --flow-file specified" }; | ||
| const template = Object.hasOwn(FLOW_TEMPLATES, resolvedName) ? FLOW_TEMPLATES[resolvedName] : null; | ||
| if (!template) return { error: `unknown flow template: ${resolvedName}` }; | ||
| return { template, name: resolvedName }; | ||
| if (!flowName) return { error: "no --flow or --flow-file specified" }; | ||
| const template = Object.hasOwn(FLOW_TEMPLATES, flowName) ? FLOW_TEMPLATES[flowName] : null; | ||
| if (!template) return { error: `unknown flow template: ${flowName}` }; | ||
| return { template, name: flowName }; | ||
| } |
@@ -63,3 +63,3 @@ import { test, describe } from "node:test"; | ||
| if (testPlan !== null) writeFileSync(join(runDir, "test-plan.md"), testPlan); | ||
| writeFileSync(join(nodeDir, "handshake.json"), JSON.stringify({ | ||
| const canonical = { | ||
| nodeId: "test-design", | ||
@@ -72,2 +72,3 @@ nodeType: "review", | ||
| timestamp: new Date().toISOString(), | ||
| testCommand: "printf ok", | ||
| artifacts: [ | ||
@@ -77,3 +78,12 @@ { type: "eval", path: "run_1/eval-skeptic-owner.md" }, | ||
| ], | ||
| })); | ||
| }; | ||
| const runScoped = { | ||
| ...canonical, | ||
| artifacts: canonical.artifacts.map((artifact) => ({ | ||
| ...artifact, | ||
| path: artifact.path.replace(/^run_1\//, ""), | ||
| })), | ||
| }; | ||
| writeFileSync(join(nodeDir, "handshake.json"), JSON.stringify(canonical)); | ||
| writeFileSync(join(runDir, "handshake.json"), JSON.stringify(runScoped)); | ||
| writeFileSync(join(dir, "flow-state.json"), JSON.stringify({ | ||
@@ -80,0 +90,0 @@ version: "1.0", |
@@ -61,2 +61,12 @@ import { | ||
| if (state.autoMode !== true && state.totalSteps === 0 && state.history.length === 0 && | ||
| nonEmptyString(state.entryNode) && state.currentNode === state.entryNode && | ||
| state.flowStartedAt == null) { | ||
| return { | ||
| runId: "run_1", | ||
| startedAt: null, | ||
| runKey: `legacy-initial:${state.entryNode}`, | ||
| }; | ||
| } | ||
| return null; | ||
@@ -63,0 +73,0 @@ } |
@@ -73,2 +73,16 @@ import { describe, test, after } from "node:test"; | ||
| test("resolves legacy non-auto initial entry without flowStartedAt", () => { | ||
| const legacy = { | ||
| entryNode: "build", | ||
| currentNode: "build", | ||
| totalSteps: 0, | ||
| history: [], | ||
| }; | ||
| assert.deepEqual(resolveCurrentRun(legacy), { | ||
| runId: "run_1", | ||
| startedAt: null, | ||
| runKey: "legacy-initial:build", | ||
| }); | ||
| }); | ||
| test("fails closed for unverifiable state", () => { | ||
@@ -88,3 +102,3 @@ const valid = { | ||
| { ...valid, currentNode: "review" }, | ||
| { ...valid, flowStartedAt: null }, | ||
| { ...valid, flowStartedAt: null, autoMode: true }, | ||
| { ...valid, flowStartedAt: "not-a-date" }, | ||
@@ -91,0 +105,0 @@ { ...valid, flowStartedAt: "2026-02-30T00:00:00.000Z" }, |
@@ -207,2 +207,3 @@ import { after, describe, test } from "node:test"; | ||
| assert.equal(state.autoRepairCounts, undefined); | ||
| assert.equal(new Date(state.flowStartedAt).toISOString(), state.flowStartedAt); | ||
| assert.equal(readSessionRegistry("unused", fixture.home), null); | ||
@@ -832,2 +833,60 @@ }); | ||
| if (recovery.name === "pass") { | ||
| const runDir = join(dir, "nodes", "acceptance", "run_1"); | ||
| mkdirSync(runDir, { recursive: true }); | ||
| const evalLines = [ | ||
| "# Skeptic Owner Review", | ||
| "Role: skeptic-owner", | ||
| "", | ||
| "## Scope", | ||
| ...Array.from({ length: 18 }, (_, i) => `Acceptance scope check ${i + 1} verified the recovery fixture without introducing a finding.`), | ||
| "", | ||
| "## Evidence", | ||
| ...Array.from({ length: 18 }, (_, i) => `Acceptance evidence ${i + 1}: state, registry, and stopped-run markers remain inspectable after recovery.`), | ||
| "", | ||
| "## Decision", | ||
| ...Array.from({ length: 18 }, (_, i) => `Acceptance decision ${i + 1}: LGTM for this recovery contract with no blocking issue.`), | ||
| "", | ||
| "VERDICT: LGTM", | ||
| ]; | ||
| const pmEvalLines = [ | ||
| "# PM Review", | ||
| "Role: pm", | ||
| "", | ||
| "## Scope", | ||
| ...Array.from({ length: 18 }, (_, i) => `PM acceptance scope check ${i + 1} confirms recovery remains within the pre-release flow contract.`), | ||
| "", | ||
| "## Evidence", | ||
| ...Array.from({ length: 18 }, (_, i) => `PM evidence ${i + 1}: recovery preserves prior stop marker and keeps acceptance evidence available.`), | ||
| "", | ||
| "## Decision", | ||
| ...Array.from({ length: 18 }, (_, i) => `PM decision ${i + 1}: LGTM for this recovery contract with no product blocker.`), | ||
| "", | ||
| "VERDICT: LGTM", | ||
| ]; | ||
| writeFileSync(join(runDir, "eval-skeptic-owner.md"), evalLines.join("\n") + "\n"); | ||
| writeFileSync(join(runDir, "eval-pm.md"), pmEvalLines.join("\n") + "\n"); | ||
| writeFileSync(join(runDir, "handshake.json"), JSON.stringify({ | ||
| nodeId: "acceptance", | ||
| nodeType: "review", | ||
| runId: "run_1", | ||
| status: "completed", | ||
| verdict: "PASS", | ||
| summary: "Acceptance complete", | ||
| timestamp: "2024-01-01T00:00:00.000Z", | ||
| artifacts: [ | ||
| { type: "eval", path: "eval-skeptic-owner.md" }, | ||
| { type: "eval", path: "eval-pm.md" }, | ||
| ], | ||
| }, null, 2)); | ||
| const statePath = join(dir, "flow-state.json"); | ||
| const seededState = JSON.parse(readFileSync(statePath, "utf8")); | ||
| seededState.history = [ | ||
| { nodeId: "acceptance", runId: "run_1", timestamp: "2024-01-01T00:00:00.000Z" }, | ||
| { nodeId: "gate-acceptance", runId: "run_1", timestamp: "2024-01-01T00:01:00.000Z" }, | ||
| ]; | ||
| seededState.totalSteps = 2; | ||
| writeFileSync(statePath, JSON.stringify(seededState, null, 2)); | ||
| } | ||
| const state = JSON.parse(readFileSync(join(dir, "flow-state.json"), "utf8")); | ||
@@ -841,2 +900,5 @@ const stopped = createStopMarker(dir, state, { reason: "tool-call-budget" }); | ||
| assert.equal(recovered.json?.error, undefined, JSON.stringify(recovered.json)); | ||
| if (recovery.name === "pass") { | ||
| assert.equal(recovered.json?.allowed, true, JSON.stringify(recovered.json)); | ||
| } | ||
| assert.deepEqual( | ||
@@ -843,0 +905,0 @@ evaluatePreToolUse(hookInput(fixture, sessionId, "after-recovery"), { home: fixture.home }), |
@@ -6,2 +6,3 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "fs"; | ||
| import { appendProvenanceEvent } from "./provenance-ledger.mjs"; | ||
| import { compareRunIds } from "./run-id.mjs"; | ||
@@ -22,6 +23,12 @@ const EXECUTION_ACTOR = "opc-harness:test-command"; | ||
| .filter(entry => entry.isDirectory() && /^run_\d+$/.test(entry.name)) | ||
| .sort((a, b) => Number(b.name.slice(4)) - Number(a.name.slice(4))); | ||
| .sort((a, b) => compareRunIds(b.name, a.name)); | ||
| return runs[0] ? join(nodeDir, runs[0].name) : null; | ||
| } | ||
| function runDirFor(nodeDir, runId) { | ||
| if (typeof runId !== "string" || !/^run_\d+$/.test(runId)) return null; | ||
| const dir = join(nodeDir, runId); | ||
| return existsSync(dir) ? dir : null; | ||
| } | ||
| function commandSpecFrom(data) { | ||
@@ -60,11 +67,23 @@ if (!data || typeof data.testCommand !== "string" || data.testCommand.trim() === "") return null; | ||
| function sourcePlanHash(sessionDir, nodeId) { | ||
| function readRunBoundJson(path, runId) { | ||
| const data = readJson(path); | ||
| if (!data) return null; | ||
| if (runId && data.runId !== runId) return null; | ||
| return data; | ||
| } | ||
| function sourcePlanHash(sessionDir, nodeId, runId = null) { | ||
| const nodeDir = join(sessionDir, "nodes", nodeId); | ||
| const runDir = latestRunDir(nodeDir); | ||
| const handshake = readJson(join(nodeDir, "handshake.json")); | ||
| const candidates = [ | ||
| planPathFromHandshake(nodeDir, handshake), | ||
| runDir ? join(runDir, "test-plan.md") : null, | ||
| join(nodeDir, "test-plan.md"), | ||
| ].filter(Boolean); | ||
| const runDir = runId ? runDirFor(nodeDir, runId) : latestRunDir(nodeDir); | ||
| const handshake = readRunBoundJson(join(nodeDir, "handshake.json"), runId); | ||
| const candidates = runId | ||
| ? [ | ||
| runDir ? join(runDir, "test-plan.md") : null, | ||
| planPathFromHandshake(nodeDir, handshake), | ||
| ].filter(Boolean) | ||
| : [ | ||
| runDir ? join(runDir, "test-plan.md") : null, | ||
| planPathFromHandshake(nodeDir, handshake), | ||
| join(nodeDir, "test-plan.md"), | ||
| ].filter(Boolean); | ||
| for (const path of candidates) { | ||
@@ -77,14 +96,21 @@ const text = readText(path); | ||
| export function loadTestCommandSpec(sessionDir, nodeId) { | ||
| export function loadTestCommandSpec(sessionDir, nodeId, runId = null) { | ||
| const nodeDir = join(sessionDir, "nodes", nodeId); | ||
| const runDir = latestRunDir(nodeDir); | ||
| const candidates = [ | ||
| join(nodeDir, "test-execution.json"), | ||
| join(nodeDir, "handshake.json"), | ||
| runDir ? join(runDir, "test-execution.json") : null, | ||
| runDir ? join(runDir, "handshake.json") : null, | ||
| ].filter(Boolean); | ||
| const runDir = runId ? runDirFor(nodeDir, runId) : latestRunDir(nodeDir); | ||
| const candidates = runId | ||
| ? [ | ||
| runDir ? join(runDir, "test-execution.json") : null, | ||
| runDir ? join(runDir, "handshake.json") : null, | ||
| join(nodeDir, "test-execution.json"), | ||
| join(nodeDir, "handshake.json"), | ||
| ].filter(Boolean) | ||
| : [ | ||
| join(nodeDir, "test-execution.json"), | ||
| runDir ? join(runDir, "test-execution.json") : null, | ||
| runDir ? join(runDir, "handshake.json") : null, | ||
| join(nodeDir, "handshake.json"), | ||
| ].filter(Boolean); | ||
| for (const path of candidates) { | ||
| const spec = commandSpecFrom(readJson(path)); | ||
| if (spec) return { ...spec, sourcePlanHash: sourcePlanHash(sessionDir, nodeId) }; | ||
| const spec = commandSpecFrom(readRunBoundJson(path, runId)); | ||
| if (spec) return { ...spec, sourcePlanHash: sourcePlanHash(sessionDir, nodeId, runId) }; | ||
| } | ||
@@ -94,2 +120,27 @@ return null; | ||
| export function collectTestCommandBindingReasons(sessionDir, nodeId, runId) { | ||
| if (!runId) return []; | ||
| const nodeDir = join(sessionDir, "nodes", nodeId); | ||
| const reasons = []; | ||
| const spec = loadTestCommandSpec(sessionDir, nodeId, runId); | ||
| if (!spec) { | ||
| reasons.push(`${nodeId}/${runId}: testCommand spec not found`); | ||
| } else if (typeof spec.sourcePlanHash !== "string" || spec.sourcePlanHash.length === 0) { | ||
| reasons.push(`${nodeId}/${runId}: source test-plan hash missing`); | ||
| } | ||
| for (const name of ["test-execution.json", "handshake.json"]) { | ||
| const path = join(nodeDir, name); | ||
| if (!existsSync(path)) continue; | ||
| const data = readJson(path); | ||
| if (!data || !commandSpecFrom(data)) continue; | ||
| if (data.nodeId && data.nodeId !== nodeId) { | ||
| reasons.push(`${nodeId}/${name} nodeId '${data.nodeId}' does not match '${nodeId}'`); | ||
| } | ||
| if (data.runId !== runId) { | ||
| reasons.push(`${nodeId}/${name} runId '${data.runId}' does not match selected source run '${runId}'`); | ||
| } | ||
| } | ||
| return reasons; | ||
| } | ||
| function trimOutput(value) { | ||
@@ -147,3 +198,3 @@ const text = String(value || ""); | ||
| function writeResultFiles(runDir, spec, result, cwdInfo) { | ||
| function writeResultFiles(runDir, spec, result, cwdInfo, sourceNode, sourceRunId) { | ||
| const stdout = trimOutput(result.stdout); | ||
@@ -161,2 +212,4 @@ const stderrText = result.error?.message ? `${result.stderr || ""}\n${result.error.message}` : result.stderr; | ||
| kind: "opc-test-command", | ||
| sourceNode, | ||
| sourceRunId, | ||
| commandHash, | ||
@@ -194,4 +247,5 @@ sourcePlanHash: spec.sourcePlanHash, | ||
| export function executeTestCommand(sessionDir, targetNode, runId, sourceNode) { | ||
| const spec = loadTestCommandSpec(sessionDir, sourceNode); | ||
| export function executeTestCommand(sessionDir, targetNode, runId, sourceNode, sourceRunId = null) { | ||
| if (!/^run_\d+$/.test(sourceRunId || "")) return null; | ||
| const spec = loadTestCommandSpec(sessionDir, sourceNode, sourceRunId); | ||
| if (!spec) return null; | ||
@@ -202,3 +256,3 @@ const runDir = join(sessionDir, "nodes", targetNode, runId); | ||
| const result = runTestCommand(spec, cwdInfo.cwd); | ||
| const summary = writeResultFiles(runDir, spec, result, cwdInfo); | ||
| const summary = writeResultFiles(runDir, spec, result, cwdInfo, sourceNode, sourceRunId); | ||
| const verdict = summary.exitCode === 0 ? "PASS" : "FAIL"; | ||
@@ -211,2 +265,3 @@ const commandHash = testCommandHash(spec.testCommand); | ||
| sourceNode, | ||
| sourceRunId, | ||
| commandHash, | ||
@@ -221,2 +276,3 @@ sourcePlanHash: spec.sourcePlanHash, | ||
| sourceNode, | ||
| sourceRunId, | ||
| commandHash, | ||
@@ -250,3 +306,10 @@ sourcePlanHash: spec.sourcePlanHash, | ||
| writeFileSync(join(sessionDir, "nodes", targetNode, "handshake.json"), JSON.stringify(handshake, null, 2) + "\n"); | ||
| writeFileSync(join(runDir, "handshake.json"), JSON.stringify({ | ||
| ...handshake, | ||
| artifacts: [ | ||
| { type: "test-result", path: "test-command-result.json" }, | ||
| { type: "cli-output", path: "test-command-output.txt" }, | ||
| ], | ||
| }, null, 2) + "\n"); | ||
| return { executed: true, verdict, exitCode: summary.exitCode, cwd: cwdInfo.cwd, cwdSource: cwdInfo.source, resultPath: join(runDir, "test-command-result.json") }; | ||
| } |
| import { existsSync, readFileSync, readdirSync } from "fs"; | ||
| import { join } from "path"; | ||
| import { TEST_LAYERS, TEST_LAYER_KEYWORDS } from "./tier-baselines.mjs"; | ||
| import { compareRunIds } from "./run-id.mjs"; | ||
@@ -9,6 +10,7 @@ const CMD_RE = /\b(npm\s+(test|run)|npx\s+\w|pytest|vitest|jest|playwright\s+test|curl\s+|bash\s+|sh\s+|node\s+|python[3]?\s+)/i; | ||
| try { | ||
| return readdirSync(nodeDir) | ||
| .filter(name => /^run_\d+$/.test(name)) | ||
| .sort((a, b) => parseInt(b.slice(4), 10) - parseInt(a.slice(4), 10)) | ||
| .map(name => join(nodeDir, name))[0] || null; | ||
| return readdirSync(nodeDir, { withFileTypes: true }) | ||
| .filter((entry) => entry.isDirectory() && /^run_\d+$/.test(entry.name)) | ||
| .map((entry) => entry.name) | ||
| .sort((a, b) => compareRunIds(b, a)) | ||
| .map((name) => join(nodeDir, name))[0] || null; | ||
| } catch { | ||
@@ -19,7 +21,30 @@ return null; | ||
| function findPlanPath(dir, nodeId) { | ||
| function runDirFor(nodeDir, runId) { | ||
| if (typeof runId !== "string" || !/^run_\d+$/.test(runId)) return null; | ||
| const dir = join(nodeDir, runId); | ||
| return existsSync(dir) ? dir : null; | ||
| } | ||
| function findPlanPath(dir, nodeId, runId = null) { | ||
| const nodeDir = join(dir, "nodes", nodeId); | ||
| const runDir = latestRunDir(nodeDir); | ||
| const runDir = runId ? runDirFor(nodeDir, runId) : latestRunDir(nodeDir); | ||
| const runPlan = runDir ? join(runDir, "test-plan.md") : null; | ||
| if (runPlan && existsSync(runPlan)) return runPlan; | ||
| if (runId) { | ||
| const canonical = join(nodeDir, "handshake.json"); | ||
| if (!existsSync(canonical)) return null; | ||
| let handshake; | ||
| try { | ||
| handshake = JSON.parse(readFileSync(canonical, "utf8")); | ||
| } catch { | ||
| return null; | ||
| } | ||
| if (!handshake || typeof handshake !== "object" || Array.isArray(handshake)) return null; | ||
| if (handshake.nodeId !== nodeId || handshake.runId !== runId) return null; | ||
| const artifact = Array.isArray(handshake.artifacts) | ||
| ? handshake.artifacts.find(a => a?.type === "test-plan" && typeof a.path === "string") | ||
| : null; | ||
| const artifactPath = artifact ? join(nodeDir, artifact.path) : null; | ||
| return artifactPath && existsSync(artifactPath) ? artifactPath : null; | ||
| } | ||
| const nodePlan = join(nodeDir, "test-plan.md"); | ||
@@ -120,4 +145,4 @@ return existsSync(nodePlan) ? nodePlan : null; | ||
| export function collectTestDesignPlanReasons(dir, nodeId) { | ||
| const planPath = findPlanPath(dir, nodeId); | ||
| export function collectTestDesignPlanReasons(dir, nodeId, runId = null) { | ||
| const planPath = findPlanPath(dir, nodeId, runId); | ||
| if (!planPath) return [`${nodeId} test-plan.md missing`]; | ||
@@ -124,0 +149,0 @@ const text = readFileSync(planPath, "utf8"); |
@@ -78,7 +78,12 @@ // Mechanical gate for structured test-result artifacts. | ||
| const event = found.event; | ||
| const resultPath = `nodes/${context.nodeId}/${context.artifact?.path}`; | ||
| if (event.eventType !== "test-command-result") return "ledger event type mismatch"; | ||
| const expectedRunId = context.runId || context.handshake?.runId; | ||
| const artifactPath = context.artifact?.path; | ||
| if (typeof artifactPath !== "string" || artifactPath.length === 0) return "ledger result path mismatch"; | ||
| const resultPath = typeof artifactPath === "string" && artifactPath.startsWith(`${expectedRunId}/`) | ||
| ? `nodes/${context.nodeId}/${artifactPath}` | ||
| : `nodes/${context.nodeId}/${expectedRunId}/${artifactPath}`; | ||
| if (event.nodeId !== context.nodeId || event.runId !== expectedRunId) return "ledger node/run mismatch"; | ||
| if (event.sourceNode !== handshakeProv.sourceNode) return "ledger source node mismatch"; | ||
| if (event.sourceRunId !== handshakeProv.sourceRunId) return "ledger source run mismatch"; | ||
| if (event.commandHash !== handshakeProv.commandHash) return "ledger command hash mismatch"; | ||
@@ -91,2 +96,41 @@ if (event.sourcePlanHash !== handshakeProv.sourcePlanHash) return "ledger source plan hash mismatch"; | ||
| export function collectTestEvidenceProvenanceReasons(handshake, context = {}) { | ||
| const prov = handshake?.testEvidenceProvenance; | ||
| if (prov == null) return []; | ||
| const reasons = []; | ||
| const needString = (field) => { | ||
| if (typeof prov[field] !== "string" || prov[field].length === 0) { | ||
| reasons.push(`testEvidenceProvenance.${field} missing or invalid`); | ||
| } | ||
| }; | ||
| if (prov.kind !== "opc-test-command") reasons.push("testEvidenceProvenance.kind must be opc-test-command"); | ||
| if (prov.executionActor !== "opc-harness:test-command") { | ||
| reasons.push("testEvidenceProvenance.executionActor mismatch"); | ||
| } | ||
| for (const field of ["sourceNode", "sourceRunId", "commandHash", "sourcePlanHash", "resultHash"]) { | ||
| needString(field); | ||
| } | ||
| if (!/^run_\d+$/.test(prov.sourceRunId || "")) { | ||
| reasons.push("testEvidenceProvenance.sourceRunId invalid"); | ||
| } | ||
| const policy = handshake?.testEvidencePolicy; | ||
| if (!policy || typeof policy !== "object" || Array.isArray(policy)) { | ||
| reasons.push("testEvidencePolicy must be a non-null object"); | ||
| } else if (!Array.isArray(policy.allowVacuousChecks)) { | ||
| reasons.push("testEvidencePolicy.allowVacuousChecks must be an array"); | ||
| } | ||
| if (prov.ledger?.kind !== "opc-hmac-ledger" || typeof prov.ledger?.recordHash !== "string") { | ||
| reasons.push("testEvidenceProvenance signed provenance ledger missing or invalid"); | ||
| } else if (context.sessionDir) { | ||
| const reason = ledgerReason(prov, { | ||
| ...context, | ||
| handshake, | ||
| nodeId: handshake?.nodeId, | ||
| runId: handshake?.runId, | ||
| }); | ||
| if (reason) reasons.push(`testEvidenceProvenance ledger invalid: ${reason}`); | ||
| } | ||
| return reasons; | ||
| } | ||
| function hasCommandProvenance(data, handshake, context) { | ||
@@ -105,2 +149,3 @@ const resultProv = data?.provenance || data?.testEvidenceProvenance; | ||
| if (resultProv.commandHash !== handshakeProv.commandHash) return "test-result and handshake command hashes differ"; | ||
| if (resultProv.sourceRunId !== handshakeProv.sourceRunId) return "test-result and handshake source run differ"; | ||
| if (resultProv.executionActor !== "opc-harness:test-command") return "test-result execution actor mismatch"; | ||
@@ -122,3 +167,5 @@ if (handshakeProv.executionActor !== "opc-harness:test-command") return "handshake execution actor mismatch"; | ||
| } | ||
| if (hasCommandProvenance(data, context.handshake, context)) return []; | ||
| const schemaReasons = collectTestEvidenceProvenanceReasons(context.handshake, context); | ||
| if (schemaReasons.length === 0 && hasCommandProvenance(data, context.handshake, context)) return []; | ||
| const reasons = [...schemaReasons]; | ||
| const resultProv = data?.provenance || data?.testEvidenceProvenance; | ||
@@ -128,6 +175,7 @@ const prov = context.handshake?.testEvidenceProvenance; | ||
| if (publicReason) { | ||
| return [`test-execute test-result lacks matching OPC testCommand provenance, source test-plan hash, and result hash: ${publicReason}`]; | ||
| reasons.push(`test-execute test-result lacks matching OPC testCommand provenance, source test-plan hash, and result hash: ${publicReason}`); | ||
| } | ||
| const reason = ledgerReason(prov, context); | ||
| if (reason) return [`test-execute test-result lacks valid OPC signed provenance ledger: ${reason}`]; | ||
| if (reason) reasons.push(`test-execute test-result lacks valid OPC signed provenance ledger: ${reason}`); | ||
| if (reasons.length > 0) return reasons; | ||
| return ["test-execute test-result lacks matching OPC testCommand provenance, source test-plan hash, and result hash — self-authored or modified test evidence is weak"]; | ||
@@ -134,0 +182,0 @@ } |
+6
-0
@@ -20,2 +20,8 @@ // Shared utilities used across all harness modules. | ||
| export function hasFlag(args, name) { | ||
| const flag = `--${name}`; | ||
| const eqPrefix = `${flag}=`; | ||
| return args.includes(flag) || args.some(a => typeof a === "string" && a.startsWith(eqPrefix)); | ||
| } | ||
| // ── Safe directory resolution with path traversal guard ───────── | ||
@@ -22,0 +28,0 @@ // When no --dir is given, prefer the latest session dir (if one exists). |
@@ -7,2 +7,3 @@ // UX simulation verdict computation — red flag aggregation, delta comparison, gate logic. | ||
| import { getFlag, resolveDir, atomicWriteSync } from "./util.mjs"; | ||
| import { parseRunOrdinal } from "./run-id.mjs"; | ||
| import { | ||
@@ -240,5 +241,5 @@ VALID_TIERS, RED_FLAG_KEYS, TRUST_SIGNAL_KEYS, TIER_FIT_BUCKETS, | ||
| function loadBaseline(dir, currentRun) { | ||
| const runNum = parseInt(currentRun, 10); | ||
| if (isNaN(runNum) || runNum <= 1) return null; | ||
| const prevRun = `run_${runNum - 1}`; | ||
| const runOrdinal = parseRunOrdinal(currentRun); | ||
| if (runOrdinal === null || runOrdinal <= 1n) return null; | ||
| const prevRun = `run_${runOrdinal - 1n}`; | ||
| const prevPath = join(dir, "nodes", "ux-simulation", prevRun, "ux-verdict.json"); | ||
@@ -245,0 +246,0 @@ try { |
@@ -38,3 +38,3 @@ // Visualization and replay commands: getMarker, cmdViz, cmdReplayData | ||
| const resolved = resolveFlowTemplate(args, state); | ||
| let resolved = resolveFlowTemplate(args, state); | ||
| if (resolved.error) { | ||
@@ -41,0 +41,0 @@ console.error(resolved.error); |
@@ -127,3 +127,3 @@ #!/usr/bin/env node | ||
| const evalsByNode = collectEvals(DIR); | ||
| const executionFixes = collectExecutionFixes(DIR); | ||
| const executionFixes = collectExecutionFixes(DIR, flowState); | ||
@@ -130,0 +130,0 @@ // Determine R1 vs R2 nodes from loop-state if available |
+1
-1
| { | ||
| "name": "@touchskyer/opc", | ||
| "version": "0.10.6", | ||
| "version": "0.10.7", | ||
| "description": "OPC — One Person Company. Task pipeline with independent multi-role evaluation.", | ||
@@ -5,0 +5,0 @@ "type": "module", |
+1
-1
@@ -14,3 +14,3 @@ #!/bin/bash | ||
| echo "═══════════════════════════════════════════" | ||
| if bash "$f"; then | ||
| if bash "$f" < /dev/null; then | ||
| TOTAL_PASS=$((TOTAL_PASS + 1)) | ||
@@ -17,0 +17,0 @@ else |
@@ -339,2 +339,3 @@ #!/bin/bash | ||
| HS | ||
| sync_run_handshakes .h-forge | ||
| OUT=$($HARNESS validate .h-forge/nodes/brief/handshake.json 2>/dev/null) | ||
@@ -363,2 +364,3 @@ VALID=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('valid'))" 2>/dev/null) | ||
| HS | ||
| sync_run_handshakes .h-real | ||
| OUT=$($HARNESS validate .h-real/nodes/brief/handshake.json 2>/dev/null) | ||
@@ -394,10 +396,26 @@ VALID=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('valid'))" 2>/dev/null) | ||
| echo '{"pass":true}' > .h-loop/nodes/brief/run_2/brief-lint-result.json | ||
| cat > .h-loop/nodes/brief/handshake.json << 'HS' | ||
| cat > .h-loop/nodes/brief/run_2/handshake.json << 'HS' | ||
| { | ||
| "nodeId": "brief", "nodeType": "brief", "runId": "run_2", | ||
| "status": "completed", "summary": "loopback no delta", "timestamp": "2024-01-01T00:00:00Z", | ||
| "artifacts": [{"type":"brief","path":"build-brief.md"},{"type":"report","path":"run_2/brief-lint-result.json"}] | ||
| "artifacts": [{"type":"brief","path":"../build-brief.md"},{"type":"report","path":"brief-lint-result.json"}] | ||
| } | ||
| HS | ||
| OUT=$($HARNESS validate .h-loop/nodes/brief/handshake.json 2>/dev/null) | ||
| mkdir -p .h-loop/nodes/brief/run_1 | ||
| python3 - <<'PY' | ||
| import json, pathlib | ||
| p = pathlib.Path(".h-loop/nodes/brief/run_2/handshake.json") | ||
| d = json.loads(p.read_text()) | ||
| d["runId"] = "run_1" | ||
| pathlib.Path(".h-loop/nodes/brief/run_1/handshake.json").write_text(json.dumps(d, indent=2) + "\n") | ||
| PY | ||
| python3 -c " | ||
| import json | ||
| p='.h-loop/flow-state.json' | ||
| s=json.load(open(p)) | ||
| s['history']=[{'nodeId':'brief','runId':'run_2','timestamp':'2024-01-01T00:00:00.000Z'}] | ||
| s['totalSteps']=1 | ||
| json.dump(s, open(p,'w'), indent=2) | ||
| " | ||
| OUT=$($HARNESS validate .h-loop/nodes/brief/run_2/handshake.json 2>/dev/null) | ||
| VALID=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('valid'))" 2>/dev/null) | ||
@@ -419,10 +437,26 @@ ERRORS=$(echo "$OUT" | python3 -c "import sys,json; print(' '.join(json.load(sys.stdin).get('errors',[])))" 2>/dev/null) | ||
| echo '{"pass":true}' > .h-loop2/nodes/brief/run_2/brief-lint-result.json | ||
| cat > .h-loop2/nodes/brief/handshake.json << 'HS' | ||
| cat > .h-loop2/nodes/brief/run_2/handshake.json << 'HS' | ||
| { | ||
| "nodeId": "brief", "nodeType": "brief", "runId": "run_2", | ||
| "status": "completed", "summary": "loopback with delta", "timestamp": "2024-01-01T00:00:00Z", | ||
| "artifacts": [{"type":"brief","path":"build-brief.md"},{"type":"report","path":"run_2/brief-lint-result.json"}] | ||
| "artifacts": [{"type":"brief","path":"../build-brief.md"},{"type":"report","path":"brief-lint-result.json"}] | ||
| } | ||
| HS | ||
| OUT=$($HARNESS validate .h-loop2/nodes/brief/handshake.json 2>/dev/null) | ||
| mkdir -p .h-loop2/nodes/brief/run_1 | ||
| python3 - <<'PY' | ||
| import json, pathlib | ||
| p = pathlib.Path(".h-loop2/nodes/brief/run_2/handshake.json") | ||
| d = json.loads(p.read_text()) | ||
| d["runId"] = "run_1" | ||
| pathlib.Path(".h-loop2/nodes/brief/run_1/handshake.json").write_text(json.dumps(d, indent=2) + "\n") | ||
| PY | ||
| python3 -c " | ||
| import json | ||
| p='.h-loop2/flow-state.json' | ||
| s=json.load(open(p)) | ||
| s['history']=[{'nodeId':'brief','runId':'run_2','timestamp':'2024-01-01T00:00:00.000Z'}] | ||
| s['totalSteps']=1 | ||
| json.dump(s, open(p,'w'), indent=2) | ||
| " | ||
| OUT=$($HARNESS validate .h-loop2/nodes/brief/run_2/handshake.json 2>/dev/null) | ||
| VALID=$(echo "$OUT" | python3 -c "import sys,json; print(json.load(sys.stdin).get('valid'))" 2>/dev/null) | ||
@@ -429,0 +463,0 @@ if [ "$VALID" = "True" ]; then |
+193
-29
| #!/bin/bash | ||
| # test-bypass-chain.sh — validate-chain honors bypass | ||
| # Ensures: a flow initialized under OPC_DISABLE_EXTENSIONS=1 does NOT fail | ||
| # validate-chain even when ~/.opc/config.json declares requiredExtensions. | ||
| # This is the benchmark-reproducibility contract from U1.1. | ||
| # test-bypass-chain.sh — validate-chain records bypass without waiving provenance. | ||
@@ -26,4 +23,16 @@ set -u | ||
| TMP=$(mktemp -d) | ||
| trap "rm -rf '$TMP'" EXIT | ||
| HARNESS="${OPC_TEST_HARNESS_NAME:-.harness-bypass-chain-$$}" | ||
| cleanup() { | ||
| rm -rf "$TMP" "$HARNESS" | ||
| rm -f "/tmp/nb-stderr.$$" | ||
| } | ||
| trap cleanup EXIT | ||
| trap 'exit 129' HUP | ||
| trap 'exit 130' INT | ||
| trap 'exit 143' TERM | ||
| sync_hs() { | ||
| SYNC_DIR="$HARNESS" bash -c 'source test/test-helpers.sh; sync_run_handshakes "$SYNC_DIR"' | ||
| } | ||
| # Seed a fake ~/.opc/config.json inside TMP (we'll override HOME for the test) | ||
@@ -36,6 +45,5 @@ mkdir -p "$TMP/fake-home/.opc" | ||
| # Work inside a harness dir under cwd so resolveDir doesn't refuse it | ||
| HARNESS=".harness-bypass-chain-$$" | ||
| rm -rf "$HARNESS" | ||
| echo "=== TEST: validate-chain honors bypass ===" | ||
| echo "=== TEST: validate-chain records bypass without provenance waiver ===" | ||
@@ -45,3 +53,6 @@ # 1) init under OPC_DISABLE_EXTENSIONS=1 | ||
| HOME="$TMP/fake-home" OPC_DISABLE_EXTENSIONS=1 node bin/opc-harness.mjs init \ | ||
| --flow review --entry review --dir "$HARNESS" >/dev/null 2>&1 | ||
| --flow build-verify --entry code-review --dir "$HARNESS" >/dev/null 2>&1 | ||
| if [ "${OPC_TEST_ABORT_AFTER_HARNESS_INIT:-0}" = "1" ]; then | ||
| exit 97 | ||
| fi | ||
| if [ -f "$HARNESS/flow-state.json" ]; then | ||
@@ -78,44 +89,71 @@ MODE=$(jq -r '.bypassMode.mode // "null"' "$HARNESS/flow-state.json") | ||
| # 3) validate-chain under bypass passes despite requiredExtensions config | ||
| echo "--- 1.3: validate-chain under bypass waives requiredExtensions" | ||
| # 3) A capability-bearing current node claims no extension provenance. | ||
| mkdir -p "$HARNESS/nodes/code-review/run_1" | ||
| cat > "$HARNESS/nodes/code-review/handshake.json" <<'EOF' | ||
| { | ||
| "nodeId": "code-review", | ||
| "nodeType": "review", | ||
| "runId": "run_1", | ||
| "status": "completed", | ||
| "summary": "review complete", | ||
| "timestamp": "2024-01-01T00:00:00Z", | ||
| "artifacts": [] | ||
| } | ||
| EOF | ||
| sync_hs | ||
| echo "--- 1.3: validate-chain under bypass still enforces requiredExtensions" | ||
| OUT=$(HOME="$TMP/fake-home" OPC_DISABLE_EXTENSIONS=1 node bin/opc-harness.mjs validate-chain \ | ||
| --dir "$HARNESS" 2>/dev/null) | ||
| VALID=$(echo "$OUT" | jq -r '.valid // false') | ||
| if [ "$VALID" = "true" ]; then | ||
| echo " ✅ validate-chain valid=true under bypass" | ||
| ERRORS=$(echo "$OUT" | jq -r '.errors | join(" ")') | ||
| if [ "$VALID" = "false" ] && [[ "$ERRORS" == *"extensionsApplied missing"* ]]; then | ||
| echo " ✅ bypass cannot turn missing required provenance green" | ||
| PASS=$((PASS + 1)) | ||
| else | ||
| echo " ❌ validate-chain failed under bypass: $OUT" | ||
| echo " ❌ validate-chain waived missing provenance under bypass: $OUT" | ||
| FAIL=$((FAIL + 1)) | ||
| fi | ||
| # 3b) validate-chain JSON exposes bypassActive/bypassSource/waivedRequiredExtensions | ||
| echo "--- 1.3b: validate-chain JSON exposes bypass state (machine-readable)" | ||
| # 3b) Bypass remains machine-readable audit metadata, but waives nothing. | ||
| echo "--- 1.3b: validate-chain exposes bypass state without waiver" | ||
| BACTIVE=$(echo "$OUT" | jq -r '.bypassActive') | ||
| BSOURCE=$(echo "$OUT" | jq -r '.bypassSource') | ||
| WAIVED=$(echo "$OUT" | jq -r '.waivedRequiredExtensions | join(",")') | ||
| if [ "$BACTIVE" = "true" ] && [[ "$BSOURCE" == flow-state* ]] && [ "$WAIVED" = "non-existent-ext" ]; then | ||
| echo " ✅ bypassActive=true, bypassSource=$BSOURCE, waived=[$WAIVED]" | ||
| WAIVED_COUNT=$(echo "$OUT" | jq -r '.waivedRequiredExtensions | length') | ||
| if [ "$BACTIVE" = "true" ] && [[ "$BSOURCE" == flow-state* ]] && [ "$WAIVED_COUNT" = "0" ]; then | ||
| echo " ✅ bypassActive=true, bypassSource=$BSOURCE, waived=[]" | ||
| PASS=$((PASS + 1)) | ||
| else | ||
| echo " ❌ JSON fields wrong: bypassActive=$BACTIVE bypassSource=$BSOURCE waived=$WAIVED" | ||
| echo " ❌ JSON fields wrong: bypassActive=$BACTIVE bypassSource=$BSOURCE waived.length=$WAIVED_COUNT" | ||
| FAIL=$((FAIL + 1)) | ||
| fi | ||
| # 4) Negative case: without bypass, validate-chain would still enforce requiredExtensions | ||
| # (We can't easily test this in a passing way because a pristine init has no handshakes | ||
| # yet, so no nodes fail. But we can confirm the waiver message only fires when bypass | ||
| # is active: it should NOT appear without bypass.) | ||
| echo "--- 1.4: without bypass, no waiver message emitted" | ||
| # 4) Without bypass, the same missing provenance fails for the same reason. | ||
| echo "--- 1.4: without bypass, required provenance is enforced without waiver" | ||
| rm -rf "$HARNESS" | ||
| HOME="$TMP/fake-home" node bin/opc-harness.mjs init \ | ||
| --flow review --entry review --dir "$HARNESS" >/dev/null 2>&1 | ||
| --flow build-verify --entry code-review --dir "$HARNESS" >/dev/null 2>&1 | ||
| mkdir -p "$HARNESS/nodes/code-review/run_1" | ||
| cat > "$HARNESS/nodes/code-review/handshake.json" <<'EOF' | ||
| { | ||
| "nodeId": "code-review", | ||
| "nodeType": "review", | ||
| "runId": "run_1", | ||
| "status": "completed", | ||
| "summary": "review complete", | ||
| "timestamp": "2024-01-01T00:00:00Z", | ||
| "artifacts": [] | ||
| } | ||
| EOF | ||
| sync_hs | ||
| OUT_NB=$(HOME="$TMP/fake-home" node bin/opc-harness.mjs validate-chain --dir "$HARNESS" 2>/tmp/nb-stderr.$$) | ||
| MSG=$(grep -c "waiving requiredExtensions" /tmp/nb-stderr.$$ || true) | ||
| NB_VALID=$(echo "$OUT_NB" | jq -r '.valid') | ||
| NB_ERRORS=$(echo "$OUT_NB" | jq -r '.errors | join(" ")') | ||
| rm -f /tmp/nb-stderr.$$ | ||
| if [ "$MSG" = "0" ]; then | ||
| echo " ✅ no 'waiving' message without bypass" | ||
| if [ "$MSG" = "0" ] && [ "$NB_VALID" = "false" ] && [[ "$NB_ERRORS" == *"extensionsApplied missing"* ]]; then | ||
| echo " ✅ missing required provenance fails without waiver" | ||
| PASS=$((PASS + 1)) | ||
| else | ||
| echo " ❌ unexpected waiver message without bypass" | ||
| echo " ❌ required provenance enforcement mismatch: $OUT_NB" | ||
| FAIL=$((FAIL + 1)) | ||
@@ -136,5 +174,131 @@ fi | ||
| # 5) Cleanup | ||
| # 5) Required extension provenance must parse and corroborate the sidecar | ||
| # Use a capability-bearing node so validate-chain actually enters the provenance gate. | ||
| rm -rf "$HARNESS" | ||
| HOME="$TMP/fake-home" node bin/opc-harness.mjs init \ | ||
| --flow build-verify --entry code-review --dir "$HARNESS" >/dev/null 2>&1 | ||
| mkdir -p "$HARNESS/nodes/code-review/run_1" | ||
| cat > "$HARNESS/nodes/code-review/run_1/eval-alpha.md" <<'EOF' | ||
| # Alpha Review | ||
| Role: alpha | ||
| LGTM from alpha after checking extension provenance. | ||
| VERDICT: LGTM | ||
| EOF | ||
| cat > "$HARNESS/nodes/code-review/run_1/eval-beta.md" <<'EOF' | ||
| # Beta Review | ||
| Role: beta | ||
| LGTM from beta after checking required extension sidecars. | ||
| VERDICT: LGTM | ||
| EOF | ||
| cat > "$HARNESS/nodes/code-review/handshake.json" <<'EOF' | ||
| { | ||
| "nodeId": "code-review", | ||
| "nodeType": "review", | ||
| "runId": "run_1", | ||
| "status": "completed", | ||
| "summary": "review complete", | ||
| "timestamp": "2024-01-01T00:00:00Z", | ||
| "artifacts": [ | ||
| {"type":"eval","path":"run_1/eval-alpha.md"}, | ||
| {"type":"eval","path":"run_1/eval-beta.md"} | ||
| ], | ||
| "extensionsApplied": ["non-existent-ext"] | ||
| } | ||
| EOF | ||
| sync_hs | ||
| printf '%s\n' '{broken' > "$HARNESS/nodes/code-review/run_1/eval-extensions.json" | ||
| OUT_BAD=$(HOME="$TMP/fake-home" node bin/opc-harness.mjs validate-chain --dir "$HARNESS" 2>/dev/null) | ||
| BAD_VALID=$(echo "$OUT_BAD" | jq -r '.valid') | ||
| BAD_ERROR=$(echo "$OUT_BAD" | jq -r '.errors | join(" ")') | ||
| if [ "$BAD_VALID" = "false" ] && [[ "$BAD_ERROR" == *"eval-extensions.json"*parse* ]]; then | ||
| echo " ✅ malformed extension sidecar fails closed" | ||
| PASS=$((PASS + 1)) | ||
| else | ||
| echo " ❌ malformed extension sidecar accepted: $OUT_BAD" | ||
| FAIL=$((FAIL + 1)) | ||
| fi | ||
| cat > "$HARNESS/nodes/code-review/run_1/eval-extensions.json" <<'EOF' | ||
| { | ||
| "version": 1, | ||
| "extensionsLoaded": [ | ||
| { "name": "non-existent-ext", "enabled": true } | ||
| ], | ||
| "extensionsApplied": ["non-existent-ext"], | ||
| "findings": [] | ||
| } | ||
| EOF | ||
| OUT_GOOD=$(HOME="$TMP/fake-home" node bin/opc-harness.mjs validate-chain --dir "$HARNESS" 2>/dev/null) | ||
| GOOD_VALID=$(echo "$OUT_GOOD" | jq -r '.valid') | ||
| if [ "$GOOD_VALID" = "true" ]; then | ||
| echo " ✅ canonical sidecar corroborates required participant" | ||
| PASS=$((PASS + 1)) | ||
| else | ||
| echo " ❌ canonical sidecar rejected: $OUT_GOOD" | ||
| FAIL=$((FAIL + 1)) | ||
| fi | ||
| # 5b) Canonical handshake runId is authoritative; a newer rogue run cannot corroborate it. | ||
| mkdir -p "$HARNESS/nodes/code-review/run_2" | ||
| printf '%s\n' '{broken' > "$HARNESS/nodes/code-review/run_1/eval-extensions.json" | ||
| cp "$HARNESS/nodes/code-review/run_1/eval-extensions.json" "$HARNESS/nodes/code-review/run_2/ignored-broken.json" | ||
| cat > "$HARNESS/nodes/code-review/run_2/eval-extensions.json" <<'EOF' | ||
| { | ||
| "version": 1, | ||
| "extensionsApplied": ["non-existent-ext"], | ||
| "findings": [] | ||
| } | ||
| EOF | ||
| OUT_BOUND=$(HOME="$TMP/fake-home" node bin/opc-harness.mjs validate-chain --dir "$HARNESS" 2>/dev/null) | ||
| BOUND_VALID=$(echo "$OUT_BOUND" | jq -r '.valid') | ||
| BOUND_ERROR=$(echo "$OUT_BOUND" | jq -r '.errors | join(" ")') | ||
| if [ "$BOUND_VALID" = "false" ] && [[ "$BOUND_ERROR" == *"run_1/eval-extensions.json"*parse* ]]; then | ||
| echo " ✅ sidecar corroboration is bound to canonical runId" | ||
| PASS=$((PASS + 1)) | ||
| else | ||
| echo " ❌ rogue run_2 corroborated run_1: $OUT_BOUND" | ||
| FAIL=$((FAIL + 1)) | ||
| fi | ||
| # 5c) Handshake self-reported nodeType cannot authorize prompt-phase exemption. | ||
| python3 - "$HARNESS/nodes/code-review/handshake.json" <<'PY' | ||
| import json, sys | ||
| path = sys.argv[1] | ||
| data = json.load(open(path)) | ||
| data["nodeType"] = "build" | ||
| json.dump(data, open(path, "w")) | ||
| PY | ||
| OUT_TYPE=$(HOME="$TMP/fake-home" node bin/opc-harness.mjs validate-chain --dir "$HARNESS" 2>/dev/null) | ||
| TYPE_VALID=$(echo "$OUT_TYPE" | jq -r '.valid') | ||
| if [ "$TYPE_VALID" = "false" ]; then | ||
| echo " ✅ forged nodeType cannot waive review provenance" | ||
| PASS=$((PASS + 1)) | ||
| else | ||
| echo " ❌ forged nodeType bypassed provenance: $OUT_TYPE" | ||
| FAIL=$((FAIL + 1)) | ||
| fi | ||
| # 5d) Handshake self-reported nodeId cannot authorize gate exemption. | ||
| python3 - "$HARNESS/nodes/code-review/handshake.json" <<'PY' | ||
| import json, sys | ||
| path = sys.argv[1] | ||
| data = json.load(open(path)) | ||
| data["nodeType"] = "review" | ||
| data["nodeId"] = "gate" | ||
| json.dump(data, open(path, "w")) | ||
| PY | ||
| OUT_ID=$(HOME="$TMP/fake-home" node bin/opc-harness.mjs validate-chain --dir "$HARNESS" 2>/dev/null) | ||
| ID_VALID=$(echo "$OUT_ID" | jq -r '.valid') | ||
| if [ "$ID_VALID" = "false" ]; then | ||
| echo " ✅ forged nodeId cannot waive review provenance" | ||
| PASS=$((PASS + 1)) | ||
| else | ||
| echo " ❌ forged nodeId bypassed provenance: $OUT_ID" | ||
| FAIL=$((FAIL + 1)) | ||
| fi | ||
| # 6) Cleanup | ||
| rm -rf "$HARNESS" | ||
| echo "" | ||
@@ -141,0 +305,0 @@ echo "===========================================" |
@@ -45,2 +45,3 @@ #!/bin/bash | ||
| "$NODE" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$VERDICT" > "$DIR/nodes/$NODE/handshake.json" | ||
| sync_run_handshakes "$DIR" | ||
| } | ||
@@ -54,2 +55,3 @@ | ||
| "$NODE" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$DIR/nodes/$NODE/handshake.json" | ||
| sync_run_handshakes "$DIR" | ||
| } | ||
@@ -63,2 +65,3 @@ | ||
| "$NODE" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$DIR/nodes/$NODE/handshake.json" | ||
| sync_run_handshakes "$DIR" | ||
| } | ||
@@ -65,0 +68,0 @@ |
| #!/bin/bash | ||
| set -e | ||
| OPC_BIN="$(dirname "$(dirname "$(realpath "$0")")")/bin/opc-harness.mjs" | ||
| SCRIPT_PATH="$(realpath "$0")" | ||
| OPC_BIN="$(dirname "$(dirname "$SCRIPT_PATH")")/bin/opc-harness.mjs" | ||
| source "$(dirname "$0")/test-helpers.sh" | ||
| source "$(dirname "$SCRIPT_PATH")/test-helpers.sh" | ||
| setup_tmpdir | ||
| opc() { node "$OPC_BIN" "$@"; } | ||
| TESTBASE="/tmp/opc-comprehensive-test2-$$" | ||
| FLOW_FIXTURE="$HOME/.claude/flows/_opc_test_schema.json" | ||
| cleanup() { | ||
| rm -rf "$TESTBASE" "$TMPDIR" | ||
| rm -f "$FLOW_FIXTURE" | ||
| } | ||
| trap cleanup EXIT | ||
| if [ "${OPC_TEST_CLEANUP_PROBE:-0}" = "1" ]; then | ||
| mkdir -p "$(dirname "$FLOW_FIXTURE")" | ||
| printf '%s\n' '{"probe":true}' > "$FLOW_FIXTURE" | ||
| exit 23 | ||
| fi | ||
| cleanup_probe() { | ||
| if OPC_TEST_CLEANUP_PROBE=1 "$SCRIPT_PATH" >/dev/null 2>&1; then | ||
| return 1 | ||
| else | ||
| [ "$?" -eq 23 ] && [ ! -e "$FLOW_FIXTURE" ] | ||
| fi | ||
| } | ||
| mkdir -p "$TESTBASE" | ||
@@ -62,6 +84,6 @@ TOTAL=0 | ||
| write_complete_test_plan "$DIR/nodes/$NODE/run_1/test-plan.md" | ||
| cat > "$DIR/nodes/$NODE/test-execution.json" <<'JSON' | ||
| {"testCommand":"node -e \"process.exit(0)\"","prerequisites":["fixture command"]} | ||
| cat > "$DIR/nodes/$NODE/run_1/test-execution.json" <<'JSON' | ||
| {"nodeId":"test-design","runId":"run_1","testCommand":"node -e \"process.exit(0)\"","prerequisites":["fixture command"]} | ||
| JSON | ||
| artifacts='[{"type":"eval","path":"run_1/eval-skeptic-owner.md"},{"type":"eval","path":"run_1/eval-comprehensive-peer.md"},{"type":"test-plan","path":"run_1/test-plan.md"},{"type":"test-plan","path":"test-execution.json"}]' | ||
| artifacts='[{"type":"eval","path":"run_1/eval-skeptic-owner.md"},{"type":"eval","path":"run_1/eval-comprehensive-peer.md"},{"type":"test-plan","path":"run_1/test-plan.md"},{"type":"test-plan","path":"run_1/test-execution.json"}]' | ||
| extra=',"testCommand":"node -e \"process.exit(0)\"","prerequisites":["fixture command"]' | ||
@@ -71,2 +93,3 @@ fi | ||
| "$NODE" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$artifacts" "$VERDICT" "$extra" > "$DIR/nodes/$NODE/handshake.json" | ||
| sync_run_handshakes "$DIR" | ||
| } | ||
@@ -80,2 +103,3 @@ | ||
| "$NODE" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$DIR/nodes/$NODE/handshake.json" | ||
| sync_run_handshakes "$DIR" | ||
| } | ||
@@ -89,2 +113,3 @@ | ||
| "$NODE" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > "$DIR/nodes/$NODE/handshake.json" | ||
| sync_run_handshakes "$DIR" | ||
| } | ||
@@ -107,7 +132,7 @@ | ||
| # 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 | ||
| # Manual goto traffic is not semantic repair; repeated reentry is bounded by maxNodeReentry. | ||
| # After prior goto test-execute, first goto build = test-execute→build, then build→build starts. | ||
| for i in 1 2 3 4 5; do opc goto build --dir .harness > /dev/null 2>&1; done | ||
| R=$(opc goto build --dir .harness) | ||
| check_json "maxLoopsPerEdge enforced" "'maxLoopsPerEdge' in d.get('error','')" "$R" | ||
| check_json "maxNodeReentry enforced" "'maxNodeReentry' in d.get('error','')" "$R" | ||
@@ -127,2 +152,14 @@ # stop | ||
| opc goto gate-test --dir .harness > /dev/null 2>&1 | ||
| write_exec_hs .harness test-execute | ||
| python3 -c " | ||
| import json | ||
| p='.harness/flow-state.json' | ||
| s=json.load(open(p)) | ||
| gate=s['history'][-1] | ||
| s['history']=[ | ||
| {'nodeId':'test-execute','runId':'run_1','timestamp':'2024-01-01T00:00:00.000Z'}, | ||
| gate, | ||
| ] | ||
| json.dump(s, open(p,'w'), indent=2) | ||
| " | ||
| R=$(opc pass --dir .harness 2>/dev/null) | ||
@@ -182,4 +219,5 @@ check_json "pass advances gate" "d.get('next')=='acceptance'" "$R" | ||
| mkdir -p ~/.claude/flows | ||
| cat > ~/.claude/flows/_opc_test_schema.json << 'EOF' | ||
| check "global flow fixture cleaned after abnormal exit" cleanup_probe | ||
| mkdir -p "$(dirname "$FLOW_FIXTURE")" | ||
| cat > "$FLOW_FIXTURE" << 'EOF' | ||
| {"nodes":["build","gate"],"edges":{"build":{"PASS":"gate"},"gate":{"PASS":null}},"limits":{"maxLoopsPerEdge":3,"maxTotalSteps":10,"maxNodeReentry":5},"nodeTypes":{"build":"build","gate":"gate"},"contextSchema":{"build":{"required":["task"],"rules":{"task":"non-empty-string"}}}} | ||
@@ -202,3 +240,3 @@ EOF | ||
| rm -f ~/.claude/flows/_opc_test_schema.json | ||
| rm -f "$FLOW_FIXTURE" | ||
@@ -211,3 +249,6 @@ # ───────────────────────────────────────────────────────────────── | ||
| mkdir -p "$T9" && cd "$T9" | ||
| git init -q && git commit --allow-empty -m "init" -q | ||
| git init -q | ||
| git config user.email "test@test.com" | ||
| git config user.name "Test" | ||
| git commit --allow-empty -m "init" -q | ||
@@ -257,2 +298,3 @@ cat > plan.md << 'EOF' | ||
| sleep 1; opc transition --from test-design --to test-execute --verdict PASS --flow build-verify --dir .harness 2>/dev/null > /dev/null | ||
| write_exec_hs ".harness" "test-execute" | ||
| sleep 1; opc transition --from test-execute --to gate --verdict PASS --flow build-verify --dir .harness 2>/dev/null > /dev/null | ||
@@ -262,9 +304,10 @@ R=$(opc finalize --dir .harness) | ||
| # legacy-linear routing | ||
| R=$(opc route --node evaluate --verdict FAIL --flow legacy-linear) | ||
| # legacy-linear routing uses an empty harness so build-verify state cannot substitute its graph. | ||
| LEGACY_HARNESS="$T10/legacy-harness" | ||
| mkdir -p "$LEGACY_HARNESS" | ||
| R=$(opc route --node evaluate --verdict FAIL --flow legacy-linear --dir "$LEGACY_HARNESS") | ||
| check_json "legacy-linear FAIL → build" "d['next']=='build'" "$R" | ||
| R=$(opc route --node deliver --verdict PASS --flow legacy-linear) | ||
| R=$(opc route --node deliver --verdict PASS --flow legacy-linear --dir "$LEGACY_HARNESS") | ||
| check_json "legacy-linear terminal" "d['next']==None" "$R" | ||
| rm -rf "$TESTBASE" | ||
| print_results |
@@ -9,5 +9,35 @@ #!/bin/bash | ||
| FLOW_DIR="$HOME/.claude/flows" | ||
| IDEA_FLOW="$FLOW_DIR/idea-factory.json" | ||
| CONTEXT_FLOW="$FLOW_DIR/test-ctx-flow.json" | ||
| IDEA_BACKUP="$TMPDIR/idea-factory.json.backup" | ||
| CONTEXT_BACKUP="$TMPDIR/test-ctx-flow.json.backup" | ||
| IDEA_EXISTED=0 | ||
| CONTEXT_EXISTED=0 | ||
| mkdir -p "$FLOW_DIR" | ||
| if [ -e "$IDEA_FLOW" ] || [ -L "$IDEA_FLOW" ]; then | ||
| mv "$IDEA_FLOW" "$IDEA_BACKUP" | ||
| IDEA_EXISTED=1 | ||
| fi | ||
| if [ -e "$CONTEXT_FLOW" ] || [ -L "$CONTEXT_FLOW" ]; then | ||
| mv "$CONTEXT_FLOW" "$CONTEXT_BACKUP" | ||
| CONTEXT_EXISTED=1 | ||
| fi | ||
| cleanup() { | ||
| rm -f "$IDEA_FLOW" "$CONTEXT_FLOW" | ||
| if [ "$IDEA_EXISTED" -eq 1 ]; then | ||
| mv "$IDEA_BACKUP" "$IDEA_FLOW" | ||
| fi | ||
| if [ "$CONTEXT_EXISTED" -eq 1 ]; then | ||
| mv "$CONTEXT_BACKUP" "$CONTEXT_FLOW" | ||
| fi | ||
| rm -rf "$TMPDIR" | ||
| } | ||
| trap cleanup EXIT | ||
| trap 'exit 129' HUP | ||
| trap 'exit 130' INT | ||
| trap 'exit 143' TERM | ||
| # Create idea-factory fixture for testing (not a built-in template) | ||
| mkdir -p "$HOME/.claude/flows" | ||
| cat > "$HOME/.claude/flows/idea-factory.json" << 'FIXTURE' | ||
| cat > "$IDEA_FLOW" << 'FIXTURE' | ||
| { | ||
@@ -103,2 +133,3 @@ "nodes": ["discover", "validate", "build", "gate", "synthesize", "pitch"], | ||
| d['edgeCounts']['gate→brief'] = d['maxLoopsPerEdge'] | ||
| d['repairEdgeCounts']['gate→brief'] = d['maxLoopsPerEdge'] | ||
| json.dump(d, open('.h-edge/flow-state.json', 'w'), indent=2) | ||
@@ -134,24 +165,17 @@ " | ||
| echo "--- CG-3.1: Duplicate transition within 5s window blocked ---" | ||
| rm -rf .h-idemp && $HARNESS init --flow build-verify --dir .h-idemp >/dev/null 2>/dev/null | ||
| mkdir -p .h-idemp/nodes/build | ||
| cat > .h-idemp/nodes/build/handshake.json << 'HS' | ||
| rm -rf .h-idemp && $HARNESS init --flow build-verify --entry build --dir .h-idemp >/dev/null 2>/dev/null | ||
| mkdir -p .h-idemp/nodes/build/run_1 | ||
| cat > .h-idemp/nodes/build/run_1/handshake.json << 'HS' | ||
| {"nodeId":"build","nodeType":"build","runId":"run_1","status":"completed","summary":"done","timestamp":"2024-01-01T00:00:00Z","artifacts":[]} | ||
| HS | ||
| $HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir .h-idemp >/dev/null 2>/dev/null | ||
| mkdir -p .h-idemp/nodes/code-review | ||
| cat > .h-idemp/nodes/code-review/handshake.json << 'HS' | ||
| {"nodeId":"code-review","nodeType":"review","runId":"run_1","status":"completed","summary":"done","timestamp":"2024-01-01T00:00:00Z","artifacts":[]} | ||
| HS | ||
| OUT=$($HARNESS transition --from code-review --to test-execute --verdict PASS --flow build-verify --dir .h-idemp 2>/dev/null) | ||
| rm -rf .h-idemp2 && $HARNESS init --flow build-verify --entry gate --dir .h-idemp2 >/dev/null 2>/dev/null | ||
| $HARNESS transition --from gate --to brief --verdict FAIL --flow build-verify --dir .h-idemp2 >/dev/null 2>/dev/null | ||
| python3 -c " | ||
| import json | ||
| d = json.load(open('.h-idemp2/flow-state.json')) | ||
| d['currentNode'] = 'gate' | ||
| json.dump(d, open('.h-idemp2/flow-state.json', 'w'), indent=2) | ||
| d = json.load(open('.h-idemp/flow-state.json')) | ||
| d['currentNode'] = 'build' | ||
| json.dump(d, open('.h-idemp/flow-state.json', 'w'), indent=2) | ||
| " | ||
| OUT=$($HARNESS transition --from gate --to brief --verdict FAIL --flow build-verify --dir .h-idemp2 2>/dev/null) | ||
| OUT=$($HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir .h-idemp 2>/dev/null) | ||
| assert_field_eq "idempotency blocked" "$OUT" "allowed" "false" | ||
| assert_contains "idempotency guard" "$OUT" "idempotency" | ||
| assert_contains "manual rewind rejected before duplicate transition" "$OUT" "cannot resolve current run" | ||
@@ -165,8 +189,19 @@ # ═══════════════════════════════════════════════════════════════ | ||
| rm -rf .h-backlog && $HARNESS init --flow build-verify --entry gate --dir .h-backlog >/dev/null 2>/dev/null | ||
| mkdir -p .h-backlog/nodes/test-execute | ||
| cat > .h-backlog/nodes/test-execute/handshake.json << 'HS' | ||
| mkdir -p .h-backlog/nodes/test-execute/run_1 | ||
| cat > .h-backlog/nodes/test-execute/run_1/handshake.json << 'HS' | ||
| {"nodeId":"test-execute","nodeType":"execute","runId":"run_1","status":"completed","summary":"done", | ||
| "timestamp":"2024-01-01T00:00:00Z","artifacts":["evidence.txt"],"findings":{"warning":2,"critical":0}} | ||
| "timestamp":"2024-01-01T00:00:00Z","artifacts":[{"type":"cli-output","path":"evidence.txt"}],"findings":{"warning":2,"critical":0}} | ||
| HS | ||
| echo "test evidence" > .h-backlog/nodes/test-execute/evidence.txt | ||
| echo "test evidence" > .h-backlog/nodes/test-execute/run_1/evidence.txt | ||
| python3 -c " | ||
| import json | ||
| p='.h-backlog/flow-state.json' | ||
| s=json.load(open(p)) | ||
| s['history']=[ | ||
| {'nodeId':'test-execute','runId':'run_1','timestamp':'2024-01-01T00:00:00.000Z'}, | ||
| {'nodeId':'gate','runId':'run_1','timestamp':'2024-01-01T00:00:01.000Z'}, | ||
| ] | ||
| s['totalSteps']=2 | ||
| json.dump(s, open(p,'w'), indent=2) | ||
| " | ||
| OUT=$($HARNESS transition --from gate --to brief --verdict ITERATE --flow build-verify --dir .h-backlog 2>/dev/null) | ||
@@ -179,8 +214,19 @@ assert_field_eq "backlog required" "$OUT" "allowed" "false" | ||
| rm -rf .h-backlog2 && $HARNESS init --flow build-verify --entry gate --dir .h-backlog2 >/dev/null 2>/dev/null | ||
| mkdir -p .h-backlog2/nodes/test-execute | ||
| cat > .h-backlog2/nodes/test-execute/handshake.json << 'HS' | ||
| mkdir -p .h-backlog2/nodes/test-execute/run_1 | ||
| cat > .h-backlog2/nodes/test-execute/run_1/handshake.json << 'HS' | ||
| {"nodeId":"test-execute","nodeType":"execute","runId":"run_1","status":"completed","summary":"done", | ||
| "timestamp":"2024-01-01T00:00:00Z","artifacts":["evidence.txt"],"findings":{"warning":2,"critical":0}} | ||
| "timestamp":"2024-01-01T00:00:00Z","artifacts":[{"type":"cli-output","path":"evidence.txt"}],"findings":{"warning":2,"critical":0}} | ||
| HS | ||
| echo "test evidence" > .h-backlog2/nodes/test-execute/evidence.txt | ||
| echo "test evidence" > .h-backlog2/nodes/test-execute/run_1/evidence.txt | ||
| python3 -c " | ||
| import json | ||
| p='.h-backlog2/flow-state.json' | ||
| s=json.load(open(p)) | ||
| s['history']=[ | ||
| {'nodeId':'test-execute','runId':'run_1','timestamp':'2024-01-01T00:00:00.000Z'}, | ||
| {'nodeId':'gate','runId':'run_1','timestamp':'2024-01-01T00:00:01.000Z'}, | ||
| ] | ||
| s['totalSteps']=2 | ||
| json.dump(s, open(p,'w'), indent=2) | ||
| " | ||
| cat > .h-backlog2/backlog.md << 'BL' | ||
@@ -197,8 +243,19 @@ # Backlog | ||
| rm -rf .h-backlog3 && $HARNESS init --flow build-verify --entry gate --dir .h-backlog3 >/dev/null 2>/dev/null | ||
| mkdir -p .h-backlog3/nodes/test-execute | ||
| cat > .h-backlog3/nodes/test-execute/handshake.json << 'HS' | ||
| mkdir -p .h-backlog3/nodes/test-execute/run_1 | ||
| cat > .h-backlog3/nodes/test-execute/run_1/handshake.json << 'HS' | ||
| {"nodeId":"test-execute","nodeType":"execute","runId":"run_1","status":"completed","summary":"done", | ||
| "timestamp":"2024-01-01T00:00:00Z","artifacts":["evidence.txt"],"findings":{"warning":3,"critical":0}} | ||
| "timestamp":"2024-01-01T00:00:00Z","artifacts":[{"type":"cli-output","path":"evidence.txt"}],"findings":{"warning":3,"critical":0}} | ||
| HS | ||
| echo "test evidence" > .h-backlog3/nodes/test-execute/evidence.txt | ||
| echo "test evidence" > .h-backlog3/nodes/test-execute/run_1/evidence.txt | ||
| python3 -c " | ||
| import json | ||
| p='.h-backlog3/flow-state.json' | ||
| s=json.load(open(p)) | ||
| s['history']=[ | ||
| {'nodeId':'test-execute','runId':'run_1','timestamp':'2024-01-01T00:00:00.000Z'}, | ||
| {'nodeId':'gate','runId':'run_1','timestamp':'2024-01-01T00:00:01.000Z'}, | ||
| ] | ||
| s['totalSteps']=2 | ||
| json.dump(s, open(p,'w'), indent=2) | ||
| " | ||
| cat > .h-backlog3/backlog.md << 'BL' | ||
@@ -241,2 +298,5 @@ - [ ] 🟡 Only one entry [test-execute] | ||
| CTX | ||
| if [ "${OPC_TEST_ABORT_AFTER_CONTEXT_FIXTURE:-0}" = "1" ]; then | ||
| exit 97 | ||
| fi | ||
| $HARNESS init --flow test-ctx-flow --dir .h-ctx2 >/dev/null 2>/dev/null | ||
@@ -243,0 +303,0 @@ OUT=$($HARNESS validate-context --flow test-ctx-flow --node step1 --dir .h-ctx2 2>/dev/null) |
@@ -53,9 +53,12 @@ #!/bin/bash | ||
| local artifacts="[]" | ||
| local run_dir="$dir/nodes/$node/run_1" | ||
| local run_id="run_1" | ||
| local run_dir="$dir/nodes/$node/$run_id" | ||
| local latest | ||
| latest=$(ls -d "$dir/nodes/$node"/run_* 2>/dev/null | sort -V | tail -1 || true) | ||
| if [ -n "$latest" ]; then | ||
| run_id=$(basename "$latest") | ||
| run_dir="$latest" | ||
| fi | ||
| if [ "$node_type" = "review" ] && [ -d "$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])) | ||
| " 2>/dev/null || echo "[]") | ||
| artifacts=$(find "$run_dir" -maxdepth 1 -name 'eval-*.md' -print | python3 -c 'import json, os, sys; run_id=sys.argv[1]; files=[line.strip() for line in sys.stdin if line.strip()]; print(json.dumps([{"path": f"{run_id}/{os.path.basename(path)}", "type": "eval"} for path in files]))' "$run_id" 2>/dev/null || echo "[]") | ||
| fi | ||
@@ -66,3 +69,3 @@ cat > "$path" << HSEOF | ||
| "nodeType": "$node_type", | ||
| "runId": "run_1", | ||
| "runId": "$run_id", | ||
| "status": "completed", | ||
@@ -75,2 +78,3 @@ "summary": "$summary", | ||
| HSEOF | ||
| sync_run_handshakes "$dir" | ||
| } | ||
@@ -188,5 +192,9 @@ | ||
| write_good_eval .harness review tester | ||
| write_good_eval .harness review skeptic-owner | ||
| write_handshake .harness review "Review round 1" "PASS" | ||
| $HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>/dev/null | ||
| write_warning_eval .harness review senior | ||
| write_good_eval .harness review tester | ||
| write_handshake .harness review "Review round 1" "ITERATE" | ||
| $HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>/dev/null | ||
| SYNTH=$($HARNESS synthesize .harness --node review) | ||
| SYNTH=$($HARNESS synthesize .harness --node review --run 1) | ||
| assert_field_eq "2.1: round 1 ITERATE" "$SYNTH" "verdict" '"ITERATE"' | ||
@@ -200,8 +208,23 @@ write_handshake .harness gate "Gate iterates" "ITERATE" gate | ||
| mkdir -p .harness/nodes/review/run_2 | ||
| write_good_eval .harness review senior | ||
| mv .harness/nodes/review/run_1/eval-senior.md .harness/nodes/review/run_2/eval-senior.md | ||
| write_good_eval .harness review tester | ||
| 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 | ||
| cat > .harness/nodes/review/run_2/eval-senior.md <<'EVAL' | ||
| # senior Review | ||
| 🔵 src/handler.ts:15 — Input validation is acceptable | ||
| Reasoning: The second review verified the boundary checks. | ||
| → Keep the existing validation. | ||
| VERDICT: PASS FINDINGS[0] | ||
| EVAL | ||
| cat > .harness/nodes/review/run_2/eval-tester.md <<'EVAL' | ||
| # tester Review | ||
| 🔵 src/service.ts:55 — Test coverage is acceptable | ||
| Reasoning: The second review verified edge cases. | ||
| → Keep the existing tests. | ||
| VERDICT: PASS FINDINGS[0] | ||
| EVAL | ||
| cat > .harness/nodes/review/run_2/eval-skeptic-owner.md <<'EVAL' | ||
| # skeptic-owner Review | ||
| 🔵 src/flow.ts:30 — Mechanism holds | ||
| Reasoning: The second review verified the requested authority boundary. | ||
| → No action required. | ||
| VERDICT: PASS FINDINGS[0] | ||
| EVAL | ||
| write_handshake .harness review "Review round 2" "PASS" | ||
@@ -208,0 +231,0 @@ $HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>/dev/null |
@@ -48,2 +48,16 @@ #!/bin/bash | ||
| assert_transition_allowed() { | ||
| local desc="$1" | ||
| shift | ||
| local out | ||
| out=$("$HARNESS" transition "$@" 2>/dev/null) | ||
| if echo "$out" | grep -q '"allowed":true'; then | ||
| echo " ✅ $desc" | ||
| PASS=$((PASS + 1)) | ||
| else | ||
| echo " ❌ $desc — $out" | ||
| FAIL=$((FAIL + 1)) | ||
| fi | ||
| } | ||
| write_handshake() { | ||
@@ -54,10 +68,38 @@ local dir="$1" node="$2" summary="$3" verdict="$4" node_type="${5:-review}" | ||
| local artifacts="[]" | ||
| local extra="" | ||
| local run_dir="$dir/nodes/$node/run_1" | ||
| if [ "$node_type" = "review" ] && [ -d "$run_dir" ]; then | ||
| artifacts=$(ls "$run_dir"/eval-*.md 2>/dev/null | python3 -c " | ||
| import sys, json | ||
| import pathlib, sys, json | ||
| files = [l.strip() for l in sys.stdin if l.strip()] | ||
| print(json.dumps([{'path': f, 'type': 'eval'} for f in files])) | ||
| print(json.dumps([{'path': 'run_1/' + pathlib.Path(f).name, 'type': 'eval'} for f in files])) | ||
| " 2>/dev/null || echo "[]") | ||
| fi | ||
| if [ "$node_type" = "brief" ]; then | ||
| mkdir -p "$run_dir" | ||
| write_golden_brief "$dir/nodes/$node/build-brief.md" | ||
| echo '{"pass":true}' > "$run_dir/brief-lint-result.json" | ||
| artifacts='[{"type":"brief","path":"build-brief.md"},{"type":"report","path":"run_1/brief-lint-result.json"}]' | ||
| fi | ||
| if [ "$node_type" = "execute" ]; then | ||
| mkdir -p "$run_dir" | ||
| echo "tests passed" > "$run_dir/command-output.txt" | ||
| artifacts='[{"type":"cli-output","path":"run_1/command-output.txt"}]' | ||
| fi | ||
| if [ "$node" = "test-design" ]; then | ||
| mkdir -p "$run_dir" | ||
| write_complete_test_plan "$run_dir/test-plan.md" | ||
| printf '%s\n' '{"nodeId":"test-design","runId":"run_1","testCommand":"node -e \"process.exit(0)\"","prerequisites":["fixture"]}' > "$run_dir/test-execution.json" | ||
| artifacts=$(python3 - "$artifacts" <<'PY' | ||
| import json, sys | ||
| items = json.loads(sys.argv[1]) | ||
| items.extend([ | ||
| {"type": "test-plan", "path": "run_1/test-plan.md"}, | ||
| {"type": "test-command", "path": "run_1/test-execution.json"}, | ||
| ]) | ||
| print(json.dumps(items)) | ||
| PY | ||
| ) | ||
| extra=', "testCommand": "node -e \"process.exit(0)\"", "prerequisites": ["fixture"]' | ||
| fi | ||
| cat > "$path" << HSEOF | ||
@@ -71,6 +113,7 @@ { | ||
| "verdict": "$verdict", | ||
| "artifacts": $artifacts, | ||
| "artifacts": $artifacts$extra, | ||
| "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)" | ||
| } | ||
| HSEOF | ||
| sync_run_handshakes "$dir" | ||
| } | ||
@@ -157,4 +200,5 @@ | ||
| write_good_eval .harness review tester | ||
| write_handshake .harness review "Review found critical" "FAIL" | ||
| $HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>/dev/null | ||
| write_good_eval .harness review skeptic-owner | ||
| write_handshake .harness review "Review found critical" "PASS" | ||
| assert_transition_allowed "3.0: review → gate" --from review --to gate --verdict PASS --flow review --dir .harness | ||
| SYNTH=$($HARNESS synthesize .harness --node review) | ||
@@ -181,3 +225,3 @@ assert_field_eq "3.1: critical → FAIL" "$SYNTH" "verdict" '"FAIL"' | ||
| assert_contains "4.1b: brief → build" "$NEXT" "build" | ||
| $HARNESS transition --from brief --to build --verdict PASS --flow build-verify --dir .harness 2>/dev/null | ||
| assert_transition_allowed "4.1c: transition brief → build" --from brief --to build --verdict PASS --flow build-verify --dir .harness | ||
@@ -188,3 +232,3 @@ write_handshake .harness build "Implementation complete" "PASS" build | ||
| assert_contains "4.2: build → code-review" "$NEXT" "code-review" | ||
| $HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir .harness 2>/dev/null | ||
| assert_transition_allowed "4.2b: transition build → code-review" --from build --to code-review --verdict PASS --flow build-verify --dir .harness | ||
@@ -198,4 +242,6 @@ write_good_eval .harness code-review frontend | ||
| assert_contains "4.3: code-review → test-design" "$NEXT" "test-design" | ||
| $HARNESS transition --from code-review --to test-design --verdict PASS --flow build-verify --dir .harness 2>/dev/null | ||
| assert_transition_allowed "4.3b: transition code-review → test-design" --from code-review --to test-design --verdict PASS --flow build-verify --dir .harness | ||
| write_good_eval .harness test-design tester | ||
| write_good_eval .harness test-design skeptic-owner | ||
| write_handshake .harness test-design "Test cases designed" "PASS" | ||
@@ -205,3 +251,3 @@ ROUTE=$($HARNESS route --node test-design --verdict PASS --flow build-verify) | ||
| assert_contains "4.4: test-design → test-execute" "$NEXT" "test-execute" | ||
| $HARNESS transition --from test-design --to test-execute --verdict PASS --flow build-verify --dir .harness 2>/dev/null | ||
| assert_transition_allowed "4.4b: transition test-design → test-execute" --from test-design --to test-execute --verdict PASS --flow build-verify --dir .harness | ||
@@ -212,3 +258,3 @@ write_handshake .harness test-execute "Tests pass" "PASS" execute | ||
| assert_contains "4.5: test-execute → gate" "$NEXT" "gate" | ||
| $HARNESS transition --from test-execute --to gate --verdict PASS --flow build-verify --dir .harness 2>/dev/null | ||
| assert_transition_allowed "4.5b: transition test-execute → gate" --from test-execute --to gate --verdict PASS --flow build-verify --dir .harness | ||
@@ -230,11 +276,14 @@ SYNTH=$($HARNESS synthesize .harness --node code-review) | ||
| write_handshake .harness build "Built" "PASS" build | ||
| $HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir .harness 2>/dev/null | ||
| assert_transition_allowed "5.0a: build → code-review" --from build --to code-review --verdict PASS --flow build-verify --dir .harness | ||
| write_critical_eval .harness code-review security | ||
| write_good_eval .harness code-review frontend | ||
| write_handshake .harness code-review "Found critical" "FAIL" | ||
| $HARNESS transition --from code-review --to test-design --verdict PASS --flow build-verify --dir .harness 2>/dev/null | ||
| write_good_eval .harness code-review skeptic-owner | ||
| write_handshake .harness code-review "Found critical" "PASS" | ||
| assert_transition_allowed "5.0b: code-review → test-design" --from code-review --to test-design --verdict PASS --flow build-verify --dir .harness | ||
| write_good_eval .harness test-design tester | ||
| write_good_eval .harness test-design skeptic-owner | ||
| write_handshake .harness test-design "Test design" "PASS" | ||
| $HARNESS transition --from test-design --to test-execute --verdict PASS --flow build-verify --dir .harness 2>/dev/null | ||
| assert_transition_allowed "5.0c: test-design → test-execute" --from test-design --to test-execute --verdict PASS --flow build-verify --dir .harness | ||
| write_handshake .harness test-execute "Tests" "PASS" execute | ||
| $HARNESS transition --from test-execute --to gate --verdict PASS --flow build-verify --dir .harness 2>/dev/null | ||
| assert_transition_allowed "5.0d: test-execute → gate" --from test-execute --to gate --verdict PASS --flow build-verify --dir .harness | ||
| SYNTH=$($HARNESS synthesize .harness --node code-review) | ||
@@ -241,0 +290,0 @@ assert_field_eq "5.1: gate FAIL on critical" "$SYNTH" "verdict" '"FAIL"' |
@@ -48,14 +48,30 @@ #!/bin/bash | ||
| current_run_id() { | ||
| local dir="$1" node="$2" | ||
| python3 - "$dir/flow-state.json" "$node" <<'PY' | ||
| import json, sys | ||
| state = json.load(open(sys.argv[1])) | ||
| history = state.get("history", []) | ||
| tail = history[-1] if history else None | ||
| if tail and tail.get("nodeId") == sys.argv[2] and tail.get("runId"): | ||
| print(tail["runId"]) | ||
| elif (state.get("totalSteps") == 0 and not history and | ||
| state.get("currentNode") == state.get("entryNode") == sys.argv[2] and | ||
| state.get("flowStartedAt")): | ||
| print("run_1") | ||
| else: | ||
| raise SystemExit(1) | ||
| PY | ||
| } | ||
| write_handshake() { | ||
| local dir="$1" node="$2" summary="$3" verdict="$4" node_type="${5:-review}" | ||
| local path="$dir/nodes/$node/handshake.json" | ||
| local run_id | ||
| run_id=$(current_run_id "$dir" "$node") || return 1 | ||
| mkdir -p "$(dirname "$path")" | ||
| local artifacts="[]" | ||
| local run_dir="$dir/nodes/$node/run_1" | ||
| local run_dir="$dir/nodes/$node/$run_id" | ||
| if [ "$node_type" = "review" ] && [ -d "$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])) | ||
| " 2>/dev/null || echo "[]") | ||
| artifacts=$(find "$run_dir" -maxdepth 1 -name 'eval-*.md' -print | python3 -c 'import json, os, sys; run_id=sys.argv[1]; files=[line.strip() for line in sys.stdin if line.strip()]; print(json.dumps([{"path": f"{run_id}/{os.path.basename(path)}", "type": "eval"} for path in files]))' "$run_id" 2>/dev/null || echo "[]") | ||
| fi | ||
@@ -66,3 +82,3 @@ cat > "$path" << HSEOF | ||
| "nodeType": "$node_type", | ||
| "runId": "run_1", | ||
| "runId": "$run_id", | ||
| "status": "completed", | ||
@@ -75,2 +91,3 @@ "summary": "$summary", | ||
| HSEOF | ||
| sync_run_handshakes "$dir" | ||
| } | ||
@@ -80,3 +97,5 @@ | ||
| local dir="$1" node="$2" role="$3" | ||
| local run_dir="$dir/nodes/$node/run_1" | ||
| local run_id | ||
| run_id=$(current_run_id "$dir" "$node") || return 1 | ||
| local run_dir="$dir/nodes/$node/$run_id" | ||
| mkdir -p "$run_dir" | ||
@@ -118,3 +137,5 @@ cat > "$run_dir/eval-${role}.md" << EVALEOF | ||
| local dir="$1" node="$2" role="$3" | ||
| local run_dir="$dir/nodes/$node/run_1" | ||
| local run_id | ||
| run_id=$(current_run_id "$dir" "$node") || return 1 | ||
| local run_dir="$dir/nodes/$node/$run_id" | ||
| mkdir -p "$run_dir" | ||
@@ -138,3 +159,5 @@ cat > "$run_dir/eval-${role}.md" << EVALEOF | ||
| local dir="$1" node="$2" role="$3" | ||
| local run_dir="$dir/nodes/$node/run_1" | ||
| local run_id | ||
| run_id=$(current_run_id "$dir" "$node") || return 1 | ||
| local run_dir="$dir/nodes/$node/$run_id" | ||
| mkdir -p "$run_dir" | ||
@@ -191,2 +214,3 @@ cat > "$run_dir/eval-${role}.md" << EVALEOF | ||
| write_good_eval .harness review "backup${round}" | ||
| write_good_eval .harness review "skeptic-owner" | ||
| write_handshake .harness review "Round $round" "PASS" | ||
@@ -202,2 +226,3 @@ $HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>/dev/null | ||
| write_good_eval .harness review "backup4" | ||
| write_good_eval .harness review "skeptic-owner" | ||
| write_handshake .harness review "Round 4" "PASS" | ||
@@ -218,3 +243,3 @@ TRANS_4=$($HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>/dev/null || echo '{"allowed":false}') | ||
| write_good_eval .harness review analyst | ||
| write_good_eval .harness review architect | ||
| write_good_eval .harness review skeptic-owner | ||
| write_handshake .harness review "Clean review" "PASS" | ||
@@ -221,0 +246,0 @@ $HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>/dev/null |
@@ -45,5 +45,5 @@ #!/bin/bash | ||
| artifacts=$(ls "$run_dir"/eval-*.md 2>/dev/null | python3 -c " | ||
| import sys, json | ||
| import pathlib, sys, json | ||
| files = [l.strip() for l in sys.stdin if l.strip()] | ||
| print(json.dumps([{'path': f, 'type': 'eval'} for f in files])) | ||
| print(json.dumps([{'path': 'run_1/' + pathlib.Path(f).name, 'type': 'eval'} for f in files])) | ||
| " 2>/dev/null || echo "[]") | ||
@@ -70,2 +70,3 @@ fi | ||
| HSEOF | ||
| sync_run_handshakes "$dir" | ||
| } | ||
@@ -72,0 +73,0 @@ |
@@ -14,8 +14,7 @@ #!/bin/bash | ||
| # - build-verify:code-review (node HAS caps) → NO WARN (control) | ||
| # - prompt-context with no flow-state + no --flow (template unresolvable) → WARN STILL fires | ||
| # - prompt-context with no flow-state + no --flow (template unresolvable) → FAIL CLOSED | ||
| # | ||
| # The last case is the preservation guard: we suppress the false positive without blanket- | ||
| # silencing the WARN. Template-unresolvable-yet-extensions-loaded is a real, actionable | ||
| # misconfig and must stay loud. The F2 raw-library WARN-once contract (extensions.test.mjs) | ||
| # covers the programmatic "forgot to pass caps" path and must remain green independently. | ||
| # CLI callers must name the state-selected node. Unresolvable templates and unknown nodes | ||
| # are rejected before extension routing; the raw-library WARN-once contract remains covered | ||
| # independently by extensions.test.mjs. | ||
@@ -46,10 +45,21 @@ source "$(dirname "$0")/test-helpers.sh" | ||
| # init <flow> into a fresh subdir, fire prompt-context on <node>, capture stderr only. | ||
| # Init <flow> at <node>, then capture prompt-context stderr for that selected node. | ||
| warn_stderr() { | ||
| local flow="$1" node="$2" sub="sess-${flow}-${node}" | ||
| local flow="$1" node="$2" sub | ||
| sub="sess-${flow}-${node}" | ||
| rm -rf "$sub"; mkdir -p "$sub" | ||
| ( cd "$sub" && $HARNESS init --flow "$flow" --dir . >/dev/null 2>&1 ) | ||
| ( cd "$sub" && $HARNESS init --flow "$flow" --entry "$node" --dir . >/dev/null 2>&1 ) | ||
| mkdir -p "$sub/nodes/$node/run_1" | ||
| OPC_EXTENSIONS_DIR="$TMPDIR/exts" $HARNESS prompt-context --node "$node" --role tester --dir "$sub" 2>&1 1>/dev/null | ||
| } | ||
| assert_rejected() { | ||
| local desc="$1" rc="$2" output="$3" | ||
| if [ "$rc" -ne 0 ] && [ -n "$output" ]; then | ||
| echo " ✅ $desc"; PASS=$((PASS + 1)) | ||
| else | ||
| echo " ❌ $desc — expected non-zero exit with diagnostic, got rc=$rc output='$output'"; FAIL=$((FAIL + 1)) | ||
| fi | ||
| } | ||
| echo "=== F10: nodeCapabilities WARN fires only on unresolved-template path ===" | ||
@@ -82,20 +92,24 @@ | ||
| # ─────────────────────────────────────────────────────────────── | ||
| # Preservation guard: no flow-state.json and no --flow → template cannot resolve → | ||
| # caps genuinely unknown while extensions are loaded → the WARN MUST still fire. | ||
| # No flow-state.json and no --flow is not a routable lifecycle invocation. | ||
| echo "" | ||
| echo "--- N5 (guard): unresolved template + extensions loaded → WARN STILL fires ---" | ||
| echo "--- N5 (guard): unresolved template + extensions loaded → FAIL CLOSED ---" | ||
| rm -rf bare; mkdir -p bare # no init: no flow-state.json | ||
| OUT=$(OPC_EXTENSIONS_DIR="$TMPDIR/exts" $HARNESS prompt-context --node review --role tester --dir bare 2>&1 1>/dev/null) | ||
| assert_warn_count "unresolved template → warns" 1 "$OUT" | ||
| set +e | ||
| OUT=$(OPC_EXTENSIONS_DIR="$TMPDIR/exts" $HARNESS prompt-context --node review --role tester --dir bare 2>&1) | ||
| RC=$? | ||
| set -e | ||
| assert_rejected "unresolved template is rejected" "$RC" "$OUT" | ||
| # ─────────────────────────────────────────────────────────────── | ||
| # Node-typo guard: template resolves, but the requested node is NOT in template.nodes. | ||
| # An empty caps list here is NOT a legitimate "capless node" — it's a misconfig (typo / | ||
| # wrong --node). Suppressing the WARN would hide a loaded-but-no-match extension with zero | ||
| # diagnostics. So an unknown node must be treated as unresolved → WARN STILL fires. | ||
| # A node typo must be rejected, not degraded into a capless extension context. | ||
| echo "" | ||
| echo "--- N6 (guard): resolved template + unknown node → WARN STILL fires ---" | ||
| OUT=$(warn_stderr build-verify typo-node) | ||
| assert_warn_count "unknown node → warns" 1 "$OUT" | ||
| echo "--- N6 (guard): resolved template + unknown node → FAIL CLOSED ---" | ||
| rm -rf sess-build-verify-typo-node | ||
| $HARNESS init --flow build-verify --entry brief --dir sess-build-verify-typo-node >/dev/null 2>&1 | ||
| set +e | ||
| OUT=$(OPC_EXTENSIONS_DIR="$TMPDIR/exts" $HARNESS prompt-context --node typo-node --role tester --dir sess-build-verify-typo-node 2>&1) | ||
| RC=$? | ||
| set -e | ||
| assert_rejected "unknown node is rejected" "$RC" "$OUT" | ||
| print_results |
@@ -15,2 +15,3 @@ #!/bin/bash | ||
| rm -rf "$DIR" | ||
| trap 'rm -rf "$DIR"' EXIT | ||
| mkdir -p "$EXT_DIR/versioned-ext" | ||
@@ -32,3 +33,6 @@ | ||
| OPC_EXTENSIONS_DIR="$EXT_DIR" "$HARNESS" init --flow build-verify --entry brief --dir "$DIR/.harness" >/dev/null | ||
| ( | ||
| cd "$REPO_DIR" | ||
| OPC_EXTENSIONS_DIR="$EXT_DIR" "$HARNESS" init --flow build-verify --entry brief --dir "$DIR/.harness" >/dev/null | ||
| ) | ||
@@ -49,6 +53,4 @@ VERSION=$(python3 - "$DIR/.harness/flow-state.json" <<'PY' | ||
| rm -rf "$DIR" | ||
| echo "" | ||
| echo "Extension version state tests: $PASS passed, $FAIL failed" | ||
| [ "$FAIL" -eq 0 ] || exit 1 |
@@ -74,2 +74,3 @@ #!/usr/bin/env bash | ||
| JSON | ||
| $HARNESS seal --node review --dir "$SESSION" >/dev/null 2>/dev/null | ||
| OUT=$($HARNESS validate 2>/dev/null) | ||
@@ -100,2 +101,3 @@ check_json "validate without path uses latest current handshake" "d['valid']==True" "$OUT" | ||
| JSON | ||
| sync_run_handshakes ".harness" | ||
| OUT=$($HARNESS transition --from test-execute --to hotfix --verdict ITERATE --flow build-verify --dir .harness 2>/dev/null) | ||
@@ -105,6 +107,37 @@ check_json "test-execute ITERATE routes to hotfix" "d['allowed']==True and d['next']=='hotfix'" "$OUT" | ||
| mkdir -p .harness/nodes/hotfix/run_1 | ||
| mkdir -p .harness/nodes/test-design | ||
| cat > .harness/nodes/test-design/test-execution.json <<'JSON' | ||
| { "testCommand": "printf retest > hotfix-retest.txt", "timeoutMs": 10000 } | ||
| mkdir -p .harness/nodes/test-design/run_1 | ||
| cat > .harness/nodes/test-design/run_1/test-plan.md <<'PLAN' | ||
| # Test Plan | ||
| Run the hotfix retest command. | ||
| PLAN | ||
| cat > .harness/nodes/test-design/run_1/test-execution.json <<'JSON' | ||
| { "runId": "run_1", "testCommand": "printf retest > hotfix-retest.txt", "timeoutMs": 10000 } | ||
| JSON | ||
| cat > .harness/nodes/test-design/run_1/handshake.json <<'JSON' | ||
| { | ||
| "nodeId": "test-design", | ||
| "nodeType": "review", | ||
| "runId": "run_1", | ||
| "status": "completed", | ||
| "verdict": "PASS", | ||
| "summary": "Retest command selected.", | ||
| "timestamp": "2026-01-01T00:00:00.000Z", | ||
| "artifacts": [{ "type": "test-plan", "path": "run_1/test-plan.md" }] | ||
| } | ||
| JSON | ||
| sync_run_handshakes ".harness" | ||
| python3 - <<'PY' | ||
| import json | ||
| p = ".harness/flow-state.json" | ||
| s = json.load(open(p)) | ||
| s["history"] = [ | ||
| {"nodeId": "test-design", "runId": "run_1", "timestamp": "2026-01-01T00:00:00.000Z"}, | ||
| {"nodeId": "test-execute", "runId": "run_1", "timestamp": "2026-01-01T00:00:01.000Z"}, | ||
| {"nodeId": "hotfix", "runId": "run_1", "timestamp": "2026-01-01T00:00:02.000Z"}, | ||
| ] | ||
| s["currentNode"] = "hotfix" | ||
| s["totalSteps"] = 3 | ||
| json.dump(s, open(p, "w"), indent=2) | ||
| PY | ||
| printf 'Added aria-label only.\n' > .harness/nodes/hotfix/run_1/hotfix-report.md | ||
@@ -129,2 +162,3 @@ cat > .harness/nodes/hotfix/handshake.json <<'JSON' | ||
| JSON | ||
| sync_run_handshakes ".harness" | ||
| OUT=$($HARNESS transition --from hotfix --to test-execute --verdict PASS --flow build-verify --dir .harness 2>/dev/null) | ||
@@ -151,2 +185,3 @@ check_json "hotfix PASS routes back to test-execute evidence node" "d['allowed']==True and d['next']=='test-execute' and d['testCommandExecution']['executed']==True" "$OUT" | ||
| JSON | ||
| sync_run_handshakes ".harness" | ||
| OUT=$($HARNESS validate .harness/nodes/hotfix/handshake.json 2>/dev/null) | ||
@@ -153,0 +188,0 @@ check_json "structural hotfix handshake is rejected" "d['valid']==False and any('hotfix.scope' in e for e in d['errors'])" "$OUT" |
@@ -102,2 +102,3 @@ #!/bin/bash | ||
| HS | ||
| sync_run_handshakes .h-trans | ||
| sleep 1 | ||
@@ -139,3 +140,3 @@ OUT=$($HARNESS transition --from brief --to build --verdict PASS --flow build-verify --dir .h-trans 2>/dev/null) | ||
| assert_field_eq "hs missing" "$OUT" "allowed" "false" | ||
| assert_contains "handshake missing" "$OUT" "handshake.json missing" | ||
| assert_contains "handshake missing" "$OUT" "missing handshake for node 'brief' run 'run_1'" | ||
@@ -153,2 +154,3 @@ echo "" | ||
| HS | ||
| sync_run_handshakes .h-trans3 | ||
| OUT=$($HARNESS transition --from brief --to build --verdict PASS --flow build-verify --dir .h-trans3 2>/dev/null) | ||
@@ -177,2 +179,3 @@ assert_field_eq "status not completed" "$OUT" "allowed" "false" | ||
| HS | ||
| sync_run_handshakes .h-trans4 | ||
| OUT=$($HARNESS transition --from brief --to build --verdict PASS --flow build-verify --dir .h-trans4 2>/dev/null) | ||
@@ -195,2 +198,3 @@ assert_field_eq "tamper detected" "$OUT" "allowed" "false" | ||
| HS | ||
| sync_run_handshakes .h-limit | ||
| OUT=$($HARNESS transition --from review --to gate --verdict PASS --flow review --dir .h-limit 2>/dev/null) | ||
@@ -252,2 +256,3 @@ assert_field_eq "steps limit" "$OUT" "allowed" "false" | ||
| HS | ||
| sync_run_handshakes .h-fin | ||
| sleep 1 | ||
@@ -259,2 +264,3 @@ $HARNESS transition --from review --to gate --verdict PASS --flow review --dir .h-fin >/dev/null 2>/dev/null | ||
| HS | ||
| sync_run_handshakes .h-fin | ||
| OUT=$($HARNESS finalize --dir .h-fin) | ||
@@ -283,3 +289,6 @@ assert_field_eq "finalized" "$OUT" "finalized" "true" | ||
| d = json.load(open('.h-strict/flow-state.json')) | ||
| d['history'].append({'nodeId': 'review', 'runId': 'run_1', 'timestamp': '2024-01-01T00:00:00Z'}) | ||
| d['history'] = [ | ||
| {'nodeId': 'review', 'runId': 'run_1', 'timestamp': '2024-01-01T00:00:00.000Z'}, | ||
| {'nodeId': 'gate', 'runId': 'run_1', 'timestamp': '2024-01-01T00:01:00.000Z'}, | ||
| ] | ||
| json.dump(d, open('.h-strict/flow-state.json', 'w'), indent=2) | ||
@@ -291,5 +300,6 @@ " | ||
| HS | ||
| sync_run_handshakes .h-strict | ||
| OUT=$($HARNESS finalize --dir .h-strict --strict) | ||
| assert_field_eq "strict fails" "$OUT" "finalized" "false" | ||
| assert_contains "missing upstream handshake" "$OUT" "handshake for review is missing" | ||
| assert_contains "missing upstream handshake" "$OUT" "missing handshake for node 'review' run 'run_1'" | ||
@@ -296,0 +306,0 @@ echo "" |
@@ -9,4 +9,24 @@ #!/bin/bash | ||
| mkdir -p "$HOME/.claude/flows" | ||
| cat > "$HOME/.claude/flows/idea-factory.json" << 'FIXTURE' | ||
| FLOW_DIR="$HOME/.claude/flows" | ||
| IDEA_FLOW="$FLOW_DIR/idea-factory.json" | ||
| IDEA_BACKUP="$TMPDIR/idea-factory.json.backup" | ||
| IDEA_EXISTED=0 | ||
| mkdir -p "$FLOW_DIR" | ||
| if [ -e "$IDEA_FLOW" ] || [ -L "$IDEA_FLOW" ]; then | ||
| mv "$IDEA_FLOW" "$IDEA_BACKUP" | ||
| IDEA_EXISTED=1 | ||
| fi | ||
| cleanup() { | ||
| rm -f "$IDEA_FLOW" | ||
| if [ "$IDEA_EXISTED" -eq 1 ]; then | ||
| mv "$IDEA_BACKUP" "$IDEA_FLOW" | ||
| fi | ||
| rm -rf "$TMPDIR" | ||
| } | ||
| trap cleanup EXIT | ||
| trap 'exit 129' HUP | ||
| trap 'exit 130' INT | ||
| trap 'exit 143' TERM | ||
| cat > "$IDEA_FLOW" << 'FIXTURE' | ||
| { | ||
@@ -34,2 +54,5 @@ "nodes": ["discover", "validate", "build", "gate", "synthesize", "pitch"], | ||
| FIXTURE | ||
| if [ "${OPC_TEST_ABORT_AFTER_FLOW_FIXTURE:-0}" = "1" ]; then | ||
| exit 97 | ||
| fi | ||
@@ -173,7 +196,7 @@ jq_field() { | ||
| rm -rf .h-reentry && $HARNESS init --flow build-verify --dir .h-reentry >/dev/null 2>/dev/null | ||
| for i in 1 2 3; do | ||
| for i in 1 2 3 4 5; do | ||
| $HARNESS goto brief --dir .h-reentry >/dev/null | ||
| done | ||
| OUT=$($HARNESS goto brief --dir .h-reentry) | ||
| assert_contains "edge limit" "$OUT" "maxLoopsPerEdge" | ||
| assert_contains "node reentry limit" "$OUT" "maxNodeReentry" | ||
@@ -226,2 +249,3 @@ echo "" | ||
| HS | ||
| sync_run_handshakes ".h-trans" | ||
| sleep 1 | ||
@@ -228,0 +252,0 @@ $HARNESS transition --from brief --to build --verdict PASS --flow build-verify --dir .h-trans 2>/dev/null |
@@ -69,4 +69,2 @@ #!/bin/bash | ||
| echo "--- 1.1: No-args shows help ---" | ||
| OUT=$(node "$(cd "$(dirname "$0")/.." 2>/dev/null || echo "$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)")" 2>&1 || true) | ||
| # Use the HARNESS variable properly | ||
| OUT=$($HARNESS 2>&1 || true) | ||
@@ -207,6 +205,7 @@ assert_contains "help output" "$OUT" "opc-harness" | ||
| rm -rf .h-fin5 && $HARNESS init --flow review --entry gate --dir .h-fin5 >/dev/null 2>/dev/null | ||
| mkdir -p .h-fin5/nodes/gate | ||
| mkdir -p .h-fin5/nodes/gate/run_1 | ||
| cat > .h-fin5/nodes/gate/handshake.json << 'HS' | ||
| {"nodeId":"gate","nodeType":"gate","runId":"run_1","status":"failed","summary":"x","timestamp":"2024-01-01T00:00:00Z","artifacts":[]} | ||
| HS | ||
| cp .h-fin5/nodes/gate/handshake.json .h-fin5/nodes/gate/run_1/handshake.json | ||
| OUT=$($HARNESS finalize --dir .h-fin5 2>/dev/null) | ||
@@ -220,9 +219,10 @@ assert_field_eq "finalize bad status" "$OUT" "finalized" "false" | ||
| rm -rf .h-fin6 && $HARNESS init --flow review --entry gate --dir .h-fin6 >/dev/null 2>/dev/null | ||
| mkdir -p .h-fin6/nodes/gate | ||
| mkdir -p .h-fin6/nodes/gate/run_1 | ||
| echo "not json" > .h-fin6/nodes/gate/handshake.json | ||
| echo "not json" > .h-fin6/nodes/gate/run_1/handshake.json | ||
| OUT=$($HARNESS finalize --dir .h-fin6 2>/dev/null) | ||
| assert_field_eq "finalize corrupt hs" "$OUT" "finalized" "false" | ||
| assert_contains "corrupt hs msg" "$OUT" "cannot parse" | ||
| assert_contains "corrupt hs msg" "$OUT" "parse error" | ||
| print_results |
+46
-13
@@ -90,3 +90,3 @@ #!/bin/bash | ||
| {"nodeId":"test-execute","nodeType":"execute","runId":"run_1","status":"completed","summary":"done", | ||
| "timestamp":"2024-01-01T00:00:00Z","artifacts":["ev.txt"],"findings":{"warning":1,"critical":0}} | ||
| "timestamp":"2024-01-01T00:00:00Z","artifacts":[{"type":"cli-output","path":"ev.txt"}],"findings":{"warning":1,"critical":0}} | ||
| HS | ||
@@ -97,8 +97,19 @@ echo "evidence" > .h-bp/nodes/test-execute/ev.txt | ||
| rm -rf .h-bp2 && $HARNESS init --flow full-stack --entry gate-test --dir .h-bp2 >/dev/null 2>/dev/null | ||
| mkdir -p .h-bp2/nodes/test-execute | ||
| cat > .h-bp2/nodes/test-execute/handshake.json << 'HS' | ||
| mkdir -p .h-bp2/nodes/test-execute/run_1 | ||
| cat > .h-bp2/nodes/test-execute/run_1/handshake.json << 'HS' | ||
| {"nodeId":"test-execute","nodeType":"execute","runId":"run_1","status":"completed","summary":"done", | ||
| "timestamp":"2024-01-01T00:00:00Z","artifacts":["ev.txt"],"findings":{"warning":1,"critical":0}} | ||
| "timestamp":"2024-01-01T00:00:00Z","artifacts":[{"type":"cli-output","path":"ev.txt"}],"findings":{"warning":1,"critical":0}} | ||
| HS | ||
| echo "evidence" > .h-bp2/nodes/test-execute/ev.txt | ||
| echo "evidence" > .h-bp2/nodes/test-execute/run_1/ev.txt | ||
| python3 -c " | ||
| import json | ||
| p='.h-bp2/flow-state.json' | ||
| s=json.load(open(p)) | ||
| s['history']=[ | ||
| {'nodeId':'test-execute','runId':'run_1','timestamp':'2024-01-01T00:00:00Z'}, | ||
| {'nodeId':'gate-test','runId':'run_1','timestamp':'2024-01-01T00:00:01Z'}, | ||
| ] | ||
| s['totalSteps']=2 | ||
| json.dump(s, open(p,'w'), indent=2) | ||
| " | ||
| OUT=$($HARNESS transition --from gate-test --to acceptance --verdict PASS --flow full-stack --dir .h-bp2 2>/dev/null) | ||
@@ -111,8 +122,19 @@ assert_field_eq "PASS backlog check" "$OUT" "allowed" "false" | ||
| rm -rf .h-bp3 && $HARNESS init --flow full-stack --entry gate-test --dir .h-bp3 >/dev/null 2>/dev/null | ||
| mkdir -p .h-bp3/nodes/test-execute | ||
| cat > .h-bp3/nodes/test-execute/handshake.json << 'HS' | ||
| mkdir -p .h-bp3/nodes/test-execute/run_1 | ||
| cat > .h-bp3/nodes/test-execute/run_1/handshake.json << 'HS' | ||
| {"nodeId":"test-execute","nodeType":"execute","runId":"run_1","status":"completed","summary":"done", | ||
| "timestamp":"2024-01-01T00:00:00Z","artifacts":["ev.txt"],"findings":{"warning":1,"critical":0}} | ||
| "timestamp":"2024-01-01T00:00:00Z","artifacts":[{"type":"cli-output","path":"ev.txt"}],"findings":{"warning":1,"critical":0}} | ||
| HS | ||
| echo "evidence" > .h-bp3/nodes/test-execute/ev.txt | ||
| echo "evidence" > .h-bp3/nodes/test-execute/run_1/ev.txt | ||
| python3 -c " | ||
| import json | ||
| p='.h-bp3/flow-state.json' | ||
| s=json.load(open(p)) | ||
| s['history']=[ | ||
| {'nodeId':'test-execute','runId':'run_1','timestamp':'2024-01-01T00:00:00Z'}, | ||
| {'nodeId':'gate-test','runId':'run_1','timestamp':'2024-01-01T00:00:01Z'}, | ||
| ] | ||
| s['totalSteps']=2 | ||
| json.dump(s, open(p,'w'), indent=2) | ||
| " | ||
| # Backlog exists but no entries from test-execute | ||
@@ -171,8 +193,19 @@ cat > .h-bp3/backlog.md << 'BL' | ||
| # gate-test upstream = test-execute. Create handshake with no warnings to skip backlog check. | ||
| mkdir -p .h-esc4/nodes/test-execute | ||
| cat > .h-esc4/nodes/test-execute/handshake.json << 'HS' | ||
| mkdir -p .h-esc4/nodes/test-execute/run_1 | ||
| cat > .h-esc4/nodes/test-execute/run_1/handshake.json << 'HS' | ||
| {"nodeId":"test-execute","nodeType":"execute","runId":"run_1","status":"completed","summary":"done", | ||
| "timestamp":"2024-01-01T00:00:00Z","artifacts":["ev.txt"],"findings":{"warning":0,"critical":0}} | ||
| "timestamp":"2024-01-01T00:00:00Z","artifacts":[{"type":"cli-output","path":"ev.txt"}],"findings":{"warning":0,"critical":0}} | ||
| HS | ||
| echo "evidence" > .h-esc4/nodes/test-execute/ev.txt | ||
| echo "evidence" > .h-esc4/nodes/test-execute/run_1/ev.txt | ||
| python3 -c " | ||
| import json | ||
| p='.h-esc4/flow-state.json' | ||
| s=json.load(open(p)) | ||
| s['history']=[ | ||
| {'nodeId':'test-execute','runId':'run_1','timestamp':'2024-01-01T00:00:00.000Z'}, | ||
| {'nodeId':'gate-test','runId':'run_1','timestamp':'2024-01-01T00:01:00.000Z'}, | ||
| ] | ||
| s['totalSteps']=1 | ||
| json.dump(s, open(p,'w'), indent=2) | ||
| " | ||
| OUT=$($HARNESS pass --dir .h-esc4 2>/dev/null) | ||
@@ -179,0 +212,0 @@ assert_field_eq "pass gate→acceptance" "$OUT" "allowed" "true" |
@@ -86,4 +86,5 @@ #!/bin/bash | ||
| rm -rf .h-vc2 && $HARNESS init --flow build-verify --dir .h-vc2 >/dev/null 2>/dev/null | ||
| mkdir -p .h-vc2/nodes/build | ||
| mkdir -p .h-vc2/nodes/build/run_1 | ||
| echo "not json" > .h-vc2/nodes/build/handshake.json | ||
| echo "not json" > .h-vc2/nodes/build/run_1/handshake.json | ||
| # Add history so validator checks build's handshake | ||
@@ -90,0 +91,0 @@ python3 -c " |
+12
-23
@@ -143,2 +143,3 @@ #!/bin/bash | ||
| EOF | ||
| sync_run_handshakes . | ||
| # Validate should produce warning (softEvidence) not error | ||
@@ -174,5 +175,5 @@ OUT=$($HARNESS validate nodes/exec-node/handshake.json 2>&1) | ||
| cd "$D5" | ||
| # Should fall back to strict (soft=false) → produce error not warning | ||
| # State-backed validation fails closed when session authority is corrupt. | ||
| OUT=$($HARNESS validate nodes/exec-node/handshake.json 2>/dev/null) | ||
| assert_contains "$OUT" "executor node missing evidence" "corrupt state → strict mode → error" | ||
| assert_contains "$OUT" "cannot parse flow-state.json" "corrupt state → authority error" | ||
| rm -rf "$D5" | ||
@@ -212,28 +213,16 @@ cd /tmp | ||
| # ───────────────────────────────────────────────────────────────── | ||
| # GAP2-7: transition without prior flow-state.json → fresh state | ||
| # GAP2-7: transition without prior flow-state.json fails closed | ||
| # ───────────────────────────────────────────────────────────────── | ||
| echo "" | ||
| echo "── GAP2-7: transition creates fresh state when no flow-state.json" | ||
| echo "── GAP2-7: transition without init fails closed" | ||
| D7=$(mktemp -d) | ||
| mkdir -p "$D7/nodes/build" | ||
| # Write handshake for 'build' so pre-transition check passes | ||
| cat > "$D7/nodes/build/handshake.json" << 'EOF' | ||
| { | ||
| "nodeId": "build", | ||
| "nodeType": "build", | ||
| "runId": "run_1", | ||
| "status": "completed", | ||
| "summary": "built", | ||
| "timestamp": "2024-01-01T00:00:00Z", | ||
| "artifacts": [], | ||
| "verdict": null | ||
| } | ||
| EOF | ||
| cd "$D7" | ||
| # Transition without prior init — should create state | ||
| OUT=$($HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir . 2>/dev/null) | ||
| assert_field_eq "$OUT" "['allowed']" "True" "transition without init creates fresh state" | ||
| # Verify state was created | ||
| test -f flow-state.json | ||
| assert_contains "$(cat flow-state.json)" "code-review" "fresh state has correct currentNode" | ||
| assert_field_eq "$OUT" "['allowed']" "False" "transition without init is rejected" | ||
| assert_contains "$OUT" "flow-state.json" "missing state diagnostic emitted" | ||
| if [ ! -e flow-state.json ] && [ ! -e nodes ]; then | ||
| echo "✅ rejected transition creates no state or node artifacts"; PASS=$((PASS+1)) | ||
| else | ||
| echo "❌ rejected transition left state or node artifacts"; FAIL=$((FAIL+1)) | ||
| fi | ||
| rm -rf "$D7" | ||
@@ -240,0 +229,0 @@ cd /tmp |
+56
-22
@@ -82,2 +82,3 @@ #!/bin/bash | ||
| EOF | ||
| sync_run_handshakes . | ||
| OUT=$($HARNESS transition --from build --to gate-check --verdict PASS --flow test-no-types --dir . 2>/dev/null) | ||
@@ -99,2 +100,13 @@ assert_field_eq "$OUT" "['allowed']" "True" "transition from build to gate-check" | ||
| D9=$(mktemp -d) | ||
| mkdir -p "$HOME/.claude/flows" | ||
| cat > "$HOME/.claude/flows/test-soft-ev.json" << 'EOF' | ||
| { | ||
| "nodes": ["exec-node", "gate"], | ||
| "edges": {"exec-node": {"PASS": "gate"}, "gate": {"PASS": null}}, | ||
| "limits": {"maxLoopsPerEdge": 3, "maxTotalSteps": 10, "maxNodeReentry": 5}, | ||
| "nodeTypes": {"exec-node": "execute", "gate": "gate"}, | ||
| "softEvidence": true, | ||
| "opc_compat": ">=0.5" | ||
| } | ||
| EOF | ||
| cd "$D9" | ||
@@ -117,2 +129,3 @@ $HARNESS init --flow test-soft-ev --dir . > /dev/null 2>&1 | ||
| EOF | ||
| sync_run_handshakes . | ||
| # Transition should succeed (softEvidence → warning not error) | ||
@@ -139,7 +152,29 @@ OUT=$($HARNESS transition --from exec-node --to gate --verdict PASS --flow test-soft-ev --dir . 2>&1) | ||
| # Advance to gate node with proper handshakes | ||
| mkdir -p nodes/build nodes/code-review nodes/test-execute | ||
| for n in build code-review test-execute; do | ||
| mkdir -p nodes/build/run_1 nodes/code-review/run_1 nodes/test-design/run_1 nodes/test-execute/run_1 | ||
| for n in build code-review test-design test-execute; do | ||
| nt="build" | ||
| [ "$n" = "code-review" ] && nt="review" | ||
| [ "$n" = "test-design" ] && nt="review" | ||
| [ "$n" = "test-execute" ] && nt="execute" | ||
| artifacts="[]" | ||
| if [ "$nt" = "review" ]; then | ||
| echo "# A" > "nodes/$n/run_1/eval-a.md" | ||
| echo "# B" > "nodes/$n/run_1/eval-b.md" | ||
| artifacts='[{"type":"eval","path":"run_1/eval-a.md"},{"type":"eval","path":"run_1/eval-b.md"}]' | ||
| fi | ||
| if [ "$nt" = "execute" ]; then | ||
| echo "ok" > "nodes/$n/run_1/cli-output.txt" | ||
| artifacts='[{"type":"cli-output","path":"run_1/cli-output.txt"}]' | ||
| fi | ||
| cat > "nodes/$n/handshake.json" << EOF | ||
| {"nodeId":"$n","nodeType":"build","runId":"run_1","status":"completed","summary":"ok","timestamp":"2024-01-01T00:00:00Z","artifacts":[],"verdict":null} | ||
| {"nodeId":"$n","nodeType":"$nt","runId":"run_1","status":"completed","summary":"ok","timestamp":"2024-01-01T00:00:00Z","artifacts":$artifacts,"verdict":null} | ||
| EOF | ||
| python3 - "nodes/$n/handshake.json" "nodes/$n/run_1/handshake.json" <<'PY' | ||
| import json, sys | ||
| d=json.load(open(sys.argv[1])) | ||
| for a in d["artifacts"]: | ||
| if a["path"].startswith("run_1/"): | ||
| a["path"]=a["path"][len("run_1/"):] | ||
| json.dump(d, open(sys.argv[2], "w")) | ||
| PY | ||
| done | ||
@@ -158,2 +193,3 @@ # Manually advance state to gate | ||
| echo "NOT JSON AT ALL" > nodes/test-execute/handshake.json | ||
| echo "NOT JSON AT ALL" > nodes/test-execute/run_1/handshake.json | ||
| # Try gate ITERATE transition — should detect corrupt upstream during backlog check | ||
@@ -245,3 +281,3 @@ OUT=$($HARNESS transition --from gate --to brief --verdict ITERATE --flow build-verify --dir . 2>/dev/null) | ||
| # ───────────────────────────────────────────────────────────────── | ||
| # GAP2-15: cmdVerify — non-ENOENT read error | ||
| # GAP2-15: cmdVerify — deterministic non-ENOENT read error | ||
| # ───────────────────────────────────────────────────────────────── | ||
@@ -251,32 +287,30 @@ echo "" | ||
| D15=$(mktemp -d) | ||
| mkdir "$D15/unreadable" | ||
| chmod 000 "$D15/unreadable" 2>/dev/null || true | ||
| # Try to read a file inside an unreadable directory | ||
| if ! $HARNESS verify "$D15/unreadable/eval.md" > /dev/null 2>&1; then | ||
| echo "✅ verify exits non-zero on permission error"; PASS=$((PASS+1)) | ||
| mkdir "$D15/not-a-file" | ||
| set +e | ||
| OUT=$($HARNESS verify "$D15/not-a-file" 2>&1) | ||
| RC=$? | ||
| set -e | ||
| if [ "$RC" -ne 0 ] && echo "$OUT" | grep -q "Cannot read"; then | ||
| echo "✅ verify rejects a directory with a non-ENOENT diagnostic"; PASS=$((PASS+1)) | ||
| else | ||
| # chmod may not work on this platform (root, container, macOS quirk) | ||
| echo "⏭️ verify handles unreadable (chmod not enforced on this OS — skip)"; PASS=$((PASS+1)) # platform-dependent skip | ||
| echo "❌ verify expected non-zero with non-ENOENT diagnostic, got rc=$RC: $OUT"; FAIL=$((FAIL+1)) | ||
| fi | ||
| chmod 755 "$D15/unreadable" 2>/dev/null || true | ||
| rm -rf "$D15" | ||
| # ───────────────────────────────────────────────────────────────── | ||
| # GAP2-16: cmdSynthesize — unreadable node dir (catch) | ||
| # GAP2-16: cmdSynthesize — deterministic unreadable node path (catch) | ||
| # ───────────────────────────────────────────────────────────────── | ||
| echo "" | ||
| echo "── GAP2-16: synthesize unreadable node dir" | ||
| echo "── GAP2-16: synthesize unreadable node path" | ||
| D16=$(mktemp -d) | ||
| mkdir -p "$D16/nodes/broken-node" | ||
| # Make node dir unreadable | ||
| chmod 000 "$D16/nodes/broken-node" 2>/dev/null || true | ||
| if ! $HARNESS synthesize "$D16" --node broken-node 2>/dev/null; then | ||
| echo "✅ synthesize exits non-zero for unreadable node dir"; PASS=$((PASS+1)) | ||
| mkdir -p "$D16/nodes" | ||
| printf 'not a directory\n' > "$D16/nodes/broken-node" | ||
| OUT=$($HARNESS synthesize "$D16" --node broken-node 2>/dev/null) | ||
| if echo "$OUT" | grep -q '"verdict":"BLOCKED"' && echo "$OUT" | grep -q "cannot read node dir"; then | ||
| echo "✅ synthesize reports BLOCKED for an unreadable node path"; PASS=$((PASS+1)) | ||
| else | ||
| # chmod may not work on this platform (root, container, macOS quirk) | ||
| echo "⏭️ synthesize handles unreadable node dir (chmod not enforced — skip)"; PASS=$((PASS+1)) # platform-dependent skip | ||
| echo "❌ synthesize expected BLOCKED read diagnostic, got: $OUT"; FAIL=$((FAIL+1)) | ||
| fi | ||
| chmod 755 "$D16/nodes/broken-node" 2>/dev/null || true | ||
| rm -rf "$D16" | ||
| print_results |
@@ -92,2 +92,3 @@ #!/bin/bash | ||
| EOF | ||
| sync_run_handshakes . | ||
| $HARNESS transition --from code-review --to test-design --verdict PASS --flow build-verify --dir . > /dev/null 2>&1 | ||
@@ -94,0 +95,0 @@ # Now viz should show entryNode code-review as ✅ (not ▶) |
@@ -148,3 +148,3 @@ #!/usr/bin/env bash | ||
| # Manually build state at gate with proper history | ||
| mkdir -p nodes/build nodes/code-review nodes/test-execute | ||
| mkdir -p nodes/build/run_1 nodes/code-review/run_1 nodes/test-execute/run_1 | ||
| # build handshake with warnings (triggers backlog check) | ||
@@ -154,9 +154,19 @@ cat > nodes/build/handshake.json << 'EOF' | ||
| EOF | ||
| cp nodes/build/handshake.json nodes/build/run_1/handshake.json | ||
| echo "# A" > nodes/code-review/run_1/eval-a.md | ||
| echo "# B" > nodes/code-review/run_1/eval-b.md | ||
| 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":[],"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-a.md"},{"type":"eval","path":"run_1/eval-b.md"}],"verdict":null} | ||
| EOF | ||
| cat > nodes/code-review/run_1/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":"eval-a.md"},{"type":"eval","path":"eval-b.md"}],"verdict":null} | ||
| EOF | ||
| # test-execute handshake is the upstream of gate — make it have warnings then corrupt it | ||
| echo "ok" > nodes/test-execute/run_1/cli-output.txt | ||
| cat > nodes/test-execute/handshake.json << 'EOF' | ||
| {"nodeId":"test-execute","nodeType":"execute","runId":"run_1","status":"completed","summary":"ok","timestamp":"2024-01-01T00:00:00Z","artifacts":[],"verdict":null,"findings":{"warning":2}} | ||
| {"nodeId":"test-execute","nodeType":"execute","runId":"run_1","status":"completed","summary":"ok","timestamp":"2024-01-01T00:00:00Z","artifacts":[{"type":"cli-output","path":"run_1/cli-output.txt"}],"verdict":null,"findings":{"warning":2}} | ||
| EOF | ||
| cat > nodes/test-execute/run_1/handshake.json << 'EOF' | ||
| {"nodeId":"test-execute","nodeType":"execute","runId":"run_1","status":"completed","summary":"ok","timestamp":"2024-01-01T00:00:00Z","artifacts":[{"type":"cli-output","path":"cli-output.txt"}],"verdict":null,"findings":{"warning":2}} | ||
| EOF | ||
| # Advance state to gate | ||
@@ -179,2 +189,3 @@ python3 -c " | ||
| echo "CORRUPT JSON {{{{" > nodes/test-execute/handshake.json | ||
| echo "CORRUPT JSON {{{{" > nodes/test-execute/run_1/handshake.json | ||
| # ITERATE from gate triggers backlog check on upstream test-execute | ||
@@ -221,3 +232,3 @@ OUT=$($HARNESS transition --from gate --to brief --verdict ITERATE --flow build-verify --dir . 2>/dev/null) | ||
| assert_field_eq "$OUT" "['allowed']" "False" "missing upstream handshake blocks gate transition" | ||
| assert_contains "$OUT" "handshake for test-execute is missing" "missing upstream handshake reported" | ||
| assert_contains "$OUT" "missing handshake for node 'test-execute' run 'run_1'" "missing upstream handshake reported" | ||
| rm -rf "$D" | ||
@@ -224,0 +235,0 @@ cd /tmp |
@@ -192,2 +192,3 @@ #!/usr/bin/env bash | ||
| HS | ||
| sync_run_handshakes . | ||
| $HARNESS transition --from review --to gate --verdict PASS --flow review --dir . > /dev/null 2>&1 | ||
@@ -194,0 +195,0 @@ OUT=$($HARNESS viz --flow review --dir . 2>/dev/null) |
@@ -100,4 +100,6 @@ #!/usr/bin/env bash | ||
| echo "── 8.1: viz with FAIL+ITERATE edges shows FAIL in ASCII" | ||
| OUT=$($HARNESS viz --flow build-verify 2>/dev/null) | ||
| VIZ_DIR=$(mktemp -d) | ||
| OUT=$($HARNESS viz --flow build-verify --dir "$VIZ_DIR" 2>/dev/null) | ||
| assert_contains "$OUT" "FAIL" "8.1a: viz ASCII shows FAIL edge for gate" | ||
| rm -rf "$VIZ_DIR" | ||
@@ -124,2 +126,3 @@ echo "" | ||
| EOF | ||
| sync_run_handshakes . | ||
| sleep 2 | ||
@@ -140,13 +143,18 @@ STDERR_FILE=$(mktemp) | ||
| echo "" | ||
| echo "── 9.1: validate-chain skips missing handshake for currentNode" | ||
| echo "── 9.1: validate-chain rejects missing currentNode handshake" | ||
| D=$(mktemp -d) | ||
| cd "$D" | ||
| $HARNESS init --flow build-verify --entry build --dir . > /dev/null 2>&1 | ||
| mkdir -p nodes/build | ||
| cat > nodes/build/handshake.json << 'EOF' | ||
| {"nodeId":"build","nodeType":"build","status":"completed","summary":"done","timestamp":"2024-01-01T00:00:00Z"} | ||
| mkdir -p nodes/build/run_1 | ||
| cat > nodes/build/run_1/handshake.json << 'EOF' | ||
| {"nodeId":"build","nodeType":"build","runId":"run_1","status":"completed","summary":"done","timestamp":"2024-01-01T00:00:00Z","artifacts":[]} | ||
| EOF | ||
| cp nodes/build/run_1/handshake.json nodes/build/handshake.json | ||
| cat > nodes/build/run_1/build.log << 'EOF' | ||
| build complete | ||
| EOF | ||
| $HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir . > /dev/null 2>&1 | ||
| OUT=$($HARNESS validate-chain --dir . 2>/dev/null) | ||
| assert_field_eq "$OUT" "['valid']" "True" "9.1a: currentNode without handshake is not an error" | ||
| assert_field_eq "$OUT" "['valid']" "False" "9.1a: currentNode without handshake fails closed" | ||
| assert_contains "$OUT" "code-review/run_1: .*status missing or invalid" "9.1b: error names exact selected run" | ||
| cd "$ORIG_DIR" | ||
@@ -153,0 +161,0 @@ rm -rf "$D" |
@@ -131,5 +131,7 @@ #!/usr/bin/env bash | ||
| echo "── 17.1: viz --json includes FAIL and ITERATE loopbacks" | ||
| OUT=$($HARNESS viz --flow build-verify --json 2>/dev/null) | ||
| VIZ_DIR=$(mktemp -d) | ||
| OUT=$($HARNESS viz --flow build-verify --dir "$VIZ_DIR" --json 2>/dev/null) | ||
| assert_contains "$OUT" '"FAIL"' "17.1a: viz --json has FAIL loopback" | ||
| assert_contains "$OUT" '"ITERATE"' "17.1b: viz --json has ITERATE loopback" | ||
| rm -rf "$VIZ_DIR" | ||
@@ -136,0 +138,0 @@ # ═══════════════════════════════════════════════════════════════════ |
+29
-23
@@ -41,36 +41,30 @@ #!/usr/bin/env bash | ||
| # ═══════════════════════════════════════════════════════════════════ | ||
| echo "=== PART 1: 🔴 HIGH — transition without init (fresh state creation) ===" | ||
| # flow-transition.mjs:73-86 — else branch when flow-state.json doesn't exist | ||
| echo "=== PART 1: 🔴 HIGH — transition without init fails closed ===" | ||
| # A transition may only advance initialized, validated state. | ||
| # ═══════════════════════════════════════════════════════════════════ | ||
| echo "" | ||
| echo "── 1.1: transition from gate without init creates fresh state" | ||
| # Gates skip pre-transition handshake check, so this path is reachable | ||
| echo "── 1.1: gate transition without init is rejected without mutation" | ||
| D=$(mktemp -d) | ||
| cd "$D" | ||
| mkdir -p nodes | ||
| # No init! Direct transition from gate node | ||
| OUT=$($HARNESS transition --from gate --to brief --verdict FAIL --flow build-verify --dir . 2>/dev/null) | ||
| assert_field_eq "$OUT" "['allowed']" "True" "1.1a: transition without init succeeds (fresh state created)" | ||
| assert_field_eq "$OUT" "['next']" "brief" "1.1b: next node is brief" | ||
| # Verify state was created with correct structure | ||
| assert_contains "$(cat flow-state.json)" '"version": "1.0"' "1.1c: fresh state has version" | ||
| assert_contains "$(cat flow-state.json)" '"flowTemplate": "build-verify"' "1.1d: fresh state has correct flow" | ||
| assert_contains "$(cat flow-state.json)" '"entryNode": "brief"' "1.1e: fresh state entryNode = first template node" | ||
| assert_contains "$(cat flow-state.json)" '"maxTotalSteps": 25' "1.1f: fresh state has limits from template" | ||
| assert_field_eq "$OUT" "['allowed']" "False" "1.1a: gate transition without init is blocked" | ||
| assert_contains "$OUT" "flow-state.json" "1.1b: error identifies missing state" | ||
| if [ ! -e "flow-state.json" ] && [ ! -e "nodes" ]; then | ||
| echo " ✅ 1.1c: rejection leaves no state or node artifacts"; PASS=$((PASS+1)) | ||
| else | ||
| echo " ❌ 1.1c: rejection mutated the harness directory"; FAIL=$((FAIL+1)) | ||
| fi | ||
| echo "" | ||
| echo "── 1.2: transition without init — non-gate node blocked by handshake check" | ||
| echo "── 1.2: non-gate transition without init is rejected without mutation" | ||
| D2=$(mktemp -d) | ||
| cd "$D2" | ||
| mkdir -p nodes | ||
| OUT=$($HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir . 2>/dev/null) | ||
| assert_field_eq "$OUT" "['allowed']" "False" "1.2a: non-gate transition without init blocked" | ||
| assert_contains "$OUT" "handshake.json missing" "1.2b: blocked by pre-transition handshake check" | ||
| # Fresh state path (L73-86) IS exercised: mkdirSync creates nodes/ dir even though | ||
| # the function returns before writing flow-state.json to disk. | ||
| if [ -d "nodes" ]; then | ||
| echo " ✅ 1.2c: fresh state path exercised (nodes/ dir created at L74)"; PASS=$((PASS+1)) | ||
| assert_field_eq "$OUT" "['allowed']" "False" "1.2a: non-gate transition without init is blocked" | ||
| assert_contains "$OUT" "flow-state.json" "1.2b: error identifies missing state" | ||
| if [ ! -e "flow-state.json" ] && [ ! -e "nodes" ]; then | ||
| echo " ✅ 1.2c: rejection leaves no state or node artifacts"; PASS=$((PASS+1)) | ||
| else | ||
| echo " ❌ 1.2c: nodes/ dir not created — fresh state path not exercised"; FAIL=$((FAIL+1)) | ||
| echo " ❌ 1.2c: rejection mutated the harness directory"; FAIL=$((FAIL+1)) | ||
| fi | ||
@@ -140,4 +134,16 @@ cd "$ORIG_DIR" | ||
| # Write corrupt handshake for test-execute (upstream of gate) | ||
| mkdir -p nodes/test-execute | ||
| mkdir -p nodes/test-execute/run_1 | ||
| echo "NOT VALID JSON {{{" > nodes/test-execute/handshake.json | ||
| echo "NOT VALID JSON {{{" > nodes/test-execute/run_1/handshake.json | ||
| python3 - <<'PY' | ||
| import json | ||
| p = "flow-state.json" | ||
| s = json.load(open(p)) | ||
| s["history"] = [ | ||
| {"nodeId": "test-execute", "runId": "run_1", "timestamp": "2024-01-01T00:00:00.000Z"}, | ||
| {"nodeId": "gate", "runId": "run_1", "timestamp": "2024-01-01T00:00:01.000Z"}, | ||
| ] | ||
| s["totalSteps"] = 2 | ||
| json.dump(s, open(p, "w"), indent=2) | ||
| PY | ||
| # Try to transition gate → brief (ITERATE) | ||
@@ -144,0 +150,0 @@ # Wait for idempotency window |
@@ -161,6 +161,7 @@ #!/usr/bin/env bash | ||
| $HARNESS init --flow review --entry gate --dir . > /dev/null 2>&1 | ||
| mkdir -p nodes/gate | ||
| mkdir -p nodes/gate/run_1 | ||
| cat > nodes/gate/handshake.json << 'EOF' | ||
| {"nodeId":"gate","nodeType":"gate","runId":"run_1","status":"in_progress","summary":"not done yet","timestamp":"2024-01-01T00:00:00Z","artifacts":[]} | ||
| EOF | ||
| cp nodes/gate/handshake.json nodes/gate/run_1/handshake.json | ||
| OUT=$($HARNESS finalize --dir . 2>/dev/null) | ||
@@ -167,0 +168,0 @@ assert_contains "$OUT" "in_progress" "13.1a: finalize rejects non-completed status" |
@@ -18,3 +18,6 @@ #!/bin/bash | ||
| data = json.load(open(path)) | ||
| data["history"] = [{"nodeId": "test-execute", "runId": "run_1", "timestamp": "2026-01-01T00:00:00.000Z"}] | ||
| data["history"] = [ | ||
| {"nodeId": "test-execute", "runId": "run_1", "timestamp": "2026-01-01T00:00:00.000Z"}, | ||
| {"nodeId": "gate", "runId": "run_1", "timestamp": "2026-01-01T00:01:00.000Z"}, | ||
| ] | ||
| data["currentNode"] = "gate" | ||
@@ -34,2 +37,3 @@ json.dump(data, open(path, "w"), indent=2) | ||
| {"nodeId": "test-execute", "runId": "run_2", "timestamp": "2026-01-01T00:01:00.000Z"}, | ||
| {"nodeId": "gate", "runId": "run_1", "timestamp": "2026-01-01T00:02:00.000Z"}, | ||
| ] | ||
@@ -43,5 +47,6 @@ data["currentNode"] = "gate" | ||
| local dir="$1" run_id="$2" | ||
| python3 - "$dir/nodes/test-execute/handshake.json" "$run_id" <<'PY' | ||
| echo "ok" > "$dir/nodes/test-execute/$run_id/cli-output.txt" | ||
| python3 - "$dir/nodes/test-execute/handshake.json" "$dir/nodes/test-execute/$run_id/handshake.json" "$run_id" <<'PY' | ||
| import json, sys | ||
| path, run_id = sys.argv[1], sys.argv[2] | ||
| canonical_path, run_path, run_id = sys.argv[1], sys.argv[2], sys.argv[3] | ||
| data = { | ||
@@ -55,5 +60,14 @@ "nodeId": "test-execute", | ||
| "timestamp": "2026-01-01T00:00:00.000Z", | ||
| "artifacts": [{"type": "report", "path": f"{run_id}/report.json"}] | ||
| "artifacts": [ | ||
| {"type": "report", "path": f"{run_id}/report.json"}, | ||
| {"type": "cli-output", "path": f"{run_id}/cli-output.txt"} | ||
| ] | ||
| } | ||
| open(path, "w").write(json.dumps(data)) | ||
| open(canonical_path, "w").write(json.dumps(data)) | ||
| run_data = dict(data) | ||
| run_data["artifacts"] = [ | ||
| {"type": "report", "path": "report.json"}, | ||
| {"type": "cli-output", "path": "cli-output.txt"} | ||
| ] | ||
| open(run_path, "w").write(json.dumps(run_data)) | ||
| PY | ||
@@ -60,0 +74,0 @@ } |
| #!/usr/bin/env bash | ||
| set -euo pipefail | ||
| # Test: goto maxLoopsPerEdge enforcement | ||
| # Test: goto audit counts and maxNodeReentry enforcement | ||
@@ -45,3 +45,3 @@ SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" | ||
| echo "=== TEST GROUP 1: goto respects maxLoopsPerEdge ===" | ||
| echo "=== TEST GROUP 1: goto ignores repair-edge limits but respects node reentry ===" | ||
@@ -54,16 +54,12 @@ setup_flow "run1" | ||
| R2=$(H goto build --dir run1) | ||
| check "goto back to build succeeds" 'echo "$R2" | grep -q "\"goto\":\"build\""' | ||
| R2=$(H goto code-review --dir run1) | ||
| check "first same-edge goto succeeds" 'echo "$R2" | grep -q "\"goto\":\"code-review\""' | ||
| 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 | ||
| R4=$(H goto code-review --dir run1) | ||
| R5=$(H goto code-review --dir run1) | ||
| check "third goto code-review succeeds" 'echo "$R5" | grep -q "\"goto\":\"code-review\""' | ||
| check "4th same-edge goto still 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"' | ||
| R6=$(H goto code-review --dir run1) | ||
| check "6th code-review entry is blocked" 'echo "$R6" | grep -q "maxNodeReentry"' | ||
| check "manual edge traversals remain audited" 'grep -q "code-review→code-review" "$TMPD/run1/flow-state.json"' | ||
@@ -70,0 +66,0 @@ echo "" |
@@ -64,2 +64,6 @@ #!/bin/bash | ||
| local artifacts="[]" | ||
| local extra="" | ||
| if [ "$node" = "test-design" ]; then | ||
| extra=', "testCommand": "printf ok"' | ||
| fi | ||
| if [ -n "$run_dir" ]; then | ||
@@ -82,5 +86,6 @@ artifacts=$(ls "$run_dir"/eval-*.md 2>/dev/null | python3 -c " | ||
| "artifacts": $artifacts, | ||
| "findings": null | ||
| "findings": null$extra | ||
| } | ||
| EOF | ||
| sync_run_handshakes "$dir" | ||
| } | ||
@@ -120,2 +125,3 @@ | ||
| EOF | ||
| sync_run_handshakes .harness | ||
| $HARNESS transition --from test-execute --to gate-test --verdict PASS --flow full-stack --dir .harness 2>/dev/null | ||
@@ -253,3 +259,3 @@ # Now at gate-test. Write upstream review evals to test-design for synthesize | ||
| write_good_eval .harness test-design engineer | ||
| write_complete_test_plan .harness/nodes/test-design/test-plan.md | ||
| write_complete_test_plan .harness/nodes/test-design/run_1/test-plan.md | ||
| write_handshake .harness test-design "Test design done" "PASS" | ||
@@ -321,2 +327,3 @@ TRANS=$($HARNESS transition --from test-design --to test-execute --verdict PASS --flow build-verify --dir .harness 2>/dev/null) | ||
| EOF | ||
| sync_run_handshakes .harness | ||
| TRANS=$($HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>/dev/null) | ||
@@ -323,0 +330,0 @@ if echo "$TRANS" | grep -q "no eval-type artifacts\|eval artifacts"; then |
+60
-2
@@ -7,3 +7,7 @@ #!/bin/bash | ||
| # ── Repo-relative harness path (portable, no hardcoded install path) ── | ||
| HARNESS="node $(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/bin/opc-harness.mjs" | ||
| OPC_HARNESS_BIN="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)/bin/opc-harness.mjs" | ||
| opc_harness() { | ||
| node "$OPC_HARNESS_BIN" "$@" | ||
| } | ||
| HARNESS=opc_harness | ||
@@ -17,3 +21,13 @@ # ── Counters ── | ||
| TMPDIR=$(mktemp -d) | ||
| trap "rm -rf $TMPDIR" EXIT | ||
| if [ -n "${OPC_TEST_HOME_OVERRIDE:-}" ]; then | ||
| export HOME="$OPC_TEST_HOME_OVERRIDE" | ||
| else | ||
| export HOME="$TMPDIR/home" | ||
| fi | ||
| mkdir -p "$HOME" | ||
| if [ -n "${OPC_TEST_HOME_PROBE:-}" ]; then | ||
| printf '%s\n' "$HOME" > "$OPC_TEST_HOME_PROBE" | ||
| fi | ||
| trap 'rm -rf "$TMPDIR"' EXIT | ||
| trap 'exit 130' HUP INT TERM | ||
| cd "$TMPDIR" | ||
@@ -99,1 +113,45 @@ } | ||
| } | ||
| sync_run_handshakes() { | ||
| local dir="$1" | ||
| python3 - "$dir" <<'PY' | ||
| import json | ||
| import pathlib | ||
| import sys | ||
| root = pathlib.Path(sys.argv[1]) | ||
| nodes = root / "nodes" | ||
| if not nodes.exists(): | ||
| raise SystemExit(0) | ||
| for canonical in nodes.glob("*/handshake.json"): | ||
| try: | ||
| data = json.loads(canonical.read_text()) | ||
| except Exception: | ||
| continue | ||
| run_id = data.get("runId") | ||
| if not isinstance(run_id, str) or not run_id.startswith("run_"): | ||
| continue | ||
| run_data = dict(data) | ||
| run_dir = canonical.parent / run_id | ||
| artifacts = [] | ||
| for artifact in data.get("artifacts") or []: | ||
| if not isinstance(artifact, dict): | ||
| artifacts.append(artifact) | ||
| continue | ||
| copied = dict(artifact) | ||
| path = copied.get("path") | ||
| prefix = f"{run_id}/" | ||
| if isinstance(path, str) and path.startswith(prefix): | ||
| copied["path"] = path[len(prefix):] | ||
| elif isinstance(path, str) and not pathlib.Path(path).is_absolute(): | ||
| node_relative = canonical.parent / path | ||
| run_relative = run_dir / path | ||
| if node_relative.exists() and not run_relative.exists(): | ||
| copied["path"] = f"../{path}" | ||
| artifacts.append(copied) | ||
| run_data["artifacts"] = artifacts | ||
| run_dir.mkdir(parents=True, exist_ok=True) | ||
| (run_dir / "handshake.json").write_text(json.dumps(run_data, indent=2) + "\n") | ||
| PY | ||
| } |
@@ -52,2 +52,17 @@ #!/bin/bash | ||
| MD | ||
| mkdir -p "$HARNESS/nodes/build/run_1" | ||
| cat > "$HARNESS/flow-state.json" <<EOF | ||
| { | ||
| "version": "1.0", | ||
| "flowTemplate": "flow", | ||
| "currentNode": "build", | ||
| "entryNode": "build", | ||
| "tier": "functional", | ||
| "flowStartedAt": "2026-01-01T00:00:00.000Z", | ||
| "totalSteps": 0, | ||
| "history": [], | ||
| "edgeCounts": {}, | ||
| "_flow_file": "$FLOW_FILE" | ||
| } | ||
| EOF | ||
@@ -54,0 +69,0 @@ HARNESS_BIN="node $REPO_ROOT/bin/opc-harness.mjs" |
@@ -85,6 +85,11 @@ #!/bin/bash | ||
| { | ||
| "version": "1.0", | ||
| "currentNode": "build", | ||
| "flow": "preflight-test", | ||
| "entryNode": "build", | ||
| "flowTemplate": "preflight-test", | ||
| "tier": "functional", | ||
| "flowStartedAt": "2026-01-01T00:00:00.000Z", | ||
| "_flow_file": "$FLOW_FILE", | ||
| "totalSteps": 0, | ||
| "history": [], | ||
| "edgeCounts": {}, | ||
@@ -244,6 +249,11 @@ "reentryCount": {} | ||
| { | ||
| "currentNode": "build", | ||
| "flow": "noop-flow", | ||
| "version": "1.0", | ||
| "currentNode": "code-review", | ||
| "entryNode": "code-review", | ||
| "flowTemplate": "noop-flow", | ||
| "tier": "functional", | ||
| "flowStartedAt": "2026-01-01T00:00:00.000Z", | ||
| "_flow_file": "$NOOP_FLOW", | ||
| "totalSteps": 0, | ||
| "history": [], | ||
| "edgeCounts": {}, | ||
@@ -253,2 +263,3 @@ "reentryCount": {} | ||
| EOF | ||
| mkdir -p "$NOOP_HARNESS/nodes/code-review/run_1" | ||
| NOOP_OUT=$(OPC_BREAKER_STATE=disabled $HARNESS_BIN node-preflight --node code-review --dir "$NOOP_HARNESS" --flow-file "$NOOP_FLOW" 2>/dev/null) | ||
@@ -255,0 +266,0 @@ if echo "$NOOP_OUT" | grep -q '"preflightResults":0'; then |
@@ -29,7 +29,8 @@ #!/bin/bash | ||
| write_quick_build() { | ||
| mkdir -p .harness/nodes/build | ||
| mkdir -p .harness/nodes/build/run_1 | ||
| cat > .harness/nodes/build/handshake.json <<'EOF' | ||
| {"nodeId":"build","nodeType":"build","runId":"run_1","status":"completed","verdict":"PASS","summary":"built","timestamp":"2026-01-01T00:01:00.000Z","artifacts":[{"type":"code","path":"x"}]} | ||
| {"nodeId":"build","nodeType":"build","runId":"run_1","status":"completed","verdict":"PASS","summary":"built","timestamp":"2026-01-01T00:01:00.000Z","artifacts":[{"type":"code","path":"run_1/x"}]} | ||
| EOF | ||
| touch .harness/nodes/build/x | ||
| touch .harness/nodes/build/run_1/x | ||
| sync_run_handshakes .harness | ||
| } | ||
@@ -44,2 +45,3 @@ | ||
| EOF | ||
| sync_run_handshakes .harness | ||
| } | ||
@@ -55,2 +57,3 @@ | ||
| EOF | ||
| sync_run_handshakes .harness | ||
| } | ||
@@ -57,0 +60,0 @@ |
@@ -24,9 +24,10 @@ #!/usr/bin/env bash | ||
| mkdir -p "$dir/nodes/review/run_1" | ||
| printf '# Reviewer A\n\n[WARNING] src/app.js:12 — Report hides warning finding\n→ Render it in the final report\nReasoning: The warning must stay visible after recovery.\nVERDICT: ITERATE FINDINGS[1]\n' > "$dir/nodes/review/run_1/eval-a.md" | ||
| printf '# Reviewer A\n\n[SUGGESTION] src/app.js:12 — Report hides warning finding\n→ Render it in the final report\nReasoning: The warning must stay visible after recovery.\nVERDICT: PASS FINDINGS[1]\n' > "$dir/nodes/review/run_1/eval-a.md" | ||
| printf '# Reviewer B\n\n[SUGGESTION] src/app.js:18 — Add recovery context\n→ Include cumulative findings in prompt context\nReasoning: Compaction needs prior findings.\nVERDICT: PASS FINDINGS[1]\n' > "$dir/nodes/review/run_1/eval-b.md" | ||
| printf '# Legacy Review\n\n## Finding 1 — Real structured issue title\n**Severity**: 🔴\n**Location**: src/legacy.js:7\n**R2 Status**: ⚠️\n\nVERDICT: FAIL FINDINGS[1]\n' > "$dir/nodes/review/run_1/eval-legacy.md" | ||
| printf '# Skeptic Owner\n\n[SUGGESTION] src/app.js:22 — Keep recovery observable\n→ Preserve cumulative findings after transition\nReasoning: Mandatory reviewer presence should not hide recovery evidence.\nVERDICT: PASS FINDINGS[1]\n' > "$dir/nodes/review/run_1/eval-skeptic-owner.md" | ||
| printf '# Legacy Review\n\n## Finding 1 — Real structured issue title\n**Severity**: 🔵\n**Location**: src/legacy.js:7\n**R2 Status**: ✅\n\nVERDICT: PASS FINDINGS[1]\n' > "$dir/nodes/review/run_1/eval-legacy.md" | ||
| mkdir -p "$dir/nodes/review/run_2" | ||
| printf '# Reviewer C\n\n[WARNING] src/retry.js:33 — Retry run finding stays visible\n→ Preserve loopback findings per run\nReasoning: Retry runs must not be hidden by node-level de-duplication.\nVERDICT: ITERATE FINDINGS[1]\n' > "$dir/nodes/review/run_2/eval-c.md" | ||
| printf '{"nodeId":"review","nodeType":"review","runId":"run_1","status":"completed","summary":"done","timestamp":"2026-06-20T00:00:00Z","artifacts":[{"type":"eval","path":"run_1/eval-a.md"},{"type":"eval","path":"run_1/eval-b.md"}],"verdict":"PASS"}\n' > "$dir/nodes/review/handshake.json" | ||
| printf '{"nodeId":"review","runId":"run_1","status":"completed","fixes_applied":["Bound report parser to canonical eval severity parsing"]}\n' > "$dir/nodes/review/run_1/handshake.json" | ||
| printf '{"nodeId":"review","nodeType":"review","runId":"run_1","status":"completed","summary":"done","timestamp":"2026-06-20T00:00:00Z","artifacts":[{"type":"eval","path":"run_1/eval-a.md"},{"type":"eval","path":"run_1/eval-b.md"},{"type":"eval","path":"run_1/eval-skeptic-owner.md"}],"verdict":"PASS","fixes_applied":["Bound report parser to canonical eval severity parsing"]}\n' > "$dir/nodes/review/handshake.json" | ||
| printf '{"nodeId":"review","nodeType":"review","runId":"run_1","status":"completed","summary":"done","timestamp":"2026-06-20T00:00:00Z","artifacts":[{"type":"eval","path":"eval-a.md"},{"type":"eval","path":"eval-b.md"},{"type":"eval","path":"eval-skeptic-owner.md"}],"verdict":"PASS","fixes_applied":["Bound report parser to canonical eval severity parsing"]}\n' > "$dir/nodes/review/run_1/handshake.json" | ||
| } | ||
@@ -46,3 +47,4 @@ | ||
| check "cumulative findings include legacy structured title" 'grep -q "Real structured issue title" .harness/cumulative-findings.md' | ||
| check "cumulative findings include retry run" 'grep -q "Retry run finding stays visible" .harness/cumulative-findings.md' | ||
| check "cumulative findings lists retry run as forensic orphan" 'grep -q "review/run_2" .harness/cumulative-findings.md' | ||
| check "cumulative findings excludes orphan retry text" '! grep -q "Retry run finding stays visible" .harness/cumulative-findings.md' | ||
| check "cumulative findings include execution fix" 'grep -q "Bound report parser" .harness/cumulative-findings.md' | ||
@@ -55,2 +57,3 @@ | ||
| check "prompt-context includes legacy structured title" 'printf "%s" "$PROMPT_APPEND" | grep -q "Real structured issue title"' | ||
| check "prompt-context excludes orphan retry text" '! printf "%s" "$PROMPT_APPEND" | grep -q "Retry run finding stays visible"' | ||
@@ -61,6 +64,6 @@ $HARNESS transition --from gate --to null --verdict PASS --flow review --dir .harness > /dev/null 2>&1 | ||
| check "viz shows completed terminal state" 'printf "%s" "$VIZ" | grep -q "FLOW COMPLETED at gate"' | ||
| check "viz no longer marks terminal as current" '! printf "%s" "$VIZ" | grep -q "▶ gate"' | ||
| check "viz json exposes completion" 'printf "%s" "$VIZ_JSON" | grep -q "\"completed\": true"' | ||
| check "viz json exposes terminal node" 'printf "%s" "$VIZ_JSON" | grep -q "\"terminalNode\": \"gate\""' | ||
| check "viz shows completed terminal state" 'grep -q "FLOW COMPLETED at gate" <<< "$VIZ"' | ||
| check "viz no longer marks terminal as current" '! grep -q "▶ gate" <<< "$VIZ"' | ||
| check "viz json exposes completion" 'grep -q "\"completed\": true" <<< "$VIZ_JSON"' | ||
| check "viz json exposes terminal node" 'grep -q "\"terminalNode\": \"gate\"" <<< "$VIZ_JSON"' | ||
@@ -67,0 +70,0 @@ node "$ROOT/bin/opc-report.mjs" --dir .harness --output report.html --title "Recovery Report" > /dev/null |
@@ -20,2 +20,3 @@ #!/bin/bash | ||
| $HARNESS init --flow build-verify --entry brief --dir .harness 2>/dev/null | ||
| mkdir -p .harness/nodes/brief/run_1 | ||
| STATE=$(cat .harness/flow-state.json) | ||
@@ -58,3 +59,5 @@ TPL=$(echo "$STATE" | python3 -c "import sys,json; print(json.load(sys.stdin).get('flowTemplate',''))" 2>/dev/null) | ||
| echo "--- 4: per-node capabilities (build ≠ brief) ---" | ||
| OUT=$($HARNESS prompt-context --node build --role implementer --dir .harness 2>/dev/null) | ||
| $HARNESS init --flow build-verify --entry build --dir .harness-build >/dev/null 2>/dev/null | ||
| mkdir -p .harness-build/nodes/build/run_1 | ||
| OUT=$($HARNESS prompt-context --node build --role implementer --dir .harness-build 2>/dev/null) | ||
| BUILD_CAPS=$(echo "$OUT" | python3 -c "import sys,json; print(','.join(json.load(sys.stdin).get('nodeCapabilities',[])))" 2>/dev/null) | ||
@@ -115,11 +118,13 @@ # build has design-system-injection@1 but NOT design-spec-conformance@1 (that's brief-only) | ||
| # ── 5: unknown node → empty caps (no crash) ── | ||
| echo "--- 5: unknown node → empty caps ---" | ||
| OUT=$($HARNESS prompt-context --node nonexistent --role implementer --dir .harness 2>/dev/null) | ||
| CAPS=$(echo "$OUT" | python3 -c "import sys,json; print(','.join(json.load(sys.stdin).get('nodeCapabilities',[])))" 2>/dev/null) | ||
| if [ -z "$CAPS" ]; then | ||
| echo " ✅ unknown node → empty caps (no crash)" | ||
| # ── 5: unknown node is rejected before capability routing ── | ||
| echo "--- 5: unknown node → fail closed ---" | ||
| set +e | ||
| OUT=$($HARNESS prompt-context --node nonexistent --role implementer --dir .harness 2>&1) | ||
| RC=$? | ||
| set -e | ||
| if [ "$RC" -ne 0 ] && echo "$OUT" | grep -q "nonexistent"; then | ||
| echo " ✅ unknown node rejected with diagnostic" | ||
| PASS=$((PASS + 1)) | ||
| else | ||
| echo " ❌ unexpected caps: '$CAPS'" | ||
| echo " ❌ expected unknown-node rejection, got rc=$RC: $OUT" | ||
| FAIL=$((FAIL + 1)) | ||
@@ -126,0 +131,0 @@ fi |
@@ -29,8 +29,9 @@ #!/bin/bash | ||
| local dir="$1" verdict="$2" | ||
| mkdir -p "$dir/nodes/code-review" | ||
| mkdir -p "$dir/nodes/code-review/run_1" | ||
| cat > "$dir/nodes/code-review/handshake.json" <<EOF | ||
| {"nodeId":"code-review","nodeType":"review","runId":"run_1","status":"completed","verdict":"$verdict","summary":"review found work for producer","timestamp":"2026-01-01T00:00:00.000Z","artifacts":[{"type":"eval","path":"eval-a.md"},{"type":"eval","path":"eval-b.md"}]} | ||
| {"nodeId":"code-review","nodeType":"review","runId":"run_1","status":"completed","verdict":"$verdict","summary":"review found work for producer","timestamp":"2026-01-01T00:00:00.000Z","artifacts":[{"type":"eval","path":"run_1/eval-a.md"},{"type":"eval","path":"run_1/eval-b.md"}]} | ||
| EOF | ||
| echo "# Eval A" > "$dir/nodes/code-review/eval-a.md" | ||
| echo "# Eval B" > "$dir/nodes/code-review/eval-b.md" | ||
| sync_run_handshakes "$dir" | ||
| echo "# Eval A" > "$dir/nodes/code-review/run_1/eval-a.md" | ||
| echo "# Eval B" > "$dir/nodes/code-review/run_1/eval-b.md" | ||
| } | ||
@@ -37,0 +38,0 @@ |
@@ -32,2 +32,25 @@ #!/bin/bash | ||
| write_build_state() { | ||
| local dir="$1" | ||
| mkdir -p "$dir" | ||
| cat > "$dir/flow-state.json" <<'JSON' | ||
| { | ||
| "version": "1.0", | ||
| "flowTemplate": "build-verify", | ||
| "currentNode": "build", | ||
| "entryNode": "build", | ||
| "totalSteps": 1, | ||
| "maxTotalSteps": 25, | ||
| "maxLoopsPerEdge": 3, | ||
| "maxNodeReentry": 5, | ||
| "history": [{ "nodeId": "build", "runId": "run_1", "timestamp": "2026-01-01T00:00:00.000Z" }], | ||
| "edgeCounts": {}, | ||
| "repairEdgeCounts": {}, | ||
| "_written_by": "opc-harness", | ||
| "_write_nonce": "test-run-handshake-fallback", | ||
| "_last_modified": "2026-01-01T00:00:00.000Z" | ||
| } | ||
| JSON | ||
| } | ||
| write_run_handshake_with_failing_report() { | ||
@@ -59,5 +82,6 @@ local dir="$1" | ||
| write_run_handshake "$DIR/.harness" | ||
| write_build_state "$DIR/.harness" | ||
| OUT=$($HARNESS validate "$DIR/.harness/nodes/build/handshake.json" 2>/dev/null) | ||
| if echo "$OUT" | grep -q '"valid":true'; then | ||
| ok "validate falls back to latest run_N/handshake.json" | ||
| ok "validate falls back to state-selected run_N/handshake.json" | ||
| else | ||
@@ -74,3 +98,3 @@ bad "validate did not use run fallback: $OUT" | ||
| if echo "$OUT" | grep -q '"allowed":true'; then | ||
| ok "transition falls back to latest run_N/handshake.json" | ||
| ok "transition falls back to state-selected run_N/handshake.json" | ||
| else | ||
@@ -82,3 +106,3 @@ bad "transition did not use run fallback: $OUT" | ||
| if echo "$OUT" | grep -q '"valid":true'; then | ||
| ok "validate-chain accepts latest run_N/handshake.json" | ||
| ok "validate-chain accepts state-selected run_N/handshake.json" | ||
| else | ||
@@ -85,0 +109,0 @@ bad "validate-chain did not use run fallback: $OUT" |
@@ -14,50 +14,90 @@ #!/bin/bash | ||
| # ── Helper: advance brief→build→code-review→test-design→test-execute→gate ── | ||
| advance_to_gate() { | ||
| mkdir -p .harness/nodes/brief/run_1 | ||
| cat > .harness/nodes/brief/handshake.json <<'EOF' | ||
| {"nodeId":"brief","nodeType":"brief","runId":"run_1","status":"completed","verdict":"PASS","summary":"brief done","timestamp":"2026-01-01T00:00:30.000Z","artifacts":[{"type":"brief","path":"build-brief.md"},{"type":"report","path":"run_1/brief-lint-result.json"}]} | ||
| EOF | ||
| write_golden_brief .harness/nodes/brief/build-brief.md | ||
| echo '{"pass":true}' > .harness/nodes/brief/run_1/brief-lint-result.json | ||
| $HARNESS transition --from brief --to build --verdict PASS --flow build-verify --dir .harness 2>/dev/null >/dev/null | ||
| current_run_id() { | ||
| local node="$1" | ||
| python3 - .harness/flow-state.json "$node" <<'PY' | ||
| import json, sys | ||
| state = json.load(open(sys.argv[1])) | ||
| history = state.get("history", []) | ||
| tail = history[-1] if history else None | ||
| if tail and tail.get("nodeId") == sys.argv[2] and tail.get("runId"): | ||
| print(tail["runId"]) | ||
| elif (state.get("totalSteps") == 0 and not history and | ||
| state.get("currentNode") == state.get("entryNode") == sys.argv[2] and | ||
| state.get("flowStartedAt")): | ||
| print("run_1") | ||
| else: | ||
| raise SystemExit(1) | ||
| PY | ||
| } | ||
| 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"}]} | ||
| advance_build_to_gate() { | ||
| local run_id | ||
| run_id=$(current_run_id build) || return 1 | ||
| mkdir -p ".harness/nodes/build/$run_id" | ||
| cat > .harness/nodes/build/handshake.json <<EOF | ||
| {"nodeId":"build","nodeType":"build","runId":"$run_id","status":"completed","verdict":"PASS","summary":"ok","timestamp":"2026-01-01T00:01:00.000Z","artifacts":[{"type":"code","path":"$run_id/x"}]} | ||
| EOF | ||
| touch .harness/nodes/build/x | ||
| touch ".harness/nodes/build/$run_id/x" | ||
| sync_run_handshakes ".harness" | ||
| $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"}]} | ||
| run_id=$(current_run_id code-review) || return 1 | ||
| mkdir -p ".harness/nodes/code-review/$run_id" | ||
| cat > .harness/nodes/code-review/handshake.json <<EOF | ||
| {"nodeId":"code-review","nodeType":"review","runId":"$run_id","status":"completed","verdict":"PASS","summary":"ok","timestamp":"2026-01-01T00:02:00.000Z","artifacts":[{"type":"eval","path":"$run_id/eval-a.md"},{"type":"eval","path":"$run_id/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 | ||
| echo "# Eval A - review findings" > ".harness/nodes/code-review/$run_id/eval-a.md" | ||
| echo "# Eval B - secondary review" > ".harness/nodes/code-review/$run_id/eval-b.md" | ||
| sync_run_handshakes ".harness" | ||
| $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"}]} | ||
| run_id=$(current_run_id test-design) || return 1 | ||
| mkdir -p ".harness/nodes/test-design/$run_id" | ||
| cat > .harness/nodes/test-design/handshake.json <<EOF | ||
| {"nodeId":"test-design","nodeType":"review","runId":"$run_id","status":"completed","verdict":"PASS","summary":"ok","timestamp":"2026-01-01T00:03:00.000Z","artifacts":[{"type":"eval","path":"$run_id/eval-a.md"},{"type":"eval","path":"$run_id/eval-b.md"},{"type":"test-plan","path":"$run_id/test-plan.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 | ||
| write_complete_test_plan .harness/nodes/test-design/test-plan.md | ||
| echo "# Eval A - test design findings" > ".harness/nodes/test-design/$run_id/eval-a.md" | ||
| echo "# Eval B - test design secondary" > ".harness/nodes/test-design/$run_id/eval-b.md" | ||
| write_complete_test_plan ".harness/nodes/test-design/$run_id/test-plan.md" | ||
| printf '%s\n' "{\"nodeId\":\"test-design\",\"runId\":\"$run_id\",\"testCommand\":\"node -e \\\"process.exit(0)\\\"\",\"prerequisites\":[\"fixture\"]}" > ".harness/nodes/test-design/$run_id/test-execution.json" | ||
| sync_run_handshakes ".harness" | ||
| $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"}]} | ||
| run_id=$(current_run_id test-execute) || return 1 | ||
| mkdir -p ".harness/nodes/test-execute/$run_id" | ||
| cat > .harness/nodes/test-execute/handshake.json <<EOF | ||
| {"nodeId":"test-execute","nodeType":"execute","runId":"$run_id","status":"completed","verdict":"PASS","summary":"ok","timestamp":"2026-01-01T00:04:00.000Z","artifacts":[{"type":"test-result","path":"$run_id/o"}]} | ||
| EOF | ||
| touch .harness/nodes/test-execute/o | ||
| touch ".harness/nodes/test-execute/$run_id/o" | ||
| sync_run_handshakes ".harness" | ||
| $HARNESS transition --from test-execute --to gate --verdict PASS --flow build-verify --dir .harness 2>/dev/null >/dev/null | ||
| } | ||
| # ── Helper: advance brief→build→code-review→test-design→test-execute→gate ── | ||
| advance_to_gate() { | ||
| local run_id | ||
| run_id=$(current_run_id brief) || return 1 | ||
| mkdir -p ".harness/nodes/brief/$run_id" | ||
| cat > .harness/nodes/brief/handshake.json <<EOF | ||
| {"nodeId":"brief","nodeType":"brief","runId":"$run_id","status":"completed","verdict":"PASS","summary":"brief done","timestamp":"2026-01-01T00:00:30.000Z","artifacts":[{"type":"brief","path":"build-brief.md"},{"type":"report","path":"$run_id/brief-lint-result.json"}]} | ||
| EOF | ||
| write_golden_brief .harness/nodes/brief/build-brief.md | ||
| if [ "$run_id" != "run_1" ]; then | ||
| printf '\n## Iteration Delta\n- Applied the prior gate findings for %s.\n' "$run_id" >> .harness/nodes/brief/build-brief.md | ||
| fi | ||
| echo '{"pass":true}' > ".harness/nodes/brief/$run_id/brief-lint-result.json" | ||
| sync_run_handshakes ".harness" | ||
| $HARNESS transition --from brief --to build --verdict PASS --flow build-verify --dir .harness 2>/dev/null >/dev/null | ||
| advance_build_to_gate | ||
| } | ||
| loopback_gate_to_brief() { | ||
| 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":[]} | ||
| local run_id | ||
| run_id=$(current_run_id gate) || return 1 | ||
| mkdir -p ".harness/nodes/gate/$run_id" | ||
| cat > .harness/nodes/gate/handshake.json <<EOF | ||
| {"nodeId":"gate","nodeType":"gate","runId":"$run_id","status":"completed","verdict":"FAIL","summary":"fail","timestamp":"2026-01-01T00:05:00.000Z","artifacts":[]} | ||
| EOF | ||
| echo "- fix" > .harness/backlog.md | ||
| sync_run_handshakes ".harness" | ||
| $HARNESS transition --from gate --to brief --verdict FAIL --flow build-verify --dir .harness 2>/dev/null >/dev/null | ||
@@ -78,28 +118,43 @@ } | ||
| # ── Test 1: after 3 loopbacks, edges are blocked at limit ── | ||
| echo "1. After 3 loops, brief→build edge (count=3) is blocked on 4th attempt" | ||
| mkdir -p .harness/nodes/brief/run_1 | ||
| cat > .harness/nodes/brief/handshake.json <<'EOF' | ||
| {"nodeId":"brief","nodeType":"brief","runId":"run_1","status":"completed","verdict":"PASS","summary":"brief done","timestamp":"2026-01-01T00:00:30.000Z","artifacts":[{"type":"brief","path":"build-brief.md"},{"type":"report","path":"run_1/brief-lint-result.json"}]} | ||
| # ── Test 1: forward PASS remains available after 3 repair loops ── | ||
| echo "1. After 3 repairs, the 4th brief→build forward PASS remains available" | ||
| BRIEF_RUN=$(current_run_id brief) | ||
| mkdir -p ".harness/nodes/brief/$BRIEF_RUN" | ||
| cat > .harness/nodes/brief/handshake.json <<EOF | ||
| {"nodeId":"brief","nodeType":"brief","runId":"$BRIEF_RUN","status":"completed","verdict":"PASS","summary":"brief done","timestamp":"2026-01-01T00:00:30.000Z","artifacts":[{"type":"brief","path":"build-brief.md"},{"type":"report","path":"$BRIEF_RUN/brief-lint-result.json"}]} | ||
| EOF | ||
| write_golden_brief .harness/nodes/brief/build-brief.md | ||
| echo '{"pass":true}' > .harness/nodes/brief/run_1/brief-lint-result.json | ||
| printf '\n## Iteration Delta\n- Applied the prior gate findings for %s.\n' "$BRIEF_RUN" >> .harness/nodes/brief/build-brief.md | ||
| echo '{"pass":true}' > ".harness/nodes/brief/$BRIEF_RUN/brief-lint-result.json" | ||
| sync_run_handshakes ".harness" | ||
| TRANS=$($HARNESS transition --from brief --to build --verdict PASS --flow build-verify --dir .harness 2>/dev/null || true) | ||
| ALLOWED=$(echo "$TRANS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('allowed', True))" 2>/dev/null) | ||
| if [ "$ALLOWED" = "False" ]; then | ||
| echo " ✅ 4th traversal of brief→build blocked (maxLoopsPerEdge=3)" | ||
| ALLOWED=$(echo "$TRANS" | python3 -c "import sys,json; print(json.load(sys.stdin).get('allowed', False))" 2>/dev/null) | ||
| if [ "$ALLOWED" = "True" ]; then | ||
| echo " ✅ 4th forward traversal allowed" | ||
| PASS=$((PASS + 1)) | ||
| else | ||
| echo " ❌ was allowed: $TRANS" | ||
| echo " ❌ was blocked: $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 | ||
| # The explicit transition above already entered build; continue from that state. | ||
| advance_build_to_gate | ||
| GATE_RUN=$(current_run_id gate) | ||
| mkdir -p ".harness/nodes/gate/$GATE_RUN" | ||
| cat > .harness/nodes/gate/handshake.json <<EOF | ||
| {"nodeId":"gate","nodeType":"gate","runId":"$GATE_RUN","status":"completed","verdict":"FAIL","summary":"fail","timestamp":"2026-01-01T00:05:00.000Z","artifacts":[]} | ||
| EOF | ||
| echo "- fix" > .harness/backlog.md | ||
| sync_run_handshakes ".harness" | ||
| REPAIR=$($HARNESS transition --from gate --to brief --verdict FAIL --flow build-verify --dir .harness 2>/dev/null || true) | ||
| # ── Test 2: fourth semantic repair is blocked by maxLoopsPerEdge ── | ||
| echo "2. Fourth gate→brief repair is blocked by maxLoopsPerEdge" | ||
| REPAIR_ALLOWED=$(echo "$REPAIR" | python3 -c "import sys,json; print(json.load(sys.stdin).get('allowed', True))" 2>/dev/null) | ||
| REASON=$(echo "$REPAIR" | python3 -c "import sys,json; print(json.load(sys.stdin).get('reason',''))" 2>/dev/null) | ||
| if [ "$REPAIR_ALLOWED" = "False" ] && echo "$REASON" | grep -q "maxLoopsPerEdge"; then | ||
| echo " ✅ reason: $REASON" | ||
| PASS=$((PASS + 1)) | ||
| else | ||
| echo " ❌ reason: $REASON" | ||
| echo " ❌ repair was not blocked: $REPAIR" | ||
| FAIL=$((FAIL + 1)) | ||
@@ -106,0 +161,0 @@ fi |
@@ -72,4 +72,8 @@ #!/bin/bash | ||
| # Skip review to get to gate | ||
| $HARNESS skip --dir .harness --flow review 2>/dev/null >/dev/null | ||
| # Complete review to get to gate with valid upstream authority | ||
| mkdir -p .harness/nodes/review/run_1 | ||
| echo "# Eval A" > .harness/nodes/review/run_1/eval-a.md | ||
| echo "# Eval B" > .harness/nodes/review/run_1/eval-b.md | ||
| $HARNESS seal --node review --dir .harness 2>/dev/null >/dev/null | ||
| $HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>/dev/null >/dev/null | ||
| NODE3=$(python3 -c "import json; print(json.load(open('.harness/flow-state.json'))['currentNode'])") | ||
@@ -76,0 +80,0 @@ if [ "$NODE3" = "gate" ]; then |
@@ -49,3 +49,3 @@ #!/bin/bash | ||
| # Need handshake for build node | ||
| mkdir -p .harness/nodes/build | ||
| mkdir -p .harness/nodes/build/run_1/src | ||
| cat > .harness/nodes/build/handshake.json <<'EOF' | ||
@@ -60,6 +60,7 @@ { | ||
| "timestamp": "2026-01-01T00:00:00.000Z", | ||
| "artifacts": [{"type":"code","path":"src/app.tsx"}] | ||
| "artifacts": [{"type":"code","path":"run_1/src/app.tsx"}] | ||
| } | ||
| EOF | ||
| mkdir -p .harness/nodes/build/src && touch .harness/nodes/build/src/app.tsx | ||
| touch .harness/nodes/build/run_1/src/app.tsx | ||
| sync_run_handshakes ".harness" | ||
@@ -91,26 +92,30 @@ TRANS=$($HARNESS transition --from build --to code-review --verdict PASS --flow build-verify --dir .harness 2>/dev/null) | ||
| # Transition code-review → test-design | ||
| mkdir -p .harness/nodes/code-review | ||
| mkdir -p .harness/nodes/code-review/run_1 | ||
| 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"},{"type":"eval","path":"eval-backend.md"}]} | ||
| {"nodeId":"code-review","nodeType":"review","runId":"run_1","status":"completed","verdict":"PASS","summary":"Review passed","timestamp":"2026-01-01T00:00:00.000Z","artifacts":[{"type":"eval","path":"run_1/eval-frontend.md"},{"type":"eval","path":"run_1/eval-backend.md"}]} | ||
| EOF | ||
| touch .harness/nodes/code-review/eval-frontend.md | ||
| touch .harness/nodes/code-review/eval-backend.md | ||
| touch .harness/nodes/code-review/run_1/eval-frontend.md | ||
| touch .harness/nodes/code-review/run_1/eval-backend.md | ||
| sync_run_handshakes ".harness" | ||
| $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 | ||
| mkdir -p .harness/nodes/test-design/run_1 | ||
| 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":"eval-a.md"},{"type":"eval","path":"eval-b.md"}]} | ||
| {"nodeId":"test-design","nodeType":"review","runId":"run_1","status":"completed","verdict":"PASS","summary":"Tests designed","timestamp":"2026-01-01T00:00:00.000Z","artifacts":[{"type":"eval","path":"run_1/eval-a.md"},{"type":"eval","path":"run_1/eval-b.md"},{"type":"test-plan","path":"run_1/test-plan.md"},{"type":"test-command","path":"run_1/test-execution.json"}]} | ||
| EOF | ||
| touch .harness/nodes/test-design/eval-a.md | ||
| touch .harness/nodes/test-design/eval-b.md | ||
| write_complete_test_plan .harness/nodes/test-design/test-plan.md | ||
| touch .harness/nodes/test-design/run_1/eval-a.md | ||
| touch .harness/nodes/test-design/run_1/eval-b.md | ||
| write_complete_test_plan .harness/nodes/test-design/run_1/test-plan.md | ||
| printf '%s\n' '{"nodeId":"test-design","runId":"run_1","testCommand":"node -e \"process.exit(0)\"","prerequisites":["fixture"]}' > .harness/nodes/test-design/run_1/test-execution.json | ||
| sync_run_handshakes ".harness" | ||
| $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 | ||
| mkdir -p .harness/nodes/test-execute/run_1 | ||
| 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"}]} | ||
| {"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":"run_1/output.txt"}]} | ||
| EOF | ||
| touch .harness/nodes/test-execute/output.txt | ||
| touch .harness/nodes/test-execute/run_1/output.txt | ||
| sync_run_handshakes ".harness" | ||
| $HARNESS transition --from test-execute --to gate --verdict PASS --flow build-verify --dir .harness 2>/dev/null >/dev/null | ||
@@ -117,0 +122,0 @@ |
@@ -15,31 +15,36 @@ #!/bin/bash | ||
| # Advance to gate | ||
| mkdir -p .harness/nodes/build | ||
| mkdir -p .harness/nodes/build/run_1 | ||
| 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"}]} | ||
| {"nodeId":"build","nodeType":"build","runId":"run_1","status":"completed","verdict":"PASS","summary":"ok","timestamp":"2026-01-01T00:01:00.000Z","artifacts":[{"type":"code","path":"run_1/x"}]} | ||
| EOF | ||
| touch .harness/nodes/build/x | ||
| touch .harness/nodes/build/run_1/x | ||
| sync_run_handshakes ".harness" | ||
| $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 | ||
| mkdir -p .harness/nodes/code-review/run_1 | ||
| 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"}]} | ||
| {"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":"run_1/eval-a.md"},{"type":"eval","path":"run_1/eval-b.md"}]} | ||
| EOF | ||
| echo "# Eval A" > .harness/nodes/code-review/eval-a.md | ||
| echo "# Eval B" > .harness/nodes/code-review/eval-b.md | ||
| echo "# Eval A" > .harness/nodes/code-review/run_1/eval-a.md | ||
| echo "# Eval B" > .harness/nodes/code-review/run_1/eval-b.md | ||
| sync_run_handshakes ".harness" | ||
| $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 | ||
| mkdir -p .harness/nodes/test-design/run_1 | ||
| 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"}]} | ||
| {"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":"run_1/eval-a.md"},{"type":"eval","path":"run_1/eval-b.md"},{"type":"test-plan","path":"run_1/test-plan.md"},{"type":"test-command","path":"run_1/test-execution.json"}]} | ||
| EOF | ||
| echo "# Eval A" > .harness/nodes/test-design/eval-a.md | ||
| echo "# Eval B" > .harness/nodes/test-design/eval-b.md | ||
| write_complete_test_plan .harness/nodes/test-design/test-plan.md | ||
| echo "# Eval A" > .harness/nodes/test-design/run_1/eval-a.md | ||
| echo "# Eval B" > .harness/nodes/test-design/run_1/eval-b.md | ||
| write_complete_test_plan .harness/nodes/test-design/run_1/test-plan.md | ||
| printf '%s\n' '{"nodeId":"test-design","runId":"run_1","testCommand":"node -e \"process.exit(0)\"","prerequisites":["fixture"]}' > .harness/nodes/test-design/run_1/test-execution.json | ||
| sync_run_handshakes ".harness" | ||
| $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 | ||
| mkdir -p .harness/nodes/test-execute/run_1 | ||
| 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"}]} | ||
| {"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":"run_1/o"}]} | ||
| EOF | ||
| touch .harness/nodes/test-execute/o | ||
| touch .harness/nodes/test-execute/run_1/o | ||
| sync_run_handshakes ".harness" | ||
| $HARNESS transition --from test-execute --to gate --verdict PASS --flow build-verify --dir .harness 2>/dev/null >/dev/null | ||
@@ -79,2 +84,3 @@ | ||
| echo "- Fix null reference" > .harness/backlog.md | ||
| sync_run_handshakes ".harness" | ||
| TRANS=$($HARNESS transition --from gate --to brief --verdict FAIL --flow build-verify --dir .harness 2>/dev/null) | ||
@@ -81,0 +87,0 @@ ALLOWED=$(echo "$TRANS" | python3 -c "import sys,json; print(json.load(sys.stdin)['allowed'])") |
@@ -13,4 +13,5 @@ #!/bin/bash | ||
| echo " GOT: $(echo "$haystack" | head -5)" | ||
| fi | ||
| } | ||
| fi | ||
| sync_run_handshakes "$dir" | ||
| } | ||
@@ -120,2 +121,3 @@ assert_not_contains() { | ||
| fi | ||
| sync_run_handshakes "$dir" | ||
| } | ||
@@ -137,4 +139,4 @@ | ||
| write_handshake "." "gate" "gate" "completed" | ||
| # Now delete review handshake to simulate missing | ||
| rm -f nodes/review/handshake.json | ||
| # Now delete review handshake to simulate missing selected evidence. | ||
| rm -f nodes/review/handshake.json nodes/review/run_1/handshake.json | ||
| OUT=$($HARNESS finalize --dir . --strict 2>/dev/null || true) | ||
@@ -170,2 +172,3 @@ assert_field_eq "$OUT" "['finalized']" "False" "6a: --strict rejects with missing handshake" | ||
| EOF | ||
| cp nodes/review/handshake.json nodes/review/run_1/handshake.json | ||
| # Write completed handshake for gate (terminal) | ||
@@ -208,4 +211,4 @@ write_handshake "." "gate" "gate" "completed" | ||
| $HARNESS transition --from review --to gate --verdict PASS --flow review --dir . > /dev/null 2>&1 | ||
| # Delete review handshake — should still finalize without --strict | ||
| rm -f nodes/review/handshake.json | ||
| # Delete review handshake — non-strict finalization keeps the compatibility path. | ||
| rm -f nodes/review/handshake.json nodes/review/run_1/handshake.json | ||
| write_handshake "." "gate" "gate" "completed" | ||
@@ -230,6 +233,7 @@ OUT=$($HARNESS finalize --dir . 2>/dev/null || true) | ||
| echo "NOT VALID JSON{{{{" > nodes/review/handshake.json | ||
| echo "NOT VALID JSON{{{{" > nodes/review/run_1/handshake.json | ||
| write_handshake "." "gate" "gate" "completed" | ||
| OUT=$($HARNESS finalize --dir . --strict 2>/dev/null || true) | ||
| assert_field_eq "$OUT" "['finalized']" "False" "10a: --strict rejects corrupt handshake" | ||
| assert_contains "$OUT" "cannot parse" "10b: error mentions parse failure" | ||
| assert_contains "$OUT" "parse error" "10b: error mentions parse failure" | ||
| rm -rf "$D" | ||
@@ -236,0 +240,0 @@ cd /tmp |
@@ -7,3 +7,3 @@ #!/usr/bin/env bash | ||
| SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" | ||
| HARNESS="node $SCRIPT_DIR/bin/opc-harness.mjs" | ||
| HARNESS=(node "$SCRIPT_DIR/bin/opc-harness.mjs") | ||
| PASS=0; FAIL=0 | ||
@@ -48,3 +48,3 @@ | ||
| SEAL_OUT=$(cd "$D1" && $HARNESS seal --node review --dir "$D1" 2>/dev/null) | ||
| 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' | ||
@@ -68,3 +68,3 @@ check "seal reports sealed=true" 'echo "$SEAL_OUT" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d[\"sealed\"]==True"' | ||
| SEAL_ERR=$(cd "$D2" && $HARNESS seal --node review --dir "$D2" 2>&1 1>/dev/null || true) | ||
| 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"' | ||
@@ -79,3 +79,3 @@ | ||
| SEAL_FAIL=$(cd "$D3" && $HARNESS seal --node build --dir "$D3" 2>/dev/null) | ||
| 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"' | ||
@@ -90,3 +90,3 @@ | ||
| ADV_OUT=$(cd "$D4" && $HARNESS advance --dir "$D4" 2>/dev/null) | ||
| 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"' | ||
@@ -125,3 +125,3 @@ check "advance error mentions gate" 'echo "$ADV_OUT" | grep -q "gate"' | ||
| echo '{"pass":true}' > "$D5/nodes/brief/run_1/brief-lint-result.json" | ||
| SEAL_BRIEF=$(cd "$D5" && $HARNESS seal --node brief --dir "$D5" 2>/dev/null) | ||
| SEAL_BRIEF=$(cd "$D5" && "${HARNESS[@]}" seal --node brief --dir "$D5" 2>/dev/null) | ||
| check "brief seal has no validation errors" 'echo "$SEAL_BRIEF" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d[\"validationErrors\"]==[], d[\"validationErrors\"]"' | ||
@@ -147,3 +147,3 @@ check "brief handshake includes brief artifact" 'python3 - "$D5/nodes/brief/handshake.json" <<PY | ||
| echo '.copy { color: black; }' > "$D6/nodes/build/run_1/src/app.css" | ||
| SEAL_BUILD=$(cd "$D6" && $HARNESS seal --node build --dir "$D6" 2>/dev/null) | ||
| SEAL_BUILD=$(cd "$D6" && "${HARNESS[@]}" seal --node build --dir "$D6" 2>/dev/null) | ||
| check "build seal finds recursive source artifacts" 'python3 - "$D6/nodes/build/handshake.json" <<PY | ||
@@ -171,3 +171,12 @@ import json,sys | ||
| EOF | ||
| SEAL_REVIEW=$(cd "$D6" && $HARNESS seal --node review --dir "$D6" 2>/dev/null) | ||
| python3 - "$D6/flow-state.json" <<'PY' | ||
| import json, sys | ||
| path = sys.argv[1] | ||
| state = json.load(open(path)) | ||
| state["currentNode"] = "review" | ||
| state["history"] = [{"nodeId": "review", "runId": "run_1", "timestamp": "2026-01-01T00:00:00.000Z"}] | ||
| state["totalSteps"] = 1 | ||
| json.dump(state, open(path, "w")) | ||
| PY | ||
| SEAL_REVIEW=$(cd "$D6" && "${HARNESS[@]}" seal --node review --dir "$D6" 2>/dev/null) | ||
| check "referential severity prose does not inflate warnings" 'python3 - "$D6/nodes/review/handshake.json" <<PY | ||
@@ -195,3 +204,3 @@ import json,sys | ||
| echo "ok" > "$D7/nodes/test-execute/run_1/cli-output.txt" | ||
| SEAL_EXEC=$(cd "$D7" && $HARNESS seal --node test-execute --dir "$D7" 2>/dev/null) | ||
| SEAL_EXEC=$(cd "$D7" && "${HARNESS[@]}" seal --node test-execute --dir "$D7" 2>/dev/null) | ||
| check "test-execution.json is classified as plan/spec" 'python3 - "$D7/nodes/test-execute/handshake.json" <<PY | ||
@@ -205,3 +214,85 @@ import json,sys | ||
| printf '{broken' > "$D7/nodes/test-execute/test-execution.json" | ||
| SEAL_EXEC_MALFORMED=$(cd "$D7" && "${HARNESS[@]}" seal --node test-execute --dir "$D7" 2>/dev/null) | ||
| check "malformed test-execution JSON rejects sealing" 'echo "$SEAL_EXEC_MALFORMED" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d[\"sealed\"] is False, d"' | ||
| check "malformed test-execution JSON produces validation error" 'echo "$SEAL_EXEC_MALFORMED" | python3 -c "import json,sys; d=json.load(sys.stdin); assert any(\"test-execution.json\" in e for e in d[\"validationErrors\"]), d[\"validationErrors\"]"' | ||
| check "malformed test-execution leaves prior canonical handshake unchanged" 'python3 - "$D7/nodes/test-execute/handshake.json" <<PY | ||
| import json, sys | ||
| handshake = json.load(open(sys.argv[1])) | ||
| assert handshake["status"] == "completed", handshake | ||
| assert {"type": "test-plan", "path": "test-execution.json"} in handshake["artifacts"], handshake["artifacts"] | ||
| PY' | ||
| echo "" | ||
| echo "=== TEST GROUP 8: seal — run-level machine-readable acceptance JSON ===" | ||
| D8="$TMPD/s8" | ||
| RUN8="$D8/nodes/build/run_1" | ||
| mkdir -p "$RUN8" | ||
| 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":{}}' > "$D8/flow-state.json" | ||
| for name in \ | ||
| functional-lifecycle-assertions.json \ | ||
| functional-lifecycle-eval-extensions.json \ | ||
| functional-lifecycle-execute-handshake.json \ | ||
| immutability-fresh-before.json \ | ||
| immutability-fresh-after.json \ | ||
| immutability-fresh-comparison.json \ | ||
| runtime-parity.json; do | ||
| printf '{"checks":[],"artifact":"%s"}\n' "$name" > "$RUN8/$name" | ||
| done | ||
| printf '{"checks":[]}\n' > "$RUN8/acceptance-report.json" | ||
| printf '{"checks":[]}\n' > "$RUN8/test-command-result.json" | ||
| printf '{"status":"internal-envelope"}\n' > "$RUN8/handshake.json" | ||
| printf '{"status":"internal-state"}\n' > "$RUN8/flow-state.json" | ||
| printf '{"status":"outside-run"}\n' > "$D8/nodes/build/outside.json" | ||
| SEAL_ACCEPTANCE=$(cd "$D8" && "${HARNESS[@]}" seal --node build --dir "$D8" 2>/dev/null) | ||
| check "build acceptance seal has no validation errors" 'echo "$SEAL_ACCEPTANCE" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d[\"validationErrors\"]==[], d[\"validationErrors\"]"' | ||
| check "all run-level acceptance JSON files enter handshake as reports" 'python3 - "$D8/nodes/build/handshake.json" <<PY | ||
| import json, sys | ||
| handshake = json.load(open(sys.argv[1])) | ||
| artifacts = {a["path"]: a["type"] for a in handshake["artifacts"]} | ||
| expected = { | ||
| "run_1/functional-lifecycle-assertions.json", | ||
| "run_1/functional-lifecycle-eval-extensions.json", | ||
| "run_1/functional-lifecycle-execute-handshake.json", | ||
| "run_1/immutability-fresh-before.json", | ||
| "run_1/immutability-fresh-after.json", | ||
| "run_1/immutability-fresh-comparison.json", | ||
| "run_1/runtime-parity.json", | ||
| } | ||
| assert expected <= artifacts.keys(), (expected - artifacts.keys(), artifacts) | ||
| assert all(artifacts[path] == "report" for path in expected), artifacts | ||
| PY' | ||
| check "named report and test-result special classifications remain specific" 'python3 - "$D8/nodes/build/handshake.json" <<PY | ||
| import json, sys | ||
| handshake = json.load(open(sys.argv[1])) | ||
| artifacts = {a["path"]: a["type"] for a in handshake["artifacts"]} | ||
| assert artifacts["run_1/acceptance-report.json"] == "report", artifacts | ||
| assert artifacts["run_1/test-command-result.json"] == "test-result", artifacts | ||
| PY' | ||
| check "seal excludes reserved state envelopes and node-level non-run JSON" 'python3 - "$D8/nodes/build/handshake.json" <<PY | ||
| import json, sys | ||
| handshake = json.load(open(sys.argv[1])) | ||
| paths = {a["path"] for a in handshake["artifacts"]} | ||
| assert "run_1/handshake.json" not in paths, paths | ||
| assert "run_1/flow-state.json" not in paths, paths | ||
| assert "outside.json" not in paths, paths | ||
| PY' | ||
| echo "" | ||
| echo "=== TEST GROUP 9: seal — malformed machine-readable JSON fails closed ===" | ||
| D9="$TMPD/s9" | ||
| RUN9="$D9/nodes/build/run_1" | ||
| mkdir -p "$RUN9" | ||
| 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":{}}' > "$D9/flow-state.json" | ||
| printf '{broken' > "$RUN9/acceptance-evidence.json" | ||
| SEAL_MALFORMED=$(cd "$D9" && "${HARNESS[@]}" seal --node build --dir "$D9" 2>/dev/null) | ||
| check "malformed acceptance JSON rejects sealing" 'echo "$SEAL_MALFORMED" | python3 -c "import json,sys; d=json.load(sys.stdin); assert d[\"sealed\"] is False, d"' | ||
| check "malformed acceptance JSON produces validation error" 'echo "$SEAL_MALFORMED" | python3 -c "import json,sys; d=json.load(sys.stdin); assert any(\"acceptance-evidence.json\" in e for e in d[\"validationErrors\"]), d[\"validationErrors\"]"' | ||
| check "malformed JSON does not create canonical handshake" '[ ! -f "$D9/nodes/build/handshake.json" ]' | ||
| echo "" | ||
| echo "===========================================" | ||
@@ -208,0 +299,0 @@ echo " Results: $PASS passed, $FAIL failed" |
@@ -14,9 +14,9 @@ #!/bin/bash | ||
| local dir="$1" command="$2" | ||
| mkdir -p "$dir/nodes/test-design" | ||
| echo "# Eval A" > "$dir/nodes/test-design/eval-a.md" | ||
| echo "# Eval B" > "$dir/nodes/test-design/eval-b.md" | ||
| write_complete_test_plan "$dir/nodes/test-design/test-plan.md" | ||
| python3 - "$dir/nodes/test-design/handshake.json" "$command" <<'PY' | ||
| mkdir -p "$dir/nodes/test-design/run_1" | ||
| echo "# Eval A" > "$dir/nodes/test-design/run_1/eval-a.md" | ||
| echo "# Eval B" > "$dir/nodes/test-design/run_1/eval-b.md" | ||
| write_complete_test_plan "$dir/nodes/test-design/run_1/test-plan.md" | ||
| python3 - "$dir/nodes/test-design/handshake.json" "$dir/nodes/test-design/run_1/handshake.json" "$command" <<'PY' | ||
| import json, sys | ||
| path, command = sys.argv[1], sys.argv[2] | ||
| path, run_path, command = sys.argv[1], sys.argv[2], sys.argv[3] | ||
| data = { | ||
@@ -31,4 +31,5 @@ "nodeId": "test-design", | ||
| "artifacts": [ | ||
| {"type": "eval", "path": "eval-a.md"}, | ||
| {"type": "eval", "path": "eval-b.md"} | ||
| {"type": "eval", "path": "run_1/eval-a.md"}, | ||
| {"type": "eval", "path": "run_1/eval-b.md"}, | ||
| {"type": "test-plan", "path": "run_1/test-plan.md"} | ||
| ], | ||
@@ -39,2 +40,6 @@ "testCommand": command, | ||
| open(path, "w").write(json.dumps(data)) | ||
| run_data = dict(data) | ||
| for artifact in run_data["artifacts"]: | ||
| artifact["path"] = artifact["path"].replace("run_1/", "") | ||
| open(run_path, "w").write(json.dumps(run_data)) | ||
| PY | ||
@@ -45,7 +50,9 @@ } | ||
| local dir="$1" command="$2" cwd="$3" | ||
| mkdir -p "$dir/nodes/test-design" | ||
| python3 - "$dir/nodes/test-design/test-execution.json" "$command" "$cwd" <<'PY' | ||
| mkdir -p "$dir/nodes/test-design/run_1" | ||
| python3 - "$dir/nodes/test-design/run_1/test-execution.json" "$command" "$cwd" <<'PY' | ||
| import json, sys | ||
| path, command, cwd = sys.argv[1], sys.argv[2], sys.argv[3] | ||
| data = { | ||
| "nodeId": "test-design", | ||
| "runId": "run_1", | ||
| "testCommand": command, | ||
@@ -88,2 +95,3 @@ "cwd": cwd, | ||
| grep -q '"sourcePlanHash":' .harness/nodes/test-execute/handshake.json && | ||
| grep -q '"sourceRunId": "run_1"' .harness/nodes/test-execute/handshake.json && | ||
| grep -q '"resultHash":' .harness/nodes/test-execute/handshake.json && | ||
@@ -95,2 +103,3 @@ grep -q '"ledger":' .harness/nodes/test-execute/handshake.json && | ||
| grep -q '"sourcePlanHash":' .harness/nodes/test-execute/run_1/test-command-result.json && | ||
| grep -q '"sourceRunId": "run_1"' .harness/nodes/test-execute/run_1/test-command-result.json && | ||
| grep -q '"executionActor": "opc-harness:test-command"' .harness/nodes/test-execute/run_1/test-command-result.json; then | ||
@@ -179,2 +188,9 @@ echo " ✅ testCommand evidence records OPC provenance" | ||
| JSON | ||
| python3 - <<'PY' | ||
| import json | ||
| data = json.load(open('.harness-forged/nodes/test-execute/handshake.json')) | ||
| run_data = dict(data) | ||
| run_data["artifacts"] = [{"type": "test-result", "path": "test-execution.json"}] | ||
| open('.harness-forged/nodes/test-execute/run_1/handshake.json', 'w').write(json.dumps(run_data)) | ||
| PY | ||
| OUT=$($HARNESS transition --from test-execute --to gate --verdict PASS --flow build-verify --dir .harness-forged 2>/dev/null) | ||
@@ -204,2 +220,4 @@ ALLOWED=$(json_field "$OUT" "allowed") | ||
| "kind": "opc-test-command", | ||
| "sourceNode": "test-design", | ||
| "sourceRunId": "run_1", | ||
| "commandHash": command_hash, | ||
@@ -227,13 +245,21 @@ "sourcePlanHash": plan_hash, | ||
| "artifacts": [{"type": "test-result", "path": "run_1/test-command-result.json"}], | ||
| "testEvidenceProvenance": { | ||
| "kind": "opc-test-command", | ||
| "sourceNode": "test-design", | ||
| "commandHash": command_hash, | ||
| "testEvidenceProvenance": { | ||
| "kind": "opc-test-command", | ||
| "sourceNode": "test-design", | ||
| "sourceRunId": "run_1", | ||
| "commandHash": command_hash, | ||
| "sourcePlanHash": plan_hash, | ||
| "resultHash": result_hash, | ||
| "executionActor": "opc-harness:test-command" | ||
| } | ||
| } | ||
| }, | ||
| "testEvidencePolicy": {"allowVacuousChecks": []} | ||
| } | ||
| design_run_hs = dict(design_hs) | ||
| design_run_hs["artifacts"] = [{"type": "test-plan", "path": "test-plan.md"}] | ||
| exec_run_hs = dict(exec_hs) | ||
| exec_run_hs["artifacts"] = [{"type": "test-result", "path": "test-command-result.json"}] | ||
| open('.harness-consistent-forge/nodes/test-design/handshake.json', 'w').write(json.dumps(design_hs)) | ||
| open('.harness-consistent-forge/nodes/test-design/run_1/handshake.json', 'w').write(json.dumps(design_run_hs)) | ||
| open('.harness-consistent-forge/nodes/test-execute/handshake.json', 'w').write(json.dumps(exec_hs)) | ||
| open('.harness-consistent-forge/nodes/test-execute/run_1/handshake.json', 'w').write(json.dumps(exec_run_hs)) | ||
| PY | ||
@@ -240,0 +266,0 @@ OUT=$($HARNESS transition --from test-execute --to gate --verdict PASS --flow build-verify --dir .harness-consistent-forge 2>/dev/null) |
+31
-13
@@ -145,4 +145,4 @@ #!/bin/bash | ||
| $HARNESS init --flow full-stack --tier polished --entry test-execute --dir "$dir" 2>/dev/null >/dev/null | ||
| mkdir -p "$dir/nodes/test-execute" | ||
| touch "$dir/nodes/test-execute/screen.png" | ||
| mkdir -p "$dir/nodes/test-execute/run_1" | ||
| touch "$dir/nodes/test-execute/run_1/screen.png" | ||
| } | ||
@@ -157,5 +157,6 @@ | ||
| "timestamp": "2024-01-01T00:00:00Z", | ||
| "artifacts": [{"type": "screenshot", "path": "screen.png"}] | ||
| "artifacts": [{"type": "screenshot", "path": "run_1/screen.png"}] | ||
| } | ||
| HS | ||
| sync_run_handshakes .h-t1 | ||
| OUT=$($HARNESS validate .h-t1/nodes/test-execute/handshake.json 2>/dev/null) | ||
@@ -168,3 +169,3 @@ assert_field_eq "missing tierCoverage rejected" "$OUT" "valid" "false" | ||
| setup_tier_flow .h-t2 | ||
| echo "npm test: 42 passed, 0 failed" > .h-t2/nodes/test-execute/test-output.txt | ||
| echo "npm test: 42 passed, 0 failed" > .h-t2/nodes/test-execute/run_1/test-output.txt | ||
| cat > .h-t2/nodes/test-execute/handshake.json << 'HS' | ||
@@ -175,3 +176,3 @@ { | ||
| "timestamp": "2024-01-01T00:00:00Z", | ||
| "artifacts": [{"type": "screenshot", "path": "screen.png"}, {"type": "cli-output", "path": "test-output.txt"}], | ||
| "artifacts": [{"type": "screenshot", "path": "run_1/screen.png"}, {"type": "cli-output", "path": "run_1/test-output.txt"}], | ||
| "tierCoverage": { | ||
@@ -183,2 +184,3 @@ "covered": ["typography","color-scheme","navigation","responsive","code-blocks","tables","loading-states","error-states","favicon-meta","focus-styles","testing-md"], | ||
| HS | ||
| sync_run_handshakes .h-t2 | ||
| OUT=$($HARNESS validate .h-t2/nodes/test-execute/handshake.json 2>/dev/null) | ||
@@ -195,3 +197,3 @@ assert_field_eq "full coverage accepted" "$OUT" "valid" "true" | ||
| "timestamp": "2024-01-01T00:00:00Z", | ||
| "artifacts": [{"type": "screenshot", "path": "screen.png"}], | ||
| "artifacts": [{"type": "screenshot", "path": "run_1/screen.png"}], | ||
| "tierCoverage": { | ||
@@ -203,2 +205,3 @@ "covered": ["typography","color-scheme","navigation","responsive","code-blocks","tables","loading-states","error-states","favicon-meta"], | ||
| HS | ||
| sync_run_handshakes .h-t3 | ||
| OUT=$($HARNESS validate .h-t3/nodes/test-execute/handshake.json 2>/dev/null) | ||
@@ -216,3 +219,3 @@ assert_field_eq "short reason rejected" "$OUT" "valid" "false" | ||
| "timestamp": "2024-01-01T00:00:00Z", | ||
| "artifacts": [{"type": "screenshot", "path": "screen.png"}], | ||
| "artifacts": [{"type": "screenshot", "path": "run_1/screen.png"}], | ||
| "tierCoverage": { | ||
@@ -224,2 +227,3 @@ "covered": ["typography","banana","color-scheme","navigation","responsive","code-blocks","tables","loading-states","error-states","favicon-meta","focus-styles"], | ||
| HS | ||
| sync_run_handshakes .h-t4 | ||
| OUT=$($HARNESS validate .h-t4/nodes/test-execute/handshake.json 2>/dev/null) | ||
@@ -239,3 +243,3 @@ assert_field_eq "unknown key rejected" "$OUT" "valid" "false" | ||
| "timestamp": "2024-01-01T00:00:00Z", | ||
| "artifacts": [{"type": "screenshot", "path": "screen.png"}], | ||
| "artifacts": [{"type": "screenshot", "path": "run_1/screen.png"}], | ||
| "tierCoverage": { | ||
@@ -247,2 +251,3 @@ "covered": ["typography","color-scheme","navigation","responsive"], | ||
| HS | ||
| sync_run_handshakes .h-t5 | ||
| OUT=$($HARNESS validate .h-t5/nodes/test-execute/handshake.json 2>/dev/null) | ||
@@ -255,3 +260,3 @@ assert_field_eq "incomplete coverage rejected" "$OUT" "valid" "false" | ||
| setup_tier_flow .h-t6 | ||
| echo "npm test: all passed" > .h-t6/nodes/test-execute/test-output.txt | ||
| echo "npm test: all passed" > .h-t6/nodes/test-execute/run_1/test-output.txt | ||
| cat > .h-t6/nodes/test-execute/handshake.json << 'HS' | ||
@@ -262,3 +267,3 @@ { | ||
| "timestamp": "2024-01-01T00:00:00Z", | ||
| "artifacts": [{"type": "screenshot", "path": "screen.png"}, {"type": "cli-output", "path": "test-output.txt"}], | ||
| "artifacts": [{"type": "screenshot", "path": "run_1/screen.png"}, {"type": "cli-output", "path": "run_1/test-output.txt"}], | ||
| "tierCoverage": { | ||
@@ -270,2 +275,3 @@ "covered": ["typography","color-scheme","navigation","responsive","tables","loading-states","error-states","favicon-meta","focus-styles","testing-md"], | ||
| HS | ||
| sync_run_handshakes .h-t6 | ||
| OUT=$($HARNESS validate .h-t6/nodes/test-execute/handshake.json 2>/dev/null) | ||
@@ -286,2 +292,13 @@ assert_field_eq "valid skip accepted" "$OUT" "valid" "true" | ||
| HS | ||
| sync_run_handshakes .h-t7 | ||
| python3 -c " | ||
| import json | ||
| p='.h-t7/flow-state.json' | ||
| s=json.load(open(p)) | ||
| s['entryNode']='build' | ||
| s['currentNode']='build' | ||
| s['totalSteps']=1 | ||
| s['history']=[{'nodeId':'build','runId':'run_1','timestamp':'2024-01-01T00:00:00.000Z'}] | ||
| json.dump(s, open(p,'w'), indent=2) | ||
| " | ||
| OUT=$($HARNESS validate .h-t7/nodes/build/handshake.json 2>/dev/null) | ||
@@ -294,4 +311,4 @@ assert_field_eq "build node unaffected" "$OUT" "valid" "true" | ||
| $HARNESS init --flow full-stack --tier functional --entry test-execute --dir .h-t8 2>/dev/null >/dev/null | ||
| mkdir -p .h-t8/nodes/test-execute | ||
| touch .h-t8/nodes/test-execute/screen.png | ||
| mkdir -p .h-t8/nodes/test-execute/run_1 | ||
| touch .h-t8/nodes/test-execute/run_1/screen.png | ||
| cat > .h-t8/nodes/test-execute/handshake.json << 'HS' | ||
@@ -302,5 +319,6 @@ { | ||
| "timestamp": "2024-01-01T00:00:00Z", | ||
| "artifacts": [{"type": "screenshot", "path": "screen.png"}] | ||
| "artifacts": [{"type": "screenshot", "path": "run_1/screen.png"}] | ||
| } | ||
| HS | ||
| sync_run_handshakes .h-t8 | ||
| OUT=$($HARNESS validate .h-t8/nodes/test-execute/handshake.json 2>/dev/null) | ||
@@ -307,0 +325,0 @@ assert_field_eq "functional tier no coverage needed" "$OUT" "valid" "true" |
@@ -7,3 +7,3 @@ #!/usr/bin/env bash | ||
| SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" | ||
| HARNESS="node $SCRIPT_DIR/bin/opc-harness.mjs" | ||
| HARNESS=(node "$SCRIPT_DIR/bin/opc-harness.mjs") | ||
| PASS=0; FAIL=0 | ||
@@ -35,2 +35,4 @@ | ||
| "$node" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$verdict" > "$dir/nodes/$node/handshake.json" | ||
| printf '{"nodeId":"%s","nodeType":"review","runId":"run_1","status":"completed","summary":"Done","timestamp":"%s","artifacts":[{"type":"eval","path":"eval-a.md"},{"type":"eval","path":"eval-b.md"}],"verdict":"%s"}\n' \ | ||
| "$node" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$verdict" > "$dir/nodes/$node/run_1/handshake.json" | ||
| } | ||
@@ -50,2 +52,6 @@ | ||
| PY | ||
| mkdir -p .harness/nodes/gate/run_1 | ||
| printf '{"nodeId":"gate","nodeType":"gate","runId":"run_1","status":"completed","summary":"gate ready","timestamp":"%s","artifacts":[],"verdict":"PASS"}\n' \ | ||
| "$(date -u +%Y-%m-%dT%H:%M:%SZ)" > .harness/nodes/gate/run_1/handshake.json | ||
| cp .harness/nodes/gate/run_1/handshake.json .harness/nodes/gate/handshake.json | ||
| } | ||
@@ -57,7 +63,7 @@ | ||
| mkdir -p "$D1" && cd "$D1" | ||
| $HARNESS init --flow review --entry review --dir .harness > /dev/null 2>&1 | ||
| "${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 | ||
| "${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) | ||
| 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"' | ||
@@ -70,5 +76,5 @@ | ||
| mkdir -p "$D2" && cd "$D2" | ||
| $HARNESS init --flow review --entry review --dir .harness > /dev/null 2>&1 | ||
| "${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) | ||
| 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"' | ||
@@ -79,15 +85,15 @@ | ||
| ROUTE_RESULT=$($HARNESS route --node gate --verdict PASS --flow review 2>&1) | ||
| 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 "=== TEST GROUP 4: sealed ITERATE cannot be overridden by CLI PASS ===" | ||
| echo "=== TEST GROUP 4: sealed ITERATE can structurally enter gate ===" | ||
| D3="$TMPD/t3" | ||
| mkdir -p "$D3" && cd "$D3" | ||
| $HARNESS init --flow review --entry review --dir .harness > /dev/null 2>&1 | ||
| "${HARNESS[@]}" init --flow review --entry review --dir .harness > /dev/null 2>&1 | ||
| write_review_hs ".harness" "review" "ITERATE" "FINDINGS[1]" | ||
| RESULT3=$(cd "$D3" && $HARNESS transition --from review --to gate --verdict PASS --flow review --dir .harness 2>&1) | ||
| check "review PASS edge rejects sealed ITERATE" 'echo "$RESULT3" | grep -q "sealed verdict is.*ITERATE"' | ||
| RESULT3=$(cd "$D3" && "${HARNESS[@]}" transition --from review --to gate --verdict PASS --flow review --dir .harness 2>&1) | ||
| check "review PASS edge reaches gate for gate adjudication" 'echo "$RESULT3" | grep -q "\"allowed\":true"' | ||
@@ -99,7 +105,7 @@ echo "" | ||
| mkdir -p "$D4" && cd "$D4" | ||
| $HARNESS init --flow review --entry review --dir .harness > /dev/null 2>&1 | ||
| "${HARNESS[@]}" init --flow review --entry review --dir .harness > /dev/null 2>&1 | ||
| write_review_hs ".harness" "review" "ITERATE" "FINDINGS[1]" | ||
| set_gate_state | ||
| RESULT4=$(cd "$D4" && $HARNESS finalize --dir .harness 2>&1) | ||
| RESULT4=$(cd "$D4" && "${HARNESS[@]}" finalize --dir .harness 2>&1) | ||
| check "finalize rejects gate with upstream ITERATE" 'echo "$RESULT4" | grep -q "sealed verdict for review is ITERATE"' | ||
@@ -112,9 +118,10 @@ | ||
| mkdir -p "$D5" && cd "$D5" | ||
| $HARNESS init --flow review --entry review --dir .harness > /dev/null 2>&1 | ||
| "${HARNESS[@]}" init --flow review --entry review --dir .harness > /dev/null 2>&1 | ||
| write_review_hs ".harness" "review" | ||
| set_gate_state | ||
| printf '{broken json\n' > .harness/nodes/review/handshake.json | ||
| printf '{broken json\n' > .harness/nodes/review/run_1/handshake.json | ||
| RESULT5=$(cd "$D5" && $HARNESS finalize --dir .harness 2>&1) | ||
| check "finalize rejects corrupt upstream handshake" 'echo "$RESULT5" | grep -q "handshake for review is corrupt"' | ||
| RESULT5=$(cd "$D5" && "${HARNESS[@]}" finalize --dir .harness 2>&1) | ||
| check "finalize rejects corrupt upstream handshake" 'echo "$RESULT5" | grep -q "parse error"' | ||
@@ -126,10 +133,10 @@ echo "" | ||
| mkdir -p "$D6" && cd "$D6" | ||
| $HARNESS init --flow review --entry review --dir .harness > /dev/null 2>&1 | ||
| "${HARNESS[@]}" init --flow review --entry review --dir .harness > /dev/null 2>&1 | ||
| write_review_hs ".harness" "review" | ||
| set_gate_state | ||
| rm -f .harness/nodes/review/handshake.json | ||
| rm -rf .harness/nodes/review/run_1 | ||
| rm -f .harness/nodes/review/run_1/handshake.json | ||
| RESULT6=$(cd "$D6" && $HARNESS finalize --dir .harness 2>&1) | ||
| check "finalize rejects missing upstream handshake" 'echo "$RESULT6" | grep -q "handshake for review is missing"' | ||
| RESULT6=$(cd "$D6" && "${HARNESS[@]}" finalize --dir .harness 2>&1) | ||
| check "finalize rejects missing upstream handshake" 'echo "$RESULT6" | grep -q "missing handshake for node '\''review'\'' run '\''run_1'\''"' | ||
@@ -141,8 +148,8 @@ echo "" | ||
| mkdir -p "$D7" && cd "$D7" | ||
| $HARNESS init --flow review --entry review --dir .harness > /dev/null 2>&1 | ||
| "${HARNESS[@]}" init --flow review --entry review --dir .harness > /dev/null 2>&1 | ||
| write_review_hs ".harness" "review" | ||
| set_gate_state | ||
| rm -rf .harness/nodes/review/run_1 | ||
| rm -f .harness/nodes/review/run_1/eval-a.md | ||
| RESULT7=$(cd "$D7" && $HARNESS finalize --dir .harness 2>&1) | ||
| RESULT7=$(cd "$D7" && "${HARNESS[@]}" finalize --dir .harness 2>&1) | ||
| check "finalize rejects missing review eval artifact" 'echo "$RESULT7" | grep -q "review eval artifact for review unreadable"' | ||
@@ -149,0 +156,0 @@ |
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Sorry, the diff of this file is too big to display
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 2 instances
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 3 instances
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
23635579
1.29%284
3.27%29458
25.65%147
5.76%23
15%