@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
@@ -522,32 +522,139 @@ const DELIVERY_DIR = 'delivery';
522
522
 
523
523
  /**
524
524
  * Single seam: every delivery/ path resolution in this file routes through here.
525
- * #335 (per-session delivery paths, e.g. delivery/<session-slug>/<filename> instead of
526
- * one shared path) is expected to change ONLY this function's body — e.g. glob
527
- * delivery/<slug-glob>/<filename> and return every match in a defined precedence order,
528
- * or
529
- * resolve the current PR's session slug and return its single subpath. discoverBundle()
530
- * and discoverDeclaredMarker() both iterate whatever candidates this returns and pick the
531
- * first existing one, so a future per-session layout composes with zero call-site changes.
525
+ *
526
+ * #335/#379 (per-session delivery paths) is IMPLEMENTED here now, not deferred: a shared
527
+ * `delivery/<filename>` guarantees a git merge conflict between ANY two concurrent
528
+ * deliveries — and a conflicting (DIRTY) PR gets NO `pull_request` workflows scheduled, so
529
+ * the required Trust Reconcile check silently never runs (field incidents #330/#358/#378).
530
+ * The fix is per-session paths so concurrent deliveries write to DISTINCT files and never
531
+ * contend.
532
+ *
533
+ * Returned candidate order (precedence, first is highest):
534
+ * 1. the flat `delivery/<filename>` — kept FIRST for full back-compat. A repo that never
535
+ * adopts per-session layout sees byte-identical behavior; an already-committed flat
536
+ * bundle from before this change still resolves.
537
+ * 2. `delivery/<slug>/<filename>` for every immediate subdirectory of `delivery/`, sorted
538
+ * by name for determinism.
539
+ *
540
+ * Ordering here is ONLY the tie-break / fallback. Which candidate actually reconciles is
541
+ * decided by COMMIT-OWNERSHIP (discoverBundle() + bundleAttestsThisChange()), not directory
542
+ * order: among the candidates, the one whose checkpoint attests an ancestor-or-equal commit
543
+ * of THIS change wins, and stale siblings from other concurrent sessions are ignored.
544
+ * discoverBundle() and discoverDeclaredMarker() both iterate whatever this returns, so the
545
+ * per-session layout composes with zero call-site changes.
532
546
  */
533
547
  function resolveDeliveryCandidates(repoRoot, filename) {
534
- return [path.join(repoRoot, DELIVERY_DIR, filename)];
548
+ const deliveryRoot = path.join(repoRoot, DELIVERY_DIR);
549
+ const candidates = [path.join(deliveryRoot, filename)];
550
+ // #379: append delivery/<slug>/<filename> for each immediate subdirectory of delivery/.
551
+ // readdirSync is best-effort — a missing delivery/ (or an unreadable one) yields just the
552
+ // flat candidate, never throws.
553
+ let entries = [];
554
+ try {
555
+ entries = fs.readdirSync(deliveryRoot, { withFileTypes: true });
556
+ } catch {
557
+ entries = [];
558
+ }
559
+ const subdirs = entries
560
+ .filter((e) => e.isDirectory())
561
+ .map((e) => e.name)
562
+ .sort();
563
+ for (const name of subdirs) {
564
+ candidates.push(path.join(deliveryRoot, name, filename));
565
+ }
566
+ return candidates;
535
567
  }
536
568
 
537
569
  /**
538
570
  * Auto-discover the delivery bundle when no explicit path is provided.
539
- * Checks delivery/trust.bundle, then delivery/trust.checkpoint.json under repo root
540
- * (via resolveDeliveryCandidates()). Returns null if neither is present — the caller
541
- * then falls through to delivery/DECLARED marker resolution (ADR 0022 §1); bundle
542
- * absence is no longer fail-open on its own.
571
+ *
572
+ * Gathers every existing candidate flat `delivery/trust.bundle`, flat
573
+ * `delivery/trust.checkpoint.json`, and their `delivery/<slug>/…` per-session siblings (via
574
+ * resolveDeliveryCandidates()) with trust.bundle preferred over trust.checkpoint.json.
575
+ *
576
+ * #379 (per-session, ownership-aware selection): when `changeSha` is known, the candidate
577
+ * whose checkpoint attests an ancestor-or-equal commit of THIS change wins (same
578
+ * bundleAttestsThisChange() semantics the flat staleness gate already uses). Stale/older
579
+ * siblings from OTHER concurrent sessions are ignored.
580
+ *
581
+ * PREFER-NEWEST among owning candidates. More than one candidate can legitimately "own" the
582
+ * change at once: an inherited FLAT bundle's commit_sha can be a real ancestor of HEAD in a
583
+ * merge-commit repo (it was committed on the trunk's linear history before this branch
584
+ * point), AND this session's fresh per-session bundle attests a NEWER ancestor (the delivery
585
+ * commit right before HEAD). "First-fresh-wins" would then select the STALE inherited flat
586
+ * bundle purely because it sorts first — reconciling last delivery's claims against this
587
+ * change's CI. So among all owning candidates we pick the one attesting the NEWEST commit
588
+ * (the descendant-most: the owning candidate whose commit_sha every other owning candidate's
589
+ * commit_sha is an ancestor of), via the same `git merge-base --is-ancestor` primitive. This
590
+ * makes an inherited/older owning bundle HARMLESS whether or not it was ever pruned — the
591
+ * fresh per-session bundle wins on recency, not on being deleted first.
592
+ *
593
+ * When no candidate owns this change (or `changeSha` is unknown, e.g. a local run with no
594
+ * TRUST_RECONCILE_SHA), the first existing candidate is returned so the caller's staleness
595
+ * gate below handles it exactly as before: a stale bundle is treated as absent, a malformed
596
+ * one still raises the Step 2 read error.
597
+ *
598
+ * Returns null if no candidate exists at all — the caller then falls through to
599
+ * delivery/DECLARED marker resolution (ADR 0022 §1); bundle absence is not fail-open.
543
600
  */
