@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
@@ -104,8 +104,9 @@ const fixtureOwnerPolicies = new Map([
104
104
  ["evals/fixtures/pull-work-provider", { owners: ["evals/integration/test_pull_work_provider.sh"], classification: "work item provider normalization fixtures" }],
105
105
  ["evals/fixtures/pull-work-wip-shepherding", { owners: ["evals/static/test_workflow_skills.sh"], classification: "WIP shepherding state fixtures" }],
106
106
  ["evals/fixtures/surface-trust", { owners: ["evals/integration/test_workflow_sidecar_writer.sh"], classification: "Surface trust evidence fixtures" }],
107
- ["evals/fixtures/trust-reconcile-exploits", { owners: ["evals/integration/test_trust_reconcile_negatives.sh"], classification: "WS8 trust-reconcile anti-gaming exploit fixtures (frozen negative regressions)" }],
108
- ["evals/fixtures/trust-reconcile-mixed-bundle", { owners: ["evals/integration/test_trust_reconcile_mixed_bundle.sh"], classification: "WS8 trust-reconcile mixed-evidence end-to-end proof fixture" }],
107
+ ["evals/fixtures/reconcile-preflight", { owners: ["evals/integration/test_reconcile_preflight.sh"], classification: "#356 reconcile-preflight shape fixtures not already covered by trust-reconcile-exploits (un-superseded disputed critique, standalone disputed session-local claim)" }],
108
+ ["evals/fixtures/trust-reconcile-exploits", { owners: ["evals/integration/test_trust_reconcile_negatives.sh", "evals/integration/test_reconcile_preflight.sh"], classification: "WS8 trust-reconcile anti-gaming exploit fixtures (frozen negative regressions); also reused by the #356 local reconcile-preflight eval (same shapes, no forked copies)" }],
109
+ ["evals/fixtures/trust-reconcile-mixed-bundle", { owners: ["evals/integration/test_trust_reconcile_mixed_bundle.sh", "evals/integration/test_reconcile_preflight.sh"], classification: "WS8 trust-reconcile mixed-evidence end-to-end proof fixture; also reused by the #356 reconcile-preflight eval as its CLEAN-BUNDLE (AC4) case" }],
109
110
  ["evals/fixtures/trust-reconcile-ws3", { owners: ["evals/integration/test_trust_reconcile_negatives.sh"], classification: "WS8 AC6 backward-compat fixture: real ws3-kit-dependencies-namespacing old-style bundle" }],
110
111
  ["evals/fixtures/usage-feedback", { owners: ["evals/integration/test_usage_feedback_import.sh", "evals/integration/test_usage_feedback_outcomes.sh", "evals/integration/test_usage_feedback_report.sh"], classification: "usage feedback import/outcome fixtures" }],
111
112
  ["evals/fixtures/veritas-governance-adapter", { owners: ["evals/integration/test_veritas_governance_adapter.sh"], classification: "Veritas governance adapter fixtures" }],
@@ -44,8 +44,7 @@ candidate selector — do not build new discovery logic. `active_wip`,
44
44
  `active_learning_followup`, `cleanup_candidate`, and `invalid` sessions are **never**
45
45
  archival candidates on age alone.
46
46
 
47
- Procedure (documented; archival itself remains a deliberate, non-automated move — the audit
48
- tool "does not delete, archive," by design):
47
+ Procedure (manual, hand-driven):
49
48
 
50
49
  1. `node build/src/cli/workflow-artifact-cleanup-audit.js <artifact-root>` to classify.
51
50
  2. From the `terminal_done` bucket, select those whose `state.json` `updatedAt` predates the
@@ -53,6 +52,21 @@ tool "does not delete, archive," by design):
53
52
  3. Move each selected `<artifact-root>/<slug>/` to `<artifact-root>/archive/<slug>/`
54
53
  (a human-reviewed or explicitly-invoked move — not a silent background job).
55
54
 
55
+ Automated `--apply` mode: `workflow-artifact-cleanup-audit --apply` (see
56
+ `docs/workflow-artifact-lifecycle.md`'s "Apply Mode" section) automates the
57
+ `cleanup_candidate`/aged-`terminal_done` selection above, but archive-moves to a
58
+ *different*, sibling destination — `.kontourai/flow-agents-archive/<date>-<runid>/<slug>/`,
59
+ next to the artifact root, not `<artifact-root>/archive/<slug>/`. The run-scoped,
60
+ sibling root keeps two same-day `--apply` runs from colliding and keeps the artifact
61
+ root's own scan set stable while a run is in progress; it is not a replacement for the
62
+ in-tree `<artifact-root>/archive/<slug>/` convention above, which remains the right
63
+ destination for a manual, hand-driven move. `--apply` never deletes anything (archive-move
64
+ only) and is gated by hard safety rails (never `active_wip`/`active_learning_followup`,
65
+ never a held liveness claim, never inside the freshness window, never an infrastructure
66
+ directory, never a substantive session misclassified `invalid` by a schema nit) — see the
67
+ doc section for the full model, including the `--apply-ambiguous` second gate and the
68
+ `MANIFEST.md`/`--confirm` evidence record.
69
+
56
70
  ## Persistence Integrity
57
71
 
58
72
  Writing a durable artifact must **fail loud, never fail-open.** If a record (state, evidence, a
@@ -94,7 +94,7 @@ delivers all four:
94
94
  | --- | --- | --- | --- |
95
95
  | `claim` | the claiming session | at selection | **#290 (this issue)** |
96
96
  | `claim` / `supersede` | `ensure-session`'s pre-entry ownership guard (a SECOND mutator, alongside `assignment-provider claim`) | on session entry, before any session directory is created — `free` establishes a claim, `reclaimable` requires explicit `--supersede-stale` | **#291** |
97
- | `clean_release` | the incumbent (Stop hook / terminal `advance-state`) | session end — unassign + handoff comment | #292 |
97
+ | `clean_release` | the incumbent (Stop hook / terminal `advance-state`) | session end — unassign + handoff comment | **#292** — the Stop hook (`scripts/hooks/stop-goal-fit.js`) on clean non-terminal session end: liveness release + local-file assignment release (`performLocalRelease`) run inline; for the github provider the hook cannot run `gh`, so it honestly defers via a `provider_release_pending` handoff intent rather than pretending to complete the release |
98
98
  | `supersede` | the successor, inside the takeover protocol | after the grace beat | #294 |
99
99
  | `crash_no_successor` | nobody, initially; corrected by the next actor to want the subject, or the janitor | lazily, or on a janitor sweep | out of scope — the Console relay's first cross-machine duty (ADR 0021 §4/§7) |
100
100
 
@@ -67,6 +67,84 @@ model backs which delegate role. This contract only says where to read it.
67
67
  fallback in the session/task artifact — do not block delegation on datum
68
68
  being present.
69
69
 
70
+ ### Tier ladder
71
+
72
+ The delegate roles form a cost/capability ladder that both escalation and the
73
+ Goodhart guard below reference. Lowest to highest:
74
+
75
+ ```
76
+ delegate-mechanical < delegate-implementation < delegate-design
77
+ ```
78
+
79
+ `orchestrator` is off-ladder — it is the session's own model (planning, gates,
80
+ adversarial verification), typically inherited rather than overridden, and sits
81
+ above `delegate-design` for escalation purposes. `extraction-default` is a
82
+ non-delegate role (bulk extraction) and is not part of this ladder.
83
+
84
+ ### Escalation on gate failure
85
+
86
+ Delegate at the step's hinted tier, then let the gates enforce cost safety:
87
+
88
+ - Dispatch the delegate at the tier its skill hint names (a cheap tier for
89
+ mechanical/fully-specified work).
90
+ - On a review or verify **gate FAILURE of that delegate's output**, re-dispatch
91
+ the **fix** one tier higher on the ladder — `delegate-mechanical` failures
92
+ escalate to `delegate-implementation`, `delegate-implementation` failures to
93
+ `delegate-design`, and a `delegate-design` failure escalates to the
94
+ `orchestrator` / a human decision. Do not re-dispatch the fix at the same
95
+ tier that already failed its gate.
96
+ - Record every escalation in the session artifact (see *Routing decisions in
97
+ the run artifact* below) with the tier it climbed **from**, so the run shows
98
+ why a more expensive model was used.
99
+ - This is the fail-closed cost story: cheap tiers are safe **because** the gates
100
+ catch their misses and escalate. Routing cheap-by-default never weakens
101
+ verification — an ungated cheap delegate is what this ladder exists to prevent.
102
+
103
+ ### Goodhart guard (review/verify never cheaper than the work)
104
+
105
+ Review and verify roles resolve at a tier **greater than or equal to** the tier
106
+ of the work they check; they never auto-downgrade below it.
107
+
108
+ - If the checked work was produced at `delegate-design`, its reviewer/verifier
109
+ resolves `delegate-design` (or `orchestrator`) — never `delegate-implementation`
110
+ or `delegate-mechanical`.
111
+ - If the checked work was produced at `delegate-implementation`, its
112
+ reviewer/verifier resolves at `delegate-implementation` or higher.
113
+ - Rationale: a cheaper checker rubber-stamping a more capable worker's output
114
+ defeats the gate and turns the routing table into a Goodhart target (optimize
115
+ the cost metric, lose the thing the metric was a proxy for). The gate must be
116
+ at least as capable as the work, or the fail-closed escalation story above is
117
+ hollow.
118
+
119
+ ### Routing decisions in the run artifact
120
+
121
+ Record each delegation's resolved role and model on the session artifact so a
122
+ downstream economics record (`flow-agents#349`) can price role assignments and a
123
+ baseline harness (`flow-agents#350`) can A/B tiers. Use the sidecar writer's
124
+ `record-agent-event` (additive `--role` / `--model` / `--escalated-from` flags):
125
+
126
+ ```bash
127
+ # per-delegation routing decision
128
+ npm run workflow:sidecar -- record-agent-event \
129
+ --agent-id <delegate-id> --kind delegation --status active \
130
+ --role <resolved-role> --model "<resolved-model@provider>" \
131
+ --summary "<what was delegated>"
132
+
133
+ # an escalate-on-gate-failure re-dispatch
134
+ npm run workflow:sidecar -- record-agent-event \
135
+ --agent-id <delegate-id> --kind escalation --status active \
136
+ --role <higher-tier-role> --model "<resolved-model@provider>" \
137
+ --escalated-from <lower-tier-role> \
138
+ --summary "<gate that failed> failed at <lower-tier-role>; fix re-dispatched one tier higher"
139
+ ```
140
+
141
+ These land as top-level `role`, `model`, and (for escalations) `escalated_from`
142
+ fields on the JSONL event under `agents/<agent-id>/events.jsonl`. The shape is
143
+ additive: events without a routing decision are byte-identical to before, so no
144
+ existing consumer breaks. An economics consumer reads events where `role` is
145
+ present as one priced delegation each; `escalated_from` marks the entries that
146
+ cost more because a gate caught a cheaper tier.
147
+
70
148
  ## Completion Rules
71
149
 
72
150
  Execution is complete only when:
@@ -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 };
@@ -159,8 +159,25 @@ function latestWorkflowState(root) {
159
159
  return states[0] || null;
160
160
  }
161
161
 
162
+ // #293 AC7 (injection discipline): strips C0 (0x00-0x1F), DEL (0x7F), and C1 (0x80-0x9F,
163
+ // which includes ANSI-CSI-adjacent bytes) BEFORE whitespace-collapse/truncation — mirroring
164
+ // src/cli/workflow-sidecar.ts's stripControlCharsForDisplay / assignment-provider.ts's
165
+ // sanitizeDisplayField exactly (same charset, same rationale: holder/actor/assignee strings
166
+ // sourced from the shared multi-writer liveness stream or an attacker-postable GitHub comment
167
+ // are untrusted display input). Previously this function only collapsed whitespace, so a raw
168
+ // ESC/BEL byte embedded in a hostile actor string passed through unstripped into every steering
169
+ // notice that calls safeStateText (resumeSteering's LIVENESS WARNING block, supersessionSteering's
170
+ // SUPERSEDED notice) — fixed here, in the one shared helper, rather than in each call site.
162
171
  function safeStateText(value, maxLength = 240) {
163
- const text = String(value || '').replace(/\s+/g, ' ').trim();
172
+ // Whitespace-collapse FIRST (so an embedded newline/tab becomes a joining space, preserving
173
+ // the existing multiline-summary neutralization behavior), THEN strip control/ANSI bytes
174
+ // (#293 AC7) — reversing this order would strip \n before the collapse ever sees it, losing
175
+ // the join-space between sentences. Only C0/DEL/C1 bytes THAT SURVIVE the collapse (ESC, BEL,
176
+ // and other non-whitespace control bytes — real \s already became a plain space above) are
177
+ // stripped, mirroring src/cli/workflow-sidecar.ts's stripControlCharsForDisplay /
178
+ // assignment-provider.ts's sanitizeDisplayField charset exactly.
179
+ const collapsed = String(value || '').replace(/\s+/g, ' ').trim();
180
+ const text = collapsed.replace(/[\u0000-\u001F\u007F-\u009F]/g, '');
164
181
  if (text.length <= maxLength) return text;
165
182
  return `${text.slice(0, maxLength - 3)}...`;
166
183
  }
@@ -411,6 +428,51 @@ function resumeSteering(root, current) {
411
428
  }
412
429
  }
