@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
@@ -230,9 +230,17 @@ function resolveEnsureSessionActor(p) {
230
230
  // the ownership guard entirely when unresolved, rather than claiming under a synthetic identity.
231
231
  return { actorStruct: { runtime: "unresolved", session_id: branchActorKey, host: os.hostname() }, actorKey: resolved.actor, branchActorKey, unresolved: true };
232
232
  }
233
+ // #398: the CI-runtime tier must reconstruct the SAME struct resolveActor serialized, or
234
+ // `serializeActor(actorStruct)` would diverge from `resolved.actor` (the else-branch would rebuild
235
+ // an ANCESTRY struct — detectRuntime→unknown, runtimeSessionId→'' — so the claim's stored
236
+ // actor_key would not match the CI actor at publish → self not recognized → false-block, the exact
237
+ // bug this issue removes). Uses the SAME helper.detectCiActor as resolveActor, single-sourced.
238
+ const ciActor = resolved.source.startsWith("ci-runtime") ? helper.detectCiActor(process.env) : null;
233
239
  const actorStruct = resolved.source === "explicit-override"
234
240
  ? { runtime: "explicit-override", session_id: resolved.actor, host: os.hostname() }
235
- : { runtime: helper.detectRuntime(process.env), session_id: helper.runtimeSessionId(process.env) || (() => { const seed = helper.ancestorActorSeed(); return seed ? `anc-${seed}` : ""; })(), host: os.hostname() };
241
+ : ciActor && ciActor.session_id
242
+ ? { runtime: ciActor.runtime, session_id: ciActor.session_id, host: os.hostname() }
243
+ : { runtime: helper.detectRuntime(process.env), session_id: helper.runtimeSessionId(process.env) || (() => { const seed = helper.ancestorActorSeed(); return seed ? `anc-${seed}` : ""; })(), host: os.hostname() };
236
244
  const actorKey = helper.serializeActor(actorStruct);
237
245
  return { actorStruct, actorKey, branchActorKey, unresolved: false };
238
246
  }
