@kontourai/flow-agents 3.1.0 → 3.2.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 (52) hide show
  1. package/.github/workflows/ci.yml +4 -0
  2. package/CHANGELOG.md +17 -0
  3. package/build/src/cli/assignment-provider.d.ts +45 -0
  4. package/build/src/cli/assignment-provider.js +97 -12
  5. package/build/src/cli/workflow-sidecar.d.ts +14 -4
  6. package/build/src/cli/workflow-sidecar.js +100 -10
  7. package/context/contracts/assignment-provider-contract.md +1 -1
  8. package/context/contracts/execution-contract.md +78 -0
  9. package/context/scripts/hooks/config-protection.js +11 -4
  10. package/context/scripts/hooks/stop-goal-fit.js +259 -4
  11. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
  12. package/evals/ci/run-baseline.sh +2 -0
  13. package/evals/integration/test_checkpoint_signing.sh +4 -3
  14. package/evals/integration/test_gate_lockdown.sh +36 -0
  15. package/evals/integration/test_model_routing_escalation.sh +145 -0
  16. package/evals/integration/test_publish_delivery.sh +14 -6
  17. package/evals/integration/test_stop_hook_release.sh +552 -0
  18. package/evals/integration/test_trust_reconcile_negatives.sh +170 -0
  19. package/evals/run.sh +6 -0
  20. package/evals/static/test_model_routing_hints.sh +107 -0
  21. package/kits/builder/skills/builder-shape/SKILL.md +10 -0
  22. package/kits/builder/skills/deliver/SKILL.md +52 -11
  23. package/kits/builder/skills/design-probe/SKILL.md +10 -0
  24. package/kits/builder/skills/execute-plan/SKILL.md +13 -0
  25. package/kits/builder/skills/fix-bug/SKILL.md +17 -0
  26. package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
  27. package/kits/builder/skills/plan-work/SKILL.md +9 -0
  28. package/kits/builder/skills/pull-work/SKILL.md +10 -0
  29. package/kits/builder/skills/review-work/SKILL.md +11 -0
  30. package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
  31. package/kits/builder/skills/verify-work/SKILL.md +11 -0
  32. package/kits/knowledge/adapters/default-store/index.js +56 -15
  33. package/kits/knowledge/adapters/flow-runner/index.js +912 -16
  34. package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
  35. package/kits/knowledge/adapters/shared/codec.js +124 -0
  36. package/kits/knowledge/docs/store-contract.md +405 -3
  37. package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
  38. package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
  39. package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
  40. package/kits/knowledge/evals/contract-suite/suite.test.js +36 -0
  41. package/kits/knowledge/evals/freshness/suite.test.js +339 -0
  42. package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
  43. package/kits/knowledge/evals/retirement/suite.test.js +1 -1
  44. package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
  45. package/package.json +1 -1
  46. package/schemas/workflow-handoff.schema.json +6 -0
  47. package/scripts/ci/mint-attestation.js +33 -6
  48. package/scripts/ci/trust-reconcile.js +144 -26
  49. package/scripts/hooks/config-protection.js +11 -4
  50. package/scripts/hooks/stop-goal-fit.js +259 -4
  51. package/src/cli/assignment-provider.ts +110 -12
  52. package/src/cli/workflow-sidecar.ts +99 -10
@@ -559,31 +559,129 @@ function claimLocalFile(argv: string[]): number {
559
559
  return 0;
560
560
  }
561
561
 
