@kontourai/flow-agents 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,115 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+ /**
4
+ * repair-command-log.js — deterministic re-linearization of a concurrent-fork
5
+ * command-log.jsonl.
6
+ *
7
+ * A "fork" happens when two PostToolUse captures append off the same parent tip
8
+ * (parallel writers before the writer lock, flow-agents#232). The records are
9
+ * all genuine and self-consistent; only their linear order is ambiguous. This
10
+ * tool produces THE canonical order — sort chained entries by (capturedAt, then
11
+ * hash) — and re-chains them, so any party re-running it gets the identical
12
+ * result. It is therefore a verifiable repair, not a judgement call.
13
+ *
14
+ * SAFETY: it refuses to run unless verifyCommandLogChain() reports "forked".
15
+ * - "broken" (real tamper: edited content, reorder, deletion) → REFUSE. The
16
+ * repair must never be usable to launder tampering.
17
+ * - "ok" / "legacy" → nothing to do.
18
+ * No record content is altered — only the _chain wrappers and line order. The
19
+ * original is backed up, and an in-chain `chain-repair` marker records that the
20
+ * re-linearization happened (so the repair is itself auditable).
21
+ *
22
+ * Usage: node scripts/repair-command-log.js <artifact-dir> [--reason "..."]
23
+ */
24
+ const fs = require('fs');
25
+ const path = require('path');
26
+ const crypto = require('crypto');
27
+
28
+ const gate = require(path.join(__dirname, 'hooks', 'stop-goal-fit.js'));
29
+ const GENESIS = gate.CHAIN_GENESIS_VERIFY;
30
+
31
+ function canon(rec) {
32
+ const keys = Object.keys(rec).filter((k) => k !== '_chain').sort();
33
+ const obj = {};
34
+ for (const k of keys) obj[k] = rec[k];
35
+ return JSON.stringify(obj);
36
+ }
37
+ function hashLink(prev, rec) {
38
+ return crypto.createHash('sha256').update(prev + canon(rec), 'utf8').digest('hex');
39
+ }
40
+
41
+ function main() {
42
+ const dir = process.argv[2];
43
+ if (!dir) { console.error('usage: repair-command-log.js <artifact-dir> [--reason "..."]'); process.exit(2); }
44
+ const reasonIdx = process.argv.indexOf('--reason');
45
+ const reason = reasonIdx !== -1 ? (process.argv[reasonIdx + 1] || '') : 'deterministic concurrent-fork re-linearization';
46
+
47
+ const verdict = gate.verifyCommandLogChain(dir);
48
+ if (verdict.status === 'ok' || verdict.status === 'legacy') {
49
+ console.log(`nothing to repair: chain status is "${verdict.status}"`);
50
+ return;
51
+ }
52
+ if (verdict.status !== 'forked') {
53
+ console.error(`REFUSING to repair: chain status is "${verdict.status}" (entry ${verdict.brokenAt}). ` +
54
+ 'This tool only re-linearizes benign concurrent forks; it will not touch a tampered chain.');
55
+ process.exit(1);
56
+ }
57
+
58
+ const file = path.join(dir, 'command-log.jsonl');
59
+ const lines = fs.readFileSync(file, 'utf8').split('\n').filter((l) => l.trim());
60
+
61
+ // Preserve legacy prefix verbatim; collect the chained records (content only).
62
+ const legacyPrefix = [];
63
+ const records = [];
64
+ let started = false;
65
+ for (const line of lines) {
66
+ let e;
67
+ try { e = JSON.parse(line); } catch { if (!started) { legacyPrefix.push(line); } continue; }
68
+ const isChained = e._chain && typeof e._chain.hash === 'string';
69
+ if (!started && !isChained) { legacyPrefix.push(line); continue; }
70
+ started = true;
71
+ const rec = { ...e };
72
+ delete rec._chain;
73
+ records.push(rec);
74
+ }
75
+
76
+ // Canonical deterministic order: capturedAt asc, then a stable content hash.
77
+ records.sort((a, b) => {
78
+ const ta = String(a.capturedAt || ''), tb = String(b.capturedAt || '');
79
+ if (ta !== tb) return ta < tb ? -1 : 1;
80
+ const ha = crypto.createHash('sha256').update(canon(a)).digest('hex');
81
+ const hb = crypto.createHash('sha256').update(canon(b)).digest('hex');
82
+ return ha < hb ? -1 : ha > hb ? 1 : 0;
83
+ });
84
+
85
+ // Re-chain from genesis.
86
+ const out = [...legacyPrefix];
87
+ let prev = GENESIS;
88
+ let seq = 0;
89
+ for (const rec of records) {
90
+ const h = hashLink(prev, rec);
91
+ out.push(JSON.stringify({ ...rec, _chain: { seq, prevHash: prev, hash: h } }));
92
+ prev = h; seq += 1;
93
+ }
94
+ // Append an in-chain repair marker so the re-linearization is itself auditable.
95
+ const marker = {
96
+ command: '(chain-repair marker)',
97
+ observedResult: `re-linearized ${records.length} entries from concurrent fork`,
98
+ exitCode: 0,
99
+ capturedAt: new Date().toISOString(),
100
+ source: 'chain-repair',
101
+ repair: { reason, entries: records.length, forkAt: verdict.forkAt },
102
+ };
103
+ const mh = hashLink(prev, marker);
104
+ out.push(JSON.stringify({ ...marker, _chain: { seq, prevHash: prev, hash: mh } }));
105
+
106
+ fs.copyFileSync(file, file + '.prebackup-repair');
107
+ fs.writeFileSync(file, out.join('\n') + '\n');
108
+
109
+ const after = gate.verifyCommandLogChain(dir);
110
+ console.log(`repaired: re-linearized ${records.length} entries (legacy prefix: ${legacyPrefix.length}); ` +
111
+ `chain status now "${after.status}". backup: command-log.jsonl.prebackup-repair`);
112
+ if (after.status !== 'ok') { console.error('repair did not produce a clean chain'); process.exit(1); }
113
+ }
114
+
115
+ main();
@@ -6,7 +6,7 @@ import { createHash } from "node:crypto";
6
6
  import { createRequire } from "node:module";