544
- function discoverBundle(repoRoot) {
601
+ function discoverBundle(repoRoot, changeSha) {
602
+ const candidates = [];
545
603
  for (const filename of ['trust.bundle', 'trust.checkpoint.json']) {
546
604
  for (const candidate of resolveDeliveryCandidates(repoRoot, filename)) {
547
- if (fs.existsSync(candidate)) return candidate;
605
+ if (fs.existsSync(candidate)) candidates.push(candidate);
548
606
  }
549
607
  }
550
- return null;
608
+ if (candidates.length === 0) return null;
609
+
610
+ const deliveryRoot = path.join(repoRoot, DELIVERY_DIR);
611
+ const hasPerSession = candidates.some((c) => path.dirname(c) !== deliveryRoot);
612
+
613
+ // Ownership-aware selection (#379). Only meaningful when we know this change's sha; a
614
+ // candidate that fails to parse is skipped here (never treated as owning) — if it turns
615
+ // out to be the only candidate, it is still returned below so Step 2's "failed to read
616
+ // bundle" diagnostic fires unchanged.
617
+ if (changeSha) {
618
+ const owning = []; // { candidate, bundleSha } — every candidate that attests this change
619
+ for (const candidate of candidates) {
620
+ let json = null;
621
+ try {
622
+ json = JSON.parse(fs.readFileSync(candidate, 'utf8'));
623
+ } catch {
624
+ json = null;
625
+ }
626
+ if (!json) continue;
627
+ const { fresh, bundleSha } = bundleAttestsThisChange(repoRoot, candidate, json, changeSha);
628
+ if (fresh) owning.push({ candidate, bundleSha });
629
+ }
630
+ if (owning.length > 0) {
631
+ // Prefer the owning candidate attesting the NEWEST (descendant-most) commit. `best` is
632
+ // upgraded to a candidate whose commit is a strict descendant of the current best's —
633
+ // i.e. best.sha is an ancestor of cand.sha. Every owning entry has a non-null bundleSha
634
+ // (bundleAttestsThisChange returns fresh=false otherwise). Ties / incomparable siblings
635
+ // keep the earlier (candidate-order) entry, so the flat path still wins a genuine tie.
636
+ let best = owning[0];
637
+ for (let i = 1; i < owning.length; i++) {
638
+ const cand = owning[i];
639
+ if (cand.bundleSha !== best.bundleSha && isAncestorCommit(repoRoot, best.bundleSha, cand.bundleSha)) {
640
+ best = cand;
641
+ }
642
+ }
643
+ if (hasPerSession) {
644
+ process.stdout.write(`[trust-reconcile] #379: selected delivery candidate ${path.relative(repoRoot, best.candidate)} — attests this change ${changeSha} (${candidates.length} candidate(s) present across flat + per-session delivery/<slug>/; ${owning.length} owning, newest wins; older/stale ignored)\n`);
645
+ }
646
+ return best.candidate;
647
+ }
648
+ // No candidate attests this change. When per-session candidates were in play, emit a
649
+ // loud, grep-stable concurrency hint (#379) so the next agent can tell a concurrent
650
+ // per-session collision apart from a plain stale/absent bundle — the per-candidate
651
+ // stale line still prints in runTrustReconcile's staleness gate for the returned one.
652
+ if (hasPerSession) {
653
+ const rels = candidates.map((c) => path.relative(repoRoot, c)).join(', ');
654
+ process.stdout.write(`[trust-reconcile] #379: examined ${candidates.length} delivery candidate(s) (flat + per-session delivery/<slug>/); none attests this change ${changeSha}. If this is a concurrent-delivery collision, the OWNING session's bundle is not on this ref — re-publish this session's delivery, or check whether main moved under the PR. Candidates: ${rels}\n`);
655
+ }
656
+ }
657
+ return candidates[0];
551
658
  }
552
659
 
553
660
  /**
@@ -813,22 +920,30 @@ function resolveDeclaredExemption(repoRoot, ctx) {
813
920
  * src/cli/workflow-sidecar.ts). trust.bundle itself carries NO commit/branch metadata
814
921
  * (schemaVersion 5: {schemaVersion, source, claims, evidence, policies, events} — confirmed
815
922
  * by inspection), so when the discovered bundle IS trust.bundle, this falls through to its
816
- * sibling delivery/trust.checkpoint.json (same resolveDeliveryCandidates() seam) for the
817
- * binding publishDelivery() always copies both together.
923
+ * SIBLING trust.checkpoint.json for the binding — publishDelivery() always writes both
924
+ * together into the SAME directory.
925
+ *
926
+ * #379: the sibling is resolved from the bundle's OWN directory (path.dirname(bundlePath)),
927
+ * not from a global resolveDeliveryCandidates() scan. In the per-session layout each session
928
+ * gets its own delivery/<slug>/ dir, and a global scan would cross-contaminate — pairing a
929
+ * per-session trust.bundle with the FLAT (or a different session's) trust.checkpoint.json and
930
+ * reading the wrong commit binding. Same-directory resolution is identical to the prior
931
+ * behavior for the flat layout (delivery/trust.bundle ↔ delivery/trust.checkpoint.json) and
932
+ * correct for per-session.
818
933
  */
819
934
  function extractBundleCommitSha(repoRoot, bundlePath, bundleJson) {
820
935
  if (bundleJson && typeof bundleJson.commit_sha === 'string' && bundleJson.commit_sha.trim()) {
821
936
  return bundleJson.commit_sha.trim();
822
937
  }
823
- for (const candidate of resolveDeliveryCandidates(repoRoot, 'trust.checkpoint.json')) {
824
- if (candidate === bundlePath || !fs.existsSync(candidate)) continue;
938
+ const siblingCheckpoint = path.join(path.dirname(bundlePath), 'trust.checkpoint.json');
939
+ if (siblingCheckpoint !== bundlePath && fs.existsSync(siblingCheckpoint)) {
825
940
  try {
826
- const checkpoint = JSON.parse(fs.readFileSync(candidate, 'utf8'));
941
+ const checkpoint = JSON.parse(fs.readFileSync(siblingCheckpoint, 'utf8'));
827
942
  if (checkpoint && typeof checkpoint.commit_sha === 'string' && checkpoint.commit_sha.trim()) {
828
943
  return checkpoint.commit_sha.trim();
829
944
  }
830
945
  } catch {
831
- // Malformed sibling checkpoint — no usable binding from it; keep looking / fall through.
946
+ // Malformed sibling checkpoint — no usable binding from it; fall through to null.
832
947
  }
833
948
  }
834
949
  return null;
@@ -907,14 +1022,17 @@ function runTrustReconcile({ bundle = null, commands = [], repoRoot = null, mani
907
1022
  // (.github/workflows/trust-reconcile.yml) never passes --bundle; it always relies on
908
1023
  // auto-discovery, which is exactly the path PR #278's stale-bundle incident went through.
909
1024
  const explicitBundlePath = bundle || process.env.TRUST_RECONCILE_BUNDLE || null;
910
- let bundlePath = explicitBundlePath || discoverBundle(resolvedRepoRoot) || null;
911
- const bundleWasAutoDiscovered = !explicitBundlePath && !!bundlePath;
912
1025
 
913
- // Scope-matching context (ref/actor/sha) — resolved once, reused by both the
914
- // bundle-ownership staleness check below and the delivery/DECLARED exemption path
915
- // further down (bundle-absent branch).
1026
+ // Scope-matching context (ref/actor/sha) — resolved once, reused by ownership-aware
1027
+ // auto-discovery (#379, immediately below), the bundle-ownership staleness check further
1028
+ // down (ADR 0022 addendum §2), and the delivery/DECLARED exemption path (bundle-absent
1029
+ // branch). Resolved BEFORE auto-discovery because discoverBundle() now uses scopeCtx.sha
1030
+ // to pick, among per-session candidates, the one that attests THIS change.
916
1031
  const scopeCtx = resolveScopeContext(resolvedRepoRoot);
917
1032
 
1033
+ let bundlePath = explicitBundlePath || discoverBundle(resolvedRepoRoot, scopeCtx.sha) || null;
1034
+ const bundleWasAutoDiscovered = !explicitBundlePath && !!bundlePath;
1035
+
918
1036
  // Event-scoped enforcement (ADR 0022 addendum, part 4): bundle-required enforcement
919
1037
  // (and the staleness-gate consequence of it, immediately below) applies ONLY when this
920
1038
  // run is gating a proposed change. A push run on main happens AFTER the gating PR run
@@ -180,7 +180,9 @@ function checkProtectedPathPattern(filePath) {
180
180
  // copy a forged bundle here to corrupt the CI trust check.
181
181
  // SAFE: publishDelivery writes via fs.copyFileSync (not Write/Edit tool).
182
182
  // RESIDUAL: runtime-constructed paths and fs writes are unaffected.
183
- if (/(?:^|\/)delivery\/trust\.bundle$/.test(norm)) {
183
+ // #379: the optional (?:[^/]+\/)? segment also covers the per-session path
184
+ // delivery/<slug>/trust.bundle — the forgery surface moved with the write path.
185
+ if (/(?:^|\/)delivery\/(?:[^/]+\/)?trust\.bundle$/.test(norm)) {
184
186
  return {
185
187
  name: "delivery/trust.bundle",
186
188
  reason: "an agent could write a forged bundle to corrupt the CI trust-reconcile anchor",
@@ -189,7 +191,8 @@ function checkProtectedPathPattern(filePath) {
189
191
 
190
192
  // delivery/trust.checkpoint.json -- the signed checkpoint companion.
191
193
  // SAFE: publishDelivery writes via fs.copyFileSync, NOT via Write/Edit tool.
192
- if (/(?:^|\/)delivery\/trust\.checkpoint\.json$/.test(norm)) {
194
+ // #379: optional (?:[^/]+\/)? segment also covers delivery/<slug>/trust.checkpoint.json.
195
+ if (/(?:^|\/)delivery\/(?:[^/]+\/)?trust\.checkpoint\.json$/.test(norm)) {
193
196
  return {
194
197
  name: "delivery/trust.checkpoint.json",
195
198
  reason: "an agent could forge a signed delivery by writing a tampered checkpoint",
@@ -421,7 +424,9 @@ function checkCommandForBypass(command) {
421
424
  * .kontourai/flow-agents/<slug>/trust.bundle, and deprecated runtime-shaped
422
425
  * .flow-agents equivalents.
423
426
  */
424
- const REDIRECT_PROTECTED_RE = /(?:^|\/|~\/)(\.bash_profile|\.bashrc|\.profile|\.zprofile|\.zshrc)$|(?:^|\/)\.claude\/settings(?:\.local)?\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\/[^/]+\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/\.goal-fit-block-streak\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/state\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/trust\.bundle$|(?:^|\/)delivery\/trust\.bundle$|(?:^|\/)delivery\/trust\.checkpoint\.json$/;
427
+ // #379: the delivery/ arms carry an optional (?:[^/]+\/)? segment so redirects/tee to the
428
+ // per-session path delivery/<slug>/trust.bundle (+ checkpoint) are caught, not just the flat path.
429
+ const REDIRECT_PROTECTED_RE = /(?:^|\/|~\/)(\.bash_profile|\.bashrc|\.profile|\.zprofile|\.zshrc)$|(?:^|\/)\.claude\/settings(?:\.local)?\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/current\/[^/]+\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/\.goal-fit-block-streak\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/state\.json$|(?:^|\/)(?:\.kontourai\/flow-agents|\.flow-agents)\/[^/]+\/trust\.bundle$|(?:^|\/)delivery\/(?:[^/]+\/)?trust\.bundle$|(?:^|\/)delivery\/(?:[^/]+\/)?trust\.checkpoint\.json$/;
425
430
 
426
431
  /**
427
432
  * Return true when a token (an unquoted redirect target or tee argument) matches
@@ -569,8 +574,10 @@ function checkInterpreterWriteToProtected(command) {
569
574
  * Delivery-protected path regex: delivery/trust.bundle and delivery/trust.checkpoint.json.
570
575
  * These are the CI anchor files whose contents must not be agent-forged.
571
576
  * Used by checkCopyMoveToProtected to catch `cp x delivery/trust.bundle`.
577
+ * #379: the optional (?:[^/]+\/)? segment also catches the per-session path
578
+ * `cp forged.json delivery/<slug>/trust.bundle`.
572
579
  */
573
- const DELIVERY_COPY_PROTECTED_RE = /(?:^|\/)delivery\/trust\.bundle$|(?:^|\/)delivery\/trust\.checkpoint\.json$/;
580
+ const DELIVERY_COPY_PROTECTED_RE = /(?:^|\/)delivery\/(?:[^/]+\/)?trust\.bundle$|(?:^|\/)delivery\/(?:[^/]+\/)?trust\.checkpoint\.json$/;
574
581
 
575
582
  /**
576
583
  * Return true when a normalized token matches a delivery-protected path.
@@ -25,6 +25,7 @@
25
25
  'use strict';
26
26
 
27
27
  const fs = require('fs');
28
+ const os = require('os');
28
29
  const path = require('path');
29
30
  const { spawnSync } = require('child_process');
30
31
  const crypto = require('crypto');
@@ -42,7 +43,7 @@ const {
42
43
  flowAgentsArtifactRoot,
43
44
  flowAgentsArtifactRootsForRead,
44
45
  } = require('./lib/local-artifact-paths');
45
- const { resolveActor } = require('./lib/actor-identity.js');
46
+ const { resolveActor, isUnresolvedActor, detectRuntime } = require('./lib/actor-identity.js');
46
47
  const { readCurrentPointer } = require('./lib/current-pointer.js');
47
48
 
48
49
  const MAX_STDIN = 1024 * 1024;
@@ -1671,7 +1672,7 @@ async function analyze(root, now = Date.now()) {
1671
1672
  artifacts = artifacts.filter(a => a && hasSidecarPresence(path.dirname(a.file)));
1672
1673
  }
1673
1674
 
1674
- if (artifacts.length === 0) return { warnings: [], blocking: false };
1675
+ if (artifacts.length === 0) return { warnings: [], blocking: false, latestArtifactDir: null };
1675
1676
 
1676
1677
  const latest = artifacts[0];
1677
1678
  const latestArtifactDir = path.dirname(latest.file);
@@ -1745,7 +1746,7 @@ async function analyze(root, now = Date.now()) {
1745
1746
  if (/\[backstop in warn mode — not blocking\]/.test(w)) return false;
1746
1747
  return blockRe.test(w);
1747
1748
  });
1748
- return { warnings, blocking, preExecution, gatePrefix: gateLabel(activeFlowStep) };
1749
+ return { warnings, blocking, preExecution, gatePrefix: gateLabel(activeFlowStep), latestArtifactDir };
1749
1750
  }
1750
1751
 
1751
1752
  /**
@@ -1844,12 +1845,266 @@ function remediationFor(warning) {
1844
1845
  return null;
1845
1846
  }
1846
1847
 
1848
+ // ─── #292 Wave 2: Stop-hook non-terminal release-with-handoff ────────────────
1849
+ //
1850
+ // When a session's Stop event fires and the session's state.json status is NOT
1851
+ // one of workflow-sidecar.ts's LIVENESS_TERMINAL statuses (delivered/accepted/
1852
+ // archived — the SAME set advance-state's terminal path already tests against,
1853
+ // imported here rather than re-declared, per AC8), the liveness claim and (for
1854
+ // the local-file assignment provider) the durable assignment claim would
1855
+ // otherwise sit held until TTL, even though the session has genuinely ended.
1856
+ // This closes that gap: always emit a provider-agnostic liveness release (AC1),
1857
+ // and for local-file additionally perform the real release inline (AC2), while
1858
+ // honestly disclosing (never executing) the equivalent GitHub-provider release
1859
+ // as a pending handoff.json intent (AC3). See the plan artifact
1860
+ // (.kontourai/flow-agents/kontourai-flow-agents-292/kontourai-flow-agents-292--plan-work.md,
1861
+ // "Wave 2") for the full design rationale and the render-don't-execute
1862
+ // constraint this deliberately does not cross.
1863
+ //
1864
+ // Fail-open (AC7): the ENTIRE body runs under one try/catch. Any failure —
1865
+ // missing build/, corrupt assignment record, unresolved actor, missing/
1866
+ // malformed provider settings — degrades to a single stderr diagnostic and
1867
+ // returns, never throwing, never affecting the Stop hook's own exit code or
1868
+ // goal-fit's warnings/blocking output (this function's return value is not
1869
+ // consumed by that contract at all — it is a pure side effect).
1870
+
1871
+ /**
1872
+ * require() the compiled workflow-sidecar.js the same hasBuild/fs.existsSync-guarded
1873
+ * way loadActiveFlowStep() already requires flow-resolver.js. Returns null (never
1874
+ * throws) when build/ is absent or the require fails — the caller treats null as
1875
+ * "the LIVENESS_TERMINAL boundary is unknown; do not guess it" and skips the whole
1876
+ * release (fail-open, never a re-derived duplicate Set — AC8).
1877
+ */
1878
+ function loadWorkflowSidecarBuilt() {
1879
+ const packageRoot = path.resolve(__dirname, '..', '..');
1880
+ const builtSidecar = path.join(packageRoot, 'build', 'src', 'cli', 'workflow-sidecar.js');
1881
+ if (!fs.existsSync(builtSidecar)) return null; // hasBuild guard
1882
+ try {
1883
+ const mod = require(builtSidecar);
1884
+ if (!(mod.LIVENESS_TERMINAL instanceof Set)) return null;
1885
+ return mod;
1886
+ } catch {
1887
+ return null; // require failed — fail-open
1888
+ }
1889
+ }
1890
+
1891
+ /** Same hasBuild-guarded require idiom, for build/src/cli/assignment-provider.js. */
1892
+ function loadAssignmentProviderBuilt() {
1893
+ const packageRoot = path.resolve(__dirname, '..', '..');
1894
+ const builtProvider = path.join(packageRoot, 'build', 'src', 'cli', 'assignment-provider.js');
1895
+ if (!fs.existsSync(builtProvider)) return null;
1896
+ try {
1897
+ const mod = require(builtProvider);
1898
+ if (typeof mod.performLocalRelease !== 'function') return null;
1899
+ return mod;
1900
+ } catch {
1901
+ return null;
1902
+ }
1903
+ }
1904
+
1905
+ /**
1906
+ * Resolve the effective assignment-provider kind for this repo ('local-file' | 'github'),
1907
+ * or null when it cannot be determined. Prefers reading context/settings/
1908
+ * assignment-provider-settings.json directly (project settings first, matching
1909
+ * effective-assignment-provider-settings.ts's own lookup precedence) rather than shelling
1910
+ * out to or refactoring that CLI-shaped file (see plan Unresolved Question 1) — this task's
1911
+ * scope is the Stop hook, not that file. Returns null (never guesses) when no settings file
1912
+ * is present/parseable or names no provider kind for a project entry.
1913
+ *
1914
+ * Deliberately reads ONLY the project settings file (context/settings/
1915
+ * assignment-provider-settings.json relative to repo root) — the common case for this repo
1916
+ * and the one confirmed present. The effective() merge/repo-matching logic in
1917
+ * effective-assignment-provider-settings.ts is intentionally NOT reimplemented here (that
1918
+ * would fork a second merge algorithm); a repo whose provider kind can only be resolved via
1919
+ * global settings or multi-project merge degrades to "unknown, skip assignment-layer
1920
+ * release" rather than guessing.
1921
+ */
1922
+ function resolveAssignmentProviderKind(root) {
1923
+ const settingsFile = path.join(root, 'context', 'settings', 'assignment-provider-settings.json');
1924
+ const settings = readJsonFile(settingsFile);
1925
+ if (!settings) return null;
1926
+ const candidates = [];
1927
+ if (settings.defaults && settings.defaults.provider) candidates.push(settings.defaults.provider);
1928
+ if (Array.isArray(settings.projects)) {
1929
+ for (const project of settings.projects) {
1930
+ if (project && project.provider) candidates.push(project.provider);
1931
+ }
1932
+ }
1933
+ const kind = candidates.map(p => p && p.kind).find(k => k === 'local-file' || k === 'github');
1934
+ return kind || null;
1935
+ }
1936
+
1937
+ /**
1938
+ * Refresh handoff.json by merge (read+spread the existing file's JSON, overwrite only the
1939
+ * fields this task owns), mirroring advanceState's own handoff-write shape
1940
+ * (`{ ...loadJson(...), ...sidecarBase(slug), summary, current_state_ref: "state.json",
1941
+ * next_steps, blockers: [], warnings: [] }`) and current-pointer.js's writePerActorCurrent
1942
+ * write idiom (`fs.writeFileSync(file, JSON.stringify(payload, null, 2) + "\n")`). next_steps
1943
+ * is appended to (not clobbered) when a provider-release next-step is due.
1944
+ */
1945
+ function refreshHandoffAfterRelease(artifactDir, slug, state, providerReleasePending, providerReleaseNextCommand) {
1946
+ const file = path.join(artifactDir, 'handoff.json');
1947
+ const existing = readJsonFile(file) || {};
1948
+ const status = state ? normalizedStatus(state.status || 'unknown') : 'unknown';
1949
+ const phase = state ? normalizedStatus(state.phase || 'unknown') : 'unknown';
1950
+ const summary = (state && state.next_action && state.next_action.summary)
1951
+ ? String(state.next_action.summary)
1952
+ : `session ended at status:${status} phase:${phase}`;
1953
+ const existingNextSteps = Array.isArray(existing.next_steps) ? existing.next_steps.slice() : [];
1954
+ const nextSteps = existingNextSteps.slice();
1955
+ if (providerReleasePending && providerReleaseNextCommand && !nextSteps.includes(providerReleaseNextCommand)) {
1956
+ nextSteps.push(providerReleaseNextCommand);
1957
+ }
1958
+ const payload = {
1959
+ ...existing,
1960
+ schema_version: existing.schema_version || '1.0',
1961
+ task_slug: existing.task_slug || slug,
1962
+ summary,
1963
+ current_state_ref: 'state.json',
1964
+ next_steps: nextSteps,
1965
+ ...(providerReleasePending ? { provider_release_pending: true } : {}),
1966
+ ...(providerReleaseNextCommand ? { provider_release_next_command: providerReleaseNextCommand } : {}),
1967
+ };
1968
+ fs.writeFileSync(file, `${JSON.stringify(payload, null, 2)}\n`);
1969
+ }
1970
+
1971
+ /**
1972
+ * #292 Wave 2: on a Stop event whose resolved session is at a NON-terminal status, release
1973
+ * the session's liveness claim (always, provider-agnostic — AC1) and, for the local-file
1974
+ * assignment provider, its durable assignment claim too (AC2), then refresh handoff.json
1975
+ * (AC4). A terminal-status session (already released by advance-state) is a deliberate no-op
1976
+ * (AC5). For the github provider, never calls render-release or gh — only records an
1977
+ * honestly-labeled pending intent in handoff.json (AC3). Actor-scoped: only ever releases
1978
+ * the CURRENT actor's own held claim (AC6, enforced inside performLocalRelease).
1979
+ *
1980
+ * Called once from run(), reusing analyze()'s already-resolved latestArtifactDir — this
1981
+ * function does not re-derive "what is the active session" a second time.
1982
+ *
1983
+ * Fail-open (AC7): the entire body is wrapped in one try/catch. Any failure degrades to a
1984
+ * single stderr diagnostic and returns, never throwing, never affecting the Stop hook's exit
1985
+ * code or goal-fit's warnings/blocking contract (this function's return value is unused by
1986
+ * that contract).
1987
+ *
1988
+ * @param {string} root - repo root (as resolved by findRepoRoot)
1989
+ * @param {string|null} artifactDir - analyze()'s already-resolved latestArtifactDir
1990
+ */
1991
+ function releaseOnNonTerminalStop(root, artifactDir) {
1992
+ try {
1993
+ if (!artifactDir) return; // no active session resolved — nothing to release.
1994
+
1995
+ const state = readJsonFile(path.join(artifactDir, 'state.json'));
1996
+ if (!state) return; // AC5: no state.json — nothing to gate a release decision on.
1997
+
1998
+ const sidecar = loadWorkflowSidecarBuilt();
1999
+ if (!sidecar) {
2000
+ process.stderr.write('[Hook] Goal Fit: stop-hook release skipped — build/src/cli/workflow-sidecar.js not available (LIVENESS_TERMINAL boundary unknown).\n');
2001
+ return;
2002
+ }
2003
+
2004
+ const status = normalizedStatus(state.status || 'unknown');
2005
+ if (sidecar.LIVENESS_TERMINAL.has(status)) return; // AC5: terminal — advance-state already released.
2006
+
2007
+ // Slug: artifact dir's basename, or state.json's own task_slug field — reuse whichever
2008
+ // this file already has, never a new slug-derivation rule.
2009
+ const slug = (state.task_slug && String(state.task_slug).trim()) || path.basename(artifactDir);
2010
+
2011
+ const { actor } = resolveActor(process.env);
2012
+ if (isUnresolvedActor(actor)) {
2013
+ process.stderr.write(`[Hook] Goal Fit: stop-hook release skipped for "${safeOneLine(slug, 80)}" — actor identity is unresolved; never releasing under a synthetic identity.\n`);
2014
+ return;
2015
+ }
2016
+
2017
+ const flowAgentsDir = path.dirname(artifactDir);
2018
+ const nowIso = new Date().toISOString();
2019
+
2020
+ // AC1: ALWAYS emit the liveness release, regardless of provider kind — liveness is
2021
+ // provider-agnostic and this is the ONE shared writer every other liveness emitter uses.
2022
+ try {
2023
+ require('./lib/liveness-write.js').appendLivenessEvent(flowAgentsDir, {
2024
+ type: 'release',
2025
+ subjectId: slug,
2026
+ actor,
2027
+ at: nowIso,
2028
+ source: 'stop-hook',
2029
+ });
2030
+ } catch (err) {
2031
+ process.stderr.write(`[Hook] Goal Fit: stop-hook liveness release failed for "${safeOneLine(slug, 80)}": ${safeOneLine(err && err.message || err, 160)}\n`);
2032
+ }
2033
+
2034
+ // Resolve the effective assignment-provider kind — unknown means "do not guess",
2035
+ // skip the assignment-layer release entirely (fail-open).
2036
+ const providerKind = resolveAssignmentProviderKind(root);
2037
+ if (!providerKind) {
2038
+ process.stderr.write(`[Hook] Goal Fit: stop-hook release — provider kind unknown for "${safeOneLine(slug, 80)}", skipping assignment-layer release (liveness release already emitted).\n`);
2039
+ refreshHandoffAfterRelease(artifactDir, slug, state, false, null);
2040
+ return;
2041
+ }
2042
+
2043
+ let providerReleasePending = false;
2044
+ let providerReleaseNextCommand = null;
2045
+
2046
+ if (providerKind === 'local-file') {
2047
+ const provider = loadAssignmentProviderBuilt();
2048
+ if (!provider) {
2049
+ process.stderr.write('[Hook] Goal Fit: stop-hook local-file release skipped — build/src/cli/assignment-provider.js not available.\n');
2050
+ } else {
2051
+ try {
2052
+ // Mirror assignment-provider.ts's loadActorStruct() exactly: runtime is derived via
2053
+ // detectRuntime (not a literal 'unknown'), since serializeActor()'s comparison key
2054
+ // includes runtime — a mismatched runtime segment here would make performLocalRelease's
2055
+ // AC6 actor-match check wrongly treat this session's own claim as foreign.
2056
+ //
2057
+ // actorKey: opts.actorKey MUST be the same canonical `resolveActor(env).actor` string
2058
+ // already resolved above (`actor`) — the identical bare-or-triple form
2059
+ // computeEffectiveState()'s holderActorKey preference (`record.actor_key ||
2060
+ // serializeActor(record.actor)`) and performLocalRelease's mirrored ownership check
2061
+ // both key on. Without this, an explicit-override actor's release-side comparison
2062
+ // would fall back to serializeActor(releasedBy) — a re-derived triple that never
2063
+ // equals the stored bare actor_key — and this hook would wrongly treat its own claim
2064
+ // as foreign (the #291 seam, relocated to the release path; see performLocalRelease's
2065
+ // doc comment in src/cli/assignment-provider.ts).
2066
+ const releasedBy = { runtime: detectRuntime(process.env), session_id: actor, host: os.hostname(), human: null };
2067
+ const artifactRoot = flowAgentsArtifactRoot(root);
2068
+ provider.performLocalRelease(artifactRoot, slug, releasedBy, {
2069
+ reason: 'stop-hook non-terminal release',
2070
+ tolerateNoActiveClaim: true,
2071
+ actorKey: actor,
2072
+ });
2073
+ } catch (err) {
2074
+ process.stderr.write(`[Hook] Goal Fit: stop-hook local-file release failed for "${safeOneLine(slug, 80)}": ${safeOneLine(err && err.message || err, 160)}\n`);
2075
+ }
2076
+ }
2077
+ } else {
2078
+ // AC3: github (or any non-local-file kind) — render-don't-execute. Never call
2079
+ // render-release, never invoke gh. Disclose the pending intent honestly.
2080
+ providerReleasePending = true;
2081
+ providerReleaseNextCommand = `npm run workflow -- assignment-provider status --provider github --subject-id "${slug}" ... # then: assignment-provider render-release --provider github --subject-id "${slug}" ... (emits the gh argv to run)`;
2082
+ }
2083
+
2084
+ // AC4: refresh handoff.json — merge, never fully overwrite.
2085
+ refreshHandoffAfterRelease(artifactDir, slug, state, providerReleasePending, providerReleaseNextCommand);
2086
+ } catch (err) {
2087
+ // Fail-open (AC7): any unexpected failure anywhere above (JSON parse error, fs error,
2088
+ // etc.) must never crash the Stop hook or affect its exit code / goal-fit's output.
2089
+ try {
2090
+ process.stderr.write(`[Hook] Goal Fit: stop-hook release logic error (fail-open): ${safeOneLine(err && err.message || err, 160)}\n`);
2091
+ } catch { /* best-effort diagnostic only */ }
2092
+ }
2093
+ }
2094
+
1847
2095
  async function run(rawInput) {
1848
2096
  const input = parseJson(rawInput);
1849
2097
  const root = findRepoRoot(input.cwd || process.cwd());
1850
2098
  const mode = resolveGoalFitMode();
1851
2099
  if (mode === 'off') return rawInput;
1852
2100
  const result = await analyze(root);
2101
+ // #292 Wave 2: additive side effect only — never changes analyze()'s warnings/blocking
2102
+ // contract or this function's return value. Reuses analyze()'s already-resolved
2103
+ // latestArtifactDir rather than re-deriving a second "what is the active session" path.
2104
+ // Runs regardless of whether goal-fit itself has any warnings this turn (AC1: the
2105
+ // liveness release must always be attempted on a non-terminal Stop, independent of
2106
+ // whether goal-fit found anything to warn about).
2107
+ releaseOnNonTerminalStop(root, result.latestArtifactDir || null);
1853
2108
  if (result.warnings.length === 0) {
1854
2109
  clearBlockStreak(root);
1855
2110
  return rawInput;
@@ -1939,4 +2194,4 @@ if (require.main === module) {
1939
2194
  });
1940
2195
  }
1941
2196
 
1942
- module.exports = { analyze, run, resolveGoalFitMode, uncheckedInSection, findRepoRoot, sidecarGuidance, safeOneLine, captureCrossReference, bundleEnforcement, loadActiveFlowStep, readCommandLog, resolveTrustedCommand, declaredManifestTarget, verifyCommandLogChain, CHAIN_GENESIS_VERIFY, hasLaunderingOperator };
2197
+ module.exports = { analyze, run, resolveGoalFitMode, uncheckedInSection, findRepoRoot, sidecarGuidance, safeOneLine, captureCrossReference, bundleEnforcement, loadActiveFlowStep, readCommandLog, resolveTrustedCommand, declaredManifestTarget, verifyCommandLogChain, CHAIN_GENESIS_VERIFY, hasLaunderingOperator, releaseOnNonTerminalStop };