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

@touchskyer/opc

Package Overview
Dependencies
Maintainers
1
Versions
25
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@touchskyer/opc - npm Package Compare versions

Comparing version
0.10.3
to
0.10.4
+325
bin/lib/flow-transition.test.mjs
// flow-transition.test.mjs — Step 1.5 structured result check
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, writeFileSync, rmSync, readFileSync } from "node:fs";
import { join, dirname } from "node:path";
import os from "node:os";
import { fileURLToPath } from "node:url";
import { execFileSync } from "node:child_process";
import { checkStructuredResults } from "./flow-transition.mjs";
const TMPBASE = join(os.homedir(), ".opc", "sessions", `ft-test-${Date.now()}`);
const HARNESS = join(dirname(fileURLToPath(import.meta.url)), "..", "opc-harness.mjs");
// Minimal template with build-verify topology
const TEMPLATE = {
nodeTypes: {
build: "build",
"code-review": "review",
gate: "gate",
},
};
// Minimal flow state: build → code-review → gate
function makeState() {
return {
flowTemplate: "build-verify",
currentNode: "gate",
history: [
{ nodeId: "build", runId: "run_1" },
{ nodeId: "code-review", runId: "run_1" },
{ nodeId: "gate", runId: "run_1" },
],
};
}
function setupDir(name, handshakes) {
const dir = join(TMPBASE, name);
for (const [nodeId, hs] of Object.entries(handshakes)) {
const nodeDir = join(dir, "nodes", nodeId);
mkdirSync(nodeDir, { recursive: true });
writeFileSync(join(nodeDir, "handshake.json"), JSON.stringify(hs));
// Write artifact files referenced by handshake
if (Array.isArray(hs.artifacts)) {
for (const art of hs.artifacts) {
if (art._content !== undefined) {
const artDir = join(nodeDir, art.path.includes("/") ? art.path.split("/").slice(0, -1).join("/") : "");
mkdirSync(artDir, { recursive: true });
const content = typeof art._content === "string" ? art._content : JSON.stringify(art._content);
writeFileSync(join(nodeDir, art.path), content);
}
}
}
}
return dir;
}
// Cleanup after all tests
test.after(() => {
try { rmSync(TMPBASE, { recursive: true, force: true }); } catch {}
});
describe("checkStructuredResults — Step 1.5", () => {
test("no artifacts → empty reasons (backward compat)", () => {
const dir = setupDir("t1-no-artifacts", {
build: { artifacts: [] },
"code-review": { artifacts: [] },
});
const reasons = checkStructuredResults(dir, makeState(), TEMPLATE, "gate");
assert.equal(reasons.length, 0, "should pass with no artifacts");
});
test("test_fail_count=3 → FAIL", () => {
const dir = setupDir("t2-test-fail", {
build: {
artifacts: [{
type: "test-result",
path: "run_1/test-report.json",
_content: { test_fail_count: 3, dead_test_count: 0 },
}],
},
"code-review": { artifacts: [] },
});
const reasons = checkStructuredResults(dir, makeState(), TEMPLATE, "gate");
assert.ok(reasons.length > 0, "should have fail reasons");
assert.ok(reasons.some(r => r.includes("3 test(s) failed")));
});
test("dead_test_count=5 → FAIL", () => {
const dir = setupDir("t3-dead-tests", {
build: {
artifacts: [{
type: "test-result",
path: "run_1/test-report.json",
_content: { test_fail_count: 0, dead_test_count: 5 },
}],
},
"code-review": { artifacts: [] },
});
const reasons = checkStructuredResults(dir, makeState(), TEMPLATE, "gate");
assert.ok(reasons.some(r => r.includes("5 dead test(s)")));
});
test("p0_count=2 → FAIL", () => {
const dir = setupDir("t4-p0", {
build: {
artifacts: [{
type: "report",
path: "run_1/report.json",
_content: { p0_count: 2 },
}],
},
"code-review": { artifacts: [] },
});
const reasons = checkStructuredResults(dir, makeState(), TEMPLATE, "gate");
assert.ok(reasons.some(r => r.includes("2 P0 issue(s)")));
});
test("sync_check_status=FAIL → FAIL", () => {
const dir = setupDir("t5-sync-fail", {
build: {
artifacts: [{
type: "report",
path: "run_1/sync-report.json",
_content: { sync_check_status: "FAIL", test_fail_count: 0 },
}],
},
"code-review": { artifacts: [] },
});
const reasons = checkStructuredResults(dir, makeState(), TEMPLATE, "gate");
assert.ok(reasons.some(r => r.includes("sync-check failed")));
});
test("malformed artifact JSON → fail-closed FAIL", () => {
const dir = setupDir("t6-malformed", {
build: {
artifacts: [{
type: "report",
path: "run_1/bad-report.json",
_content: "NOT VALID JSON{{{",
}],
},
"code-review": { artifacts: [] },
});
const reasons = checkStructuredResults(dir, makeState(), TEMPLATE, "gate");
assert.ok(reasons.some(r => r.includes("unreadable")));
});
test("all zeros → empty reasons (PASS)", () => {
const dir = setupDir("t7-all-zero", {
build: {
artifacts: [{
type: "test-result",
path: "run_1/test-report.json",
_content: { test_fail_count: 0, dead_test_count: 0, p0_count: 0, sync_check_status: "PASS" },
}],
},
"code-review": { artifacts: [] },
});
const reasons = checkStructuredResults(dir, makeState(), TEMPLATE, "gate");
assert.equal(reasons.length, 0, "all zeros should pass");
});
test("string type coercion: test_fail_count='3' → FAIL", () => {
const dir = setupDir("t8-string-coerce", {
build: {
artifacts: [{
type: "test-result",
path: "run_1/test-report.json",
_content: { test_fail_count: "3" },
}],
},
"code-review": { artifacts: [] },
});
const reasons = checkStructuredResults(dir, makeState(), TEMPLATE, "gate");
assert.ok(reasons.some(r => r.includes("3 test(s) failed")));
});
test("artifact type=screenshot → ignored (PASS)", () => {
const dir = setupDir("t9-screenshot-ignored", {
build: {
artifacts: [{
type: "screenshot",
path: "run_1/screenshot.png",
_content: "binary-data-irrelevant",
}],
},
"code-review": { artifacts: [] },
});
const reasons = checkStructuredResults(dir, makeState(), TEMPLATE, "gate");
assert.equal(reasons.length, 0, "screenshot artifacts should be ignored");
});
});
// ─── Integration: bypass path enforcement via harness CLI ─────────────
/** Create a full session dir that cmdTransition/cmdPass will accept. */
function createSession(name, { artifacts = [], failingReport = false } = {}) {
const dir = join(TMPBASE, name);
mkdirSync(join(dir, "nodes", "build", "run_1"), { recursive: true });
mkdirSync(join(dir, "nodes", "code-review", "run_1"), { recursive: true });
mkdirSync(join(dir, "nodes", "test-design", "run_1"), { recursive: true });
mkdirSync(join(dir, "nodes", "test-execute", "run_1"), { recursive: true });
mkdirSync(join(dir, "nodes", "gate"), { recursive: true });
// Write eval files so synthesize produces a verdict
writeFileSync(join(dir, "nodes", "test-execute", "run_1", "eval-engineer.md"),
"# Engineer Review\n**Verdict: ✅ APPROVE**\nNo issues.\n");
// Write handshakes for upstream nodes
for (const nodeId of ["build", "code-review", "test-design", "test-execute"]) {
const hs = {
nodeId, nodeType: TEMPLATE.nodeTypes[nodeId] || "build", runId: "run_1",
status: "completed", summary: "done", timestamp: new Date().toISOString(),
artifacts: nodeId === "build" ? artifacts : [],
verdict: null,
};
writeFileSync(join(dir, "nodes", nodeId, "handshake.json"), JSON.stringify(hs));
// test-execute needs evidence
if (nodeId === "test-execute") {
writeFileSync(join(dir, "nodes", nodeId, "run_1", "evidence.md"), "test passed");
hs.artifacts = [{ type: "log", path: "run_1/evidence.md" }];
hs.nodeType = "execute";
writeFileSync(join(dir, "nodes", nodeId, "handshake.json"), JSON.stringify(hs));
}
}
// Write failing test report if requested
if (failingReport) {
const reportPath = join(dir, "nodes", "build", "run_1", "test-report.json");
writeFileSync(reportPath, JSON.stringify({ test_fail_count: 3, dead_test_count: 0 }));
// Update build handshake with artifact reference
const buildHs = JSON.parse(
readFileSync(join(dir, "nodes", "build", "handshake.json"), "utf8")
);
buildHs.artifacts = [{ type: "test-result", path: "run_1/test-report.json" }];
writeFileSync(join(dir, "nodes", "build", "handshake.json"), JSON.stringify(buildHs));
}
// flow-state.json: currentNode = gate
const flowState = {
version: "1.0",
flowTemplate: "build-verify",
currentNode: "gate",
entryNode: "build",
totalSteps: 4,
maxTotalSteps: 25,
maxLoopsPerEdge: 3,
maxNodeReentry: 5,
edgeCounts: {},
history: [
{ nodeId: "build", runId: "run_1", timestamp: new Date().toISOString() },
{ nodeId: "code-review", runId: "run_1", timestamp: new Date().toISOString() },
{ nodeId: "test-design", runId: "run_1", timestamp: new Date().toISOString() },
{ nodeId: "test-execute", runId: "run_1", timestamp: new Date().toISOString() },
{ nodeId: "gate", runId: "run_1", timestamp: new Date().toISOString() },
],
_written_by: "opc-harness",
_write_nonce: `test-${Date.now()}`,
_last_modified: new Date().toISOString(),
};
writeFileSync(join(dir, "flow-state.json"), JSON.stringify(flowState, null, 2));
return dir;
}
function runHarness(cmd, args) {
try {
const output = execFileSync("node", [HARNESS, cmd, ...args], {
encoding: "utf8", stdio: ["pipe", "pipe", "pipe"],
});
const lines = output.trim().split("\n");
return JSON.parse(lines[lines.length - 1]);
} catch (err) {
const stdout = err.stdout || "";
const lines = stdout.trim().split("\n");
try { return JSON.parse(lines[lines.length - 1]); } catch {
return { error: err.message, stderr: err.stderr };
}
}
}
describe("Step 1.5 bypass enforcement — cmdTransition", () => {
test("direct transition PASS with failing artifacts → rejected", () => {
const dir = createSession("bypass-transition", { failingReport: true });
const result = runHarness("transition", [
"--from", "gate", "--to", "null", "--verdict", "PASS",
"--flow", "build-verify", "--dir", dir,
]);
assert.equal(result.allowed, false, `should be rejected, got: ${JSON.stringify(result)}`);
assert.ok(
result.reason?.includes("Step 1.5") || result.reason?.includes("structural"),
`reason should mention Step 1.5, got: ${result.reason}`
);
});
test("direct transition FAIL with failing artifacts → allowed (correct verdict)", () => {
const dir = createSession("bypass-transition-fail", { failingReport: true });
const result = runHarness("transition", [
"--from", "gate", "--to", "build", "--verdict", "FAIL",
"--flow", "build-verify", "--dir", dir,
]);
assert.equal(result.allowed, true, `FAIL verdict should be allowed, got: ${JSON.stringify(result)}`);
});
test("direct transition PASS with clean artifacts → allowed (finalized)", () => {
const dir = createSession("bypass-transition-clean");
const result = runHarness("transition", [
"--from", "gate", "--to", "null", "--verdict", "PASS",
"--flow", "build-verify", "--dir", dir,
]);
// Terminal PASS → delegates to cmdFinalize, returns {finalized: true}
const allowed = result.allowed === true || result.finalized === true;
assert.ok(allowed, `clean PASS should be allowed/finalized, got: ${JSON.stringify(result)}`);
});
});
describe("Step 1.5 bypass enforcement — cmdPass", () => {
test("/opc pass with failing artifacts → rejected", () => {
const dir = createSession("bypass-pass", { failingReport: true });
const result = runHarness("pass", ["--dir", dir]);
// cmdPass either returns {error: ...} or delegates to transition which returns {allowed: false}
const rejected = result.allowed === false || result.error != null;
assert.ok(rejected, `should be rejected, got: ${JSON.stringify(result)}`);
});
});
+1
-1

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

