@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.
- package/.github/workflows/ci.yml +16 -0
- package/CHANGELOG.md +30 -0
- package/build/src/cli/assignment-provider.d.ts +45 -0
- package/build/src/cli/assignment-provider.js +107 -13
- package/build/src/cli/workflow-artifact-cleanup-audit.js +418 -11
- package/build/src/cli/workflow-sidecar.d.ts +238 -4
- package/build/src/cli/workflow-sidecar.js +875 -14
- package/build/src/tools/validate-source-tree.js +3 -2
- package/context/contracts/artifact-contract.md +16 -2
- package/context/contracts/assignment-provider-contract.md +1 -1
- package/context/contracts/execution-contract.md +78 -0
- package/context/scripts/hooks/config-protection.js +11 -4
- package/context/scripts/hooks/stop-goal-fit.js +259 -4
- package/context/scripts/hooks/workflow-steering.js +73 -1
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +111 -0
- package/docs/coordination-guide.md +370 -0
- package/docs/decisions/agent-coordination.md +26 -9
- package/docs/decisions/index.md +2 -2
- package/docs/decisions/trust-reconcile.md +42 -9
- package/docs/fixture-ownership.md +3 -2
- package/docs/index.md +4 -0
- package/docs/integrations/flow-agents-console.md +108 -0
- package/docs/integrations/index.md +4 -0
- package/docs/workflow-artifact-lifecycle.md +38 -1
- package/evals/ci/antigaming-suite.sh +1 -0
- package/evals/ci/run-baseline.sh +8 -0
- package/evals/fixtures/reconcile-preflight/disputed-critique-unsuperseded.json +48 -0
- package/evals/fixtures/reconcile-preflight/standalone-disputed-session-local.json +59 -0
- package/evals/integration/test_checkpoint_signing.sh +14 -5
- package/evals/integration/test_ci_actor_identity.sh +221 -0
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_gate_lockdown.sh +36 -0
- package/evals/integration/test_model_routing_escalation.sh +145 -0
- package/evals/integration/test_publish_delivery.sh +73 -8
- package/evals/integration/test_reconcile_preflight.sh +304 -0
- package/evals/integration/test_stop_hook_release.sh +552 -0
- package/evals/integration/test_takeover_protocol.sh +340 -0
- package/evals/integration/test_trust_reconcile_negatives.sh +261 -0
- package/evals/integration/test_verify_hold.sh +910 -0
- package/evals/integration/test_veritas_governance_kit.sh +257 -0
- package/evals/integration/test_workflow_artifact_cleanup_audit.sh +575 -3
- package/evals/run.sh +14 -0
- package/evals/static/test_model_routing_hints.sh +107 -0
- package/kits/builder/skills/builder-shape/SKILL.md +10 -0
- package/kits/builder/skills/continue-work/SKILL.md +2 -0
- package/kits/builder/skills/deliver/SKILL.md +125 -11
- package/kits/builder/skills/design-probe/SKILL.md +10 -0
- package/kits/builder/skills/execute-plan/SKILL.md +13 -0
- package/kits/builder/skills/fix-bug/SKILL.md +17 -0
- package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
- package/kits/builder/skills/plan-work/SKILL.md +9 -0
- package/kits/builder/skills/pull-work/SKILL.md +22 -2
- package/kits/builder/skills/review-work/SKILL.md +11 -0
- package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
- package/kits/builder/skills/verify-work/SKILL.md +11 -0
- package/kits/knowledge/adapters/default-store/index.js +56 -15
- package/kits/knowledge/adapters/flow-runner/index.js +912 -16
- package/kits/knowledge/adapters/obsidian-store/index.js +29 -11
- package/kits/knowledge/adapters/shared/codec.js +124 -0
- package/kits/knowledge/docs/store-contract.md +405 -3
- package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
- package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
- package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
- package/kits/knowledge/evals/contract-suite/suite.test.js +36 -0
- package/kits/knowledge/evals/freshness/suite.test.js +339 -0
- package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
- package/kits/knowledge/evals/retirement/suite.test.js +1 -1
- package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
- package/kits/veritas-governance/docs/README.md +81 -3
- package/kits/veritas-governance/fixtures/exemption/approved.trust-bundle.json +74 -0
- package/kits/veritas-governance/fixtures/exemption/not-approved.trust-bundle.json +74 -0
- package/kits/veritas-governance/flows/exemption-issuance.flow.json +35 -0
- package/kits/veritas-governance/kit.json +5 -0
- package/package.json +1 -1
- package/schemas/workflow-handoff.schema.json +6 -0
- package/scripts/ci/mint-attestation.js +33 -6
- package/scripts/ci/trust-reconcile.js +222 -279
- package/scripts/hooks/config-protection.js +11 -4
- package/scripts/hooks/lib/actor-identity.js +82 -0
- package/scripts/hooks/stop-goal-fit.js +259 -4
- package/scripts/hooks/workflow-steering.js +73 -1
- package/scripts/lib/reconcile-shape.js +381 -0
- package/src/cli/assignment-provider.ts +122 -13
- package/src/cli/workflow-artifact-cleanup-audit.ts +483 -10
- package/src/cli/workflow-sidecar.ts +965 -14
- 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/
|
|
108
|
-
["evals/fixtures/trust-reconcile-
|
|
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 (
|
|
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 |
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|