7
7
  import { fileURLToPath } from "node:url";
8
8
  // ADR 0016 Abstraction A: shared FlowDefinition resolver (P-a)
9
- import { resolveActiveFlowStep, resolveFlowFilePath, resolvePhaseMap, type ActiveFlowStep } from "../lib/flow-resolver.js";
9
+ import { resolveActiveFlowStep, resolveFlowFilePath, resolvePhaseMap, resolveRouteBackPolicy, type ActiveFlowStep } from "../lib/flow-resolver.js";
10
10
 
11
11
  type AnyObj = Record<string, any>;
12
12
 
@@ -1026,21 +1026,71 @@ function deriveSurfaceStatus(ref: AnyObj): string {
1026
1026
  if (ref.integrity?.status !== "matched") return "fail";
1027
1027
  return "pass";
1028
1028
  }
1029
+ /**
1030
+ * Derive kit identity from a parsed trust.bundle by structurally reading the
1031
+ * DECLARED primary claim (kit-typed) rather than hardcoding "builder".
1032
+ *
1033
+ * Resolution order (no fallbacks to "builder"):
1034
+ * 1. First non-workflow.* claim in bundle.claims[] → claimType drives kitId + subject.
1035
+ * 2. No kit-typed claim: try current.json active_flow_id adjacent to the bundle file
1036
+ * (bundle lives at <session-dir>/trust.bundle → flowAgentsDir = grandparent).
1037
+ * 3. Genuinely unknown: mark as "unknown" — never hardcode a kit identity.
1038
+ */
1039
+ export function kitIdentityFromBundle(
1040
+ raw: AnyObj,
1041
+ bundleFile: string,
1042
+ ): { claimType: string; kitId: string; subject: string; gateId: string } {
1043
+ // 1. Structurally read the bundle's declared kit-typed claim.
1044
+ const claims: AnyObj[] = Array.isArray(raw.claims) ? raw.claims : [];
1045
+ for (const claim of claims) {
1046
+ const ct = typeof claim?.claimType === "string" ? claim.claimType : "";
1047
+ if (ct && !ct.startsWith("workflow.")) {
1048
+ const kitId = ct.split(".")[0] ?? "unknown";
1049
+ if (kitId && kitId !== "unknown") {
1050
+ return { claimType: ct, kitId, subject: `${kitId}-kit`, gateId: ct };
1051
+ }
1052
+ }
1053
+ }
1054
+ // 2. No kit-typed claim in bundle — try to derive kit from current.json active_flow_id.
1055
+ // The bundle lives at <session-dir>/trust.bundle, so:
1056
+ // sessionDir = path.dirname(bundleFile)
1057
+ // flowAgentsDir = path.dirname(sessionDir)
1058
+ try {
1059
+ const sessionDir = path.dirname(bundleFile);
1060
+ const flowAgentsDir = path.dirname(sessionDir);
1061
+ const currentFile = path.join(flowAgentsDir, "current.json");
1062
+ const current = JSON.parse(fs.readFileSync(currentFile, "utf8")) as Record<string, unknown>;
1063
+ const flowId = typeof current["active_flow_id"] === "string" ? current["active_flow_id"] : null;
1064
+ if (flowId && flowId.includes(".")) {
1065
+ const kitId = flowId.split(".")[0]!;
1066
+ if (kitId) {
1067
+ const derivedClaimType = `${kitId}.trust.bundle`;
1068
+ return { claimType: derivedClaimType, kitId, subject: `${kitId}-kit`, gateId: derivedClaimType };
1069
+ }
1070
+ }
1071
+ } catch {
1072
+ // Ignore — fall through to unknown
1073
+ }
1074
+ // 3. Genuinely unknown — never fallback to "builder".
1075
+ return { claimType: "unknown.trust.bundle", kitId: "unknown", subject: "unknown-kit", gateId: "unknown.trust.bundle" };
1076
+ }
1029
1077
  function surfaceCheckFromArtifact(file: string, index: number): AnyObj {
1030
1078
  const raw = JSON.parse(read(file));
1031
1079
  const lower = JSON.stringify(raw).toLowerCase();
1080
+ // Structurally read kit identity from the bundle — never hardcode "builder".
1081
+ const { claimType: bundleClaimType, subject: bundleSubject, gateId: bundleGateId } = kitIdentityFromBundle(raw, file);
1032
1082
  let ref: AnyObj;
1033
1083
  if (lower.includes("provider") && lower.includes("absent")) {
1034
- ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: "provider.unavailable", claim_type: "builder.trust.bundle", claim_status: "unknown", subject: "builder-kit", freshness: { status: "unknown", summary: "No trust provider is configured" }, authority: { producer: "unknown", summary: "No trust provider is configured" }, integrity: { status: "unknown", summary: "Unknown" }, status: "not_verified", summary: "No trust provider is configured" };
1084
+ ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: "provider.unavailable", claim_type: bundleClaimType, claim_status: "unknown", subject: bundleSubject, freshness: { status: "unknown", summary: "No trust provider is configured" }, authority: { producer: "unknown", summary: "No trust provider is configured" }, integrity: { status: "unknown", summary: "Unknown" }, status: "not_verified", summary: "No trust provider is configured" };
1035
1085
  } else if (lower.includes("artifact") && lower.includes("absent")) {
1036
- ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: "artifact.unavailable", claim_type: "builder.trust.bundle", claim_status: "unknown", subject: "builder-kit", freshness: { status: "unknown", summary: "Artifact not readable" }, authority: { producer: "unknown", summary: "Artifact not readable" }, integrity: { status: "unknown", summary: "Artifact not readable" }, status: "not_verified", summary: "artifact not readable" };
1086
+ ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: "artifact.unavailable", claim_type: bundleClaimType, claim_status: "unknown", subject: bundleSubject, freshness: { status: "unknown", summary: "Artifact not readable" }, authority: { producer: "unknown", summary: "Artifact not readable" }, integrity: { status: "unknown", summary: "Artifact not readable" }, status: "not_verified", summary: "artifact not readable" };
1037
1087
  } else {
1038
1088
  const claimStatus = lower.includes("rejected") ? "rejected" : "accepted";
1039
1089
  const freshness = lower.includes("stale") ? "stale" : "fresh";
1040
1090
  const producer = lower.includes("missing-authority") ? "unknown" : "surface-local";
1041
1091
  const integrity = lower.includes("mismatch") ? "mismatch" : "matched";
1042
1092
  // Use trust.bundle as the canonical Hachure-aligned artifact_kind for all trust-backed evidence refs
1043
- ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: "builder.trust.bundle", claim_type: "builder.trust.bundle", claim_status: claimStatus, subject: "builder-kit", freshness: { status: freshness, summary: freshness === "fresh" ? "fresh" : "not currently verifiable" }, authority: { producer, summary: producer === "unknown" ? "missing authority" : "Local Surface trust producer." }, integrity: { status: integrity, summary: integrity === "matched" ? "matched" : "integrity mismatch" } };
1093
+ ref = { artifact_kind: "trust.bundle", artifact_ref: file, gate_id: bundleGateId, claim_type: bundleClaimType, claim_status: claimStatus, subject: bundleSubject, freshness: { status: freshness, summary: freshness === "fresh" ? "fresh" : "not currently verifiable" }, authority: { producer, summary: producer === "unknown" ? "missing authority" : "Local Surface trust producer." }, integrity: { status: integrity, summary: integrity === "matched" ? "matched" : "integrity mismatch" } };
1044
1094
  ref.status = deriveSurfaceStatus(ref);