413
430
 
431
+ /**
432
+ * Compose the every-turn SUPERSEDED steering notice (issue #293).
433
+ *
434
+ * Unlike resumeSteering()'s RESUME block (SessionStart only), this notice must
435
+ * surface on EVERY turn (UserPromptSubmit as well as SessionStart) once another
436
+ * actor holds a fresh liveness claim on the caller's own subject — the
437
+ * "don't publish, you may be stale" signal the verify-hold gate backstops at
438
+ * publish time. Reuses the SAME liveness-only join resumeSteering()'s liveness
439
+ * advisory block already performs (freshHolders(events, slug, selfActor, now)),
440
+ * not a second computation.
441
+ *
442
+ * freshHolders() already excludes selfActor internally (see
443
+ * scripts/hooks/lib/liveness-read.js), so any holder returned here is by
444
+ * definition another actor — no additional self-filter is required.
445
+ *
446
+ * Fully fail-open: any error anywhere in this function returns '' rather than
447
+ * throwing, matching every other steering helper in this file.
448
+ *
449
+ * @param {string} root Repository root
450
+ * @param {{ file: string, payload: object }} current Latest active state entry (this actor's own)
451
+ * @returns {string}
452
+ */
453
+ function supersessionSteering(root, current) {
454
+ try {
455
+ const state = current.payload;
456
+ const workflowDir = path.dirname(current.file);
457
+ const slug = state.task_slug || path.basename(workflowDir);
458
+
459
+ const resolved = resolveActor(process.env);
460
+ const selfActor = resolved.actor || 'local';
461
+
462
+ const events = flowAgentsArtifactRootsForRead(root)
463
+ .flatMap(artifactRoot => readLivenessEvents(path.join(artifactRoot, 'liveness', 'events.jsonl')));
464
+ if (events.length === 0) return '';
465
+
466
+ const holders = freshHolders(events, slug, selfActor, Date.now());
467
+ if (holders.length === 0) return '';
468
+
469
+ const holder = holders[0];
470
+ return `[SUPERSEDED: another agent appears live on this work: actor ${safeStateText(holder.actor)}, last seen ${holder.lastAt}. Your claim on this subject may be stale — do not publish until you re-verify hold (see verify-hold gate) or hand off.]`;
471
+ } catch {
472
+ return '';
473
+ }
474
+ }
475
+
414
476
  function run(rawInput) {
415
477
  try {
416
478
  const input = JSON.parse(rawInput);
@@ -451,10 +513,19 @@ function run(rawInput) {
451
513
  }
452
514
  }
453
515
 
516
+ // Every-turn supersession notice (#293): fires on UserPromptSubmit (every turn),
517
+ // not just SessionStart, so a takeover mid-session keeps surfacing the warning.
518
+ if (event === 'UserPromptSubmit' && current) {
519
+ const supersessionHint = supersessionSteering(root, current);
520
+ if (supersessionHint) hints.push(supersessionHint);
521
+ }
522
+
454
523
  // SessionStart only: append the RESUME block for richer situational awareness
455
524
  if (event === 'SessionStart' && current) {
456
525
  const resumeBlock = resumeSteering(root, current);
457
526
  if (resumeBlock) hints.push(resumeBlock);
527
+ const supersessionHint = supersessionSteering(root, current);
528
+ if (supersessionHint) hints.push(supersessionHint);
458
529
  }
459
530
 
460
531
  if (shouldAppendWorkflowContext) {
@@ -492,6 +563,7 @@ module.exports = {
492
563
  safeStateText,
493
564
  stateNeedsAmbientSteering,
494
565
  resumeSteering,
566
+ supersessionSteering,
495
567
  promptText,
496
568
  looksLikeBuilderWork,
497
569
  builderWorkflowSteering,