562
- function releaseLocalFile(argv: string[]): number {
563
- const args = parseArgs(argv);
564
- const provider = flagString(args.flags, "provider", "local-file");
565
- if (provider !== "local-file") throw new Error(`release: --provider must be local-file (use render-release for github); got ${provider}`);
566
- const artifactRoot = requireFlag(args, "artifact-root");
567
- const subjectId = requireFlag(args, "subject-id");
568
- const releasedBy = flagString(args.flags, "actor-json") ? loadActorStructFromFile(requireFlag(args, "actor-json")) : null;
569
- const reason = flagString(args.flags, "reason") ?? "released";
562
+ /**
563
+ * Wave 1 (#292) extraction: the durable-write body previously inlined inside releaseLocalFile's
564
+ * withSubjectLock() closure, now a parameter-driven pure function so the Stop hook's non-terminal
565
+ * release lifecycle (scripts/hooks/stop-goal-fit.js, #292 Wave 2) can reuse the EXACT same release
566
+ * logic — actor-ownership verification, audit-trail append, atomic write under withSubjectLock —
567
+ * rather than reimplementing a second, parallel release path. releaseLocalFile (CLI wrapper,
568
+ * below) is now a thin parse-args/print-envelope shell around this, mirroring the
569
+ * performLocalSupersede/supersedeLocalFile extraction shape exactly.
570
+ *
571
+ * Two behaviors are deliberately DIFFERENT from a naive inline release, both required for the
572
+ * Stop hook's idempotent, actor-scoped lifecycle release (never for the interactive CLI, which
573
+ * keeps `tolerateNoActiveClaim` unset/false and therefore 100% of its prior throw-on-error shape):
574
+ *
575
+ * - `opts.tolerateNoActiveClaim === true` and there is no existing record, or the existing
576
+ * record's status is not `"claimed"`: return `null` (a tolerated no-op) instead of throwing
577
+ * "no active claim to release". This is the one deliberate idempotency change vs today's
578
+ * releaseLocalFile — a second release call (e.g. a double Stop event) must be a safe no-op.
579
+ * - `releasedBy` is provided and does not match the existing record's holder: never force-release
580
+ * a claim held by a different actor — return `null` (if tolerateNoActiveClaim) or throw
581
+ * (otherwise), same as the no-active-claim case. The comparison mirrors computeEffectiveState()'s
582
+ * `record.actor_key || helper.serializeActor(record.actor)` canonical-key preference EXACTLY
583
+ * (actor_key-first, falling back to serializeActor only when actor_key is absent) — the read
584
+ * path (status/effective-state) and this write path (release) must use the identical
585
+ * canonical-key comparison, or a claim written under an explicit-override actor (`actor_key`
586
+ * bare, e.g. `"canonical-x"`, but `serializeActor(record.actor)` a DIFFERENT triple, e.g.
587
+ * `"explicit-override:canonical-x:host"`) can be self-recognized by computeEffectiveState() yet
588
+ * fail to release here because the releaser's canonical key was compared against the wrong
589
+ * (re-derived, triple) form instead of the stored actor_key. Comparing two serializeActor()
590
+ * calls unconditionally — as a prior version of this function did — is NOT correct for override
591
+ * actors and reintroduces the exact #291 seam on the release path.
592
+ *
593
+ * Contract: when `releasedBy` is provided AND the existing record is `actor_key`-stamped,
594
+ * `opts.actorKey` is REQUIRED (the canonical `resolveActor(env).actor` string) — otherwise
595
+ * ownership cannot be verified. A caller that passes `releasedBy` without `opts.actorKey` against
596
+ * an `actor_key`-stamped record would have its ownership compared as
597
+ * `existing.actor_key` (bare canonical) vs `serializeActor(releasedBy)` (re-derived triple), which
598
+ * can NEVER match even for the legitimate holder — a silent-failure trap, not a real ownership
599
+ * check. This is refused loudly (see the guard at the top of the `releasedBy` branch below) rather
600
+ * than allowed to silently no-op or wrongly refuse.
601
+ */
602
+ export function performLocalRelease(
603
+ artifactRoot: string,
604
+ subjectId: string,
605
+ releasedBy: ActorStruct | null,
606
+ opts: { reason?: string; actorKey?: string; tolerateNoActiveClaim?: boolean } = {},
607
+ ): AssignmentClaimRecord | null {
608
+ const helper = loadActorIdentityHelper();
609
+ const reason = opts.reason ?? "released";
610
+ const tolerateNoActiveClaim = opts.tolerateNoActiveClaim ?? false;
570
611
 
571
612
  // F1 fix (fix-plan iteration 1, CRITICAL): release mutates the same record file claim/supersede
572
613
  // do, under the same per-subject lock (see withSubjectLock()'s doc comment).
573
- return withSubjectLock(artifactRoot, subjectId, (): number => {
614
+ return withSubjectLock(artifactRoot, subjectId, (): AssignmentClaimRecord | null => {
574
615
  const existing = readLocalRecord(artifactRoot, subjectId);
575
- if (!existing || existing.status !== "claimed") throw new Error(`no active claim to release for subject: ${subjectId}`);
616
+ if (!existing || existing.status !== "claimed") {
617
+ if (tolerateNoActiveClaim) return null;
618
+ throw new Error(`no active claim to release for subject: ${subjectId}`);
619
+ }
620
+
621
+ if (releasedBy) {
622
+ // Contract guard (hardening fix, #292 review): a caller that supplies `releasedBy` but NOT
623
+ // `opts.actorKey` against a record that already carries `actor_key` cannot reliably prove
624
+ // ownership — see this function's doc comment. This is the ONLY combination that fires: it
625
+ // does NOT fire when `existing.actor_key` is absent (the CLI/fixture path, where both sides
626
+ // fall back to serializeActor() and legitimately compare equal). Fail loudly rather than
627
+ // silently no-op (tolerant callers) or wrongly refuse (throwing callers) — never silent.
628
+ if (!opts.actorKey && existing.actor_key) {
629
+ if (tolerateNoActiveClaim) {
630
+ console.error(
631
+ `[performLocalRelease] cannot verify ownership of an actor_key-stamped record without opts.actorKey; skipping release for ${subjectId}`,
632
+ );
633
+ return null;
634
+ }
635
+ throw new Error(
636
+ "performLocalRelease: pass opts.actorKey (the canonical resolveActor().actor string) when releasedBy is set and the record carries actor_key — serializeActor(releasedBy) is not a valid ownership key for actor_key-stamped records",
637
+ );
638
+ }
639
+
640
+ // AC6: never force-release a claim held by a different actor. Mirrors
641
+ // computeEffectiveState()'s canonical self-recognition comparison EXACTLY —
642
+ // `holderActorKey` prefers the stored `actor_key` (the canonical resolveActor(env).actor
643
+ // string, present on records written by the fixed performLocalClaim/performLocalSupersede
644
+ // paths) and only falls back to `serializeActor(existing.actor)` when `actor_key` is
645
+ // absent (every pre-fix record, every #290 eval fixture). The releaser's side must use the
646
+ // SAME canonical form: `opts.actorKey` (the caller's resolveActor(env).actor string, e.g.
647
+ // scripts/hooks/stop-goal-fit.js's Stop hook) when provided, else re-derived via
648
+ // serializeActor(releasedBy) — never serializeActor() unconditionally on both sides, which
649
+ // would compare the bare actor_key form against a re-derived triple form for an
650
+ // explicit-override actor and spuriously reject a legitimate same-actor release (the #291
651
+ // seam, relocated to this write path).
652
+ const holderActorKey = existing.actor_key || helper.serializeActor(existing.actor);
653
+ const releasedByActorKey = opts.actorKey || helper.serializeActor(releasedBy);
654
+ if (holderActorKey !== releasedByActorKey) {
655
+ if (tolerateNoActiveClaim) return null;
656
+ throw new Error(`--actor-json does not match the current holder (${holderActorKey}); refusing to release a claim held by someone else`);
657
+ }
658
+ }
659
+
576
660
  const record: AssignmentClaimRecord = {
577
661
  ...existing,
662
+ ...(opts.actorKey ? { actor_key: opts.actorKey } : {}),
578
663
  status: "released",
579
664
  audit_trail: [...(existing.audit_trail ?? []), { at: isoNow(), transition: "release", from_actor: existing.actor, to_actor: releasedBy, reason }],
580
665
  };
581
666
  writeLocalRecord(artifactRoot, subjectId, record);
582
- console.log(JSON.stringify({ role: "AssignmentReleaseResult", subject_id: subjectId, record }, null, 2));
583
- return 0;
667
+ return record;
584
668
  });
585
669
  }
586
670
 
671
+ function releaseLocalFile(argv: string[]): number {
672
+ const args = parseArgs(argv);
673
+ const provider = flagString(args.flags, "provider", "local-file");
674
+ if (provider !== "local-file") throw new Error(`release: --provider must be local-file (use render-release for github); got ${provider}`);
675
+ const artifactRoot = requireFlag(args, "artifact-root");
676
+ const subjectId = requireFlag(args, "subject-id");
677
+ const releasedBy = flagString(args.flags, "actor-json") ? loadActorStructFromFile(requireFlag(args, "actor-json")) : null;
678
+ const reason = flagString(args.flags, "reason") ?? "released";
679
+
680
+ const record = performLocalRelease(artifactRoot, subjectId, releasedBy, { reason, tolerateNoActiveClaim: false });
681
+ console.log(JSON.stringify({ role: "AssignmentReleaseResult", subject_id: subjectId, record }, null, 2));
682
+ return 0;
683
+ }
684
+
587
685
  /**
588
686
  * Wave 1 (#291) extraction: the durable-write body previously inlined inside supersedeLocalFile's
589
687
  * withSubjectLock() closure, now a parameter-driven pure function so ensure-session's
@@ -1619,7 +1619,30 @@ function recordAgentEvent(p: ReturnType<typeof parseArgs>): number {
1619
1619
  if (hasExplicitRoot || !explicit) requireArtifactDirUnderRoot(dir, root);
1620
1620
  const timestamp = opt(p, "timestamp", now());
1621
1621
  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") } : {}) };
1622
+ // #376 model routing: optionally stamp the delegate role/model resolved from
1623
+ // .datum/config.json onto the event so a downstream economics record (#349)
1624
+ // can price role assignments per delegation, and so an escalate-on-gate-failure
1625
+ // re-dispatch records which tier it climbed FROM. Fully additive/optional: when
1626
+ // no routing flag is passed the event shape is byte-identical to before.
1627
+ // These live as TOP-LEVEL event fields (not nested) on purpose: appendJsonl's
1628
+ // serializer (spacedLine) uses the top-level key list as a JSON.stringify array
1629
+ // replacer, which is an allowlist applied at every nesting level — a nested
1630
+ // routing object would have its inner keys stripped. Flat keeps the shape a
1631
+ // simple per-event routing record a JSONL economics feed can read directly.
1632
+ const role = opt(p, "role");
1633
+ const model = opt(p, "model");
1634
+ const escalatedFrom = opt(p, "escalated-from");
1635
+ const event = {
1636
+ timestamp,
1637
+ agent_id: agent,
1638
+ kind: opt(p, "kind", "note"),
1639
+ status: opt(p, "status", "info"),
1640
+ summary: opt(p, "summary"),
1641
+ ...(opt(p, "ref") ? { ref: opt(p, "ref") } : {}),
1642
+ ...(role ? { role } : {}),
1643
+ ...(model ? { model } : {}),
1644
+ ...(escalatedFrom ? { escalated_from: escalatedFrom } : {}),
1645
+ };
1623
1646
  appendJsonl(path.join(dir, "agents", agent, "events.jsonl"), event);
1624
1647
  updateCurrentAgent(root, dir, agent, event.status, timestamp, actorKey);
1625
1648
  return 0;
@@ -2526,52 +2549,118 @@ async function sealCheckpoint(p: ReturnType<typeof parseArgs>): Promise<number>
2526
2549
  // Called automatically from recordRelease and advanceState→delivered (best-effort).
2527
2550
  // Also exposed as the `publish-delivery <artifact-dir>` subcommand for explicit use.
2528
2551
 
2552
+ /**
2553
+ * #379 supersede-on-publish cleanup: keep delivery/ bounded by pruning inherited PER-SESSION
2554
+ * seal dirs (the growth vector), scoped to avoid any cross-PR conflict.
2555
+ *
2556
+ * An inherited per-session seal dir (`delivery/<other-slug>/`) is UNIQUELY named, so pruning
2557
+ * it can never conflict with a concurrent PR: two branches deleting the same inherited dir is
2558
+ * a delete/delete (auto-merges), and each new delivery adds its OWN distinct
2559
+ * `delivery/<slug>/`. And it is HARMLESS to leave: trust-reconcile.js's prefer-newest
2560
+ * ownership selection always picks THIS session's fresher bundle over an older inherited one.
2561
+ * Pruning is therefore purely to stop unbounded accumulation of permanently-superseded dirs.
2562
+ *
2563
+ * The SHARED FLAT path (`delivery/trust.bundle` + checkpoints) is deliberately NOT pruned
2564
+ * here. During the migration window other concurrent PRs may still seal to the flat path;
2565
+ * deleting it would produce a modify/delete conflict → a DIRTY PR → the no-CI failure this
2566
+ * whole change fixes. The flat path is a single fixed location (not a growth vector) and the
2567
+ * reconciler treats a stale flat bundle as non-owning / older-owning, so leaving it is safe.
2568
+ * Removing the flat legacy seals is a one-time cleanup for a dedicated PR once no open PR
2569
+ * still seals to it — intentionally NOT bundled into every delivery.
2570
+ *
2571
+ * Best-effort: a prune failure is logged, never fatal to the delivery. Never touches
2572
+ * README.md, DECLARED, the flat seal files, or any subdir that is not itself a seal dir.
2573
+ */
2574
+ function pruneSupersededSeals(deliveryDir: string, keepSlug: string): void {
2575
+ let entries: fs.Dirent[] = [];
2576
+ try {
2577
+ entries = fs.readdirSync(deliveryDir, { withFileTypes: true });
2578
+ } catch {
2579
+ entries = [];
2580
+ }
2581
+ for (const entry of entries) {
2582
+ if (!entry.isDirectory() || entry.name === keepSlug) continue;
2583
+ const subdir = path.join(deliveryDir, entry.name);
2584
+ // Only prune dirs that actually look like a seal dir (contain a trust.bundle or
2585
+ // trust.checkpoint.json) — never an unrelated directory a human placed under delivery/.
2586
+ const looksLikeSeal =
2587
+ fs.existsSync(path.join(subdir, "trust.bundle")) ||
2588
+ fs.existsSync(path.join(subdir, "trust.checkpoint.json"));
2589
+ if (!looksLikeSeal) continue;
2590
+ try {
2591
+ fs.rmSync(subdir, { recursive: true, force: true });
2592
+ 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`);
2593
+ } catch (err) {
2594
+ 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`);
2595
+ }
2596
+ }
2597
+ }
2598
+
2529
2599
  /**
2530
2600
  * Publish the session's trust artifacts to the committed delivery/ path.
2531
2601
  *
2602
+ * #379: writes to a PER-SESSION path `<repoRoot>/delivery/<slug>/` (slug = the session
2603
+ * artifact dir's basename) rather than the shared flat `delivery/`. A shared path guarantees
2604
+ * a git conflict between ANY two concurrent deliveries, and a conflicting (DIRTY) PR gets NO
2605
+ * pull_request workflows — the required Trust Reconcile check silently never runs (field
2606
+ * incidents #330/#358/#378). Per-session paths make concurrent deliveries write DISTINCT
2607
+ * files that never contend. trust-reconcile.js reads both the flat (back-compat) and
2608
+ * per-session layouts via resolveDeliveryCandidates() and selects the owning candidate by
2609
+ * commit ancestry.
2610
+ *
2532
2611
  * 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/.
2612
+ * trust.checkpoint.intoto.json / trust.checkpoint.sig.json / trust.checkpoint.attestation.json
2613
+ * from the session artifact dir to <repoRoot>/delivery/<slug>/.
2535
2614
  *
2536
2615
  * Fail-soft on a missing bundle: if trust.bundle is absent, returns without throwing.
2537
2616
  * Fail-CLOSED on repo-root resolution: repoRoot must be a real, resolved kits/ ancestor
2538
2617
  * (see findRepoRootFromDirStrict) — null (no ancestor found) skips the publish with a
2539
2618
  * visible warning instead of writing to whatever process.cwd() happens to be. This
2540
2619
  * 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
2620
+ * unrelated real repo's delivery/ seal when invoked with that repo as cwd (see
2542
2621
  * evals/integration/test_checkpoint_signing.sh TEST 2 and the WS5 session findings at
2543
2622
  * .kontourai/flow-agents/ws5-governance-kit-slice1 for the root cause this fixes).
2544
- * Idempotent: overwrites on re-delivery.
2623
+ * Idempotent: overwrites on re-delivery to the same slug.
2545
2624
  */
2546
2625
  export async function publishDelivery(dir: string, repoRoot: string | null): Promise<void> {
2547
2626
  const bundleSrc = path.join(dir, "trust.bundle");
2548
2627
  if (!fs.existsSync(bundleSrc)) return; // no bundle — skip gracefully
2549
2628
 
2550
2629
  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`);
2630
+ 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
2631
  return;
2553
2632
  }
2554
2633
 
2555
2634
  const deliveryDir = path.join(repoRoot, "delivery");
2635
+ // #379: slug is the session artifact dir's basename — the same human-meaningful id used
2636
+ // throughout the session (.kontourai/flow-agents/<slug>/). The per-session dir NAME is only
2637
+ // a collision-avoidance handle; ownership is decided by commit ancestry, not by name.
2638
+ const slug = path.basename(path.resolve(dir));
2639
+ const sessionDeliveryDir = path.join(deliveryDir, slug);
2640
+
2556
2641
  fs.mkdirSync(deliveryDir, { recursive: true });
2642
+ // Supersede inherited/flat seals BEFORE writing this session's dir (keepSlug = our own).
2643
+ pruneSupersededSeals(deliveryDir, slug);
2644
+ fs.mkdirSync(sessionDeliveryDir, { recursive: true });
2557
2645
 
2558
2646
  // Required: trust.bundle (the CI anchor)
2559
- fs.copyFileSync(bundleSrc, path.join(deliveryDir, "trust.bundle"));
2647
+ fs.copyFileSync(bundleSrc, path.join(sessionDeliveryDir, "trust.bundle"));
2560
2648
 
2561
2649
  // Optional companions: checkpoint + signing artifacts
2562
2650
  const companions = [
2563
2651
  "trust.checkpoint.json",
2564
2652
  "trust.checkpoint.intoto.json",
2565
2653
  "trust.checkpoint.sig.json",
2654
+ "trust.checkpoint.attestation.json",
2566
2655
  ];
2567
2656
  for (const filename of companions) {
2568
2657
  const src = path.join(dir, filename);
2569
2658
  if (fs.existsSync(src)) {
2570
- fs.copyFileSync(src, path.join(deliveryDir, filename));
2659
+ fs.copyFileSync(src, path.join(sessionDeliveryDir, filename));
2571
2660
  }
2572
2661
  }
2573
2662
 
2574
- process.stderr.write(`[publish-delivery] published trust.bundle and companions to ${deliveryDir}\n`);
2663
+ process.stderr.write(`[publish-delivery] published trust.bundle and companions to ${sessionDeliveryDir} (per-session path, #379)\n`);
2575
2664
  }
2576
2665
 
2577
2666
  /**
@@ -3330,7 +3419,7 @@ function loadLivenessReadHelper(): {
3330
3419
  // init-plan claims the work-item; advance-state heartbeats (or releases on terminal),
3331
3420
  // so the workflow lifecycle itself maintains the liveness claim — no manual liveness calls.
3332
3421
  // Additive + fail-open: a liveness-emit failure never affects the workflow command.
3333
- const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
3422
+ export const LIVENESS_TERMINAL = new Set(["delivered", "accepted", "archived"]);
3334
3423
  /**
3335
3424
  * Delegate to the shared pure-CJS resolver (scripts/hooks/lib/actor-identity.js), mirroring the
3336
3425
  * createRequire pattern used by readLivenessEvents() above. Deliberately NO inline duplicate