files = readdirSync(targetRunDir)
.filter((f) => f.startsWith("eval") && f.endsWith(".md"))
.filter((f) => f.startsWith("eval") && f.endsWith(".md") && f !== "eval-extensions.md")
.map((f) => ({ name: f, path: join(targetRunDir, f) }));

@@ -181,0 +181,0 @@ } catch (err) {

@@ -21,7 +21,7 @@ // ext-commands.mjs — CLI commands for extension system

function loadOpcConfig(harnessDir) {
export function loadOpcConfig(harnessDir) {
return stripProvenance(loadLayeredOpcConfig(harnessDir || process.cwd(), {}));
}
function readTaskFromAC(dir) {
export function readTaskFromAC(dir) {
const acPath = resolve(dir, "acceptance-criteria.md");

@@ -35,3 +35,3 @@ if (!existsSync(acPath)) return "";

function findLatestRunDir(nodeDir) {
export function findLatestRunDir(nodeDir) {
if (!existsSync(nodeDir)) return null;

@@ -38,0 +38,0 @@ try {

@@ -5,3 +5,3 @@ // Flow core commands: route, init, validate, validateHandshakeData, validate-context

import { readFileSync, mkdirSync, existsSync, readdirSync } from "fs";
import { join, dirname } from "path";
import { join, dirname, resolve } from "path";
import { createHash } from "crypto";

@@ -17,3 +17,3 @@ import { FLOW_TEMPLATES, resolveFlowTemplate, loadFlowFromFile } from "./flow-templates.mjs";

import { checkEvalDistinctness } from "./eval-parser.mjs";
import { loadExtensions, saveRegistryCache, resolveBypass, clearBreakerState } from "./extensions.mjs";
import { loadExtensions, saveRegistryCache, resolveBypass, clearBreakerState, fireNodePreflight } from "./extensions.mjs";
import { parseBypassArgs } from "./bypass-args.mjs";

@@ -195,3 +195,39 @@

console.log(JSON.stringify({ created: true, flow, entry: entryNode, tier: tier || null, dir }));
// ── Auto-preflight for entry node or first build node ──────────
// Fire preflight hooks so design artifacts (tokens, brief) are ready
// before the first node executes. Preflight failures must not block init.
let preflightNode = null;
let preflightResult = null;
if (bypassCfg.noExtensions !== true) {
try {
const firstBuildNode = template.nodes.find(n =>
template.nodeTypes?.[n] === "build" || n === "build"
);
preflightNode = firstBuildNode || entryNode;
const preflightCaps = template.nodeCapabilities?.[preflightNode] || [];
if (preflightCaps.length > 0) {
const preflightRegistry = await loadExtensions(bypassCfg);
const preflightCtx = {
node: preflightNode,
nodeId: preflightNode,
nodeType: template.nodeTypes?.[preflightNode] || null,
role: "preflight",
task: "",
flowDir: resolve(dir),
devServerUrl: process.env.DEV_SERVER_URL || "",
nodeCapabilities: preflightCaps,
};
preflightResult = await fireNodePreflight(preflightRegistry, preflightCtx);
console.error(`[init] auto-preflight for '${preflightNode}': ${preflightResult?.length ? 'artifacts generated' : 'no output'}`);
}
} catch (err) {
console.error(`WARN: auto-preflight failed: ${err.message}`);
}
}
console.log(JSON.stringify({
created: true, flow, entry: entryNode, tier: tier || null, dir,
...(preflightResult?.length ? { preflight: { node: preflightNode, status: "ok" } } : {}),
}));
}

@@ -198,0 +234,0 @@

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

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

@@ -89,2 +90,3 @@ },

"code-review": ["code-quality-check@1", "visual-consistency-check@1"],
"test-execute": ["visual-consistency-check@1"],
acceptance: ["visual-consistency-check@1", "user-simulation@1"],

@@ -91,0 +93,0 @@ audit: ["security-check@1", "a11y-check@1"],

// Flow transition commands: transition, validate-chain, finalize
// Depends on: flow-templates.mjs, flow-core.mjs (validateHandshakeData), viz-commands.mjs, util.mjs, file-lock.mjs
import { readFileSync, readdirSync, mkdirSync, existsSync } from "fs";
import { join, dirname } from "path";
import { readFileSync, readdirSync, mkdirSync, existsSync, writeFileSync } from "fs";
import { join, dirname, resolve } from "path";
import { fileURLToPath } from "url";

@@ -17,8 +17,72 @@ import os from "os";

import { lockFile } from "./file-lock.mjs";
import { resolveBypass } from "./extensions.mjs";
import { resolveBypass, loadExtensions, firePromptAppend, fireVerdictAppend, survivingExtensions, saveRegistryCache } from "./extensions.mjs";
import { parseBypassArgs } from "./bypass-args.mjs";
import { loadOpcConfig, readTaskFromAC, findLatestRunDir } from "./ext-commands.mjs";
// ─── Step 1.5: Structured result check (extracted for testability) ───
/**
* Scan upstream nodes (since last gate) for artifacts with type "report" or
* "test-result". Returns an array of fail reasons. Empty array = PASS.
* Fail-closed: unreadable artifacts produce a fail reason.
*/
export function checkStructuredResults(dir, state, template, currentNode) {
const structuredFailReasons = [];
const histNoGates = state.history.filter(h => {
const nt = template.nodeTypes?.[h.nodeId];
return nt && nt !== "gate";
});
let lastGateHistIdx = -1;
for (let i = state.history.length - 1; i >= 0; i--) {
const h = state.history[i];
const nt = template.nodeTypes?.[h.nodeId];
if (nt === "gate" && h.nodeId !== currentNode) {
lastGateHistIdx = i;
break;
}
}
const upstreamNodes = lastGateHistIdx === -1
? histNoGates
: state.history.slice(lastGateHistIdx + 1).filter(h => {
const nt = template.nodeTypes?.[h.nodeId];
return nt && nt !== "gate";
});
const seen = new Set();
for (const entry of upstreamNodes) {
if (seen.has(entry.nodeId)) continue;
seen.add(entry.nodeId);
const hsPath = join(dir, "nodes", entry.nodeId, "handshake.json");
if (!existsSync(hsPath)) continue;
let hs;
try { hs = JSON.parse(readFileSync(hsPath, "utf8")); } catch { continue; }
if (!Array.isArray(hs.artifacts)) continue;
for (const art of hs.artifacts) {
if (art.type !== "report" && art.type !== "test-result") continue;
const artPath = resolve(join(dir, "nodes", entry.nodeId), art.path);
let data;
try {
data = JSON.parse(readFileSync(artPath, "utf8"));
} catch (e) {
structuredFailReasons.push(`artifact ${art.path} unreadable — fail-closed`);
continue;
}
const safeInt = (v) => { const n = parseInt(v, 10); return Number.isFinite(n) ? n : 0; };
if (safeInt(data.test_fail_count) > 0)
structuredFailReasons.push(`${safeInt(data.test_fail_count)} test(s) failed`);
if (safeInt(data.dead_test_count) > 0)
structuredFailReasons.push(`${safeInt(data.dead_test_count)} dead test(s) detected`);
if (safeInt(data.p0_count) > 0)
structuredFailReasons.push(`${safeInt(data.p0_count)} P0 issue(s) unresolved`);
if (String(data.sync_check_status || "").toUpperCase() === "FAIL")
structuredFailReasons.push("sync-check failed");
}
}
return structuredFailReasons;
}
// ─── transition ─────────────────────────────────────────────────
export function cmdTransition(args) {
export async function cmdTransition(args) {
const from = getFlag(args, "from");

@@ -44,2 +108,22 @@ const toRaw = getFlag(args, "to");

if (edges && edges[verdict] === null) {
// ── Step 1.5: Structured result check for terminal gate transitions ──
// Terminal PASS edges delegate to cmdFinalize, bypassing _cmdTransitionLocked.
// We must check here to prevent finalize-path bypass.
const nodeType = resolvedTpl.template.nodeTypes?.[from];
if (nodeType === "gate" && verdict !== "FAIL") {
const stPath = join(dir, "flow-state.json");
let st = null;
try { st = JSON.parse(readFileSync(stPath, "utf8")); } catch { /* handled below */ }
if (st) {
const failReasons = checkStructuredResults(dir, st, resolvedTpl.template, from);
if (failReasons.length > 0) {
console.log(JSON.stringify({
allowed: false,
reason: `Step 1.5 structural check failed: ${failReasons.join("; ")} — verdict must be FAIL, not ${verdict}`,
structuredFailReasons: failReasons,
}));
return;
}
}
}
// Valid terminal edge — run finalize instead

@@ -81,3 +165,3 @@ cmdFinalize(args);

try {
_cmdTransitionLocked(from, to, verdict, flow, dir, template, statePath);
await _cmdTransitionLocked(from, to, verdict, flow, dir, template, statePath);
} finally {

@@ -88,3 +172,3 @@ lock.release();

function _cmdTransitionLocked(from, to, verdict, flow, dir, template, statePath) {
async function _cmdTransitionLocked(from, to, verdict, flow, dir, template, statePath) {
let state;

@@ -151,2 +235,3 @@ if (existsSync(statePath)) {

// ── Pre-transition handshake validation ──
// Structural checks block. Quality checks (eval artifacts) become warnings.
if (!isGate) {

@@ -256,2 +341,42 @@ const fromHandshakePath = join(dir, "nodes", from, "handshake.json");

// ── Auto verdictAppend when leaving review node ──
// Fire verdict.append so eval-extensions.json is written before validate checks it.
// Only fires when there are actual eval files to supplement — avoids injecting
// extension verdicts into empty review dirs (which would poison synthesize).
if (!isGate && fromNodeType === "review") {
try {
const vConfig = loadOpcConfig(dir);
Object.assign(vConfig, parseBypassArgs([]), { flowDir: dir });
const vTask = readTaskFromAC(dir);
const vRegistry = await loadExtensions(vConfig);
const fromNodeCaps = template.nodeCapabilities?.[from] || [];
if (fromNodeCaps.length > 0 && vRegistry.extensions?.length > 0) {
const fromNodeDir = join(dir, "nodes", from);
const latestRunDir = findLatestRunDir(fromNodeDir);
if (latestRunDir) {
// Check that real eval files exist (not just eval-extensions.md)
let hasRealEvals = false;
try {
hasRealEvals = readdirSync(latestRunDir)
.filter(f => /^eval-.*\.md$/.test(f) && f !== "eval-extensions.md")
.length >= 2;
} catch { /* best effort */ }
if (hasRealEvals) {
const vCtx = {
node: from, nodeId: from, nodeType: fromNodeType,
role: "verdict-auto", task: vTask, flowDir: resolve(dir),
runDir: latestRunDir,
devServerUrl: process.env.DEV_SERVER_URL || vConfig.devServerUrl || "",
nodeCapabilities: fromNodeCaps,
};
await fireVerdictAppend(vRegistry, vCtx);
saveRegistryCache(resolve(dir), vRegistry);
}
}
}
} catch (err) {
console.error(`WARN: auto verdictAppend failed: ${err.message}`);
}
}
// ── Idempotency guard ──

@@ -332,2 +457,15 @@ if (state.history.length > 0) {

if (isGate) {
// ── Step 1.5: Structured result check (universal enforcement) ──
// This runs on EVERY gate transition, regardless of entry path
// (advance, pass, direct transition). Belt-and-suspenders with cmdAdvance.
const structuredFailReasons = checkStructuredResults(dir, state, template, from);
if (structuredFailReasons.length > 0 && verdict !== "FAIL") {
console.log(JSON.stringify({
allowed: false,
reason: `Step 1.5 structural check failed: ${structuredFailReasons.join("; ")} — verdict must be FAIL, not ${verdict}`,
structuredFailReasons,
}));
return;
}
const gateDir = join(dir, "nodes", from);

@@ -373,3 +511,50 @@ mkdirSync(gateDir, { recursive: true });

const autoReminder = state.autoMode ? "auto mode — do not pause, do not ask user, keep executing" : undefined;
console.log(JSON.stringify({ allowed: true, reason: "ok", next: to, runId, state, ...(autoReminder ? { reminder: autoReminder } : {}) }));
// ── Extension context for next node ────────────────────────────
// Fire promptAppend so the orchestrator gets extension context without
// having to remember to call prompt-context separately.
let extensionContext = null;
try {
const config = loadOpcConfig(dir);
Object.assign(config, parseBypassArgs([]), { flowDir: dir });
const task = readTaskFromAC(dir);
const registry = await loadExtensions(config);
const nextNodeCaps = template.nodeCapabilities?.[to] || [];
const nextNodeType = template.nodeTypes?.[to] || null;
if (nextNodeCaps.length > 0 && registry.extensions?.length > 0) {
const context = {
node: to,
nodeId: to,
nodeType: nextNodeType,
role: "transition-prefetch",
task,
flowDir: resolve(dir),
devServerUrl: process.env.DEV_SERVER_URL || config.devServerUrl || "",
nodeCapabilities: nextNodeCaps,
};
const append = await firePromptAppend(registry, context);
extensionContext = {
append,
applied: survivingExtensions(registry),
nodeCapabilities: nextNodeCaps,
};
saveRegistryCache(resolve(dir), registry);
// Write to file so orchestrator can Read it instead of parsing stdout
if (append) {
const ctxDir = join(dir, "nodes", to);
mkdirSync(ctxDir, { recursive: true });
writeFileSync(join(ctxDir, "extension-context.md"), append, "utf8");
}
}
} catch (err) {
// Extension failures must not block transition
console.error(`WARN: extension context prefetch failed: ${err.message}`);
}
console.log(JSON.stringify({
allowed: true, reason: "ok", next: to, runId, state,
...(autoReminder ? { reminder: autoReminder } : {}),
...(extensionContext?.append ? { extensionContextPath: resolve(join(dir, "nodes", to, "extension-context.md")) } : {}),
}));
}

@@ -399,3 +584,5 @@

// Load config to get requiredExtensions
// Load requiredExtensions from explicit config only.
// validate-chain is post-hoc — it verifies claims, not environment state.
// Auto-discover (filesystem scan) is a runtime concern (init/transition).
let requiredExtensions = [];

@@ -406,6 +593,13 @@ try {

const cfg = JSON.parse(readFileSync(configPath, "utf8"));
requiredExtensions = Array.isArray(cfg.requiredExtensions) ? cfg.requiredExtensions : [];
if (Array.isArray(cfg.requiredExtensions)) requiredExtensions = cfg.requiredExtensions;
}
} catch { /* best effort */ }
// Resolve template for capability-aware enforcement
let chainTemplate = null;
if (state.flowTemplate) {
if (state._flow_file) loadFlowFromFile(state._flow_file);
chainTemplate = FLOW_TEMPLATES[state.flowTemplate] || null;
}
// ─── Bypass-aware requiredExtensions enforcement ─────────────────

@@ -461,5 +655,6 @@ // If the flow was initialized under bypass (recorded in flow-state.bypassMode),

if (!data.status) errors.push(`${nd}/handshake.json: missing status`);
// Check extensionsApplied for required extensions — skip gate nodes (auto-generated, no extension context)
// Check extensionsApplied for required extensions — only on nodes with capabilities
const isGateNode = nd.startsWith("gate") || data.node === "gate" || data.nodeId === "gate";
if (requiredExtensions.length > 0 && !isGateNode) {
const nodeCaps = chainTemplate?.nodeCapabilities?.[nd] || [];
if (requiredExtensions.length > 0 && !isGateNode && nodeCaps.length > 0) {
if (!Object.hasOwn(data, "extensionsApplied")) {

@@ -574,5 +769,12 @@ errors.push(`${nd}/handshake.json: extensionsApplied missing — run \`extension-verdict\` after review nodes`);

}
const verdict = synthResult.verdict || "PASS";
console.error(`[advance] verdict: ${verdict}`);
let verdict = synthResult.verdict || "PASS";
console.error(`[advance] synthesize verdict: ${verdict}`);
// ── Step 1.5: Structured result check ──────────────────────────
const structuredFailReasons = checkStructuredResults(dir, state, template, currentNode);
if (structuredFailReasons.length > 0) {
verdict = "FAIL";
console.error(`[advance] Step 1.5 override → FAIL: ${structuredFailReasons.join("; ")}`);
}
// Step 2: route

@@ -579,0 +781,0 @@ console.error(`[advance] routing ${currentNode} --${verdict}-->...`);

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

case "validate": cmdValidate(args); break;
case "transition": cmdTransition(args); break;
case "transition": await cmdTransition(args); break;
case "validate-chain": cmdValidateChain(args); break;

@@ -39,0 +39,0 @@ case "finalize": cmdFinalize(args); break;

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

@@ -5,0 +5,0 @@ "type": "module",

@@ -26,2 +26,34 @@ # Gate Protocol

### Step 1.5 — Structured Result Check
Before mechanical validation, the gate reads structured data from upstream artifacts. This catches failures that the verdict alone cannot express (e.g., a node can PASS at the orchestration level while its report contains test failures).
**Artifact schema:** Upstream nodes (especially execute and build nodes) MAY write structured result files as part of their artifacts. These files are JSON objects containing any subset of the fields below. The artifact's `type` in the handshake must be `"report"` or `"test-result"` for this check to read it. The path in `artifacts[].path` is relative to the node directory.
**Procedure:**
1. Scan `$SESSION_DIR/nodes/*/handshake.json` for all upstream nodes in this gate's path
2. For each handshake, inspect the `artifacts[]` array. For artifacts with type `report` or `test-result`, read the referenced file
3. **Error handling:** If an artifact file is missing, unreadable, or contains malformed JSON → treat as **FAIL** with reason `"artifact {path} unreadable — fail-closed"`. Structured checks are fail-closed: broken data = gate FAIL, not silent pass.
4. Parse these structured fields (if present in the artifact JSON). **Type coercion:** numeric fields may appear as strings (e.g., `"3"` vs `3`); coerce to integer before comparison. If coercion fails (non-numeric string) → treat as 0 and log a warning.
- `test_fail_count` — number of failed tests
- `dead_test_count` — number of dead/unreachable tests
- `p0_count` — number of unresolved P0 issues
- `sync_check_status` — sync verification result (`"PASS"` or `"FAIL"`)
5. Apply hard FAIL rules — any single violation triggers gate FAIL:
| Field | Condition | Gate action | Reason string |
|-------|-----------|-------------|---------------|
| `test_fail_count` | `> 0` | **FAIL** | `"{N} test(s) failed"` |
| `dead_test_count` | `> 0` | **FAIL** | `"{N} dead test(s) detected"` |
| `p0_count` | `> 0` | **FAIL** | `"{N} P0 issue(s) unresolved"` |
| `sync_check_status` | `== "FAIL"` | **FAIL** | `"sync-check failed"` |
6. If multiple fields trigger, concatenate all reasons (semicolon-separated) into one FAIL verdict
7. If no artifacts with type `report` or `test-result` exist in any upstream handshake, this step is a no-op (backward compatible — older sessions without structured data pass through)
**This check applies to ALL gate nodes** (gate-test, gate-acceptance, gate-audit, gate-e2e, gate-final), not just gate-final. The principle: if any upstream node produced structured evidence of failure, the gate must catch it regardless of the node-level verdict.
**Override:** The orchestrator MUST NOT skip or relax these rules. If structured data says tests failed, the gate FAILs — even if the upstream node verdict was PASS. The only way past this is `/opc pass` (explicit user override).
### Step 2 — Mechanical Validation

@@ -28,0 +60,0 @@