@@ -1418,6 +1426,11 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution) {
1418
1426
  // assignment-provider.ts's sanitizeAuditEntryForDisplay) — this guard never echoes `--reason`
1419
1427
  // into a die() message, so it has no free-text field requiring the 240 tier today.
1420
1428
  const sanitize = (value) => stripControlCharsForDisplay(value).slice(0, 64);
1429
+ // #294: the 240-char free-text tier (repo convention: 64 for id-like, 240 for free text — cf.
1430
+ // assignment-provider.ts sanitizeDisplayField(record.branch/reason, 240)). Used for the takeover
1431
+ // `resumed_branch` (a realistic agent/<actor>/<slug> branch exceeds 64 — truncation would produce a
1432
+ // bad `git checkout` target) and the audit `reason` (else "resuming from trust bundle" is cut off).
1433
+ const sanitizeWide = (value) => stripControlCharsForDisplay(value).slice(0, 240);
1421
1434
  const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : Date.now();
1422
1435
  const assignmentProviderKind = opt(p, "assignment-provider", "local-file");
1423
1436
  let effective;
@@ -1496,15 +1509,32 @@ function enforceEnsureSessionOwnership(p, root, slug, dir, resolution) {
1496
1509
  const fromActor = assignment.record?.actor;
1497
1510
  if (!fromActor)
1498
1511
  die(`ensure-session --supersede-stale: no existing local-file claim record found for subject ${sanitize(slug)} to supersede`);
1512
+ // #294 (ADR 0021 §5): takeover is resumption — the successor CONTINUES the incumbent's branch,
1513
+ // never a parallel one. Capture the incumbent's branch (from its pre-supersede record) and
1514
+ // surface it as `resumed_branch` so the skill can `git checkout` it; default the audit reason to
1515
+ // the ADR §5 wording (superseded actor X, last seen T, resuming from trust bundle).
1516
+ const incumbentBranch = assignment.record?.branch;
1517
+ const incumbentLastSeen = effective.holder?.last_at ?? assignment.record?.claimed_at;
1518
+ const takeoverReason = opt(p, "reason") || `takeover: superseded ${holderActor}${incumbentLastSeen ? `, last seen ${sanitize(incumbentLastSeen)}` : ""}, resuming from trust bundle`;
1499
1519
  performLocalSupersede(root, slug, fromActor, resolution.actorStruct, {
1500
- branch: resolveBranchForClaim(),
1520
+ // #294 (ADR 0021 §5): takeover is RESUMPTION — the successor continues the incumbent's branch,
1521
+ // so the record must keep pointing at that branch, NOT be overwritten with the successor's
1522
+ // current branch (`resolveBranchForClaim()`). At supersede time the successor has not yet
1523
+ // `git checkout`ed the resume branch (the skill claims ownership first, then checks out), so
1524
+ // resolveBranchForClaim() would otherwise clobber the record with a parallel branch and make a
1525
+ // later reader (resume surface / verify-hold guidance) see the wrong branch. Preserve the
1526
+ // incumbent's branch; fall back to the successor's only if the incumbent record had none.
1527
+ branch: incumbentBranch || resolveBranchForClaim(),
1501
1528
  artifactDir: path.relative(root, dir) || ".",
1502
- reason: opt(p, "reason", "ensure-session takeover: stale claim"),
1529
+ reason: takeoverReason,
1503
1530
  // F1 fix (fix-plan iteration 1, HIGH): persist the canonical actor_key on the record so
1504
1531
  // computeEffectiveState's holderActorKey (assignment-provider.ts) matches this same
1505
1532
  // branchActorKey string on the next status/guard check, cross-tool.
1506
1533
  actorKey: resolution.branchActorKey,
1507
1534
  });
1535
+ // Render-don't-execute: emit the incumbent's branch so the skill continues it (never a new
1536
+ // branch). The successor re-enters the SAME artifact dir (deterministic slug) by construction.
1537
+ printJson({ role: "SupersedeTakeover", subject: sanitize(slug), superseded_actor: holderActor, ...(incumbentBranch ? { resumed_branch: sanitizeWide(incumbentBranch) } : {}), reason: sanitizeWide(takeoverReason) });
1508
1538
  return;
1509
1539
  }
1510
1540
  case "free": {
@@ -1623,7 +1653,30 @@ function recordAgentEvent(p) {
1623
1653
  requireArtifactDirUnderRoot(dir, root);
1624
1654
  const timestamp = opt(p, "timestamp", now());
1625
1655
  const agent = validateAgentId(opt(p, "agent-id"));
1626
- 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") } : {}) };
1656
+ // #376 model routing: optionally stamp the delegate role/model resolved from
1657
+ // .datum/config.json onto the event so a downstream economics record (#349)
1658
+ // can price role assignments per delegation, and so an escalate-on-gate-failure
1659
+ // re-dispatch records which tier it climbed FROM. Fully additive/optional: when
1660
+ // no routing flag is passed the event shape is byte-identical to before.
1661
+ // These live as TOP-LEVEL event fields (not nested) on purpose: appendJsonl's
1662
+ // serializer (spacedLine) uses the top-level key list as a JSON.stringify array
1663
+ // replacer, which is an allowlist applied at every nesting level — a nested
1664
+ // routing object would have its inner keys stripped. Flat keeps the shape a
1665
+ // simple per-event routing record a JSONL economics feed can read directly.
1666
+ const role = opt(p, "role");
1667
+ const model = opt(p, "model");
1668
+ const escalatedFrom = opt(p, "escalated-from");
1669
+ const event = {
1670
+ timestamp,
1671
+ agent_id: agent,
1672
+ kind: opt(p, "kind", "note"),
1673
+ status: opt(p, "status", "info"),
1674
+ summary: opt(p, "summary"),
1675
+ ...(opt(p, "ref") ? { ref: opt(p, "ref") } : {}),
1676
+ ...(role ? { role } : {}),
1677
+ ...(model ? { model } : {}),
1678
+ ...(escalatedFrom ? { escalated_from: escalatedFrom } : {}),
1679
+ };
1627
1680
  appendJsonl(path.join(dir, "agents", agent, "events.jsonl"), event);
1628
1681
  updateCurrentAgent(root, dir, agent, event.status, timestamp, actorKey);
1629
1682
  return 0;
@@ -1907,6 +1960,14 @@ function checksFromBundle(dir) {
1907
1960
  const seen = new Set();
1908
1961
  const checks = [];
1909
1962
  const kindOf = (claim) => String(claim.metadata.check_kind);
1963
+ // Read side of the buildTrustBundle waiver round-trip (write side: buildTrustBundle reads
1964
+ // check._waiver at line ~689 and stamps it onto claimMetadata.waiver at line ~705). Without
1965
+ // this, any caller that rebuilds checks via checksFromBundle() (recordCritique/recordLearning)
1966
+ // silently drops a previously-recorded waiver on the next bundle write.
1967
+ const waiverOf = (claim) => {
1968
+ const md = claim.metadata;
1969
+ return md && typeof md === "object" && md.waiver && typeof md.waiver === "object" ? md.waiver : undefined;
1970
+ };
1910
1971
  for (const ev of bundle.evidence) {
1911
1972
  if (!ev || !ev.claimId)
1912
1973
  continue;
@@ -1925,6 +1986,9 @@ function checksFromBundle(dir) {
1925
1986
  check.command = ev.execution.label;
1926
1987
  if (ev.evidenceType)
1927
1988
  check.evidenceType = ev.evidenceType;
1989
+ const waiver = waiverOf(claim);
1990
+ if (waiver)
1991
+ check._waiver = waiver;
1928
1992
  checks.push(check);
1929
1993
  }
1930
1994
  // Also include check claims that have no evidence item (surface_trust_refs style).
@@ -1937,7 +2001,11 @@ function checksFromBundle(dir) {
1937
2001
  continue;
1938
2002
  seen.add(claim.id);
1939
2003
  const kind = kindOf(claim);
1940
- checks.push({ id: String(claim.subjectId || "").split("/").pop() || claim.id, kind, status: claim.value ?? "not_verified", summary: claim.fieldOrBehavior || "" });
2004
+ const check = { id: String(claim.subjectId || "").split("/").pop() || claim.id, kind, status: claim.value ?? "not_verified", summary: claim.fieldOrBehavior || "" };
2005
+ const waiver = waiverOf(claim);
2006
+ if (waiver)
2007
+ check._waiver = waiver;
2008
+ checks.push(check);
1941
2009
  }
1942
2010
  return checks;
1943
2011
  }
@@ -2201,7 +2269,15 @@ async function promote(p) {
2201
2269
  // Optionally republish so delivery/trust.bundle carries the promotion claim for CI.
2202
2270
  if (p.flags.has("publish")) {
2203
2271
  const publishRepoRoot = opt(p, "publish-repo-root") ? path.resolve(opt(p, "publish-repo-root")) : findRepoRootFromDirStrict(dir);
2272
+ // #356 AC6: an InvalidBundleShapeError refusal must be LOUD (rethrown, so `promote`
2273
+ // itself fails/exits non-zero) — it is NOT one of the best-effort failure modes (missing
2274
+ // kits/ ancestor, I/O) this catch otherwise tolerates. A `.catch(() => {})`-style swallow
2275
+ // here would silently defeat the whole preflight for the --publish path.
2204
2276
  await publishDelivery(dir, publishRepoRoot).catch((err) => {
2277
+ if (err instanceof InvalidBundleShapeError)
2278
+ throw err;
2279
+ if (err instanceof NotFreshHolderError)
2280
+ throw err;
2205
2281
  process.stderr.write(`[promote] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
2206
2282
  });
2207
2283
  }
@@ -2273,8 +2349,16 @@ async function advanceState(p) {
2273
2349
  // publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir
2274
2350
  // with no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures
2275
2351
  // are visible (stderr warning), not silently swallowed.
2352
+ // #356 AC6: an InvalidBundleShapeError refusal is NOT one of those best-effort failure
2353
+ // modes — it must be LOUD and cause advance-state itself to fail (rethrown here so the
2354
+ // outer command surfaces a non-zero exit), never silently swallowed alongside a genuine
2355
+ // repo-root-resolution/I-O failure.
2276
2356
  const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
2277
2357
  await publishDelivery(dir, publishRepoRoot).catch((err) => {
2358
+ if (err instanceof InvalidBundleShapeError)
2359
+ throw err;
2360
+ if (err instanceof NotFreshHolderError)
2361
+ throw err;
2278
2362
  process.stderr.write(`[advance-state] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
2279
2363
  });
2280
2364
  }
@@ -2370,8 +2454,16 @@ async function recordRelease(p) {
2370
2454
  // publishDelivery below. An explicit --repo-root (e.g. for a scratch/test artifact dir with
2371
2455
  // no kits/ ancestor of its own) always wins, matching publishDeliveryCmd. Failures are
2372
2456
  // visible (stderr warning), not silently swallowed.
2457
+ // #356 AC6: an InvalidBundleShapeError refusal is NOT best-effort — rethrow so record-release
2458
+ // itself fails loudly (non-zero exit) rather than silently publishing nothing while reporting
2459
+ // success. This is the crux of AC6: record-release is one of the auto-publish paths that must
2460
+ // never let a shape-invalid bundle slip past unnoticed.
2373
2461
  const publishRepoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
2374
2462
  await publishDelivery(dir, publishRepoRoot).catch((err) => {
2463
+ if (err instanceof InvalidBundleShapeError)
2464
+ throw err;
2465
+ if (err instanceof NotFreshHolderError)
2466
+ throw err;
2375
2467
  process.stderr.write(`[record-release] WARNING: publish-delivery failed: ${err instanceof Error ? err.message : String(err)}\n`);
2376
2468
  });
2377
2469
  return 0;
@@ -2573,6 +2665,660 @@ async function sealCheckpoint(p) {
2573
2665
  }
2574
2666
  return 0;
2575
2667
  }
2668
+ // ─── Reconcile Preflight (#356) ───────────────────────────────────────────────
2669
+ // Local, pre-push shape-only preflight for a session's trust.bundle, reusing
2670
+ // scripts/lib/reconcile-shape.js (WS8/#356 extraction) and scripts/ci/trust-reconcile.js's
2671
+ // own exported manifest resolver — never a forked reimplementation, so this can never
2672
+ // silently drift from what the CI trust-reconcile job actually enforces. Deliberately
2673
+ // shape-only: it never spawns a fresh manifest/CI command (AC5) — only the already-cheap
2674
+ // `run-baseline.sh --manifest-json` static registry emit, which prints the manifest, not
2675
+ // test results.
2676
+ /**
2677
+ * Delegate to the shared pure-CJS bundle-shape module (scripts/lib/reconcile-shape.js),
2678
+ * mirroring the createRequire pattern used by loadActorIdentityHelper()/
2679
+ * loadLivenessWriteHelper() above — the one repo/runtime boundary #356's plan flagged as
2680
+ * worth double-checking (workflow-sidecar.ts is TS→ESM-compiled-to-CJS-compatible-output;
2681
+ * scripts/lib/reconcile-shape.js is plain CommonJS). Verified clean via `npm run build`
2682
+ * + a require() smoke against build/src/cli/workflow-sidecar.js before this was wired in.
2683
+ */
2684
+ function loadReconcileShapeHelper() {
2685
+ const _req = createRequire(import.meta.url);
2686
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/lib/reconcile-shape.js");
2687
+ return _req(helperPath);
2688
+ }
2689
+ /**
2690
+ * Delegate to scripts/ci/trust-reconcile.js's own EXPORTED pure helpers (manifest
2691
+ * resolution + the git-ancestor primitive) — same createRequire idiom as
2692
+ * loadReconcileShapeHelper() above. trust-reconcile.js is CommonJS; these exports were
2693
+ * added alongside `runTrustReconcile` specifically so a local caller (this preflight)
2694
+ * never needs a second implementation of "how the manifest is resolved" (Q1/AC5).
2695
+ */
2696
+ function loadTrustReconcileHelper() {
2697
+ const _req = createRequire(import.meta.url);
2698
+ const helperPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/ci/trust-reconcile.js");
2699
+ return _req(helperPath);
2700
+ }
2701
+ /**
2702
+ * Shell to scripts/ci/derive-claim-status.mjs exactly as trust-reconcile.js's own
2703
+ * deriveClaimStatuses() does (Q1's recommendation: full status-misassertion/unwaived-assumed
2704
+ * parity with CI, at the cost of one cheap local-only spawn — no CI command execution, just
2705
+ * Surface's pure deriveClaimStatus over the bundle's own evidence/events/policies). Returns
2706
+ * null when re-derivation is unavailable (Surface could not load / helper failed) — callers
2707
+ * degrade to reconcile-shape.js's documented reduced-coverage mode, they do not fail the
2708
+ * whole preflight over this.
2709
+ */
2710
+ function derivePreflightClaimStatuses(bundlePath, repoRoot) {
2711
+ const helper = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../../scripts/ci/derive-claim-status.mjs");
2712
+ if (!fs.existsSync(helper))
2713
+ return null;
2714
+ let stdout;
2715
+ try {
2716
+ stdout = execFileSync(process.execPath, [helper, bundlePath], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], timeout: 60000 });
2717
+ }
2718
+ catch {
2719
+ return null;
2720
+ }
2721
+ if (!stdout)
2722
+ return null;
2723
+ try {
2724
+ const obj = JSON.parse(stdout);
2725
+ const m = new Map();
2726
+ for (const [k, v] of Object.entries(obj))
2727
+ m.set(k, v);
2728
+ return m;
2729
+ }
2730
+ catch {
2731
+ return null;
2732
+ }
2733
+ }
2734
+ /**
2735
+ * Distinct, identifiable error for a shape-invalid trust.bundle — NOT a generic Error, so
2736
+ * every publishDelivery() call site (advanceState/recordRelease/promote/publishDeliveryCmd)
2737
+ * can positively distinguish "refuses to publish an invalid bundle" (must be LOUD, fail
2738
+ * closed, AC6) from the other failure modes those call sites already tolerate as best-effort
2739
+ * (missing kits/ ancestor for a scratch dir, I/O errors, etc — see each catch's own comment).
2740
+ * A bare `instanceof Error` check would not suffice since every thrown failure in this file is
2741
+ * already an Error; `code` is the recognizable, grep-stable discriminator.
2742
+ */
2743
+ export class InvalidBundleShapeError extends Error {
2744
+ code = "RECONCILE_PREFLIGHT_INVALID_SHAPE";
2745
+ issues;
2746
+ constructor(issues) {
2747
+ super(`trust.bundle failed the reconcile-preflight shape check (${issues.length} issue(s)) — see .issues for the full report`);
2748
+ this.name = "InvalidBundleShapeError";
2749
+ this.issues = issues;
2750
+ }
2751
+ }
2752
+ /**
2753
+ * Distinct, identifiable error for a publish attempt by an actor who is NOT the fresh,
2754
+ * non-superseded holder of the subject (issue #293, ADR 0021 §3) — NOT a generic Error and NOT
2755
+ * `InvalidBundleShapeError` (#356), so every publishDelivery() call site can positively
2756
+ * distinguish "refuses to publish because the bundle shape is invalid" from "refuses to publish
2757
+ * because this actor no longer holds the subject" — two genuinely distinct fail-closed tiers
2758
+ * that must never be conflated in a catch handler (a worker fixing a shape issue must not be
2759
+ * told to "re-record evidence" when the real problem is a stale/superseded claim, and vice
2760
+ * versa). Mirrors `InvalidBundleShapeError`'s shape exactly: same `extends Error` base, same
2761
+ * `readonly code` discriminator convention, same doc-comment structure — only the payload
2762
+ * differs (the effective-state/holder/reason/guidance `runVerifyHold()` computed, rather than a
2763
+ * shape-issue list).
2764
+ */
2765
+ export class NotFreshHolderError extends Error {
2766
+ code = "VERIFY_HOLD_NOT_FRESH_HOLDER";
2767
+ effective_state;
2768
+ holder;
2769
+ reason;
2770
+ guidance;
2771
+ constructor(result) {
2772
+ super(`verify-hold refused publish — not the fresh holder of this subject (${result.reason}) — see .guidance for reconcile steps`);
2773
+ this.name = "NotFreshHolderError";
2774
+ this.effective_state = result.effective_state;
2775
+ this.holder = result.holder;
2776
+ this.reason = result.reason;
2777
+ this.guidance = result.guidance;
2778
+ }
2779
+ }
2780
+ /**
2781
+ * Human-actionable fix text per divergence type (Q2: unwaived-assumed's message always
2782
+ * carries the "waiver voided by a mixed record-evidence call" root-cause hint — shape #5 is
2783
+ * NOT a distinct predicate, it is #2 enriched, per the plan's Q2 resolution).
2784
+ */
2785
+ function preflightFixHint(type) {
2786
+ switch (type) {
2787
+ case "unwaived-assumed":
2788
+ 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).";
2789
+ case "waiver-on-command-check":
2790
+ 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.";
2791
+ case "not-run":
2792
+ 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.";
2793
+ case "laundering":
2794
+ return "FIX: remove the exit-code-laundering operator (||, ; true, ; exit 0, etc.) from the command — a laundered command cannot be trusted to reconcile.";
2795
+ case "session-local-failed":
2796
+ 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.";
2797
+ case "status-misassertion":
2798
+ 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.";
2799
+ case "status-underivable":
2800
+ 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.";
2801
+ case "unwaived-session-local":
2802
+ 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'.";
2803
+ default:
2804
+ return "FIX: see the divergence message above for the specific shape defect.";
2805
+ }
2806
+ }
2807
+ /**
2808
+ * F4 (iteration-1): extracted from runReconcilePreflight so the main function stays under
2809
+ * the 50-line guideline. Q3 (optional, non-blocking): warn if the checkpoint's commit_sha is
2810
+ * not yet an ancestor of local HEAD — never affects ok/exit code, best-effort only. Already a
2811
+ * self-contained try/catch with no effect on the caller's `ok`/`issues`.
2812
+ */
2813
+ function checkpointStalenessWarning(dir, repoRoot, tr) {
2814
+ const warnings = [];
2815
+ try {
2816
+ const checkpointPath = path.join(dir, "trust.checkpoint.json");
2817
+ if (fs.existsSync(checkpointPath)) {
2818
+ const checkpoint = loadJson(checkpointPath);
2819
+ const commitSha = checkpoint && typeof checkpoint === "object" ? checkpoint.commit_sha : undefined;
2820
+ if (typeof commitSha === "string" && commitSha) {
2821
+ let headSha = "";
2822
+ try {
2823
+ headSha = execFileSync("git", ["rev-parse", "HEAD"], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
2824
+ }
2825
+ catch {
2826
+ headSha = "";
2827
+ }
2828
+ if (headSha && !tr.isAncestorCommit(repoRoot, commitSha, headSha)) {
2829
+ 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.`);
2830
+ }
2831
+ }
2832
+ }
2833
+ }
2834
+ catch {
2835
+ // best-effort only — never affects ok/exit code.
2836
+ }
2837
+ return warnings;
2838
+ }
2839
+ export function runReconcilePreflight(dir, repoRoot, manifestOverride) {
2840
+ const bundlePath = path.join(dir, "trust.bundle");
2841
+ if (!fs.existsSync(bundlePath)) {
2842
+ // A preflight with nothing to check is a usage error, not a soft pass — an agent must
2843
+ // never read "no bundle" as "bundle is valid" (see reconcilePreflightCmd for the CLI-side
2844
+ // die()). The library entrypoint throws too, since publishDelivery() itself already has
2845
+ // its OWN, separate, pre-existing fail-soft branch for bundle-absence (guarded before this
2846
+ // function is ever called there) — this function is never invoked without a bundle.
2847
+ throw new Error(`reconcile-preflight: no trust.bundle found at ${bundlePath} — nothing to check. This is a usage error, not a soft pass.`);
2848
+ }
2849
+ const bundle = loadJson(bundlePath);
2850
+ const shape = loadReconcileShapeHelper();
2851
+ const tr = loadTrustReconcileHelper();
2852
+ // Resolve the SAME canonical (fresh-verify) commands CI would fall back to feeding into
2853
+ // resolveManifest's legacy tier (tier 5), for genuine parity on that fallback path too —
2854
+ // CLI --commands is not a preflight concept, so only the TRUST_RECONCILE_COMMANDS env /
2855
+ // package.json trust-reconcile-verify tiers apply locally; a repo with neither and no
2856
+ // manifest source resolves to the same empty legacy fallback CI itself would in that case.
2857
+ const canonicalCommands = tr.resolveCanonicalCommands({ commands: [] }, repoRoot) ?? [];
2858
+ const manifestResolution = tr.resolveManifest({ manifest: manifestOverride ?? null }, repoRoot, canonicalCommands);
2859
+ const manifestByCmd = new Map();
2860
+ for (const e of manifestResolution.entries)
2861
+ manifestByCmd.set(tr.normalizeCmd(e.command), e);
2862
+ const derivedStatus = derivePreflightClaimStatuses(bundlePath, repoRoot);
2863
+ const { reconcilable, sessionLocal, noEvidenceCommand, waiverOnCommand } = shape.classifyBundleClaims(bundle);
2864
+ const issues = [];
2865
+ issues.push(...shape.waiverOnCommandIssues(waiverOnCommand));
2866
+ issues.push(...shape.noEvidenceCommandIssues(noEvidenceCommand));
2867
+ const { issues: manifestIssues } = shape.reconcilableManifestIssues(reconcilable, manifestByCmd);
2868
+ issues.push(...manifestIssues);
2869
+ // iteration-1 F1: the local preflight explicitly opts into reduced-coverage degradation
2870
+ // when re-derivation is unavailable (Surface not installed, spawn failure, etc.) — CI's
2871
+ // trust-reconcile.js does NOT opt in and stays fail-closed on the same condition (see
2872
+ // reconcile-shape.js's sessionLocalShapeIssues docstring for the full mode contract).
2873
+ const { issues: sessionLocalIssues } = shape.sessionLocalShapeIssues(sessionLocal, derivedStatus, { onUnderivable: "reduce" });
2874
+ issues.push(...sessionLocalIssues);
2875
+ const report = issues.map((i) => `${i.message} — ${preflightFixHint(i.type)}`);
2876
+ const warnings = checkpointStalenessWarning(dir, repoRoot, tr);
2877
+ return { ok: report.length === 0, issues: report, warnings };
2878
+ }
2879
+ /**
2880
+ * reconcile-preflight <artifact-dir> [--manifest <json>] [--repo-root <path>]
2881
+ *
2882
+ * Local, pre-push shape-only check of the session's trust.bundle — reuses
2883
+ * scripts/lib/reconcile-shape.js and scripts/ci/trust-reconcile.js's own exported
2884
+ * classification/manifest-resolution so this can never silently drift from what CI's
2885
+ * Trust Reconcile job enforces. Prints every divergence with the claim id, divergence
2886
+ * type, and a human fix instruction; exits 0 with zero issues, 1 otherwise. Never spawns a
2887
+ * fresh manifest/CI command re-run (AC5) — resolves the manifest and re-derives status
2888
+ * (both local-only, no CI command execution) but never re-runs a manifest command itself.
2889
+ *
2890
+ * Usage: workflow-sidecar reconcile-preflight <artifactDir> [--manifest <json>] [--repo-root <path>]
2891
+ */
2892
+ async function reconcilePreflightCmd(p) {
2893
+ if (p.flags.has("help") || (!p.positional[0] && !p.opts["help"])) {
2894
+ if (p.flags.has("help")) {
2895
+ console.log("Usage: workflow-sidecar reconcile-preflight <artifactDir> [--manifest <json>] [--repo-root <path>]");
2896
+ console.log("Local, pre-push shape-only check of <artifactDir>/trust.bundle. Exits 0 with no issues, 1 otherwise.");
2897
+ return 0;
2898
+ }
2899
+ }
2900
+ const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
2901
+ const repoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
2902
+ if (!repoRoot)
2903
+ die(`reconcile-preflight: no kits/ ancestor found from ${dir}; pass --repo-root explicitly.`);
2904
+ const manifestOverride = p.opts["manifest"]?.at(-1) ?? null;
2905
+ const bundlePath = path.join(dir, "trust.bundle");
2906
+ if (!fs.existsSync(bundlePath)) {
2907
+ 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.`);
2908
+ }
2909
+ const result = runReconcilePreflight(dir, repoRoot, manifestOverride);
2910
+ for (const w of result.warnings) {
2911
+ process.stderr.write(`[reconcile-preflight] ${w}\n`);
2912
+ }
2913
+ if (result.ok) {
2914
+ console.log(`[reconcile-preflight] OK — no shape issues found in ${bundlePath}`);
2915
+ return 0;
2916
+ }
2917
+ process.stderr.write(`[reconcile-preflight] FAILED — ${result.issues.length} shape issue(s) found in ${bundlePath}:\n`);
2918
+ for (const issue of result.issues) {
2919
+ process.stderr.write(`[reconcile-preflight] - ${issue}\n`);
2920
+ }
2921
+ return 1;
2922
+ }
2923
+ /**
2924
+ * The ONE hard-stop gate (issue #293, ADR 0021 §3): "is my actor the fresh, non-superseded
2925
+ * holder of this subject (or is the subject free/self-held)?" Reuses the SAME assignment ⋈
2926
+ * liveness join #290/#291 already built (`computeEffectiveState` + `readLocalAssignmentStatus`
2927
+ * + `readLivenessEvents`/`freshHolders`) — no second computation is invented here. This is the
2928
+ * mirror-image check, at the OTHER end of the session lifecycle, of
2929
+ * `enforceEnsureSessionOwnership`'s pre-entry guard above: that guard decides whether entry
2930
+ * should claim/supersede/refuse; this gate decides whether PUBLISH should proceed, with a
2931
+ * different (new) per-effective-state mapping — the ownership guard's "claim"/"supersede"
2932
+ * actions are wrong at publish time, so this is new interpretation of the existing join output,
2933
+ * not a new join and not a new `EffectiveState` value (there is no literal `"superseded"` state
2934
+ * — a superseded-away actor's own re-check naturally resolves to `held`(holder=successor) or
2935
+ * `reclaimable`, never `self_is_holder`; see assignment-provider.ts's `computeEffectiveState`).
2936
+ *
2937
+ * Effective-state -> publish-decision mapping (the operative spec, from the plan's table).
2938
+ * IMPORTANT (bug fix, #397): this gate fences the durable ASSIGNMENT hold, not ambient
2939
+ * liveness — liveness is advisory everywhere else in the system, and this is the ONE hard
2940
+ * gate, so it must never false-block on liveness alone. A subject with fresh liveness by
2941
+ * another actor but NO durable assignment record (`held`, reason
2942
+ * `liveness_claim_present_assignment_lagging`) therefore PASSES, not BLOCKs — there is no
2943
+ * durable ownership conflict to protect, only ambient presence. This does NOT weaken zombie
2944
+ * protection: a superseded zombie always has an assignment record (either its own stale
2945
+ * assignment — `reclaimable` — or the successor's assignment-backed `held`/
2946
+ * `fresh_liveness_heartbeat`), both of which still BLOCK below.
2947
+ * - `free` (no assignment ever recorded) -> PASS (never false-block an untracked subject)
2948
+ * - `held`, reason `self_is_holder` -> PASS (the caller IS the current holder)
2949
+ * - `held`, reason `liveness_claim_present_assignment_lagging`
2950
+ * (fresh liveness by another actor, NO durable assignment record) -> PASS (liveness alone
2951
+ * is never a durable ownership conflict — see #397 fix note above)
2952
+ * - `held`, reason `fresh_liveness_heartbeat`
2953
+ * (durable assignment held by another actor + fresh liveness) -> BLOCK (a different
2954
+ * actor durably holds this subject and is demonstrably still live)
2955
+ * - `reclaimable` (assignment present, stale/absent liveness) -> BLOCK (not proof of
2956
+ * self — safe default blocks; a durable assignment record exists)
2957
+ * - `human-held` -> BLOCK (never publish over a human assignee)
2958
+ * - no resolvable join at all (non-local-file provider
2959
+ * with no --effective-state-json) -> `not_evaluated`, PASS (documented scope
2960
+ * boundary, loud stderr note, never a silent block)
2961
+ *
2962
+ * Actor resolution mirrors `enforceEnsureSessionOwnership` EXACTLY: the bare
2963
+ * `resolveActor(env).actor` / branchActorKey string, NEVER `serializeActor(actorStruct)`'s
2964
+ * triple — the #291 flat-vs-triple seam. `opts.actorKey` (the CLI's --actor / FLOW_AGENTS_ACTOR
2965
+ * override, already sanitized by the caller) takes precedence when provided.
2966
+ *
2967
+ * Slug/artifactRoot derivation matches `publishDelivery()`'s own `slug =
2968
+ * path.basename(path.resolve(dir))` byte-for-byte (both must resolve the SAME assignment
2969
+ * record) and the `artifactRoot = path.dirname(dir)` pattern already used elsewhere in this
2970
+ * file for a `dir`-only consumer.
2971
+ *
2972
+ * github provider: read-only precomputed `--effective-state-json` escape hatch, matching
2973
+ * `enforceEnsureSessionOwnership`'s existing github branch precedent exactly (render-don't-
2974
+ * execute — assignment-provider.ts never calls `gh` directly from this CLI).
2975
+ *
2976
+ * Every interpolated actor/holder/reason string is sanitized via
2977
+ * `stripControlCharsForDisplay(...).slice(0, 64)` — reusing the exact top-level helper, never a
2978
+ * second sanitizer (AC7).
2979
+ *
2980
+ * SECOND CI-blocking false-block fix (this iteration): the hard gate above enforces ONLY for a
2981
+ * STABLY-identified actor. A caller's identity is STABLE when it comes from an explicit
2982
+ * `opts.actorKey` (a caller that hands in an actor key is asserting a stable identity — e.g. the
2983
+ * CLI's --actor / FLOW_AGENTS_ACTOR override, or the github skill's precomputed path), from
2984
+ * `FLOW_AGENTS_ACTOR` (`resolveActor` source `"explicit-override"`), or from a runtime-native
2985
+ * session id (`resolveActor` source `` `runtime-session-id:${runtime}` ``, e.g. Claude Code's
2986
+ * `CLAUDE_CODE_SESSION_ID`). It is UNSTABLE when `resolveActor` falls all the way through to the
2987
+ * process-ancestry fallback (source `"process-ancestry"`) or fails to resolve at all (source
2988
+ * `"unresolved"` / `isUnresolvedActor(actor)`). This mirrors `enforceEnsureSessionOwnership`'s
2989
+ * Design Decision 4 (never hard-fail on actor ambiguity): a session that cannot stably
2990
+ * self-identify cannot be meaningfully fenced against a durable assignment record, so hard-
2991
+ * blocking it produces exactly the false-block CI hit (no `FLOW_AGENTS_ACTOR`/runtime session id
2992
+ * in CI -> ancestry fallback -> a DIFFERENT ancestry-derived actor string than the one that
2993
+ * created the claim -> `reclaimable`/not-self -> hard BLOCK of a legitimate self-publish). A
2994
+ * real coordination participant always has a stable identity (explicit override or runtime
2995
+ * session id), so zombie protection under a stable actor is completely unaffected — this only
2996
+ * changes behavior for a caller that was never fenceable in the first place. When the resolved
2997
+ * actor identity is UNSTABLE, this function short-circuits to `{ ok: true, effective_state:
2998
+ * "not_evaluated", reason: "actor-identity-unstable-advisory-only" }` (holder included, sanitized,
2999
+ * when one exists) BEFORE running the assignment ⋈ liveness join at all, with one loud stderr
3000
+ * note — the gate degrades to advisory-only rather than ever false-blocking an unstable identity.
3001
+ * When the actor identity IS stable, the effective-state -> publish-decision mapping above
3002
+ * applies exactly as documented, unchanged.
3003
+ */
3004
+ export function runVerifyHold(dir, repoRoot, opts) {
3005
+ // repoRoot is accepted (not derived) for signature symmetry with runReconcilePreflight(dir,
3006
+ // repoRoot, ...) / publishDelivery(dir, repoRoot) — the local-file join below only needs
3007
+ // artifactRoot/slug, but a future non-local-file provider branch that resolves a live
3008
+ // repo-relative fixture would need it, so the parameter stays part of the public contract.
3009
+ void repoRoot;
3010
+ const sanitize = (value) => stripControlCharsForDisplay(value).slice(0, 64);
3011
+ const slug = path.basename(path.resolve(dir));
3012
+ const artifactRoot = path.dirname(path.resolve(dir));
3013
+ const nowMs = opts?.now ?? Date.now();
3014
+ const assignmentProviderKind = opts?.assignmentProviderKind || "local-file";
3015
+ const helper = loadActorIdentityHelper();
3016
+ // Stability check (SECOND CI-blocking false-block fix, this iteration): an explicitly-provided
3017
+ // opts.actorKey is treated as stable outright (the caller is asserting a stable identity — see
3018
+ // doc comment above). Otherwise resolve BOTH the actor and its source so we can tell an
3019
+ // explicit-override / runtime-session-id actor (stable, enforce as today) apart from a
3020
+ // process-ancestry / unresolved one (unstable, advisory-only — never hard-block).
3021
+ const resolved = opts?.actorKey ? { actor: opts.actorKey, source: "explicit-override" } : helper.resolveActor(process.env);
3022
+ const actorKey = resolved.actor;
3023
+ const isStableActor = !!opts?.actorKey
3024
+ || resolved.source === "explicit-override"
3025
+ || resolved.source.startsWith("runtime-session-id")
3026
+ // #398: a CI-runtime actor is STABLE across every subprocess in a CI job (derived from the
3027
+ // provider's published run/job identifiers), so the verify-hold gate ENFORCES for CI sessions
3028
+ // instead of degrading to advisory — the root fix for the #293 CI false-block workaround.
3029
+ || resolved.source.startsWith("ci-runtime");
3030
+ const guidanceLines = (holderActor) => [
3031
+ "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.",
3032
+ holderActor
3033
+ ? `The current holder appears to be ${sanitize(holderActor)} — confirm with them before proceeding.`
3034
+ : "Confirm with the assigned human before proceeding.",
3035
+ "If a human confirms this session should resume ownership, run `ensure-session --supersede-stale` to explicitly re-claim the subject before retrying publish.",
3036
+ ];
3037
+ let effective = null;
3038
+ if (opts?.effectiveStateJson !== undefined) {
3039
+ const parsed = opts.effectiveStateJson;
3040
+ const candidate = parsed && typeof parsed === "object" ? parsed.effective : undefined;
3041
+ const validStates = new Set(["held", "reclaimable", "human-held", "free"]);
3042
+ if (candidate && typeof candidate.effective_state === "string" && validStates.has(candidate.effective_state)) {
3043
+ effective = candidate;
3044
+ }
3045
+ }
3046
+ else if (assignmentProviderKind === "local-file") {
3047
+ // Same call shape as enforceEnsureSessionOwnership's local-file branch — no second
3048
+ // freshness/ownership computation is invented (see that function's F1 fix comment for why
3049
+ // branchActorKey, not the serialized ActorStruct triple, is the correct selfActor here).
3050
+ const assignment = readLocalAssignmentStatus(artifactRoot, slug);
3051
+ const events = readLivenessEvents(artifactRoot);
3052
+ const freshList = loadLivenessReadHelper().freshHolders(events, slug, actorKey, nowMs);
3053
+ effective = computeEffectiveState(assignment, freshList, actorKey, nowMs);
3054
+ }
3055
+ if (!effective) {
3056
+ // Documented scope boundary (Design Constraint): a non-local-file provider with no
3057
+ // precomputed --effective-state-json cannot be evaluated in-CLI (render-don't-execute — no
3058
+ // live `gh` call from workflow-sidecar.ts). PASS-through, never a silent block, with a loud
3059
+ // stderr note — mirroring enforceEnsureSessionOwnership's existing github branch precedent.
3060
+ process.stderr.write(`[verify-hold] not evaluated: provider "${sanitize(assignmentProviderKind)}" requires --effective-state-json (or use --assignment-provider local-file)\n`);
3061
+ return { ok: true, effective_state: "not_evaluated", reason: "provider_not_evaluated", guidance: [] };
3062
+ }
3063
+ // F1 fix (fix-plan iteration 1, HIGH): sanitize EVERY untrusted string field of
3064
+ // effective.holder before it reaches the returned JSON / NotFreshHolderError.holder — never
3065
+ // spread the raw holder and override only the discriminated field. `last_at` (sourced from
3066
+ // record.claimed_at or a liveness event's `at`, both attacker-writable in the shared
3067
+ // multi-writer liveness/assignment stream) and `actor`/`assignee` are all display strings and
3068
+ // go through the SAME `sanitize` helper (stripControlCharsForDisplay(...).slice(0, 64)) as
3069
+ // enforceEnsureSessionOwnership's equivalent call sites (AC7 injection-discipline parity).
3070
+ // `idle_days` is a `number | null` computed by computeEffectiveState from Date.parse/Math.floor
3071
+ // arithmetic (assignment-provider.ts) — never an attacker-controlled string — so it is passed
3072
+ // through as-is, but defensively re-coerced with `typeof === "number"` so a future shape change
3073
+ // upstream can never smuggle a string through this field.
3074
+ const sanitizeHolder = (holder) => {
3075
+ if (!holder)
3076
+ return undefined;
3077
+ return {
3078
+ actor: holder.actor ? sanitize(holder.actor) : undefined,
3079
+ assignee: holder.assignee ? sanitize(holder.assignee) : holder.assignee,
3080
+ idle_days: typeof holder.idle_days === "number" ? holder.idle_days : undefined,
3081
+ last_at: holder.last_at ? sanitize(holder.last_at) : undefined,
3082
+ };
3083
+ };
3084
+ // SECOND CI-blocking false-block fix (this iteration): an actor identity that cannot stably
3085
+ // self-identify (process-ancestry fallback or unresolved) cannot be meaningfully fenced against
3086
+ // a durable assignment record — hard-blocking it is exactly the CI false-block this fix targets
3087
+ // (see doc comment above). Degrade to advisory-only: never hard-block, regardless of what the
3088
+ // join above computed. This check runs AFTER the join so an existing holder (if any) can still
3089
+ // be surfaced for visibility, but strictly BEFORE the effective-state switch below so no
3090
+ // unstable-actor request can ever reach a `case` that returns `ok: false`.
3091
+ if (!isStableActor) {
3092
+ process.stderr.write("[verify-hold] actor identity is ancestry-derived/unresolved — gate is advisory only for unstable identities; not hard-blocking publish\n");
3093
+ return {
3094
+ ok: true,
3095
+ effective_state: "not_evaluated",
3096
+ reason: "actor-identity-unstable-advisory-only",
3097
+ guidance: [],
3098
+ ...(effective.holder ? { holder: sanitizeHolder(effective.holder) } : {}),
3099
+ };
3100
+ }
3101
+ switch (effective.effective_state) {
3102
+ case "free":
3103
+ return { ok: true, effective_state: "free", reason: effective.reason, guidance: [] };
3104
+ case "held": {
3105
+ const isSelf = effective.reason === "self_is_holder" || (!!effective.holder?.actor && effective.holder.actor === actorKey);
3106
+ if (isSelf)
3107
+ return { ok: true, effective_state: "held", holder: sanitizeHolder(effective.holder), reason: effective.reason, guidance: [] };
3108
+ // Bug fix (#397): `liveness_claim_present_assignment_lagging` means computeEffectiveState
3109
+ // found NO durable assignment record at all — only ambient liveness by another actor
3110
+ // (assignment-provider.ts's `if (!isAssigned) { if (freshHoldersList.length > 0) return
3111
+ // held/liveness_claim_present_assignment_lagging }`). Liveness alone is advisory
3112
+ // everywhere else in this system; this ONE hard gate must fence the durable ASSIGNMENT
3113
+ // hold, not ambient presence, so this case PASSES rather than false-blocking. Every other
3114
+ // `held` reason (`fresh_liveness_heartbeat`) is reached only when an assignment record
3115
+ // DOES exist (assignment-provider.ts's `isAssigned` branch) and the recorded holder is
3116
+ // still fresh-live — a genuine durable-ownership conflict, which still BLOCKs below.
3117
+ if (effective.reason === "liveness_claim_present_assignment_lagging") {
3118
+ return { ok: true, effective_state: "held", holder: sanitizeHolder(effective.holder), reason: effective.reason, guidance: [] };
3119
+ }
3120
+ const holderActor = effective.holder?.actor;
3121
+ return {
3122
+ ok: false,
3123
+ effective_state: "held",
3124
+ holder: sanitizeHolder(effective.holder),
3125
+ reason: `subject ${sanitize(slug)} is currently held by a different, still-live actor (${sanitize(holderActor ?? "unknown")})`,
3126
+ guidance: guidanceLines(holderActor),
3127
+ };
3128
+ }
3129
+ case "reclaimable": {
3130
+ // Stop-short risk (plan): reclaimable is NEVER treated as PASS. A lapsed self-claim looks
3131
+ // reclaimable, not self_is_holder, to a woken zombie's own re-check — the safe default
3132
+ // blocks and asks for reconcile guidance rather than auto-passing a stale self-claim.
3133
+ const holderActor = effective.holder?.actor;
3134
+ return {
3135
+ ok: false,
3136
+ effective_state: "reclaimable",
3137
+ holder: sanitizeHolder(effective.holder),
3138
+ reason: `subject ${sanitize(slug)}'s existing claim (held by ${sanitize(holderActor ?? "unknown")}) is stale/unverified as self — not a confirmed fresh hold`,
3139
+ guidance: guidanceLines(holderActor),
3140
+ };
3141
+ }
3142
+ case "human-held": {
3143
+ const assignee = effective.holder?.assignee;
3144
+ return {
3145
+ ok: false,
3146
+ effective_state: "human-held",
3147
+ holder: sanitizeHolder(effective.holder),
3148
+ reason: `subject ${sanitize(slug)} is assigned to ${assignee ? sanitize(assignee) : "an assigned human"} — never publish over a human assignment without confirmation`,
3149
+ guidance: guidanceLines(undefined),
3150
+ };
3151
+ }
3152
+ default:
3153
+ return { ok: true, effective_state: "not_evaluated", reason: "unrecognized_effective_state", guidance: [] };
3154
+ }
3155
+ }
3156
+ /**
3157
+ * verify-hold <artifact-dir> [--actor <key>] [--now <iso>] [--assignment-provider <kind>]
3158
+ * [--effective-state-json <path|->]
3159
+ *
3160
+ * CLI surface for runVerifyHold() (issue #293). Prints `{ role: "VerifyHoldResult", ... }` JSON
3161
+ * and exits 0 when `ok` (including `not_evaluated` — a documented scope boundary is never a
3162
+ * failure exit), 1 otherwise. NEVER throws at the CLI boundary — matches
3163
+ * `reconcilePreflightCmd`'s existing convention of printing then exiting rather than throwing;
3164
+ * the thrown `NotFreshHolderError` variant is reserved for the LIBRARY call inside
3165
+ * `publishDelivery()`.
3166
+ *
3167
+ * Usage: workflow-sidecar verify-hold <artifactDir> [--actor <key>] [--now <iso>]
3168
+ * [--assignment-provider <kind>] [--effective-state-json <path|->]
3169
+ */
3170
+ async function verifyHoldCmd(p) {
3171
+ if (p.flags.has("help") || (!p.positional[0] && !p.opts["help"])) {
3172
+ if (p.flags.has("help")) {
3173
+ console.log("Usage: workflow-sidecar verify-hold <artifactDir> [--actor <key>] [--now <iso>] [--assignment-provider <kind>] [--effective-state-json <path|->]");
3174
+ 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.");
3175
+ return 0;
3176
+ }
3177
+ }
3178
+ const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
3179
+ const repoRoot = opt(p, "repo-root") ? path.resolve(opt(p, "repo-root")) : findRepoRootFromDirStrict(dir);
3180
+ // SECOND CI-blocking false-block fix (this iteration): only pass an explicit actorKey through
3181
+ // to runVerifyHold when the CALLER explicitly passed --actor — that is the one case where an
3182
+ // actorKey should be treated as an asserted-stable identity (see runVerifyHold's doc comment).
3183
+ // When --actor is omitted, actorKey is left undefined so runVerifyHold performs its OWN
3184
+ // resolveActor(process.env) call internally and can see the real `source` (explicit-override /
3185
+ // runtime-session-id / process-ancestry / unresolved) to decide stability — calling
3186
+ // resolveReadActorKey(p) here first would discard that source and wrongly force every ambient
3187
+ // resolution (including an ancestry fallback) to be treated as an explicit/stable actorKey.
3188
+ const actorKey = opt(p, "actor") ? loadActorIdentityHelper().sanitizeSegment(opt(p, "actor")) : undefined;
3189
+ const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : undefined;
3190
+ const assignmentProviderKind = opt(p, "assignment-provider", "local-file");
3191
+ const effectiveStateJsonFlag = opt(p, "effective-state-json");
3192
+ const effectiveStateJson = effectiveStateJsonFlag ? loadJsonInputFile(effectiveStateJsonFlag) : undefined;
3193
+ const result = runVerifyHold(dir, repoRoot, { actorKey, now: nowMs, assignmentProviderKind, effectiveStateJson });
3194
+ printJson({ role: "VerifyHoldResult", ...result });
3195
+ return result.ok ? 0 : 1;
3196
+ }
3197
+ // ─── Takeover Preflight (#294, ADR 0021 §5) ───────────────────────────────────
3198
+ /**
3199
+ * Render (never execute) the takeover protocol for a candidate the skill has selected. Takeover is
3200
+ * RESUMPTION, not restart: when a subject is `reclaimable` (a durable assignment whose incumbent is
3201
+ * no longer heartbeating), a successor must (1) grace-beat — wait one heartbeat interval and re-check,
3202
+ * backing off if the incumbent revives — (2) supersede via `ensure-session --supersede-stale`, and
3203
+ * (3) CONTINUE THE INCUMBENT'S BRANCH (from the incumbent's claim record), never a parallel one, and
3204
+ * re-enter the SAME artifact dir (deterministic slug). This function computes the effective state
3205
+ * (the same assignment ⋈ liveness join verify-hold/ensure-session use) and emits the appropriate
3206
+ * action + the incumbent's `resume_branch`; the skill executes the beat/supersede/git steps.
3207
+ *
3208
+ * Race-safety is CLI-enforced at supersede time, not here: `ensure-session --supersede-stale`
3209
+ * re-computes the state and refuses (dies on `held`) if the incumbent revived during the beat, so a
3210
+ * successor that skipped or lost the grace race still cannot supersede a live incumbent (AC2a).
3211
+ *
3212
+ * ADVISORY, not a gate: the assignment ⋈ liveness JOIN is identical to verify-hold's, but this render
3213
+ * only advises the skill which action to take — the ACTUAL enforcement is `ensure-session
3214
+ * --supersede-stale` (re-check under a subject lock). So it deliberately does NOT replicate
3215
+ * verify-hold's #398 `isStableActor` tiering (which exists there only because verify-hold is a hard
3216
+ * publish BLOCK that must not false-block an unstable/ancestry identity): a wrong self-identification
3217
+ * here yields only a wrong advisory string, never a wrong mutation.
3218
+ *
3219
+ * Ordering note: ADR 0021 §5's literal wording is supersede→grace→maybe-undo; this implements
3220
+ * grace→recheck→supersede (never write until the beat confirms the incumbent stayed stale) — a
3221
+ * deliberate, safer variant that preserves the ADR's intent ("laptop just woke resolves in the
3222
+ * incumbent's favor") without a premature write to walk back.
3223
+ *
3224
+ * All untrusted fields (incumbent actor / branch / last_at, sourced from the shared multi-writer
3225
+ * assignment+liveness stream) pass through `sanitize`/`sanitizeWide` (stripControlCharsForDisplay +
3226
+ * the repo's 64/240 cap tiers — branch uses the 240 free-text tier so a valid long branch is never
3227
+ * truncated into a bad checkout target) — the #287/#320/#293 injection class.
3228
+ */
3229
+ export function runTakeoverPreflight(dir, opts) {
3230
+ const sanitize = (value) => stripControlCharsForDisplay(value).slice(0, 64);
3231
+ // Branch names use the repo's 240-char free-text tier (matching assignment-provider.ts's
3232
+ // sanitizeDisplayField(record.branch, 240)), NOT the 64-char id tier: a realistic
3233
+ // `agent/<actor>/<slug>` branch can exceed 64 chars, and truncating `resume_branch` would hand the
3234
+ // skill a `git checkout <truncated>` target that does not exist — defeating the resume-the-branch
3235
+ // guarantee. Control chars are still stripped (injection), only the cap widens.
3236
+ const sanitizeWide = (value) => stripControlCharsForDisplay(value).slice(0, 240);
3237
+ const slug = path.basename(path.resolve(dir));
3238
+ const artifactRoot = path.dirname(path.resolve(dir));
3239
+ const nowMs = opts?.now ?? Date.now();
3240
+ const helper = loadActorIdentityHelper();
3241
+ const actorKey = opts?.actorKey ? helper.sanitizeSegment(opts.actorKey) : helper.resolveActor(process.env).actor;
3242
+ const graceSeconds = opts?.graceSeconds ?? loadLivenessPolicyHelper().resolveHeartbeatThrottleSeconds(process.env);
3243
+ const assignment = readLocalAssignmentStatus(artifactRoot, slug);
3244
+ const events = readLivenessEvents(artifactRoot);
3245
+ const freshList = loadLivenessReadHelper().freshHolders(events, slug, actorKey, nowMs);
3246
+ const effective = computeEffectiveState(assignment, freshList, actorKey, nowMs);
3247
+ const reason = sanitize(effective.reason);
3248
+ const holderActor = effective.holder?.actor ? sanitize(effective.holder.actor) : undefined;
3249
+ const holderLastAt = effective.holder?.last_at ? sanitize(effective.holder.last_at) : undefined;
3250
+ switch (effective.effective_state) {
3251
+ case "reclaimable": {
3252
+ // The incumbent's branch is the resume target — read it from the pre-supersede record, never
3253
+ // derive a new one (AC1: takeover continues the incumbent's branch, no parallel branch).
3254
+ const resumeBranch = assignment.record?.branch ? sanitizeWide(assignment.record.branch) : undefined;
3255
+ return {
3256
+ ok: true,
3257
+ action: "grace-then-supersede",
3258
+ effective_state: "reclaimable",
3259
+ reason,
3260
+ holder: { actor: holderActor, last_at: holderLastAt },
3261
+ ...(resumeBranch ? { resume_branch: resumeBranch } : {}),
3262
+ grace_seconds: graceSeconds,
3263
+ next_steps: [
3264
+ `Grace beat: wait ${graceSeconds}s (one heartbeat interval) for the incumbent to revive.`,
3265
+ `Re-run \`workflow-sidecar takeover-preflight ${slug}\`; if it now reports action "back-off" the incumbent revived — STOP and reselect.`,
3266
+ `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.`,
3267
+ resumeBranch
3268
+ ? `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.`
3269
+ : `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.`,
3270
+ ],
3271
+ };
3272
+ }
3273
+ case "held": {
3274
+ const selfIsHolder = effective.reason === "self_is_holder";
3275
+ return {
3276
+ ok: selfIsHolder,
3277
+ action: selfIsHolder ? "proceed" : "back-off",
3278
+ effective_state: "held",
3279
+ reason,
3280
+ holder: { actor: holderActor, last_at: holderLastAt },
3281
+ next_steps: selfIsHolder
3282
+ ? ["This subject is already held by you — proceed with your existing session; no takeover needed."]
3283
+ : [`The incumbent is live/revived (held by ${holderActor ?? "another actor"}). Back off — do NOT supersede a live holder — and reselect a different item.`],
3284
+ };
3285
+ }
3286
+ case "human-held": {
3287
+ return {
3288
+ ok: false,
3289
+ action: "ask-first",
3290
+ effective_state: "human-held",
3291
+ reason,
3292
+ holder: { actor: holderActor, last_at: holderLastAt },
3293
+ next_steps: [`A human is assigned (${holderActor ?? "assignee"}). Ask first — never auto-supersede a human assignment (ADR 0021 §6).`],
3294
+ };
3295
+ }
3296
+ case "free":
3297
+ default: {
3298
+ return {
3299
+ ok: true,
3300
+ action: "claim",
3301
+ effective_state: "free",
3302
+ reason,
3303
+ next_steps: ["No durable claim to supersede — this is a normal claim, not a takeover. Run `ensure-session` to claim it."],
3304
+ };
3305
+ }
3306
+ }
3307
+ }
3308
+ async function takeoverPreflightCmd(p) {
3309
+ if (p.flags.has("help")) {
3310
+ console.log("Usage: workflow-sidecar takeover-preflight <artifactDir> [--actor <key>] [--now <iso>] [--grace-seconds <n>]");
3311
+ 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.");
3312
+ return 0;
3313
+ }
3314
+ const dir = artifactDirFrom(p.positional[0] || die("artifact directory is required"));
3315
+ const actorKey = opt(p, "actor") ? loadActorIdentityHelper().sanitizeSegment(opt(p, "actor")) : undefined;
3316
+ const nowMs = opt(p, "now") ? Date.parse(opt(p, "now")) : undefined;
3317
+ const graceSeconds = opt(p, "grace-seconds") ? Number(opt(p, "grace-seconds")) : undefined;
3318
+ const result = runTakeoverPreflight(dir, { actorKey, now: nowMs, graceSeconds });
3319
+ printJson({ role: "TakeoverPreflight", ...result });
3320
+ return result.ok ? 0 : 1;
3321
+ }
2576
3322
  // ─── Publish Delivery Bundle ──────────────────────────────────────────────────
2577
3323
  // Copies the session's trust.bundle (+ checkpoint companions) from the gitignored
2578
3324
  // session artifact dir (.kontourai/flow-agents/<slug>/) to the committed delivery/ transport
@@ -2582,48 +3328,160 @@ async function sealCheckpoint(p) {
2582
3328
  // Idempotent: overwrites on re-delivery.
2583
3329
  // Called automatically from recordRelease and advanceState→delivered (best-effort).
2584
3330
  // Also exposed as the `publish-delivery <artifact-dir>` subcommand for explicit use.
3331
+ /**
3332
+ * #379 supersede-on-publish cleanup: keep delivery/ bounded by pruning inherited PER-SESSION
3333
+ * seal dirs (the growth vector), scoped to avoid any cross-PR conflict.
3334
+ *
3335
+ * An inherited per-session seal dir (`delivery/<other-slug>/`) is UNIQUELY named, so pruning
3336
+ * it can never conflict with a concurrent PR: two branches deleting the same inherited dir is
3337
+ * a delete/delete (auto-merges), and each new delivery adds its OWN distinct
3338
+ * `delivery/<slug>/`. And it is HARMLESS to leave: trust-reconcile.js's prefer-newest
3339
+ * ownership selection always picks THIS session's fresher bundle over an older inherited one.
3340
+ * Pruning is therefore purely to stop unbounded accumulation of permanently-superseded dirs.
3341
+ *
3342
+ * The SHARED FLAT path (`delivery/trust.bundle` + checkpoints) is deliberately NOT pruned
3343
+ * here. During the migration window other concurrent PRs may still seal to the flat path;
3344
+ * deleting it would produce a modify/delete conflict → a DIRTY PR → the no-CI failure this
3345
+ * whole change fixes. The flat path is a single fixed location (not a growth vector) and the
3346
+ * reconciler treats a stale flat bundle as non-owning / older-owning, so leaving it is safe.
3347
+ * Removing the flat legacy seals is a one-time cleanup for a dedicated PR once no open PR
3348
+ * still seals to it — intentionally NOT bundled into every delivery.
3349
+ *
3350
+ * Best-effort: a prune failure is logged, never fatal to the delivery. Never touches
3351
+ * README.md, DECLARED, the flat seal files, or any subdir that is not itself a seal dir.
3352
+ */
3353
+ function pruneSupersededSeals(deliveryDir, keepSlug) {
3354
+ let entries = [];
3355
+ try {
3356
+ entries = fs.readdirSync(deliveryDir, { withFileTypes: true });
3357
+ }
3358
+ catch {
3359
+ entries = [];
3360
+ }
3361
+ for (const entry of entries) {
3362
+ if (!entry.isDirectory() || entry.name === keepSlug)
3363
+ continue;
3364
+ const subdir = path.join(deliveryDir, entry.name);
3365
+ // Only prune dirs that actually look like a seal dir (contain a trust.bundle or
3366
+ // trust.checkpoint.json) — never an unrelated directory a human placed under delivery/.
3367
+ const looksLikeSeal = fs.existsSync(path.join(subdir, "trust.bundle")) ||
3368
+ fs.existsSync(path.join(subdir, "trust.checkpoint.json"));
3369
+ if (!looksLikeSeal)
3370
+ continue;
3371
+ try {
3372
+ fs.rmSync(subdir, { recursive: true, force: true });
3373
+ 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`);
3374
+ }
3375
+ catch (err) {
3376
+ 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`);
3377
+ }
3378
+ }
3379
+ }
2585
3380
  /**
2586
3381
  * Publish the session's trust artifacts to the committed delivery/ path.
2587
3382
  *
3383
+ * #379: writes to a PER-SESSION path `<repoRoot>/delivery/<slug>/` (slug = the session
3384
+ * artifact dir's basename) rather than the shared flat `delivery/`. A shared path guarantees
3385
+ * a git conflict between ANY two concurrent deliveries, and a conflicting (DIRTY) PR gets NO
3386
+ * pull_request workflows — the required Trust Reconcile check silently never runs (field
3387
+ * incidents #330/#358/#378). Per-session paths make concurrent deliveries write DISTINCT
3388
+ * files that never contend. trust-reconcile.js reads both the flat (back-compat) and
3389
+ * per-session layouts via resolveDeliveryCandidates() and selects the owning candidate by
3390
+ * commit ancestry.
3391
+ *
2588
3392
  * Copies trust.bundle, trust.checkpoint.json, and (if present)
2589
- * trust.checkpoint.intoto.json / trust.checkpoint.sig.json from the
2590
- * session artifact dir to <repoRoot>/delivery/.
3393
+ * trust.checkpoint.intoto.json / trust.checkpoint.sig.json / trust.checkpoint.attestation.json
3394
+ * from the session artifact dir to <repoRoot>/delivery/<slug>/.
2591
3395
  *
2592
3396
  * Fail-soft on a missing bundle: if trust.bundle is absent, returns without throwing.
2593
3397
  * Fail-CLOSED on repo-root resolution: repoRoot must be a real, resolved kits/ ancestor
2594
3398
  * (see findRepoRootFromDirStrict) — null (no ancestor found) skips the publish with a
2595
3399
  * visible warning instead of writing to whatever process.cwd() happens to be. This
2596
3400
  * prevents a scratch/test session dir (no kits/ ancestor) from silently clobbering an
2597
- * unrelated real repo's delivery/trust.bundle when invoked with that repo as cwd (see
3401
+ * unrelated real repo's delivery/ seal when invoked with that repo as cwd (see
2598
3402
  * evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings at
2599
3403
  * .kontourai/flow-agents/ws5-governance-kit-slice1 for the root cause this fixes).
2600
- * Idempotent: overwrites on re-delivery.
3404
+ * Idempotent: overwrites on re-delivery to the same slug.
3405
+ *
3406
+ * #356 Wave 3 (AC6): Fail-CLOSED on bundle SHAPE. After the fail-soft absence guard below,
3407
+ * runs the SAME reconcile-preflight shape check (runReconcilePreflight) the
3408
+ * `reconcile-preflight` CLI subcommand exposes, BEFORE copying anything into delivery/. A
3409
+ * shape-invalid bundle throws InvalidBundleShapeError (a distinct, identifiable error — see
3410
+ * its own doc comment) rather than silently publishing a bundle CI's Trust Reconcile job
3411
+ * would reject anyway. This is intentionally a NEW, additive fail-closed branch — it must
3412
+ * never be conflated with the pre-existing fail-soft absence/repo-root branches above/below,
3413
+ * which stay exactly as before (see each publishDelivery call site's catch handler for how
3414
+ * the distinction is preserved end-to-end).
3415
+ *
3416
+ * #293 (SECOND, DISTINCT fail-closed gate): immediately after the shape check above and BEFORE
3417
+ * writing anything into delivery/, runs `runVerifyHold()` — the assignment ⋈ liveness join
3418
+ * asking "is the calling actor the fresh, non-superseded holder of this subject (or is it
3419
+ * free/self-held)?" A not-fresh-holder result throws `NotFreshHolderError` (distinct from
3420
+ * `InvalidBundleShapeError` — a different `code`, a different failure mode: actor hold vs
3421
+ * bundle shape). There are now THREE tiers here, and they must never be conflated in prose or
3422
+ * in a call site's catch handler: (1) fail-SOFT bundle absence / repo-root resolution (silent
3423
+ * no-op / visible warning, unchanged from before #356), (2) fail-CLOSED bundle shape (#356,
3424
+ * `InvalidBundleShapeError`), (3) fail-CLOSED verify-hold (#293, `NotFreshHolderError`). The
3425
+ * shape check runs first (per the plan's ordering) — a bundle that is BOTH shape-invalid and
3426
+ * not-held throws `InvalidBundleShapeError` specifically, never `NotFreshHolderError`.
2601
3427
  */
2602
3428
  export async function publishDelivery(dir, repoRoot) {
2603
3429
  const bundleSrc = path.join(dir, "trust.bundle");
2604
3430
  if (!fs.existsSync(bundleSrc))
2605
3431
  return; // no bundle — skip gracefully
2606
3432
  if (!repoRoot) {
2607
- 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`);
3433
+ 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`);
2608
3434
  return;
2609
3435
  }
3436
+ // #356 AC6: fail-CLOSED on shape-invalidity — runs AFTER the fail-soft absence/repo-root
3437
+ // guards above (both preserved unchanged) and BEFORE any copy into delivery/. Throws
3438
+ // InvalidBundleShapeError (never a generic Error) so every call site can positively
3439
+ // distinguish this from the failure modes they already tolerate as best-effort.
3440
+ const preflight = runReconcilePreflight(dir, repoRoot);
3441
+ if (!preflight.ok) {
3442
+ process.stderr.write(`[publish-delivery] REFUSING to publish — trust.bundle failed the reconcile-preflight shape check (${preflight.issues.length} issue(s)):\n`);
3443
+ for (const issue of preflight.issues) {
3444
+ process.stderr.write(`[publish-delivery] - ${issue}\n`);
3445
+ }
3446
+ throw new InvalidBundleShapeError(preflight.issues);
3447
+ }
3448
+ // #293: SECOND, DISTINCT fail-closed gate — runs after the shape check above and before any
3449
+ // copy into delivery/. Throws NotFreshHolderError (never conflated with InvalidBundleShapeError)
3450
+ // so every call site can positively distinguish "not the fresh holder" from "bundle shape
3451
+ // invalid".
3452
+ const holdCheck = runVerifyHold(dir, repoRoot);
3453
+ if (!holdCheck.ok) {
3454
+ process.stderr.write(`[publish-delivery] REFUSING to publish — verify-hold gate: ${holdCheck.reason}\n`);
3455
+ for (const line of holdCheck.guidance)
3456
+ process.stderr.write(`[publish-delivery] - ${line}\n`);
3457
+ throw new NotFreshHolderError(holdCheck);
3458
+ }
2610
3459
  const deliveryDir = path.join(repoRoot, "delivery");
3460
+ // #379: slug is the session artifact dir's basename — the same human-meaningful id used
3461
+ // throughout the session (.kontourai/flow-agents/<slug>/). The per-session dir NAME is only
3462
+ // a collision-avoidance handle; ownership is decided by commit ancestry, not by name.
3463
+ const slug = path.basename(path.resolve(dir));
3464
+ const sessionDeliveryDir = path.join(deliveryDir, slug);
2611
3465
  fs.mkdirSync(deliveryDir, { recursive: true });
3466
+ // Supersede inherited/flat seals BEFORE writing this session's dir (keepSlug = our own).
3467
+ pruneSupersededSeals(deliveryDir, slug);
3468
+ fs.mkdirSync(sessionDeliveryDir, { recursive: true });
2612
3469
  // Required: trust.bundle (the CI anchor)
2613
- fs.copyFileSync(bundleSrc, path.join(deliveryDir, "trust.bundle"));
3470
+ fs.copyFileSync(bundleSrc, path.join(sessionDeliveryDir, "trust.bundle"));
2614
3471
  // Optional companions: checkpoint + signing artifacts
2615
3472
  const companions = [
2616
3473
  "trust.checkpoint.json",
2617
3474
  "trust.checkpoint.intoto.json",
2618
3475
  "trust.checkpoint.sig.json",
3476
+ "trust.checkpoint.attestation.json",
2619
3477
  ];
2620
3478
  for (const filename of companions) {
2621
3479
  const src = path.join(dir, filename);
2622
3480
  if (fs.existsSync(src)) {
2623
- fs.copyFileSync(src, path.join(deliveryDir, filename));
3481
+ fs.copyFileSync(src, path.join(sessionDeliveryDir, filename));
2624
3482
  }
2625
3483
  }
2626
- process.stderr.write(`[publish-delivery] published trust.bundle and companions to ${deliveryDir}\n`);
3484
+ process.stderr.write(`[publish-delivery] published trust.bundle and companions to ${sessionDeliveryDir} (per-session path, #379)\n`);
2627
3485
  }
2628
3486
  /**
2629
3487
  * publish-delivery <artifact-dir> [--repo-root <path>]
@@ -3369,7 +4227,7 @@ function loadLivenessReadHelper() {
3369
4227
  // init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
3370
4228
  // so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.
3371
4229
  // Additive + fail-open: a liveness-emit failure never affects the workflow command.
3372
- const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
4230
+ export const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
3373
4231
  /**
3374
4232
  * Delegate to the shared pure-CJS resolver (scripts/hooks/lib/actor-identity.js), mirroring the
3375
4233
  * createRequire pattern used by readLivenessEvents() above. Deliberately NO inline duplicate
@@ -3858,6 +4716,9 @@ async function main() {
3858
4716
  case "resolve-slug": return resolveSlugCmd(p);
3859
4717
  case "seal-checkpoint": return sealCheckpoint(p);
3860
4718
  case "publish-delivery": return publishDeliveryCmd(p);
4719
+ case "reconcile-preflight": return reconcilePreflightCmd(p);
4720
+ case "verify-hold": return verifyHoldCmd(p);
4721
+ case "takeover-preflight": return takeoverPreflightCmd(p);
3861
4722
  default: die(`unknown command: ${p.command}`);
3862
4723
  }
3863
4724
  });