@kontourai/flow-agents 3.1.0 → 3.3.0

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.
Files changed (86) hide show
  1. package/.github/workflows/ci.yml +16 -0
  2. package/CHANGELOG.md +30 -0
  3. package/build/src/cli/assignment-provider.d.ts +45 -0
  4. package/build/src/cli/assignment-provider.js +107 -13
  5. package/build/src/cli/workflow-artifact-cleanup-audit.js +418 -11
  6. package/build/src/cli/workflow-sidecar.d.ts +238 -4
  7. package/build/src/cli/workflow-sidecar.js +875 -14
  8. package/build/src/tools/validate-source-tree.js +3 -2
  9. package/context/contracts/artifact-contract.md +16 -2
  10. package/context/contracts/assignment-provider-contract.md +1 -1
  11. package/context/contracts/execution-contract.md +78 -0
  12. package/context/scripts/hooks/config-protection.js +11 -4
  13. package/context/scripts/hooks/stop-goal-fit.js +259 -4
  14. package/context/scripts/hooks/workflow-steering.js +73 -1
  15. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
  16. package/docs/coordination-guide.md +370 -0
  17. package/docs/decisions/agent-coordination.md +26 -9
  18. package/docs/decisions/index.md +2 -2
  19. package/docs/decisions/trust-reconcile.md +42 -9
  20. package/docs/fixture-ownership.md +3 -2
  21. package/docs/index.md +4 -0
  22. package/docs/integrations/flow-agents-console.md +108 -0
  23. package/docs/integrations/index.md +4 -0
  24. package/docs/workflow-artifact-lifecycle.md +38 -1
  25. package/evals/ci/antigaming-suite.sh +1 -0
  26. package/evals/ci/run-baseline.sh +8 -0
  27. package/evals/fixtures/reconcile-preflight/disputed-critique-unsuperseded.json +48 -0
  28. package/evals/fixtures/reconcile-preflight/standalone-disputed-session-local.json +59 -0
  29. package/evals/integration/test_checkpoint_signing.sh +14 -5
  30. package/evals/integration/test_ci_actor_identity.sh +221 -0
  31. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  32. package/evals/integration/test_gate_lockdown.sh +36 -0
  33. package/evals/integration/test_model_routing_escalation.sh +145 -0
  34. package/evals/integration/test_publish_delivery.sh +73 -8
  35. package/evals/integration/test_reconcile_preflight.sh +304 -0
  36. package/evals/integration/test_stop_hook_release.sh +552 -0
  37. package/evals/integration/test_takeover_protocol.sh +340 -0
  38. package/evals/integration/test_trust_reconcile_negatives.sh +261 -0
  39. package/evals/integration/test_verify_hold.sh +910 -0
  40. package/evals/integration/test_veritas_governance_kit.sh +257 -0
  41. package/evals/integration/test_workflow_artifact_cleanup_audit.sh +575 -3
  42. package/evals/run.sh +14 -0
  43. package/evals/static/test_model_routing_hints.sh +107 -0
  44. package/kits/builder/skills/builder-shape/SKILL.md +10 -0
  45. package/kits/builder/skills/continue-work/SKILL.md +2 -0
  46. package/kits/builder/skills/deliver/SKILL.md +125 -11
  47. package/kits/builder/skills/design-probe/SKILL.md +10 -0
  48. package/kits/builder/skills/execute-plan/SKILL.md +13 -0
  49. package/kits/builder/skills/fix-bug/SKILL.md +17 -0
  50. package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
  51. package/kits/builder/skills/plan-work/SKILL.md +9 -0
  52. package/kits/builder/skills/pull-work/SKILL.md +22 -2
  53. package/kits/builder/skills/review-work/SKILL.md +11 -0
  54. package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
  55. package/kits/builder/skills/verify-work/SKILL.md +11 -0
  56. package/kits/knowledge/adapters/default-store/index.js +56 -15
  57. package/kits/knowledge/adapters/flow-runner/index.js +912 -16
  58. package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
  59. package/kits/knowledge/adapters/shared/codec.js +124 -0
  60. package/kits/knowledge/docs/store-contract.md +405 -3
  61. package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
  62. package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
  63. package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
  64. package/kits/knowledge/evals/contract-suite/suite.test.js +36 -0
  65. package/kits/knowledge/evals/freshness/suite.test.js +339 -0
  66. package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
  67. package/kits/knowledge/evals/retirement/suite.test.js +1 -1
  68. package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
  69. package/kits/veritas-governance/docs/README.md +81 -3
  70. package/kits/veritas-governance/fixtures/exemption/approved.trust-bundle.json +74 -0
  71. package/kits/veritas-governance/fixtures/exemption/not-approved.trust-bundle.json +74 -0
  72. package/kits/veritas-governance/flows/exemption-issuance.flow.json +35 -0
  73. package/kits/veritas-governance/kit.json +5 -0
  74. package/package.json +1 -1
  75. package/schemas/workflow-handoff.schema.json +6 -0
  76. package/scripts/ci/mint-attestation.js +33 -6
  77. package/scripts/ci/trust-reconcile.js +222 -279
  78. package/scripts/hooks/config-protection.js +11 -4
  79. package/scripts/hooks/lib/actor-identity.js +82 -0
  80. package/scripts/hooks/stop-goal-fit.js +259 -4
  81. package/scripts/hooks/workflow-steering.js +73 -1
  82. package/scripts/lib/reconcile-shape.js +381 -0
  83. package/src/cli/assignment-provider.ts +122 -13
  84. package/src/cli/workflow-artifact-cleanup-audit.ts +483 -10
  85. package/src/cli/workflow-sidecar.ts +965 -14
  86. package/src/tools/validate-source-tree.ts +3 -2
@@ -219,9 +219,17 @@ function resolveEnsureSessionActor(p: ReturnType<typeof parseArgs>): { actorStru
219
219
  return { actorStruct: { runtime: "unresolved", session_id: branchActorKey, host: os.hostname() }, actorKey: resolved.actor, branchActorKey, unresolved: true };
220
220
  }
221
221
 
222
+ // #398: the CI-runtime tier must reconstruct the SAME struct resolveActor serialized, or
223
+ // `serializeActor(actorStruct)` would diverge from `resolved.actor` (the else-branch would rebuild
224
+ // an ANCESTRY struct — detectRuntime→unknown, runtimeSessionId→'' — so the claim's stored
225
+ // actor_key would not match the CI actor at publish → self not recognized → false-block, the exact
226
+ // bug this issue removes). Uses the SAME helper.detectCiActor as resolveActor, single-sourced.
227
+ const ciActor = resolved.source.startsWith("ci-runtime") ? helper.detectCiActor(process.env) : null;
222
228
  const actorStruct: ActorStruct = resolved.source === "explicit-override"
223
229
  ? { runtime: "explicit-override", session_id: resolved.actor, host: os.hostname() }
224
- : { runtime: helper.detectRuntime(process.env), session_id: helper.runtimeSessionId(process.env) || (() => { const seed = helper.ancestorActorSeed(); return seed ? `anc-${seed}` : ""; })(), host: os.hostname() };
230
+ : ciActor && ciActor.session_id
231
+ ? { runtime: ciActor.runtime, session_id: ciActor.session_id, host: os.hostname() }
232
+ : { runtime: helper.detectRuntime(process.env), session_id: helper.runtimeSessionId(process.env) || (() => { const seed = helper.ancestorActorSeed(); return seed ? `anc-${seed}` : ""; })(), host: os.hostname() };
225
233
  const actorKey = helper.serializeActor(actorStruct);
226
234
  return { actorStruct, actorKey, branchActorKey, unresolved: false };
227
235
  }