1045
1095
  ref.summary = ref.status === "pass" ? "accepted" : ref.status === "not_verified" ? "not currently verifiable" : (claimStatus === "rejected" ? "rejected" : producer === "unknown" ? "missing authority" : "integrity mismatch");
1046
1096
  }
@@ -1279,14 +1329,20 @@ async function advanceState(p: ReturnType<typeof parseArgs>): Promise<number> {
1279
1329
  const prev = loadJson(path.join(dir, "state.json"));
1280
1330
  if ((status === "archived" || status === "accepted") && prev.phase !== "learning") diagnostic(dir, "terminal_jump_rejected", "Terminal workflow states require release and learning gates.");
1281
1331
  const flow = opt(p, "flow-definition");
1282
- if (flow === "builder.build" && prev.phase === "verification" && phase === "execution") {
1332
+ // Route-back guard: FlowDefinition-driven (not hardcoded to builder.build).
1333
+ // Fires when the active flow's gate for prev.phase declares a route_back_policy
1334
+ // AND the target phase maps to a step listed in on_route_back values.
1335
+ // builder.build verify-gate already carries this declaration — behavior preserved.
1336
+ const repoRoot = flow ? findRepoRootFromDir(dir) : "";
1337
+ const routeBack = flow ? resolveRouteBackPolicy(flow, prev.phase, phase, repoRoot) : null;
1338
+ if (routeBack) {
1283
1339
  const reason = opt(p, "route-back-reason");
1284
- if (!reason) diagnostic(dir, "route_back_reason_required", "Builder Kit route-back requires implementation_defect or equivalent reason.");
1340
+ if (!reason) diagnostic(dir, "route_back_reason_required", `Route-back from ${prev.phase} to ${phase} requires a --route-back-reason (e.g. implementation_defect).`);
1285
1341
  const file = path.join(dir, "transition-attempts.json");
1286
1342
  const attempts = loadJson(file);
1287
- const key = `verification->execution:${reason}`;
1343
+ const key = `${prev.phase}->${phase}:${reason}`;
1288
1344
  const count = attempts[key]?.count ?? 0;
1289
- if (count >= 3) diagnostic(dir, "route_back_attempts_exceeded", "Builder Kit route-back attempts exceeded.");
1345
+ if (count >= routeBack.maxAttempts) diagnostic(dir, "route_back_attempts_exceeded", `Route-back attempt limit (${routeBack.maxAttempts}) exceeded for ${prev.phase}→${phase}.`);
1290
1346
  attempts[key] = { count: count + 1, reason, updated_at: opt(p, "timestamp", now()) };
1291
1347
  writeJson(file, attempts);
1292
1348
  }
@@ -1300,7 +1356,7 @@ async function advanceState(p: ReturnType<typeof parseArgs>): Promise<number> {
1300
1356
  // --step-id individually. The repoRoot is derived by walking up from dir to find kits/.
1301
1357
  if (flow) {
1302
1358
  const root = path.resolve(opt(p, "artifact-root", path.dirname(dir)));
1303
- const repoRoot = findRepoRootFromDir(dir);
1359
+ // repoRoot already computed above when flow is present
1304
1360
  const phaseMap = resolvePhaseMap(flow, repoRoot);
1305
1361
  const stepId = phaseMap?.[phase] ?? undefined;
1306
1362
  if (stepId) {
@@ -124,6 +124,10 @@ export type ActiveFlowStep = {
124
124
  type FlowGate = {
125
125
  step: string;
126
126
  expects?: GateExpectation[];
127
+ /** Reason-to-step mapping for route-back transitions (e.g. {"implementation_defect": "execute"}). */
128
+ on_route_back?: Record<string, string>;
129
+ /** Policy governing route-back attempt limits. */
130
+ route_back_policy?: { max_attempts: number; on_exceeded: string };
127
131
  };
128
132
 
129
133
  /** Shape of a FlowDefinition JSON file. */
@@ -282,3 +286,84 @@ export function resolveActiveFlowStep(flowAgentsDir: string): ActiveFlowStep | n
282
286
  const repoRoot = findRepoRoot(path.dirname(flowAgentsDir));
283
287
  return resolveFlowStep(flowId, stepId, repoRoot);
284
288
  }
289
+
290
+ /** The resolved route-back policy for a phase transition. */
291
+ export type RouteBackPolicy = {
292
+ /** Maximum allowed route-back attempts for this transition key. */
293
+ maxAttempts: number;
294
+ /** Action when attempts are exceeded (e.g. "block"). */
295
+ onExceeded: string;
296
+ /** The step id whose gate declared this policy (e.g. "verify"). */
297
+ fromStepId: string;
298
+ };
299
+
300
+ /**
301
+ * Resolve the route-back policy for a phase transition, if the active FlowDefinition
302
+ * declares one on the source phase's gate.
303
+ *
304
+ * A route-back is a transition where the source phase's gate declares both
305
+ * `route_back_policy` and `on_route_back`, and the target phase maps to a step
306
+ * listed as a route-back target in `on_route_back` values.
307
+ *
308
+ * This is the FlowDefinition-driven replacement for the hardcoded
309
+ * `flow === "builder.build" && prev.phase === "verification" && phase === "execution"`
310
+ * guard in advance-state. Any flow that declares `route_back_policy` on a gate
311
+ * automatically gets route-back enforcement without code changes.
312
+ *
313
+ * @param flowId e.g. "builder.build" — kitId is the prefix before the first ".".
314
+ * @param fromPhase Lifecycle phase leaving (e.g. "verification").
315
+ * @param toPhase Lifecycle phase entering (e.g. "execution").
316
+ * @param repoRoot Absolute path to the repository root (kits/ lives here).
317
+ * @returns RouteBackPolicy when the transition is a declared route-back, null otherwise.
318
+ */
319
+ export function resolveRouteBackPolicy(
320
+ flowId: string,
321
+ fromPhase: string,
322
+ toPhase: string,
323
+ repoRoot: string,
324
+ ): RouteBackPolicy | null {
325
+ if (!flowId || !fromPhase || !toPhase) return null;
326
+ const dotIdx = flowId.indexOf(".");
327
+ if (dotIdx < 1) return null;
328
+ const kitId = flowId.slice(0, dotIdx);
329
+ const flowName = flowId.slice(dotIdx + 1);
330
+ if (!kitId || !flowName) return null;
331
+
332
+ const flowFilePath = resolveFlowFilePath(kitId, flowName, flowId, repoRoot);
333
+ if (!flowFilePath) return null;
334
+
335
+ let flowDef: FlowDefinition;
336
+ try {
337
+ const raw = fs.readFileSync(flowFilePath, "utf8");
338
+ flowDef = JSON.parse(raw) as FlowDefinition;
339
+ } catch {
340
+ return null; // ENOENT, permission error, or parse error — fail-open
341
+ }
342
+
343
+ if (!flowDef || typeof flowDef !== "object") return null;
344
+ const phaseMap = flowDef.phase_map;
345
+ if (!phaseMap || typeof phaseMap !== "object" || Array.isArray(phaseMap)) return null;
346
+
347
+ const fromStep = phaseMap[fromPhase];
348
+ const toStep = phaseMap[toPhase];
349
+ if (!fromStep || !toStep) return null; // phases not in this flow
350
+
351
+ if (!flowDef.gates) return null;
352
+ for (const gate of Object.values(flowDef.gates)) {
353
+ if (!gate || gate.step !== fromStep) continue;
354
+ if (!gate.route_back_policy || !gate.on_route_back) return null;
355
+ // Check if toStep is a valid route-back target declared in on_route_back
356
+ const routeBackTargets = Object.values(gate.on_route_back);
357
+ if (!routeBackTargets.includes(toStep)) return null;
358
+ const maxAttempts =
359
+ typeof gate.route_back_policy.max_attempts === "number"
360
+ ? gate.route_back_policy.max_attempts
361
+ : 3;
362
+ return {
363
+ maxAttempts,
364
+ onExceeded: gate.route_back_policy.on_exceeded ?? "block",
365
+ fromStepId: fromStep,
366
+ };
367
+ }
368
+ return null;
369
+ }