@@ -1416,6 +1424,11 @@ function enforceEnsureSessionOwnership(
1416
1424
  // assignment-provider.ts's sanitizeAuditEntryForDisplay) — this guard never echoes `--reason`
1417
1425
  // into a die() message, so it has no free-text field requiring the 240 tier today.
1418
1426
  const sanitize = (value: unknown): string => stripControlCharsForDisplay(value).slice(0, 64);
1427
+ // #294: the 240-char free-text tier (repo convention: 64 for id-like, 240 for free text — cf.
1428
+ // assignment-provider.ts sanitizeDisplayField(record.branch/reason, 240)). Used for the takeover
1429
+ // `resumed_branch` (a realistic agent/<actor>/<slug> branch exceeds 64 — truncation would produce a
1430
+ // bad `git checkout` target) and the audit `reason` (else "resuming from trust bundle" is cut off).
1431
+ const sanitizeWide = (value: unknown): string => stripControlCharsForDisplay(value).slice(0, 240);
1419
1432
  const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : Date.now();
1420
1433
  const assignmentProviderKind = opt(p, "assignment-provider", "local-file");
1421
1434
 
@@ -1495,15 +1508,32 @@ function enforceEnsureSessionOwnership(
1495
1508
  const assignment = readLocalAssignmentStatus(root, slug);
1496
1509
  const fromActor = assignment.record?.actor;
1497
1510
  if (!fromActor) die(`ensure-session --supersede-stale: no existing local-file claim record found for subject ${sanitize(slug)} to supersede`);
1511
+ // #294 (ADR 0021 §5): takeover is resumption — the successor CONTINUES the incumbent's branch,
1512
+ // never a parallel one. Capture the incumbent's branch (from its pre-supersede record) and
1513
+ // surface it as `resumed_branch` so the skill can `git checkout` it; default the audit reason to
1514
+ // the ADR §5 wording (superseded actor X, last seen T, resuming from trust bundle).
1515
+ const incumbentBranch = assignment.record?.branch;
1516
+ const incumbentLastSeen = effective.holder?.last_at ?? assignment.record?.claimed_at;
1517
+ const takeoverReason = opt(p, "reason") || `takeover: superseded ${holderActor}${incumbentLastSeen ? `, last seen ${sanitize(incumbentLastSeen)}` : ""}, resuming from trust bundle`;
1498
1518
  performLocalSupersede(root, slug, fromActor, resolution.actorStruct, {
1499
- branch: resolveBranchForClaim(),
1519
+ // #294 (ADR 0021 §5): takeover is RESUMPTION — the successor continues the incumbent's branch,
1520
+ // so the record must keep pointing at that branch, NOT be overwritten with the successor's
1521
+ // current branch (`resolveBranchForClaim()`). At supersede time the successor has not yet
1522
+ // `git checkout`ed the resume branch (the skill claims ownership first, then checks out), so
1523
+ // resolveBranchForClaim() would otherwise clobber the record with a parallel branch and make a
1524
+ // later reader (resume surface / verify-hold guidance) see the wrong branch. Preserve the
1525
+ // incumbent's branch; fall back to the successor's only if the incumbent record had none.
1526
+ branch: incumbentBranch || resolveBranchForClaim(),
1500
1527
  artifactDir: path.relative(root, dir) || ".",
1501
- reason: opt(p, "reason", "ensure-session takeover: stale claim"),
1528
+ reason: takeoverReason,
1502
1529
  // F1 fix (fix-plan iteration 1, HIGH): persist the canonical actor_key on the record so
1503
1530
  // computeEffectiveState's holderActorKey (assignment-provider.ts) matches this same
1504
1531
  // branchActorKey string on the next status/guard check, cross-tool.
1505
1532
  actorKey: resolution.branchActorKey,
1506
1533
  });
1534
+ // Render-don't-execute: emit the incumbent's branch so the skill continues it (never a new
1535
+ // branch). The successor re-enters the SAME artifact dir (deterministic slug) by construction.
1536
+ printJson({ role: "SupersedeTakeover", subject: sanitize(slug), superseded_actor: holderActor, ...(incumbentBranch ? { resumed_branch: sanitizeWide(incumbentBranch) } : {}), reason: sanitizeWide(takeoverReason) });
1507
1537
  return;
1508
1538
  }
1509
1539
  case "free": {
@@ -1619,7 +1649,30 @@ function recordAgentEvent(p: ReturnType<typeof parseArgs>): number {
1619
1649
  if (hasExplicitRoot || !explicit) requireArtifactDirUnderRoot(dir, root);
1620
1650
  const timestamp = opt(p, "timestamp", now());
1621
1651
  const agent = validateAgentId(opt(p, "agent-id"));
1622
- const event = { timestamp, agent_id: agent, kind: opt(p, "kind", "note"), status: opt(p, "status", "info"), summary: opt(p, "summary"), ...(opt(p, "ref") ? { ref: opt(p, "ref") } : {}) };
1652
+ // #376 model routing: optionally stamp the delegate role/model resolved from
1653
+ // .datum/config.json onto the event so a downstream economics record (#349)
1654
+ // can price role assignments per delegation, and so an escalate-on-gate-failure
1655
+ // re-dispatch records which tier it climbed FROM. Fully additive/optional: when
1656
+ // no routing flag is passed the event shape is byte-identical to before.
1657
+ // These live as TOP-LEVEL event fields (not nested) on purpose: appendJsonl's
1658
+ // serializer (spacedLine) uses the top-level key list as a JSON.stringify array
1659
+ // replacer, which is an allowlist applied at every nesting level — a nested
1660
+ // routing object would have its inner keys stripped. Flat keeps the shape a
1661
+ // simple per-event routing record a JSONL economics feed can read directly.
1662
+ const role = opt(p, "role");
1663
+ const model = opt(p, "model");
1664
+ const escalatedFrom = opt(p, "escalated-from");
1665
+ const event = {
1666
+ timestamp,
1667
+ agent_id: agent,
1668
+ kind: opt(p, "kind", "note"),
1669
+ status: opt(p, "status", "info"),
1670
+ summary: opt(p, "summary"),
1671
+ ...(opt(p, "ref") ? { ref: opt(p, "ref") } : {}),
1672
+ ...(role ? { role } : {}),
1673
+ ...(model ? { model } : {}),
1674
+ ...(escalatedFrom ? { escalated_from: escalatedFrom } : {}),
1675
+ };
1623
1676
  appendJsonl(path.join(dir, "agents", agent, "events.jsonl"), event);
1624
1677
  updateCurrentAgent(root, dir, agent, event.status, timestamp, actorKey);
1625
1678
  return 0;
@@ -1856,6 +1909,14 @@ function checksFromBundle(dir: string): AnyObj[] {
1856
1909
  const seen = new Set<string>();
1857
1910
  const checks: AnyObj[] = [];
1858
1911
  const kindOf = (claim: AnyObj): string => String((claim.metadata as AnyObj).check_kind);
1912
+ // Read side of the buildTrustBundle waiver round-trip (write side: buildTrustBundle reads
1913
+ // check._waiver at line ~689 and stamps it onto claimMetadata.waiver at line ~705). Without
1914
+ // this, any caller that rebuilds checks via checksFromBundle() (recordCritique/recordLearning)
1915
+ // silently drops a previously-recorded waiver on the next bundle write.
1916
+ const waiverOf = (claim: AnyObj): AnyObj | undefined => {
1917
+ const md = claim.metadata as AnyObj;
1918
+ return md && typeof md === "object" && md.waiver && typeof md.waiver === "object" ? md.waiver as AnyObj : undefined;
1919
+ };
1859
1920
  for (const ev of bundle.evidence) {
1860
1921
  if (!ev || !ev.claimId) continue;
1861
1922
  const claim = claimById.get(ev.claimId);
@@ -1868,6 +1929,8 @@ function checksFromBundle(dir: string): AnyObj[] {
1868
1929
  const check: AnyObj = { id: String(claim.subjectId || "").split("/").pop() || ev.claimId, kind, status, summary: claim.fieldOrBehavior || "" };
1869
1930
  if (ev.execution && typeof ev.execution.label === "string") check.command = ev.execution.label;
1870
1931
  if (ev.evidenceType) check.evidenceType = ev.evidenceType;
1932
+ const waiver = waiverOf(claim);
1933
+ if (waiver) check._waiver = waiver;
1871
1934
  checks.push(check);
1872
1935
  }
1873
1936
  // Also include check claims that have no evidence item (surface_trust_refs style).
@@ -1877,7 +1940,10 @@ function checksFromBundle(dir: string): AnyObj[] {
1877
1940
  if (seen.has(claim.id)) continue;
1878
1941
  seen.add(claim.id);
1879
1942
  const kind = kindOf(claim);
1880
- checks.push({ id: String(claim.subjectId || "").split("/").pop() || claim.id, kind, status: claim.value ?? "not_verified", summary: claim.fieldOrBehavior || "" });
1943
+ const check: AnyObj = { id: String(claim.subjectId || "").split("/").pop() || claim.id, kind, status: claim.value ?? "not_verified", summary: claim.fieldOrBehavior || "" };
1944
+ const waiver = waiverOf(claim);
1945
+ if (waiver) check._waiver = waiver;
1946
+ checks.push(check);
1881
1947
  }
1882
1948
  return checks;
1883
1949
  }
@@ -2142,7 +2208,13 @@ async function promote(p: ReturnType<typeof parseArgs>): Promise<number> {
2142
2208
  // Optionally republish so delivery/trust.bundle carries the promotion claim for CI.
2143
2209
  if (p.flags.has("publish")) {
2144
2210
  const publishRepoRoot = opt(p, "publish-repo-root") ? path.resolve(opt(p, "publish-repo-root")) : findRepoRootFromDirStrict(dir);
2211
+ // #356 AC6: an InvalidBundleShapeError refusal must be LOUD (rethrown, so `promote`
2212
+ // itself fails/exits non-zero) — it is NOT one of the best-effort failure modes (missing
2213
+ // kits/ ancestor, I/O) this catch otherwise tolerates. A `.catch(() => {})`-style swallow
2214
+ // here would silently defeat the whole preflight for the --publish path.
2145
2215
  await publishDelivery(dir, publishRepoRoot).catch((err: unknown) => {
2216
+ if (err instanceof InvalidBundleShapeError) throw err;
2217
+ if (err instanceof NotFreshHolderError) throw err;
2146
2218
  process.stderr.write(`[promote] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
2147
2219
  });
2148
2220
  }
@@ -2210,8 +2282,14 @@ async function advanceState(p: ReturnType<typeof parseArgs>): Promise<number> {
2210
2282
  // publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir
2211
2283
  // with no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures
2212
2284
  // are visible (stderr warning), not silently swallowed.
2285
+ // #356 AC6: an InvalidBundleShapeError refusal is NOT one of those best-effort failure
2286
+ // modes — it must be LOUD and cause advance-state itself to fail (rethrown here so the
2287
+ // outer command surfaces a non-zero exit), never silently swallowed alongside a genuine
2288
+ // repo-root-resolution/I-O failure.
2213
2289
  const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
2214
2290
  await publishDelivery(dir, publishRepoRoot).catch((err: unknown) => {
2291
+ if (err instanceof InvalidBundleShapeError) throw err;
2292
+ if (err instanceof NotFreshHolderError) throw err;
2215
2293
  process.stderr.write(`[advance-state] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
2216
2294
  });
2217
2295
  }
@@ -2300,8 +2378,14 @@ async function recordRelease(p: ReturnType<typeof parseArgs>): Promise<number> {
2300
2378
  // publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir with
2301
2379
  // no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures are
2302
2380
  // visible (stderr warning), not silently swallowed.
2381
+ // #356 AC6: an InvalidBundleShapeError refusal is NOT best-effort — rethrow so record-release
2382
+ // itself fails loudly (non-zero exit) rather than silently publishing nothing while reporting
2383
+ // success. This is the crux of AC6: record-release is one of the auto-publish paths that must
2384
+ // never let a shape-invalid bundle slip past unnoticed.
2303
2385
  const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
2304
2386
  await publishDelivery(dir, publishRepoRoot).catch((err: unknown) => {
2387
+ if (err instanceof InvalidBundleShapeError) throw err;
2388
+ if (err instanceof NotFreshHolderError) throw err;
2305
2389
  process.stderr.write(`[record-release] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
2306
2390
  });
2307
2391
  return 0;
@@ -2516,6 +2600,754 @@ async function sealCheckpoint(p: ReturnType<typeof parseArgs>): Promise<number>
2516
2600
  return 0;
2517
2601
  }
2518
2602
 
2603
+
2604
+ // ─── Reconcile Preflight (#356) ───────────────────────────────────────────────
2605
+ // Local, pre-push shape-only preflight for a session's trust.bundle, reusing
2606
+ // scripts/lib/reconcile-shape.js (WS8/#356 extraction) and scripts/ci/trust-reconcile.js's
2607
+ // own exported manifest resolver — never a forked reimplementation, so this can never
2608
+ // silently drift from what the CI trust-reconcile job actually enforces. Deliberately
2609
+ // shape-only: it never spawns a fresh manifest/CI command (AC5) — only the already-cheap
2610
+ // `run-baseline.sh --manifest-json` static registry emit, which prints the manifest, not
2611
+ // test results.
2612
+
2613
+ /**
2614
+ * Delegate to the shared pure-CJS bundle-shape module (scripts/lib/reconcile-shape.js),
2615
+ * mirroring the createRequire pattern used by loadActorIdentityHelper()/
2616
+ * loadLivenessWriteHelper() above — the one repo/runtime boundary #356's plan flagged as
2617
+ * worth double-checking (workflow-sidecar.ts is TS→ESM-compiled-to-CJS-compatible-output;
2618
+ * scripts/lib/reconcile-shape.js is plain CommonJS). Verified clean via `npm run build`
2619
+ * + a require() smoke against build/src/cli/workflow-sidecar.js before this was wired in.
2620
+ */
2621
+ function loadReconcileShapeHelper(): {
2622
+ classifyBundleClaims: (bundle: AnyObj) => {
2623
+ reconcilable: AnyObj[];
2624
+ sessionLocal: AnyObj[];
2625
+ noEvidenceCommand: AnyObj[];
2626
+ waiverOnCommand: AnyObj[];
2627
+ };
2628
+ normalizeCmd: (cmd: string) => string;
2629
+ waiverOnCommandIssues: (waiverOnCommand: AnyObj[]) => AnyObj[];
2630
+ noEvidenceCommandIssues: (noEvidenceCommand: AnyObj[]) => AnyObj[];
2631
+ reconcilableManifestIssues: (reconcilable: AnyObj[], manifestByCmd: Map<string, AnyObj>) => { issues: AnyObj[]; unresolved: AnyObj[] };
2632
+ sessionLocalShapeIssues: (
2633
+ sessionLocal: AnyObj[],
2634
+ derivedStatus: Map<string, string | null> | null,
2635
+ opts?: { onUnderivable?: "fail" | "reduce" }
2636
+ ) => { issues: AnyObj[]; attestedCount: number; logEvents: AnyObj[] };
2637
+ } {
2638
+ const _req = createRequire(import.meta.url);
2639
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/lib/reconcile-shape.js");
2640
+ return _req(helperPath);
2641
+ }
2642
+
2643
+ /**
2644
+ * Delegate to scripts/ci/trust-reconcile.js's own EXPORTED pure helpers (manifest
2645
+ * resolution + the git-ancestor primitive) — same createRequire idiom as
2646
+ * loadReconcileShapeHelper() above. trust-reconcile.js is CommonJS; these exports were
2647
+ * added alongside `runTrustReconcile` specifically so a local caller (this preflight)
2648
+ * never needs a second implementation of "how the manifest is resolved" (Q1/AC5).
2649
+ */
2650
+ function loadTrustReconcileHelper(): {
2651
+ resolveManifest: (args: { manifest?: string | null }, repoRoot: string, canonicalCommands: string[]) => { entries: Array<{ id: string; command: string }>; source: string };
2652
+ runBaselineManifest: (repoRoot: string) => Array<{ id: string; command: string }> | null;
2653
+ normalizeManifestEntries: (raw: unknown) => Array<{ id: string; command: string }> | null;
2654
+ slugifyLabel: (s: string) => string;
2655
+ normalizeCmd: (cmd: string) => string;
2656
+ isAncestorCommit: (repoRoot: string, ancestorSha: string, descendantSha: string) => boolean;
2657
+ resolveCanonicalCommands: (args: { commands: string[] }, repoRoot: string) => string[] | null;
2658
+ } {
2659
+ const _req = createRequire(import.meta.url);
2660
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/ci/trust-reconcile.js");
2661
+ return _req(helperPath);
2662
+ }
2663
+
2664
+ /**
2665
+ * Shell to scripts/ci/derive-claim-status.mjs exactly as trust-reconcile.js's own
2666
+ * deriveClaimStatuses() does (Q1's recommendation: full status-misassertion/unwaived-assumed
2667
+ * parity with CI, at the cost of one cheap local-only spawn — no CI command execution, just
2668
+ * Surface's pure deriveClaimStatus over the bundle's own evidence/events/policies). Returns
2669
+ * null when re-derivation is unavailable (Surface could not load / helper failed) — callers
2670
+ * degrade to reconcile-shape.js's documented reduced-coverage mode, they do not fail the
2671
+ * whole preflight over this.
2672
+ */
2673
+ function derivePreflightClaimStatuses(bundlePath: string, repoRoot: string): Map<string, string | null> | null {
2674
+ const helper = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/ci/derive-claim-status.mjs");
2675
+ if (!fs.existsSync(helper)) return null;
2676
+ let stdout: string;
2677
+ try {
2678
+ stdout = execFileSync(process.execPath, [helper, bundlePath], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 60000 });
2679
+ } catch {
2680
+ return null;
2681
+ }
2682
+ if (!stdout) return null;
2683
+ try {
2684
+ const obj = JSON.parse(stdout);
2685
+ const m = new Map<string, string | null>();
2686
+ for (const [k, v] of Object.entries(obj)) m.set(k, v as string | null);
2687
+ return m;
2688
+ } catch {
2689
+ return null;
2690
+ }
2691
+ }
2692
+
2693
+ /**
2694
+ * Distinct, identifiable error for a shape-invalid trust.bundle — NOT a generic Error, so
2695
+ * every publishDelivery() call site (advanceState/recordRelease/promote/publishDeliveryCmd)
2696
+ * can positively distinguish "refuses to publish an invalid bundle" (must be LOUD, fail
2697
+ * closed, AC6) from the other failure modes those call sites already tolerate as best-effort
2698
+ * (missing kits/ ancestor for a scratch dir, I/O errors, etc — see each catch's own comment).
2699
+ * A bare `instanceof Error` check would not suffice since every thrown failure in this file is
2700
+ * already an Error; `code` is the recognizable, grep-stable discriminator.
2701
+ */
2702
+ export class InvalidBundleShapeError extends Error {
2703
+ readonly code = "RECONCILE_PREFLIGHT_INVALID_SHAPE" as const;
2704
+ readonly issues: string[];
2705
+ constructor(issues: string[]) {
2706
+ super(`trust.bundle failed the reconcile-preflight shape check (${issues.length} issue(s)) — see .issues for the full report`);
2707
+ this.name = "InvalidBundleShapeError";
2708
+ this.issues = issues;
2709
+ }
2710
+ }
2711
+
2712
+ /**
2713
+ * Distinct, identifiable error for a publish attempt by an actor who is NOT the fresh,
2714
+ * non-superseded holder of the subject (issue #293, ADR 0021 §3) — NOT a generic Error and NOT
2715
+ * `InvalidBundleShapeError` (#356), so every publishDelivery() call site can positively
2716
+ * distinguish "refuses to publish because the bundle shape is invalid" from "refuses to publish
2717
+ * because this actor no longer holds the subject" — two genuinely distinct fail-closed tiers
2718
+ * that must never be conflated in a catch handler (a worker fixing a shape issue must not be
2719
+ * told to "re-record evidence" when the real problem is a stale/superseded claim, and vice
2720
+ * versa). Mirrors `InvalidBundleShapeError`'s shape exactly: same `extends Error` base, same
2721
+ * `readonly code` discriminator convention, same doc-comment structure — only the payload
2722
+ * differs (the effective-state/holder/reason/guidance `runVerifyHold()` computed, rather than a
2723
+ * shape-issue list).
2724
+ */
2725
+ export class NotFreshHolderError extends Error {
2726
+ readonly code = "VERIFY_HOLD_NOT_FRESH_HOLDER" as const;
2727
+ readonly effective_state: EffectiveState | "not_evaluated";
2728
+ readonly holder?: { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string };
2729
+ readonly reason: string;
2730
+ readonly guidance: string[];
2731
+ constructor(result: { effective_state: EffectiveState | "not_evaluated"; holder?: { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string }; reason: string; guidance: string[] }) {
2732
+ super(`verify-hold refused publish — not the fresh holder of this subject (${result.reason}) — see .guidance for reconcile steps`);
2733
+ this.name = "NotFreshHolderError";
2734
+ this.effective_state = result.effective_state;
2735
+ this.holder = result.holder;
2736
+ this.reason = result.reason;
2737
+ this.guidance = result.guidance;
2738
+ }
2739
+ }
2740
+
2741
+ /**
2742
+ * Human-actionable fix text per divergence type (Q2: unwaived-assumed's message always
2743
+ * carries the "waiver voided by a mixed record-evidence call" root-cause hint — shape #5 is
2744
+ * NOT a distinct predicate, it is #2 enriched, per the plan's Q2 resolution).
2745
+ */
2746
+ function preflightFixHint(type: string): string {
2747
+ switch (type) {
2748
+ case "unwaived-assumed":
2749
+ return "FIX: make it pass for real, or re-record with --accepted-gap-reason/--waived-by on a SEPARATE record-evidence call — a command-backed check in the SAME call voids/rejects the waiver (see the command-backed-waiver guard in workflow-sidecar.ts's recordEvidence).";
2750
+ case "waiver-on-command-check":
2751
+ return "FIX: a command-backed (test_output) check cannot be waived — either drop the waiver metadata and let it reconcile against the manifest for real, or record it as a session-local (non-command) claim if it genuinely cannot be re-run.";
2752
+ case "not-run":
2753
+ return "FIX: fold this into a non-command summary (session-local claim, no execution.label), or name the EXACT verbatim manifest command in evidence.execution.label so it reconciles.";
2754
+ case "laundering":
2755
+ return "FIX: remove the exit-code-laundering operator (||, ; true, ; exit 0, etc.) from the command — a laundered command cannot be trusted to reconcile.";
2756
+ case "session-local-failed":
2757
+ return "FIX: a disputed/failing claim always blocks reconcile. Document a disjoint pre-existing failure as prose in a WAIVED non-command summary, not as a standalone claim.";
2758
+ case "status-misassertion":
2759
+ return "FIX: the claim's asserted status does not match what Surface re-derives from the bundle's own evidence/events/policies — re-record evidence so the bundle's own data supports the asserted status; do not hand-edit status.";
2760
+ case "status-underivable":
2761
+ return "FIX: CI-side status re-derivation failed for this claim — ensure @kontourai/surface is installed/resolvable and the claim's evidence/events are well-formed, then re-record.";
2762
+ case "unwaived-session-local":
2763
+ return "FIX: this claim asserts pass but has neither a waiver nor a CI-re-derived 'verified' status — add a waiver (--accepted-gap-reason/--waived-by) or resolve it so Surface derives 'verified'.";
2764
+ default:
2765
+ return "FIX: see the divergence message above for the specific shape defect.";
2766
+ }
2767
+ }
2768
+
2769
+ /**
2770
+ * Callable core shared by BOTH the `reconcile-preflight` CLI handler and
2771
+ * publishDelivery()'s fail-closed gate (#356 Wave 3) — one implementation, not two entry
2772
+ * points that could drift. SHAPE-only: never spawns a fresh manifest/CI command (AC5) — the
2773
+ * only subprocess calls here are the already-cheap run-baseline.sh --manifest-json static
2774
+ * registry emit (inside resolveManifest, reused unchanged from trust-reconcile.js) and the
2775
+ * local-only derive-claim-status.mjs re-derivation (no CI command execution either).
2776
+ *
2777
+ * @param dir artifact/session directory containing trust.bundle
2778
+ * @param repoRoot repo root used for manifest resolution + the optional ancestor warning
2779
+ * @param manifestOverride optional --manifest JSON string (CLI passthrough)
2780
+ */
2781
+ type TrustReconcileHelper = ReturnType<typeof loadTrustReconcileHelper>;
2782
+
2783
+ /**
2784
+ * F4 (iteration-1): extracted from runReconcilePreflight so the main function stays under
2785
+ * the 50-line guideline. Q3 (optional, non-blocking): warn if the checkpoint's commit_sha is
2786
+ * not yet an ancestor of local HEAD — never affects ok/exit code, best-effort only. Already a
2787
+ * self-contained try/catch with no effect on the caller's `ok`/`issues`.
2788
+ */
2789
+ function checkpointStalenessWarning(dir: string, repoRoot: string, tr: TrustReconcileHelper): string[] {
2790
+ const warnings: string[] = [];
2791
+ try {
2792
+ const checkpointPath = path.join(dir, "trust.checkpoint.json");
2793
+ if (fs.existsSync(checkpointPath)) {
2794
+ const checkpoint = loadJson(checkpointPath);
2795
+ const commitSha = checkpoint && typeof checkpoint === "object" ? (checkpoint as AnyObj).commit_sha : undefined;
2796
+ if (typeof commitSha === "string" && commitSha) {
2797
+ let headSha = "";
2798
+ try {
2799
+ headSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
2800
+ } catch {
2801
+ headSha = "";
2802
+ }
2803
+ if (headSha && !tr.isAncestorCommit(repoRoot, commitSha, headSha)) {
2804
+ warnings.push(`NON-BLOCKING: trust.checkpoint.json commit_sha '${commitSha}' is not an ancestor of local HEAD '${headSha}' — the checkpoint may be stale (e.g. after an amend/rebase). Consider re-sealing (seal-checkpoint) before publishing. This is a warning only — see #335 for the full checkpoint-staleness ownership check.`);
2805
+ }
2806
+ }
2807
+ }
2808
+ } catch {
2809
+ // best-effort only — never affects ok/exit code.
2810
+ }
2811
+ return warnings;
2812
+ }
2813
+
2814
+ export function runReconcilePreflight(
2815
+ dir: string,
2816
+ repoRoot: string,
2817
+ manifestOverride?: string | null
2818
+ ): { ok: boolean; issues: string[]; warnings: string[] } {
2819
+ const bundlePath = path.join(dir, "trust.bundle");
2820
+ if (!fs.existsSync(bundlePath)) {
2821
+ // A preflight with nothing to check is a usage error, not a soft pass — an agent must
2822
+ // never read "no bundle" as "bundle is valid" (see reconcilePreflightCmd for the CLI-side
2823
+ // die()). The library entrypoint throws too, since publishDelivery() itself already has
2824
+ // its OWN, separate, pre-existing fail-soft branch for bundle-absence (guarded before this
2825
+ // function is ever called there) — this function is never invoked without a bundle.
2826
+ throw new Error(`reconcile-preflight: no trust.bundle found at ${bundlePath} — nothing to check. This is a usage error, not a soft pass.`);
2827
+ }
2828
+
2829
+ const bundle = loadJson(bundlePath);
2830
+ const shape = loadReconcileShapeHelper();
2831
+ const tr = loadTrustReconcileHelper();
2832
+
2833
+ // Resolve the SAME canonical (fresh-verify) commands CI would fall back to feeding into
2834
+ // resolveManifest's legacy tier (tier 5), for genuine parity on that fallback path too —
2835
+ // CLI --commands is not a preflight concept, so only the TRUST_RECONCILE_COMMANDS env /
2836
+ // package.json trust-reconcile-verify tiers apply locally; a repo with neither and no
2837
+ // manifest source resolves to the same empty legacy fallback CI itself would in that case.
2838
+ const canonicalCommands = tr.resolveCanonicalCommands({ commands: [] }, repoRoot) ?? [];
2839
+ const manifestResolution = tr.resolveManifest({ manifest: manifestOverride ?? null }, repoRoot, canonicalCommands);
2840
+ const manifestByCmd = new Map<string, AnyObj>();
2841
+ for (const e of manifestResolution.entries) manifestByCmd.set(tr.normalizeCmd(e.command), e);
2842
+
2843
+ const derivedStatus = derivePreflightClaimStatuses(bundlePath, repoRoot);
2844
+
2845
+ const { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand } = shape.classifyBundleClaims(bundle);
2846
+
2847
+ const issues: AnyObj[] = [];
2848
+ issues.push(...shape.waiverOnCommandIssues(waiverOnCommand));
2849
+ issues.push(...shape.noEvidenceCommandIssues(noEvidenceCommand));
2850
+ const { issues: manifestIssues } = shape.reconcilableManifestIssues(reconcilable, manifestByCmd);
2851
+ issues.push(...manifestIssues);
2852
+ // iteration-1 F1: the local preflight explicitly opts into reduced-coverage degradation
2853
+ // when re-derivation is unavailable (Surface not installed, spawn failure, etc.) — CI's
2854
+ // trust-reconcile.js does NOT opt in and stays fail-closed on the same condition (see
2855
+ // reconcile-shape.js's sessionLocalShapeIssues docstring for the full mode contract).
2856
+ const { issues: sessionLocalIssues } = shape.sessionLocalShapeIssues(sessionLocal, derivedStatus, { onUnderivable: "reduce" });
2857
+ issues.push(...sessionLocalIssues);
2858
+
2859
+ const report = issues.map((i) => `${i.message} — ${preflightFixHint(i.type)}`);
2860
+ const warnings = checkpointStalenessWarning(dir, repoRoot, tr);
2861
+
2862
+ return { ok: report.length === 0, issues: report, warnings };
2863
+ }
2864
+
2865
+ /**
2866
+ * reconcile-preflight <artifact-dir> [--manifest <json>] [--repo-root <path>]
2867
+ *
2868
+ * Local, pre-push shape-only check of the session's trust.bundle — reuses
2869
+ * scripts/lib/reconcile-shape.js and scripts/ci/trust-reconcile.js's own exported
2870
+ * classification/manifest-resolution so this can never silently drift from what CI's
2871
+ * Trust Reconcile job enforces. Prints every divergence with the claim id, divergence
2872
+ * type, and a human fix instruction; exits 0 with zero issues, 1 otherwise. Never spawns a
2873
+ * fresh manifest/CI command re-run (AC5) — resolves the manifest and re-derives status
2874
+ * (both local-only, no CI command execution) but never re-runs a manifest command itself.
2875
+ *
2876
+ * Usage: workflow-sidecar reconcile-preflight <artifactDir> [--manifest <json>] [--repo-root <path>]
2877
+ */
2878
+ async function reconcilePreflightCmd(p: ReturnType<typeof parseArgs>): Promise<number> {
2879
+ if (p.flags.has("help") || (!p.positional[0] && !p.opts["help"])) {
2880
+ if (p.flags.has("help")) {
2881
+ console.log("Usage: workflow-sidecar reconcile-preflight <artifactDir> [--manifest <json>] [--repo-root <path>]");
2882
+ console.log("Local, pre-push shape-only check of <artifactDir>/trust.bundle. Exits 0 with no issues, 1 otherwise.");
2883
+ return 0;
2884
+ }
2885
+ }
2886
+ const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
2887
+ const repoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
2888
+ if (!repoRoot) die(`reconcile-preflight: no kits/ ancestor found from ${dir}; pass --repo-root explicitly.`);
2889
+ const manifestOverride = p.opts["manifest"]?.at(-1) ?? null;
2890
+
2891
+ const bundlePath = path.join(dir, "trust.bundle");
2892
+ if (!fs.existsSync(bundlePath)) {
2893
+ die(`reconcile-preflight: no trust.bundle at ${bundlePath} — a preflight with nothing to check is a usage error, not a soft pass. Record evidence first (record-evidence) before running reconcile-preflight.`);
2894
+ }
2895
+
2896
+ const result = runReconcilePreflight(dir, repoRoot, manifestOverride);
2897
+
2898
+ for (const w of result.warnings) {
2899
+ process.stderr.write(`[reconcile-preflight] ${w}\n`);
2900
+ }
2901
+
2902
+ if (result.ok) {
2903
+ console.log(`[reconcile-preflight] OK — no shape issues found in ${bundlePath}`);
2904
+ return 0;
2905
+ }
2906
+
2907
+ process.stderr.write(`[reconcile-preflight] FAILED — ${result.issues.length} shape issue(s) found in ${bundlePath}:\n`);
2908
+ for (const issue of result.issues) {
2909
+ process.stderr.write(`[reconcile-preflight] - ${issue}\n`);
2910
+ }
2911
+ return 1;
2912
+ }
2913
+
2914
+ /**
2915
+ * The ONE hard-stop gate (issue #293, ADR 0021 §3): "is my actor the fresh, non-superseded
2916
+ * holder of this subject (or is the subject free/self-held)?" Reuses the SAME assignment ⋈
2917
+ * liveness join #290/#291 already built (`computeEffectiveState` + `readLocalAssignmentStatus`
2918
+ * + `readLivenessEvents`/`freshHolders`) — no second computation is invented here. This is the
2919
+ * mirror-image check, at the OTHER end of the session lifecycle, of
2920
+ * `enforceEnsureSessionOwnership`'s pre-entry guard above: that guard decides whether entry
2921
+ * should claim/supersede/refuse; this gate decides whether PUBLISH should proceed, with a
2922
+ * different (new) per-effective-state mapping — the ownership guard's "claim"/"supersede"
2923
+ * actions are wrong at publish time, so this is new interpretation of the existing join output,
2924
+ * not a new join and not a new `EffectiveState` value (there is no literal `"superseded"` state
2925
+ * — a superseded-away actor's own re-check naturally resolves to `held`(holder=successor) or
2926
+ * `reclaimable`, never `self_is_holder`; see assignment-provider.ts's `computeEffectiveState`).
2927
+ *
2928
+ * Effective-state -> publish-decision mapping (the operative spec, from the plan's table).
2929
+ * IMPORTANT (bug fix, #397): this gate fences the durable ASSIGNMENT hold, not ambient
2930
+ * liveness — liveness is advisory everywhere else in the system, and this is the ONE hard
2931
+ * gate, so it must never false-block on liveness alone. A subject with fresh liveness by
2932
+ * another actor but NO durable assignment record (`held`, reason
2933
+ * `liveness_claim_present_assignment_lagging`) therefore PASSES, not BLOCKs — there is no
2934
+ * durable ownership conflict to protect, only ambient presence. This does NOT weaken zombie
2935
+ * protection: a superseded zombie always has an assignment record (either its own stale
2936
+ * assignment — `reclaimable` — or the successor's assignment-backed `held`/
2937
+ * `fresh_liveness_heartbeat`), both of which still BLOCK below.
2938
+ * - `free` (no assignment ever recorded) -> PASS (never false-block an untracked subject)
2939
+ * - `held`, reason `self_is_holder` -> PASS (the caller IS the current holder)
2940
+ * - `held`, reason `liveness_claim_present_assignment_lagging`
2941
+ * (fresh liveness by another actor, NO durable assignment record) -> PASS (liveness alone
2942
+ * is never a durable ownership conflict — see #397 fix note above)
2943
+ * - `held`, reason `fresh_liveness_heartbeat`
2944
+ * (durable assignment held by another actor + fresh liveness) -> BLOCK (a different
2945
+ * actor durably holds this subject and is demonstrably still live)
2946
+ * - `reclaimable` (assignment present, stale/absent liveness) -> BLOCK (not proof of
2947
+ * self — safe default blocks; a durable assignment record exists)
2948
+ * - `human-held` -> BLOCK (never publish over a human assignee)
2949
+ * - no resolvable join at all (non-local-file provider
2950
+ * with no --effective-state-json) -> `not_evaluated`, PASS (documented scope
2951
+ * boundary, loud stderr note, never a silent block)
2952
+ *
2953
+ * Actor resolution mirrors `enforceEnsureSessionOwnership` EXACTLY: the bare
2954
+ * `resolveActor(env).actor` / branchActorKey string, NEVER `serializeActor(actorStruct)`'s
2955
+ * triple — the #291 flat-vs-triple seam. `opts.actorKey` (the CLI's --actor / FLOW_AGENTS_ACTOR
2956
+ * override, already sanitized by the caller) takes precedence when provided.
2957
+ *
2958
+ * Slug/artifactRoot derivation matches `publishDelivery()`'s own `slug =
2959
+ * path.basename(path.resolve(dir))` byte-for-byte (both must resolve the SAME assignment
2960
+ * record) and the `artifactRoot = path.dirname(dir)` pattern already used elsewhere in this
2961
+ * file for a `dir`-only consumer.
2962
+ *
2963
+ * github provider: read-only precomputed `--effective-state-json` escape hatch, matching
2964
+ * `enforceEnsureSessionOwnership`'s existing github branch precedent exactly (render-don't-
2965
+ * execute — assignment-provider.ts never calls `gh` directly from this CLI).
2966
+ *
2967
+ * Every interpolated actor/holder/reason string is sanitized via
2968
+ * `stripControlCharsForDisplay(...).slice(0, 64)` — reusing the exact top-level helper, never a
2969
+ * second sanitizer (AC7).
2970
+ *
2971
+ * SECOND CI-blocking false-block fix (this iteration): the hard gate above enforces ONLY for a
2972
+ * STABLY-identified actor. A caller's identity is STABLE when it comes from an explicit
2973
+ * `opts.actorKey` (a caller that hands in an actor key is asserting a stable identity — e.g. the
2974
+ * CLI's --actor / FLOW_AGENTS_ACTOR override, or the github skill's precomputed path), from
2975
+ * `FLOW_AGENTS_ACTOR` (`resolveActor` source `"explicit-override"`), or from a runtime-native
2976
+ * session id (`resolveActor` source `` `runtime-session-id:${runtime}` ``, e.g. Claude Code's
2977
+ * `CLAUDE_CODE_SESSION_ID`). It is UNSTABLE when `resolveActor` falls all the way through to the
2978
+ * process-ancestry fallback (source `"process-ancestry"`) or fails to resolve at all (source
2979
+ * `"unresolved"` / `isUnresolvedActor(actor)`). This mirrors `enforceEnsureSessionOwnership`'s
2980
+ * Design Decision 4 (never hard-fail on actor ambiguity): a session that cannot stably
2981
+ * self-identify cannot be meaningfully fenced against a durable assignment record, so hard-
2982
+ * blocking it produces exactly the false-block CI hit (no `FLOW_AGENTS_ACTOR`/runtime session id
2983
+ * in CI -> ancestry fallback -> a DIFFERENT ancestry-derived actor string than the one that
2984
+ * created the claim -> `reclaimable`/not-self -> hard BLOCK of a legitimate self-publish). A
2985
+ * real coordination participant always has a stable identity (explicit override or runtime
2986
+ * session id), so zombie protection under a stable actor is completely unaffected — this only
2987
+ * changes behavior for a caller that was never fenceable in the first place. When the resolved
2988
+ * actor identity is UNSTABLE, this function short-circuits to `{ ok: true, effective_state:
2989
+ * "not_evaluated", reason: "actor-identity-unstable-advisory-only" }` (holder included, sanitized,
2990
+ * when one exists) BEFORE running the assignment ⋈ liveness join at all, with one loud stderr
2991
+ * note — the gate degrades to advisory-only rather than ever false-blocking an unstable identity.
2992
+ * When the actor identity IS stable, the effective-state -> publish-decision mapping above
2993
+ * applies exactly as documented, unchanged.
2994
+ */
2995
+ export function runVerifyHold(
2996
+ dir: string,
2997
+ repoRoot: string | null,
2998
+ opts?: { actorKey?: string; now?: number; assignmentProviderKind?: string; effectiveStateJson?: unknown },
2999
+ ): {
3000
+ ok: boolean;
3001
+ effective_state: EffectiveState | "not_evaluated";
3002
+ holder?: { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string };
3003
+ reason: string;
3004
+ guidance: string[];
3005
+ } {
3006
+ // repoRoot is accepted (not derived) for signature symmetry with runReconcilePreflight(dir,
3007
+ // repoRoot, ...) / publishDelivery(dir, repoRoot) — the local-file join below only needs
3008
+ // artifactRoot/slug, but a future non-local-file provider branch that resolves a live
3009
+ // repo-relative fixture would need it, so the parameter stays part of the public contract.
3010
+ void repoRoot;
3011
+ const sanitize = (value: unknown): string => stripControlCharsForDisplay(value).slice(0, 64);
3012
+ const slug = path.basename(path.resolve(dir));
3013
+ const artifactRoot = path.dirname(path.resolve(dir));
3014
+ const nowMs = opts?.now ?? Date.now();
3015
+ const assignmentProviderKind = opts?.assignmentProviderKind || "local-file";
3016
+ const helper = loadActorIdentityHelper();
3017
+ // Stability check (SECOND CI-blocking false-block fix, this iteration): an explicitly-provided
3018
+ // opts.actorKey is treated as stable outright (the caller is asserting a stable identity — see
3019
+ // doc comment above). Otherwise resolve BOTH the actor and its source so we can tell an
3020
+ // explicit-override / runtime-session-id actor (stable, enforce as today) apart from a
3021
+ // process-ancestry / unresolved one (unstable, advisory-only — never hard-block).
3022
+ const resolved = opts?.actorKey ? { actor: opts.actorKey, source: "explicit-override" } : helper.resolveActor(process.env);
3023
+ const actorKey = resolved.actor;
3024
+ const isStableActor = !!opts?.actorKey
3025
+ || resolved.source === "explicit-override"
3026
+ || resolved.source.startsWith("runtime-session-id")
3027
+ // #398: a CI-runtime actor is STABLE across every subprocess in a CI job (derived from the
3028
+ // provider's published run/job identifiers), so the verify-hold gate ENFORCES for CI sessions
3029
+ // instead of degrading to advisory — the root fix for the #293 CI false-block workaround.
3030
+ || resolved.source.startsWith("ci-runtime");
3031
+
3032
+ const guidanceLines = (holderActor?: string): string[] => [
3033
+ "Re-run pull-work/pickup-probe to discover the current holder of this subject and hand off cleanly (learning-review/handoff) rather than publishing over it.",
3034
+ holderActor
3035
+ ? `The current holder appears to be ${sanitize(holderActor)} — confirm with them before proceeding.`
3036
+ : "Confirm with the assigned human before proceeding.",
3037
+ "If a human confirms this session should resume ownership, run `ensure-session --supersede-stale` to explicitly re-claim the subject before retrying publish.",
3038
+ ];
3039
+
3040
+ type EffectiveResult = { effective_state: EffectiveState; reason: string; holder?: { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string } };
3041
+ let effective: EffectiveResult | null = null;
3042
+
3043
+ if (opts?.effectiveStateJson !== undefined) {
3044
+ const parsed = opts.effectiveStateJson as AnyObj;
3045
+ const candidate = parsed && typeof parsed === "object" ? (parsed.effective as AnyObj | undefined) : undefined;
3046
+ const validStates = new Set(["held", "reclaimable", "human-held", "free"]);
3047
+ if (candidate && typeof candidate.effective_state === "string" && validStates.has(candidate.effective_state)) {
3048
+ effective = candidate as EffectiveResult;
3049
+ }
3050
+ } else if (assignmentProviderKind === "local-file") {
3051
+ // Same call shape as enforceEnsureSessionOwnership's local-file branch — no second
3052
+ // freshness/ownership computation is invented (see that function's F1 fix comment for why
3053
+ // branchActorKey, not the serialized ActorStruct triple, is the correct selfActor here).
3054
+ const assignment = readLocalAssignmentStatus(artifactRoot, slug);
3055
+ const events = readLivenessEvents(artifactRoot);
3056
+ const freshList = loadLivenessReadHelper().freshHolders(events, slug, actorKey, nowMs);
3057
+ effective = computeEffectiveState(assignment, freshList, actorKey, nowMs);
3058
+ }
3059
+
3060
+ if (!effective) {
3061
+ // Documented scope boundary (Design Constraint): a non-local-file provider with no
3062
+ // precomputed --effective-state-json cannot be evaluated in-CLI (render-don't-execute — no
3063
+ // live `gh` call from workflow-sidecar.ts). PASS-through, never a silent block, with a loud
3064
+ // stderr note — mirroring enforceEnsureSessionOwnership's existing github branch precedent.
3065
+ process.stderr.write(`[verify-hold] not evaluated: provider "${sanitize(assignmentProviderKind)}" requires --effective-state-json (or use --assignment-provider local-file)\n`);
3066
+ return { ok: true, effective_state: "not_evaluated", reason: "provider_not_evaluated", guidance: [] };
3067
+ }
3068
+
3069
+ // F1 fix (fix-plan iteration 1, HIGH): sanitize EVERY untrusted string field of
3070
+ // effective.holder before it reaches the returned JSON / NotFreshHolderError.holder — never
3071
+ // spread the raw holder and override only the discriminated field. `last_at` (sourced from
3072
+ // record.claimed_at or a liveness event's `at`, both attacker-writable in the shared
3073
+ // multi-writer liveness/assignment stream) and `actor`/`assignee` are all display strings and
3074
+ // go through the SAME `sanitize` helper (stripControlCharsForDisplay(...).slice(0, 64)) as
3075
+ // enforceEnsureSessionOwnership's equivalent call sites (AC7 injection-discipline parity).
3076
+ // `idle_days` is a `number | null` computed by computeEffectiveState from Date.parse/Math.floor
3077
+ // arithmetic (assignment-provider.ts) — never an attacker-controlled string — so it is passed
3078
+ // through as-is, but defensively re-coerced with `typeof === "number"` so a future shape change
3079
+ // upstream can never smuggle a string through this field.
3080
+ const sanitizeHolder = (
3081
+ holder: { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string } | undefined,
3082
+ ): { actor?: string; assignee?: string | null; idle_days?: number | null; last_at?: string } | undefined => {
3083
+ if (!holder) return undefined;
3084
+ return {
3085
+ actor: holder.actor ? sanitize(holder.actor) : undefined,
3086
+ assignee: holder.assignee ? sanitize(holder.assignee) : holder.assignee,
3087
+ idle_days: typeof holder.idle_days === "number" ? holder.idle_days : undefined,
3088
+ last_at: holder.last_at ? sanitize(holder.last_at) : undefined,
3089
+ };
3090
+ };
3091
+
3092
+ // SECOND CI-blocking false-block fix (this iteration): an actor identity that cannot stably
3093
+ // self-identify (process-ancestry fallback or unresolved) cannot be meaningfully fenced against
3094
+ // a durable assignment record — hard-blocking it is exactly the CI false-block this fix targets
3095
+ // (see doc comment above). Degrade to advisory-only: never hard-block, regardless of what the
3096
+ // join above computed. This check runs AFTER the join so an existing holder (if any) can still
3097
+ // be surfaced for visibility, but strictly BEFORE the effective-state switch below so no
3098
+ // unstable-actor request can ever reach a `case` that returns `ok: false`.
3099
+ if (!isStableActor) {
3100
+ process.stderr.write(
3101
+ "[verify-hold] actor identity is ancestry-derived/unresolved — gate is advisory only for unstable identities; not hard-blocking publish\n"
3102
+ );
3103
+ return {
3104
+ ok: true,
3105
+ effective_state: "not_evaluated",
3106
+ reason: "actor-identity-unstable-advisory-only",
3107
+ guidance: [],
3108
+ ...(effective.holder ? { holder: sanitizeHolder(effective.holder) } : {}),
3109
+ };
3110
+ }
3111
+
3112
+ switch (effective.effective_state) {
3113
+ case "free":
3114
+ return { ok: true, effective_state: "free", reason: effective.reason, guidance: [] };
3115
+ case "held": {
3116
+ const isSelf = effective.reason === "self_is_holder" || (!!effective.holder?.actor && effective.holder.actor === actorKey);
3117
+ if (isSelf) return { ok: true, effective_state: "held", holder: sanitizeHolder(effective.holder), reason: effective.reason, guidance: [] };
3118
+ // Bug fix (#397): `liveness_claim_present_assignment_lagging` means computeEffectiveState
3119
+ // found NO durable assignment record at all — only ambient liveness by another actor
3120
+ // (assignment-provider.ts's `if (!isAssigned) { if (freshHoldersList.length > 0) return
3121
+ // held/liveness_claim_present_assignment_lagging }`). Liveness alone is advisory
3122
+ // everywhere else in this system; this ONE hard gate must fence the durable ASSIGNMENT
3123
+ // hold, not ambient presence, so this case PASSES rather than false-blocking. Every other
3124
+ // `held` reason (`fresh_liveness_heartbeat`) is reached only when an assignment record
3125
+ // DOES exist (assignment-provider.ts's `isAssigned` branch) and the recorded holder is
3126
+ // still fresh-live — a genuine durable-ownership conflict, which still BLOCKs below.
3127
+ if (effective.reason === "liveness_claim_present_assignment_lagging") {
3128
+ return { ok: true, effective_state: "held", holder: sanitizeHolder(effective.holder), reason: effective.reason, guidance: [] };
3129
+ }
3130
+ const holderActor = effective.holder?.actor;
3131
+ return {
3132
+ ok: false,
3133
+ effective_state: "held",
3134
+ holder: sanitizeHolder(effective.holder),
3135
+ reason: `subject ${sanitize(slug)} is currently held by a different, still-live actor (${sanitize(holderActor ?? "unknown")})`,
3136
+ guidance: guidanceLines(holderActor),
3137
+ };
3138
+ }
3139
+ case "reclaimable": {
3140
+ // Stop-short risk (plan): reclaimable is NEVER treated as PASS. A lapsed self-claim looks
3141
+ // reclaimable, not self_is_holder, to a woken zombie's own re-check — the safe default
3142
+ // blocks and asks for reconcile guidance rather than auto-passing a stale self-claim.
3143
+ const holderActor = effective.holder?.actor;
3144
+ return {
3145
+ ok: false,
3146
+ effective_state: "reclaimable",
3147
+ holder: sanitizeHolder(effective.holder),
3148
+ reason: `subject ${sanitize(slug)}'s existing claim (held by ${sanitize(holderActor ?? "unknown")}) is stale/unverified as self — not a confirmed fresh hold`,
3149
+ guidance: guidanceLines(holderActor),
3150
+ };
3151
+ }
3152
+ case "human-held": {
3153
+ const assignee = effective.holder?.assignee;
3154
+ return {
3155
+ ok: false,
3156
+ effective_state: "human-held",
3157
+ holder: sanitizeHolder(effective.holder),
3158
+ reason: `subject ${sanitize(slug)} is assigned to ${assignee ? sanitize(assignee) : "an assigned human"} — never publish over a human assignment without confirmation`,
3159
+ guidance: guidanceLines(undefined),
3160
+ };
3161
+ }
3162
+ default:
3163
+ return { ok: true, effective_state: "not_evaluated", reason: "unrecognized_effective_state", guidance: [] };
3164
+ }
3165
+ }
3166
+
3167
+ /**
3168
+ * verify-hold <artifact-dir> [--actor <key>] [--now <iso>] [--assignment-provider <kind>]
3169
+ * [--effective-state-json <path|->]
3170
+ *
3171
+ * CLI surface for runVerifyHold() (issue #293). Prints `{ role: "VerifyHoldResult", ... }` JSON
3172
+ * and exits 0 when `ok` (including `not_evaluated` — a documented scope boundary is never a
3173
+ * failure exit), 1 otherwise. NEVER throws at the CLI boundary — matches
3174
+ * `reconcilePreflightCmd`'s existing convention of printing then exiting rather than throwing;
3175
+ * the thrown `NotFreshHolderError` variant is reserved for the LIBRARY call inside
3176
+ * `publishDelivery()`.
3177
+ *
3178
+ * Usage: workflow-sidecar verify-hold <artifactDir> [--actor <key>] [--now <iso>]
3179
+ * [--assignment-provider <kind>] [--effective-state-json <path|->]
3180
+ */
3181
+ async function verifyHoldCmd(p: ReturnType<typeof parseArgs>): Promise<number> {
3182
+ if (p.flags.has("help") || (!p.positional[0] && !p.opts["help"])) {
3183
+ if (p.flags.has("help")) {
3184
+ console.log("Usage: workflow-sidecar verify-hold <artifactDir> [--actor <key>] [--now <iso>] [--assignment-provider <kind>] [--effective-state-json <path|->]");
3185
+ console.log("Checks whether the calling actor is the fresh, non-superseded holder of <artifactDir>'s subject. Exits 0 if ok (including not_evaluated), 1 otherwise.");
3186
+ return 0;
3187
+ }
3188
+ }
3189
+ const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
3190
+ const repoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
3191
+ // SECOND CI-blocking false-block fix (this iteration): only pass an explicit actorKey through
3192
+ // to runVerifyHold when the CALLER explicitly passed --actor — that is the one case where an
3193
+ // actorKey should be treated as an asserted-stable identity (see runVerifyHold's doc comment).
3194
+ // When --actor is omitted, actorKey is left undefined so runVerifyHold performs its OWN
3195
+ // resolveActor(process.env) call internally and can see the real `source` (explicit-override /
3196
+ // runtime-session-id / process-ancestry / unresolved) to decide stability — calling
3197
+ // resolveReadActorKey(p) here first would discard that source and wrongly force every ambient
3198
+ // resolution (including an ancestry fallback) to be treated as an explicit/stable actorKey.
3199
+ const actorKey = opt(p, "actor") ? loadActorIdentityHelper().sanitizeSegment(opt(p, "actor")) : undefined;
3200
+ const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : undefined;
3201
+ const assignmentProviderKind = opt(p, "assignment-provider", "local-file");
3202
+ const effectiveStateJsonFlag = opt(p, "effective-state-json");
3203
+ const effectiveStateJson = effectiveStateJsonFlag ? loadJsonInputFile(effectiveStateJsonFlag) : undefined;
3204
+
3205
+ const result = runVerifyHold(dir, repoRoot, { actorKey, now: nowMs, assignmentProviderKind, effectiveStateJson });
3206
+ printJson({ role: "VerifyHoldResult", ...result });
3207
+ return result.ok ? 0 : 1;
3208
+ }
3209
+
3210
+ // ─── Takeover Preflight (#294, ADR 0021 §5) ───────────────────────────────────
3211
+ /**
3212
+ * Render (never execute) the takeover protocol for a candidate the skill has selected. Takeover is
3213
+ * RESUMPTION, not restart: when a subject is `reclaimable` (a durable assignment whose incumbent is
3214
+ * no longer heartbeating), a successor must (1) grace-beat — wait one heartbeat interval and re-check,
3215
+ * backing off if the incumbent revives — (2) supersede via `ensure-session --supersede-stale`, and
3216
+ * (3) CONTINUE THE INCUMBENT'S BRANCH (from the incumbent's claim record), never a parallel one, and
3217
+ * re-enter the SAME artifact dir (deterministic slug). This function computes the effective state
3218
+ * (the same assignment ⋈ liveness join verify-hold/ensure-session use) and emits the appropriate
3219
+ * action + the incumbent's `resume_branch`; the skill executes the beat/supersede/git steps.
3220
+ *
3221
+ * Race-safety is CLI-enforced at supersede time, not here: `ensure-session --supersede-stale`
3222
+ * re-computes the state and refuses (dies on `held`) if the incumbent revived during the beat, so a
3223
+ * successor that skipped or lost the grace race still cannot supersede a live incumbent (AC2a).
3224
+ *
3225
+ * ADVISORY, not a gate: the assignment ⋈ liveness JOIN is identical to verify-hold's, but this render
3226
+ * only advises the skill which action to take — the ACTUAL enforcement is `ensure-session
3227
+ * --supersede-stale` (re-check under a subject lock). So it deliberately does NOT replicate
3228
+ * verify-hold's #398 `isStableActor` tiering (which exists there only because verify-hold is a hard
3229
+ * publish BLOCK that must not false-block an unstable/ancestry identity): a wrong self-identification
3230
+ * here yields only a wrong advisory string, never a wrong mutation.
3231
+ *
3232
+ * Ordering note: ADR 0021 §5's literal wording is supersede→grace→maybe-undo; this implements
3233
+ * grace→recheck→supersede (never write until the beat confirms the incumbent stayed stale) — a
3234
+ * deliberate, safer variant that preserves the ADR's intent ("laptop just woke resolves in the
3235
+ * incumbent's favor") without a premature write to walk back.
3236
+ *
3237
+ * All untrusted fields (incumbent actor / branch / last_at, sourced from the shared multi-writer
3238
+ * assignment+liveness stream) pass through `sanitize`/`sanitizeWide` (stripControlCharsForDisplay +
3239
+ * the repo's 64/240 cap tiers — branch uses the 240 free-text tier so a valid long branch is never
3240
+ * truncated into a bad checkout target) — the #287/#320/#293 injection class.
3241
+ */
3242
+ export function runTakeoverPreflight(
3243
+ dir: string,
3244
+ opts?: { actorKey?: string; now?: number; graceSeconds?: number },
3245
+ ): {
3246
+ ok: boolean;
3247
+ action: "grace-then-supersede" | "back-off" | "claim" | "ask-first" | "proceed";
3248
+ effective_state: EffectiveState;
3249
+ reason: string;
3250
+ holder?: { actor?: string; last_at?: string };
3251
+ resume_branch?: string;
3252
+ grace_seconds?: number;
3253
+ next_steps: string[];
3254
+ } {
3255
+ const sanitize = (value: unknown): string => stripControlCharsForDisplay(value).slice(0, 64);
3256
+ // Branch names use the repo's 240-char free-text tier (matching assignment-provider.ts's
3257
+ // sanitizeDisplayField(record.branch, 240)), NOT the 64-char id tier: a realistic
3258
+ // `agent/<actor>/<slug>` branch can exceed 64 chars, and truncating `resume_branch` would hand the
3259
+ // skill a `git checkout <truncated>` target that does not exist — defeating the resume-the-branch
3260
+ // guarantee. Control chars are still stripped (injection), only the cap widens.
3261
+ const sanitizeWide = (value: unknown): string => stripControlCharsForDisplay(value).slice(0, 240);
3262
+ const slug = path.basename(path.resolve(dir));
3263
+ const artifactRoot = path.dirname(path.resolve(dir));
3264
+ const nowMs = opts?.now ?? Date.now();
3265
+ const helper = loadActorIdentityHelper();
3266
+ const actorKey = opts?.actorKey ? helper.sanitizeSegment(opts.actorKey) : helper.resolveActor(process.env).actor;
3267
+ const graceSeconds = opts?.graceSeconds ?? loadLivenessPolicyHelper().resolveHeartbeatThrottleSeconds(process.env);
3268
+
3269
+ const assignment = readLocalAssignmentStatus(artifactRoot, slug);
3270
+ const events = readLivenessEvents(artifactRoot);
3271
+ const freshList = loadLivenessReadHelper().freshHolders(events, slug, actorKey, nowMs);
3272
+ const effective = computeEffectiveState(assignment, freshList, actorKey, nowMs);
3273
+ const reason = sanitize(effective.reason);
3274
+ const holderActor = effective.holder?.actor ? sanitize(effective.holder.actor) : undefined;
3275
+ const holderLastAt = effective.holder?.last_at ? sanitize(effective.holder.last_at) : undefined;
3276
+
3277
+ switch (effective.effective_state) {
3278
+ case "reclaimable": {
3279
+ // The incumbent's branch is the resume target — read it from the pre-supersede record, never
3280
+ // derive a new one (AC1: takeover continues the incumbent's branch, no parallel branch).
3281
+ const resumeBranch = assignment.record?.branch ? sanitizeWide(assignment.record.branch) : undefined;
3282
+ return {
3283
+ ok: true,
3284
+ action: "grace-then-supersede",
3285
+ effective_state: "reclaimable",
3286
+ reason,
3287
+ holder: { actor: holderActor, last_at: holderLastAt },
3288
+ ...(resumeBranch ? { resume_branch: resumeBranch } : {}),
3289
+ grace_seconds: graceSeconds,
3290
+ next_steps: [
3291
+ `Grace beat: wait ${graceSeconds}s (one heartbeat interval) for the incumbent to revive.`,
3292
+ `Re-run \`workflow-sidecar takeover-preflight ${slug}\`; if it now reports action "back-off" the incumbent revived — STOP and reselect.`,
3293
+ `If still "grace-then-supersede", run \`ensure-session --supersede-stale\` to take over (it re-checks and refuses if the incumbent revived), recording the audit trail.`,
3294
+ resumeBranch
3295
+ ? `Continue the incumbent's branch: \`git fetch origin ${resumeBranch} && git checkout ${resumeBranch}\` — NEVER create a new branch. Re-enter the existing artifact dir (deterministic slug); do not restart the plan.`
3296
+ : `Re-enter the existing artifact dir (deterministic slug) and continue the incumbent's branch from state.json.branch — never a new branch; do not restart the plan.`,
3297
+ ],
3298
+ };
3299
+ }
3300
+ case "held": {
3301
+ const selfIsHolder = effective.reason === "self_is_holder";
3302
+ return {
3303
+ ok: selfIsHolder,
3304
+ action: selfIsHolder ? "proceed" : "back-off",
3305
+ effective_state: "held",
3306
+ reason,
3307
+ holder: { actor: holderActor, last_at: holderLastAt },
3308
+ next_steps: selfIsHolder
3309
+ ? ["This subject is already held by you — proceed with your existing session; no takeover needed."]
3310
+ : [`The incumbent is live/revived (held by ${holderActor ?? "another actor"}). Back off — do NOT supersede a live holder — and reselect a different item.`],
3311
+ };
3312
+ }
3313
+ case "human-held": {
3314
+ return {
3315
+ ok: false,
3316
+ action: "ask-first",
3317
+ effective_state: "human-held",
3318
+ reason,
3319
+ holder: { actor: holderActor, last_at: holderLastAt },
3320
+ next_steps: [`A human is assigned (${holderActor ?? "assignee"}). Ask first — never auto-supersede a human assignment (ADR 0021 §6).`],
3321
+ };
3322
+ }
3323
+ case "free":
3324
+ default: {
3325
+ return {
3326
+ ok: true,
3327
+ action: "claim",
3328
+ effective_state: "free",
3329
+ reason,
3330
+ next_steps: ["No durable claim to supersede — this is a normal claim, not a takeover. Run `ensure-session` to claim it."],
3331
+ };
3332
+ }
3333
+ }
3334
+ }
3335
+
3336
+ async function takeoverPreflightCmd(p: ReturnType<typeof parseArgs>): Promise<number> {
3337
+ if (p.flags.has("help")) {
3338
+ console.log("Usage: workflow-sidecar takeover-preflight <artifactDir> [--actor <key>] [--now <iso>] [--grace-seconds <n>]");
3339
+ console.log("Renders the ADR 0021 §5 takeover protocol (grace-then-supersede | back-off | claim | ask-first | proceed) for a reclaimable candidate. Read-only; never mutates.");
3340
+ return 0;
3341
+ }
3342
+ const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
3343
+ const actorKey = opt(p, "actor") ? loadActorIdentityHelper().sanitizeSegment(opt(p, "actor")) : undefined;
3344
+ const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : undefined;
3345
+ const graceSeconds = opt(p, "grace-seconds") ? Number(opt(p, "grace-seconds")) : undefined;
3346
+ const result = runTakeoverPreflight(dir, { actorKey, now: nowMs, graceSeconds });
3347
+ printJson({ role: "TakeoverPreflight", ...result });
3348
+ return result.ok ? 0 : 1;
3349
+ }
3350
+
2519
3351
  // ─── Publish Delivery Bundle ──────────────────────────────────────────────────
2520
3352
  // Copies the session's trust.bundle (+ checkpoint companions) from the gitignored
2521
3353
  // session artifact dir (.kontourai/flow-agents/<slug>/) to the committed delivery/ transport
@@ -2526,52 +3358,164 @@ async function sealCheckpoint(p: ReturnType<typeof parseArgs>): Promise<number>
2526
3358
  // Called automatically from recordRelease and advanceState→delivered (best-effort).
2527
3359
  // Also exposed as the `publish-delivery <artifact-dir>` subcommand for explicit use.
2528
3360
 
3361
+ /**
3362
+ * #379 supersede-on-publish cleanup: keep delivery/ bounded by pruning inherited PER-SESSION
3363
+ * seal dirs (the growth vector), scoped to avoid any cross-PR conflict.
3364
+ *
3365
+ * An inherited per-session seal dir (`delivery/<other-slug>/`) is UNIQUELY named, so pruning
3366
+ * it can never conflict with a concurrent PR: two branches deleting the same inherited dir is
3367
+ * a delete/delete (auto-merges), and each new delivery adds its OWN distinct
3368
+ * `delivery/<slug>/`. And it is HARMLESS to leave: trust-reconcile.js's prefer-newest
3369
+ * ownership selection always picks THIS session's fresher bundle over an older inherited one.
3370
+ * Pruning is therefore purely to stop unbounded accumulation of permanently-superseded dirs.
3371
+ *
3372
+ * The SHARED FLAT path (`delivery/trust.bundle` + checkpoints) is deliberately NOT pruned
3373
+ * here. During the migration window other concurrent PRs may still seal to the flat path;
3374
+ * deleting it would produce a modify/delete conflict → a DIRTY PR → the no-CI failure this
3375
+ * whole change fixes. The flat path is a single fixed location (not a growth vector) and the
3376
+ * reconciler treats a stale flat bundle as non-owning / older-owning, so leaving it is safe.
3377
+ * Removing the flat legacy seals is a one-time cleanup for a dedicated PR once no open PR
3378
+ * still seals to it — intentionally NOT bundled into every delivery.
3379
+ *
3380
+ * Best-effort: a prune failure is logged, never fatal to the delivery. Never touches
3381
+ * README.md, DECLARED, the flat seal files, or any subdir that is not itself a seal dir.
3382
+ */
3383
+ function pruneSupersededSeals(deliveryDir: string, keepSlug: string): void {
3384
+ let entries: fs.Dirent[] = [];
3385
+ try {
3386
+ entries = fs.readdirSync(deliveryDir, { withFileTypes: true });
3387
+ } catch {
3388
+ entries = [];
3389
+ }
3390
+ for (const entry of entries) {
3391
+ if (!entry.isDirectory() || entry.name === keepSlug) continue;
3392
+ const subdir = path.join(deliveryDir, entry.name);
3393
+ // Only prune dirs that actually look like a seal dir (contain a trust.bundle or
3394
+ // trust.checkpoint.json) — never an unrelated directory a human placed under delivery/.
3395
+ const looksLikeSeal =
3396
+ fs.existsSync(path.join(subdir, "trust.bundle")) ||
3397
+ fs.existsSync(path.join(subdir, "trust.checkpoint.json"));
3398
+ if (!looksLikeSeal) continue;
3399
+ try {
3400
+ fs.rmSync(subdir, { recursive: true, force: true });
3401
+ process.stderr.write(`[publish-delivery] #379: pruned superseded per-session seal delivery/${entry.name}/ (older-owning/stale; the reconciler selects the newest bundle regardless)\n`);
3402
+ } catch (err) {
3403
+ process.stderr.write(`[publish-delivery] #379: could not prune per-session seal delivery/${entry.name}/ (non-fatal): ${err instanceof Error ? err.message : String(err)}\n`);
3404
+ }
3405
+ }
3406
+ }
3407
+
2529
3408
  /**
2530
3409
  * Publish the session's trust artifacts to the committed delivery/ path.
2531
3410
  *
3411
+ * #379: writes to a PER-SESSION path `<repoRoot>/delivery/<slug>/` (slug = the session
3412
+ * artifact dir's basename) rather than the shared flat `delivery/`. A shared path guarantees
3413
+ * a git conflict between ANY two concurrent deliveries, and a conflicting (DIRTY) PR gets NO
3414
+ * pull_request workflows — the required Trust Reconcile check silently never runs (field
3415
+ * incidents #330/#358/#378). Per-session paths make concurrent deliveries write DISTINCT
3416
+ * files that never contend. trust-reconcile.js reads both the flat (back-compat) and
3417
+ * per-session layouts via resolveDeliveryCandidates() and selects the owning candidate by
3418
+ * commit ancestry.
3419
+ *
2532
3420
  * Copies trust.bundle, trust.checkpoint.json, and (if present)
2533
- * trust.checkpoint.intoto.json / trust.checkpoint.sig.json from the
2534
- * session artifact dir to <repoRoot>/delivery/.
3421
+ * trust.checkpoint.intoto.json / trust.checkpoint.sig.json / trust.checkpoint.attestation.json
3422
+ * from the session artifact dir to <repoRoot>/delivery/<slug>/.
2535
3423
  *
2536
3424
  * Fail-soft on a missing bundle: if trust.bundle is absent, returns without throwing.
2537
3425
  * Fail-CLOSED on repo-root resolution: repoRoot must be a real, resolved kits/ ancestor
2538
3426
  * (see findRepoRootFromDirStrict) — null (no ancestor found) skips the publish with a
2539
3427
  * visible warning instead of writing to whatever process.cwd() happens to be. This
2540
3428
  * prevents a scratch/test session dir (no kits/ ancestor) from silently clobbering an
2541
- * unrelated real repo's delivery/trust.bundle when invoked with that repo as cwd (see
3429
+ * unrelated real repo's delivery/ seal when invoked with that repo as cwd (see
2542
3430
  * evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings at
2543
3431
  * .kontourai/flow-agents/ws5-governance-kit-slice1 for the root cause this fixes).
2544
- * Idempotent: overwrites on re-delivery.
3432
+ * Idempotent: overwrites on re-delivery to the same slug.
3433
+ *
3434
+ * #356 Wave 3 (AC6): Fail-CLOSED on bundle SHAPE. After the fail-soft absence guard below,
3435
+ * runs the SAME reconcile-preflight shape check (runReconcilePreflight) the
3436
+ * `reconcile-preflight` CLI subcommand exposes, BEFORE copying anything into delivery/. A
3437
+ * shape-invalid bundle throws InvalidBundleShapeError (a distinct, identifiable error — see
3438
+ * its own doc comment) rather than silently publishing a bundle CI's Trust Reconcile job
3439
+ * would reject anyway. This is intentionally a NEW, additive fail-closed branch — it must
3440
+ * never be conflated with the pre-existing fail-soft absence/repo-root branches above/below,
3441
+ * which stay exactly as before (see each publishDelivery call site's catch handler for how
3442
+ * the distinction is preserved end-to-end).
3443
+ *
3444
+ * #293 (SECOND, DISTINCT fail-closed gate): immediately after the shape check above and BEFORE
3445
+ * writing anything into delivery/, runs `runVerifyHold()` — the assignment ⋈ liveness join
3446
+ * asking "is the calling actor the fresh, non-superseded holder of this subject (or is it
3447
+ * free/self-held)?" A not-fresh-holder result throws `NotFreshHolderError` (distinct from
3448
+ * `InvalidBundleShapeError` — a different `code`, a different failure mode: actor hold vs
3449
+ * bundle shape). There are now THREE tiers here, and they must never be conflated in prose or
3450
+ * in a call site's catch handler: (1) fail-SOFT bundle absence / repo-root resolution (silent
3451
+ * no-op / visible warning, unchanged from before #356), (2) fail-CLOSED bundle shape (#356,
3452
+ * `InvalidBundleShapeError`), (3) fail-CLOSED verify-hold (#293, `NotFreshHolderError`). The
3453
+ * shape check runs first (per the plan's ordering) — a bundle that is BOTH shape-invalid and
3454
+ * not-held throws `InvalidBundleShapeError` specifically, never `NotFreshHolderError`.
2545
3455
  */
2546
3456
  export async function publishDelivery(dir: string, repoRoot: string | null): Promise<void> {
2547
3457
  const bundleSrc = path.join(dir, "trust.bundle");
2548
3458
  if (!fs.existsSync(bundleSrc)) return; // no bundle — skip gracefully
2549
3459
 
2550
3460
  if (!repoRoot) {
2551
- process.stderr.write(`[publish-delivery] WARNING: no kits/ ancestor found from ${dir}; skipping publish. Refusing to fall back to process.cwd() to avoid clobbering an unrelated repo's delivery/trust.bundle. Pass --repo-root explicitly if this session dir is intentionally outside a repo checkout.\n`);
3461
+ process.stderr.write(`[publish-delivery] WARNING: no kits/ ancestor found from ${dir}; skipping publish. Refusing to fall back to process.cwd() to avoid clobbering an unrelated repo's delivery/ seal. Pass --repo-root explicitly if this session dir is intentionally outside a repo checkout.\n`);
2552
3462
  return;
2553
3463
  }
2554
3464
 
3465
+ // #356 AC6: fail-CLOSED on shape-invalidity — runs AFTER the fail-soft absence/repo-root
3466
+ // guards above (both preserved unchanged) and BEFORE any copy into delivery/. Throws
3467
+ // InvalidBundleShapeError (never a generic Error) so every call site can positively
3468
+ // distinguish this from the failure modes they already tolerate as best-effort.
3469
+ const preflight = runReconcilePreflight(dir, repoRoot);
3470
+ if (!preflight.ok) {
3471
+ process.stderr.write(`[publish-delivery] REFUSING to publish — trust.bundle failed the reconcile-preflight shape check (${preflight.issues.length} issue(s)):\n`);
3472
+ for (const issue of preflight.issues) {
3473
+ process.stderr.write(`[publish-delivery] - ${issue}\n`);
3474
+ }
3475
+ throw new InvalidBundleShapeError(preflight.issues);
3476
+ }
3477
+
3478
+ // #293: SECOND, DISTINCT fail-closed gate — runs after the shape check above and before any
3479
+ // copy into delivery/. Throws NotFreshHolderError (never conflated with InvalidBundleShapeError)
3480
+ // so every call site can positively distinguish "not the fresh holder" from "bundle shape
3481
+ // invalid".
3482
+ const holdCheck = runVerifyHold(dir, repoRoot);
3483
+ if (!holdCheck.ok) {
3484
+ process.stderr.write(`[publish-delivery] REFUSING to publish — verify-hold gate: ${holdCheck.reason}\n`);
3485
+ for (const line of holdCheck.guidance) process.stderr.write(`[publish-delivery] - ${line}\n`);
3486
+ throw new NotFreshHolderError(holdCheck);
3487
+ }
3488
+
2555
3489
  const deliveryDir = path.join(repoRoot, "delivery");
3490
+ // #379: slug is the session artifact dir's basename — the same human-meaningful id used
3491
+ // throughout the session (.kontourai/flow-agents/<slug>/). The per-session dir NAME is only
3492
+ // a collision-avoidance handle; ownership is decided by commit ancestry, not by name.
3493
+ const slug = path.basename(path.resolve(dir));
3494
+ const sessionDeliveryDir = path.join(deliveryDir, slug);
3495
+
2556
3496
  fs.mkdirSync(deliveryDir, { recursive: true });
3497
+ // Supersede inherited/flat seals BEFORE writing this session's dir (keepSlug = our own).
3498
+ pruneSupersededSeals(deliveryDir, slug);
3499
+ fs.mkdirSync(sessionDeliveryDir, { recursive: true });
2557
3500
 
2558
3501
  // Required: trust.bundle (the CI anchor)
2559
- fs.copyFileSync(bundleSrc, path.join(deliveryDir, "trust.bundle"));
3502
+ fs.copyFileSync(bundleSrc, path.join(sessionDeliveryDir, "trust.bundle"));
2560
3503
 
2561
3504
  // Optional companions: checkpoint + signing artifacts
2562
3505
  const companions = [
2563
3506
  "trust.checkpoint.json",
2564
3507
  "trust.checkpoint.intoto.json",
2565
3508
  "trust.checkpoint.sig.json",
3509
+ "trust.checkpoint.attestation.json",
2566
3510
  ];
2567
3511
  for (const filename of companions) {
2568
3512
  const src = path.join(dir, filename);
2569
3513
  if (fs.existsSync(src)) {
2570
- fs.copyFileSync(src, path.join(deliveryDir, filename));
3514
+ fs.copyFileSync(src, path.join(sessionDeliveryDir, filename));
2571
3515
  }
2572
3516
  }
2573
3517
 
2574
- process.stderr.write(`[publish-delivery] published trust.bundle and companions to ${deliveryDir}\n`);
3518
+ process.stderr.write(`[publish-delivery] published trust.bundle and companions to ${sessionDeliveryDir} (per-session path, #379)\n`);
2575
3519
  }
2576
3520
 
2577
3521
  /**
@@ -3330,7 +4274,7 @@ function loadLivenessReadHelper(): {
3330
4274
  // init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
3331
4275
  // so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.
3332
4276
  // Additive + fail-open: a liveness-emit failure never affects the workflow command.
3333
- const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
4277
+ export const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
3334
4278
  /**
3335
4279
  * Delegate to the shared pure-CJS resolver (scripts/hooks/lib/actor-identity.js), mirroring the
3336
4280
  * createRequire pattern used by readLivenessEvents() above. Deliberately NO inline duplicate
@@ -3350,6 +4294,7 @@ function loadActorIdentityHelper(): {
3350
4294
  serializeActor: (actor: Partial<ActorStruct> | undefined) => string;
3351
4295
  detectRuntime: (env: NodeJS.ProcessEnv) => string;
3352
4296
  runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
4297
+ detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
3353
4298
  ancestorActorSeed: () => string;
3354
4299
  } {
3355
4300
  const _req = createRequire(import.meta.url);
@@ -3361,6 +4306,7 @@ function loadActorIdentityHelper(): {
3361
4306
  serializeActor: (actor: Partial<ActorStruct> | undefined) => string;
3362
4307
  detectRuntime: (env: NodeJS.ProcessEnv) => string;
3363
4308
  runtimeSessionId: (env: NodeJS.ProcessEnv) => string;
4309
+ detectCiActor: (env: NodeJS.ProcessEnv) => { runtime: string; session_id: string } | null;
3364
4310
  ancestorActorSeed: () => string;
3365
4311
  };
3366
4312
  }
@@ -3381,12 +4327,14 @@ function resolveLivenessActor(): string {
3381
4327
  function loadLivenessPolicyHelper(): {
3382
4328
  isLivenessEnabled: (env: NodeJS.ProcessEnv) => boolean;
3383
4329
  resolveTtlSeconds: (env: NodeJS.ProcessEnv) => number;
4330
+ resolveHeartbeatThrottleSeconds: (env: NodeJS.ProcessEnv) => number;
3384
4331
  } {
3385
4332
  const _req = createRequire(import.meta.url);
3386
4333
  const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/hooks/lib/liveness-policy.js");
3387
4334
  return _req(helperPath) as {
3388
4335
  isLivenessEnabled: (env: NodeJS.ProcessEnv) => boolean;
3389
4336
  resolveTtlSeconds: (env: NodeJS.ProcessEnv) => number;
4337
+ resolveHeartbeatThrottleSeconds: (env: NodeJS.ProcessEnv) => number;
3390
4338
  };
3391
4339
  }
3392
4340
  function livenessEnabled(): boolean { return loadLivenessPolicyHelper().isLivenessEnabled(process.env); }
@@ -3838,6 +4786,9 @@ async function main(): Promise<number> {
3838
4786
  case "resolve-slug": return resolveSlugCmd(p);
3839
4787
  case "seal-checkpoint": return sealCheckpoint(p);
3840
4788
  case "publish-delivery": return publishDeliveryCmd(p);
4789
+ case "reconcile-preflight": return reconcilePreflightCmd(p);
4790
+ case "verify-hold": return verifyHoldCmd(p);
4791
+ case "takeover-preflight": return takeoverPreflightCmd(p);
3841
4792
  default: die(`unknown command: ${p.command}`);
3842
4793
  }
3843
4794
  });