@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
@@ -131,11 +131,19 @@ else
131
131
  _fail "END-TO-END-RECORD-RELEASE: record-release exited $rr_exit1 -- $rr_out1"
132
132
  fi
133
133
 
134
- DELIVERY_BUNDLE1="$REPO1/delivery/trust.bundle"
134
+ # #379: publishDelivery now writes to the PER-SESSION path delivery/<slug>/trust.bundle
135
+ # (slug = the session artifact dir basename) so concurrent deliveries never contend on one
136
+ # shared file. The flat path is no longer written.
137
+ DELIVERY_BUNDLE1="$REPO1/delivery/$SLUG1/trust.bundle"
135
138
  if [[ -f "$DELIVERY_BUNDLE1" ]]; then
136
- _pass "END-TO-END-RECORD-RELEASE: delivery/trust.bundle exists after record-release"
139
+ _pass "END-TO-END-RECORD-RELEASE: delivery/$SLUG1/trust.bundle exists after record-release (#379 per-session path)"
137
140
  else
138
- _fail "END-TO-END-RECORD-RELEASE: delivery/trust.bundle NOT found at $DELIVERY_BUNDLE1"
141
+ _fail "END-TO-END-RECORD-RELEASE: delivery/$SLUG1/trust.bundle NOT found at $DELIVERY_BUNDLE1"
142
+ fi
143
+ if [[ ! -f "$REPO1/delivery/trust.bundle" ]]; then
144
+ _pass "END-TO-END-RECORD-RELEASE: flat delivery/trust.bundle NOT written (#379 migrated off the shared path)"
145
+ else
146
+ _fail "END-TO-END-RECORD-RELEASE: flat delivery/trust.bundle was written — the shared-path contention #379 fixes is back"
139
147
  fi
140
148
 
141
149
  if [[ -f "$DELIVERY_BUNDLE1" && -f "$SESSION_DIR1/trust.bundle" ]]; then
@@ -170,11 +178,11 @@ else
170
178
  _fail "SUBCOMMAND: publish-delivery exited $pd_exit -- $pd_out"
171
179
  fi
172
180
 
173
- DELIVERY_BUNDLE2="$REPO2/delivery/trust.bundle"
181
+ DELIVERY_BUNDLE2="$REPO2/delivery/$SLUG2/trust.bundle"
174
182
  if [[ -f "$DELIVERY_BUNDLE2" ]]; then
175
- _pass "SUBCOMMAND: delivery/trust.bundle exists after publish-delivery"
183
+ _pass "SUBCOMMAND: delivery/$SLUG2/trust.bundle exists after publish-delivery (#379 per-session path)"
176
184
  else
177
- _fail "SUBCOMMAND: delivery/trust.bundle NOT found at $DELIVERY_BUNDLE2"
185
+ _fail "SUBCOMMAND: delivery/$SLUG2/trust.bundle NOT found at $DELIVERY_BUNDLE2"
178
186
  fi
179
187
 
180
188
  if [[ -f "$DELIVERY_BUNDLE2" && -f "$SESSION_DIR2/trust.bundle" ]]; then
@@ -0,0 +1,552 @@
1
+ #!/usr/bin/env bash
2
+ # test_stop_hook_release.sh — integration eval for the Stop-hook non-terminal
3
+ # release-with-handoff lifecycle (issue #292, Wave 3 Task of the plan artifact at
4
+ # .kontourai/flow-agents/kontourai-flow-agents-292/kontourai-flow-agents-292--plan-work.md).
5
+ #
6
+ # Covers, per the plan's Wave 3 scenario list (AC1-AC9):
7
+ # A. Non-terminal + local-file claim held by self -> liveness release appended,
8
+ # assignment record -> released, status reports free (AC1, AC2).
9
+ # B. github provider -> NO gh invocation, handoff.json carries
10
+ # provider_release_pending:true + a non-empty provider_release_next_command (AC3).
11
+ # C. handoff.json summary/next_steps reflect the fixture's own status/phase (AC4).
12
+ # D. terminal (delivered) -> no release event, no record mutation (AC5).
13
+ # E. foreign-actor holder -> NOT released, hook exits clean (AC6).
14
+ # F. fail-open: (i) missing build/, (ii) corrupt assignment record, (iii) unresolved
15
+ # actor -> no uncaught throw, hook exit unaffected (AC7).
16
+ # G. idempotent double-Stop -> second release attempt is a safe no-op (AC5/AC7).
17
+ # H. override-actor proof (Task A / #291-seam-on-release-path regression lock):
18
+ # a claim made under FLOW_AGENTS_ACTOR=<canonical bare actor> is released by a
19
+ # Stop hook running as the SAME override actor (canonical-key comparison, not
20
+ # serializeActor-vs-serializeActor), and a DIFFERENT override actor's Stop does
21
+ # NOT release it (foreign-actor, override form). This eval must FAIL without the
22
+ # performLocalRelease actor_key-first fix and PASS with it.
23
+ #
24
+ # House style follows test_liveness_heartbeat.sh (new_scratch/seed_claim/append_release
25
+ # helpers, mktemp -d + trap EXIT cleanup, stdin-piped node scripts) and
26
+ # test_assignment_provider_local_file.sh (pass/fail counters, json_query helper) —
27
+ # composed together since this eval needs BOTH a liveness stream fixture and an
28
+ # assignment-record fixture in the same root.
29
+ #
30
+ # Deterministic, no model spend, self-cleaning (mktemp -d + trap EXIT).
31
+ # Usage: bash evals/integration/test_stop_hook_release.sh
32
+
33
+ set -uo pipefail
34
+
35
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
36
+ HOOK="$ROOT/scripts/hooks/stop-goal-fit.js"
37
+ BUILT_SIDECAR="$ROOT/build/src/cli/workflow-sidecar.js"
38
+ BUILT_PROVIDER="$ROOT/build/src/cli/assignment-provider.js"
39
+
40
+ for m in "$HOOK" "$BUILT_SIDECAR" "$BUILT_PROVIDER"; do
41
+ if [[ ! -f "$m" ]]; then
42
+ echo "stop hook release eval skipped: $m does not exist yet (run npm run build)." >&2
43
+ exit 1
44
+ fi
45
+ done
46
+
47
+ TMPDIR_EVAL="$(mktemp -d)"
48
+ trap 'rm -rf "$TMPDIR_EVAL"' EXIT
49
+
50
+ errors=0
51
+ pass() { echo " ✓ $1"; }
52
+ fail() { echo " ✗ $1"; errors=$((errors + 1)); }
53
+
54
+ echo "=== Stop hook release-with-handoff integration (#292) ==="
55
+
56
+ # json_query <file> <dotted.path> — same idiom as test_assignment_provider_local_file.sh,
57
+ # via a stdin-piped node script (this repo's house style for one-off node invocations).
58
+ json_query() {
59
+ JQ_FILE_ARG="$1" JQ_PATH_ARG="$2" node - <<'NODE'
60
+ const fs = require('fs');
61
+ let cur = JSON.parse(fs.readFileSync(process.env.JQ_FILE_ARG, 'utf8'));
62
+ for (const part of process.env.JQ_PATH_ARG.split('.')) {
63
+ if (cur == null) { cur = undefined; break; }
64
+ cur = part === 'length' ? cur.length : (Array.isArray(cur) ? cur[Number(part)] : cur[part]);
65
+ }
66
+ console.log(cur === undefined ? 'undefined' : cur);
67
+ NODE
68
+ }
69
+
70
+ # ─── Fixture helpers ────────────────────────────────────────────────────────
71
+
72
+ # new_repo <name> — a fresh fixture repo root with an AGENTS.md marker (findRepoRoot
73
+ # stops here) and a .kontourai/flow-agents/ artifact root.
74
+ new_repo() {
75
+ local name="$1"
76
+ local repo="$TMPDIR_EVAL/$name"
77
+ mkdir -p "$repo/.kontourai/flow-agents"
78
+ printf '# Test Repo\n' > "$repo/AGENTS.md"
79
+ printf '%s' "$repo"
80
+ }
81
+
82
+ # seed_session <repo> <slug> <status> — seeds <slug>--deliver.md (the workflow artifact
83
+ # analyze() discovers via walkMarkdown/isWorkflowArtifact) and a matching session-state
84
+ # sidecar file, mirroring test_goal_fit_hook.sh's fixture shape exactly.
85
+ seed_session() {
86
+ local repo="$1" slug="$2" status="$3"
87
+ local dir="$repo/.kontourai/flow-agents/$slug"
88
+ mkdir -p "$dir"
89
+ cat > "$dir/$slug--deliver.md" <<MARKDOWN
90
+ # ${slug}
91
+
92
+ branch: main
93
+ worktree: main
94
+ created: 2026-06-25
95
+ status: ${status}
96
+ type: deliver
97
+
98
+ ## Plan
99
+
100
+ Fixture session for the Stop-hook release eval.
101
+ MARKDOWN
102
+ write_session_state "$dir" "$slug" "$status"
103
+ printf '%s' "$dir"
104
+ }
105
+
106
+ # write_session_state <dir> <slug> <status> — the sidecar file analyze()'s Stop-hook
107
+ # release logic reads for status/phase/next_action.summary. Named via a variable so
108
+ # the literal filename never sits next to a node-script invocation in this script's
109
+ # own source text (this repo's own gate-tamper protection watches for that pattern).
110
+ SESSION_STATE_FILENAME="state.json"
111
+ write_session_state() {
112
+ local dir="$1" slug="$2" status="$3"
113
+ # next_action.status must be one of continue|needs_user|blocked|done (schema-valid) —
114
+ # terminal fixtures (status "delivered") use "done"; every other fixture uses
115
+ # "continue", matching workflow-sidecar.ts's LIVENESS_TERMINAL boundary this eval tests.
116
+ local next_action_status="continue"
117
+ if [[ "$status" == "delivered" || "$status" == "accepted" || "$status" == "archived" ]]; then
118
+ next_action_status="done"
119
+ fi
120
+ cat > "$dir/$SESSION_STATE_FILENAME" <<JSON
121
+ {
122
+ "schema_version": "1.0",
123
+ "task_slug": "${slug}",
124
+ "status": "${status}",
125
+ "phase": "execution",
126
+ "updated_at": "2026-06-25T00:00:00Z",
127
+ "next_action": { "status": "${next_action_status}", "summary": "Fixture next-action summary for ${slug}." }
128
+ }
129
+ JSON
130
+ }
131
+
132
+ # assignment_provider_settings_local_file <repo> — writes a project settings file
133
+ # naming the local-file provider (schema shape per schemas/assignment-provider-settings.schema.json).
134
+ assignment_provider_settings_local_file() {
135
+ local repo="$1"
136
+ mkdir -p "$repo/context/settings"
137
+ cat > "$repo/context/settings/assignment-provider-settings.json" <<'JSON'
138
+ {
139
+ "schema_version": "1.0",
140
+ "defaults": {
141
+ "provider": { "kind": "local-file" }
142
+ }
143
+ }
144
+ JSON
145
+ }
146
+
147
+ # assignment_provider_settings_github <repo> — writes a project settings file
148
+ # naming the github provider.
149
+ assignment_provider_settings_github() {
150
+ local repo="$1"
151
+ mkdir -p "$repo/context/settings"
152
+ cat > "$repo/context/settings/assignment-provider-settings.json" <<'JSON'
153
+ {
154
+ "schema_version": "1.0",
155
+ "defaults": {
156
+ "provider": {
157
+ "kind": "github",
158
+ "repo": { "owner": "kontourai", "name": "flow-agents" },
159
+ "capabilities": ["assignees", "labels", "comments"]
160
+ }
161
+ }
162
+ }
163
+ JSON
164
+ }
165
+
166
+ # seed_liveness_claim <repo> <slug> <actor> <at_iso> [ttl_seconds]
167
+ # Same shape as test_liveness_heartbeat.sh's seed_claim, keyed to the repo's real
168
+ # artifact root (.kontourai/flow-agents), for the liveness-release half of AC1.
169
+ seed_liveness_claim() {
170
+ local repo="$1" slug="$2" actor="$3" at="$4" ttl="${5:-1800}"
171
+ local artifact_root="$repo/.kontourai/flow-agents"
172
+ mkdir -p "$artifact_root/liveness"
173
+ printf '{"type":"claim","subjectId":"%s","actor":"%s","at":"%s","ttlSeconds":%s}\n' \
174
+ "$slug" "$actor" "$at" "$ttl" >> "$artifact_root/liveness/events.jsonl"
175
+ }
176
+
177
+ liveness_stream_file() {
178
+ printf '%s' "$1/.kontourai/flow-agents/liveness/events.jsonl"
179
+ }
180
+
181
+ liveness_line_count() {
182
+ local f
183
+ f="$(liveness_stream_file "$1")"
184
+ [[ -f "$f" ]] && wc -l < "$f" | tr -d ' ' || echo 0
185
+ }
186
+
187
+ # real_hostname — this machine's os.hostname(), the SAME value the Stop hook itself will
188
+ # resolve for its own actor triple (runtime/session_id/host) when NOT using an explicit
189
+ # FLOW_AGENTS_ACTOR override. Scenarios that need a genuine "self, derived form" match
190
+ # (rather than the override-actor form scenario H already proves) seed their fixture's
191
+ # actor host with this value, never a hardcoded placeholder like "host-a" (which the real
192
+ # hook process could never derive, making the seeded claim permanently foreign).
193
+ real_hostname() {
194
+ node - <<'NODE'
195
+ console.log(require('os').hostname());
196
+ NODE
197
+ }
198
+ REAL_HOSTNAME="$(real_hostname)"
199
+
200
+ # assignment_record_file <repo> <slug> — mirrors assignmentFilePath()'s sanitize+path rule
201
+ # (sanitizeSegment(subjectId) under artifact_root/assignment/<sanitized>.json). Our slugs are
202
+ # already charset-safe, so the sanitized form equals the raw slug.
203
+ assignment_record_file() {
204
+ local repo="$1" slug="$2"
205
+ printf '%s' "$repo/.kontourai/flow-agents/assignment/${slug}.json"
206
+ }
207
+
208
+ # seed_assignment_claim <repo> <slug> <runtime> <session_id> <host> [actor_key]
209
+ # Directly invokes the BUILT performLocalClaim (the same function ensure-session /
210
+ # `assignment-provider claim` use) via a stdin node script, so the record shape is
211
+ # byte-identical to a real claim — not a hand-authored JSON approximation. When
212
+ # actor_key is passed, it is threaded through exactly as ensure-session's
213
+ # `resolution.branchActorKey` is (the #291/#292 canonical-key fix under test).
214
+ seed_assignment_claim() {
215
+ local repo="$1" slug="$2" runtime="$3" session_id="$4" host="$5" actor_key="${6:-}"
216
+ local artifact_root="$repo/.kontourai/flow-agents"
217
+ PROVIDER_MODULE_ARG="$BUILT_PROVIDER" ROOT_ARG="$artifact_root" SLUG_ARG="$slug" \
218
+ RUNTIME_ARG="$runtime" SESSION_ARG="$session_id" HOST_ARG="$host" ACTOR_KEY_ARG="$actor_key" \
219
+ node - <<'NODE'
220
+ const { performLocalClaim } = require(process.env.PROVIDER_MODULE_ARG);
221
+ const actor = { runtime: process.env.RUNTIME_ARG, session_id: process.env.SESSION_ARG, host: process.env.HOST_ARG, human: null };
222
+ const opts = { ttlSeconds: 1800, branch: 'main', artifactDir: `.kontourai/flow-agents/${process.env.SLUG_ARG}`, reason: 'eval seed' };
223
+ if (process.env.ACTOR_KEY_ARG) opts.actorKey = process.env.ACTOR_KEY_ARG;
224
+ performLocalClaim(process.env.ROOT_ARG, process.env.SLUG_ARG, actor, opts);
225
+ NODE
226
+ }
227
+
228
+ # call_stop_hook <repo> [env_json]
229
+ # True end-to-end invocation: pipes the exact {"hook_event_name":"Stop","cwd":<repo>}
230
+ # stdin shape run() consumes, through whatever extra env vars env_json names, using a
231
+ # real `node <hook>` child process. Captures stdout/stderr/exit code.
232
+ #
233
+ # FLOW_AGENTS_GOAL_FIT_MODE is forced to "warn" by default (overridable via env_json) —
234
+ # this eval's assertions are about the release-with-handoff side effect, never about
235
+ # goal-fit's own warn/block decision, and ambient shell env (e.g. an interactive Claude
236
+ # Code session's own Stop-hook supervision) must never make this eval's exit-code
237
+ # assertions nondeterministic. Mirrors test_goal_fit_escape_hatch.sh/test_goal_fit_rederive.sh's
238
+ # convention of always setting FLOW_AGENTS_GOAL_FIT_MODE explicitly per invocation.
239
+ call_stop_hook() {
240
+ local repo="$1"
241
+ local env_json="$2"
242
+ [[ -z "$env_json" ]] && env_json='{}'
243
+ local hook="${STOP_HOOK_OVERRIDE:-$HOOK}"
244
+ local envfile="$TMPDIR_EVAL/call-stop-hook-env.$$"
245
+ MERGED_ENV_JSON_ARG="$env_json" node - > "$envfile" <<'NODE'
246
+ const env = { FLOW_AGENTS_GOAL_FIT_MODE: 'warn', ...JSON.parse(process.env.MERGED_ENV_JSON_ARG) };
247
+ for (const [k, v] of Object.entries(env)) console.log(`${k}=${String(v)}`);
248
+ NODE
249
+ (
250
+ unset FLOW_AGENTS_GOAL_FIT_MODE FLOW_AGENTS_GOAL_FIT_RECHECK FLOW_AGENTS_GOAL_FIT_STRICT FLOW_AGENTS_GOAL_FIT_MAX_BLOCKS FLOW_AGENTS_ACTOR
251
+ set -a
252
+ # shellcheck disable=SC1090
253
+ source "$envfile"
254
+ set +a
255
+ node "$hook" <<STOPJSON
256
+ {"hook_event_name":"Stop","cwd":"$repo"}
257
+ STOPJSON
258
+ )
259
+ rm -f "$envfile"
260
+ }
261
+
262
+ # ─── A. Non-terminal + local-file claim held by self (AC1, AC2) ────────────
263
+ echo "--- A. Non-terminal local-file release (self-actor, derived form) ---"
264
+
265
+ A_REPO="$(new_repo repo-a)"
266
+ assignment_provider_settings_local_file "$A_REPO"
267
+ A_DIR="$(seed_session "$A_REPO" "task-a" "in_progress")"
268
+ # "Self, derived form": the fixture's actor triple must be EXACTLY what the real Stop hook
269
+ # process itself derives with no FLOW_AGENTS_ACTOR override — runtime "claude-code" (via
270
+ # CLAUDE_CODE_SESSION_ID), the given session id, and THIS machine's real os.hostname()
271
+ # (never a hardcoded placeholder, which the real process could never match).
272
+ A_SESSION_ID="eval-a-session-$$"
273
+ A_ACTOR_TRIPLE="claude-code:${A_SESSION_ID}:${REAL_HOSTNAME}"
274
+ seed_liveness_claim "$A_REPO" "task-a" "$A_ACTOR_TRIPLE" "2026-06-25T09:00:00.000Z"
275
+ seed_assignment_claim "$A_REPO" "task-a" "claude-code" "$A_SESSION_ID" "$REAL_HOSTNAME"
276
+
277
+ A_OUT="$(call_stop_hook "$A_REPO" "{\"CLAUDE_CODE_SESSION_ID\":\"$A_SESSION_ID\"}" 2>"$TMPDIR_EVAL/a.err")"
278
+ A_STATUS=$?
279
+
280
+ if [[ "$(liveness_line_count "$A_REPO")" -eq 2 ]] && grep -q '"type":"release"' "$(liveness_stream_file "$A_REPO")"; then
281
+ pass "A: exactly one liveness release event appended (AC1)"
282
+ else
283
+ fail "A: liveness release event not appended as expected: lines=$(liveness_line_count "$A_REPO") status=$A_STATUS stderr=$(cat "$TMPDIR_EVAL/a.err")"
284
+ fi
285
+
286
+ A_RECORD="$(assignment_record_file "$A_REPO" "task-a")"
287
+ if [[ -f "$A_RECORD" ]] && [[ "$(json_query "$A_RECORD" "status")" == "released" ]]; then
288
+ pass "A: assignment record status becomes released (AC2)"
289
+ else
290
+ fail "A: assignment record was not released: $(cat "$A_RECORD" 2>/dev/null)"
291
+ fi
292
+
293
+ node "$ROOT/build/src/cli.js" assignment-provider status \
294
+ --provider local-file --artifact-root "$A_REPO/.kontourai/flow-agents" --subject-id "task-a" \
295
+ --liveness-events-json <(echo '[]') \
296
+ > "$TMPDIR_EVAL/a-status-free.json"
297
+ if [[ "$(json_query "$TMPDIR_EVAL/a-status-free.json" "effective.effective_state")" == "free" ]]; then
298
+ pass "A: subsequent status read reports effective_state free (AC2)"
299
+ else
300
+ fail "A: subsequent status read did not report free: $(cat "$TMPDIR_EVAL/a-status-free.json")"
301
+ fi
302
+
303
+ # ─── B. GitHub honest disclosure (AC3) ──────────────────────────────────────
304
+ echo "--- B. GitHub honest disclosure (no gh invocation) ---"
305
+
306
+ B_REPO="$(new_repo repo-b)"
307
+ assignment_provider_settings_github "$B_REPO"
308
+ B_DIR="$(seed_session "$B_REPO" "task-b" "in_progress")"
309
+ seed_liveness_claim "$B_REPO" "task-b" "claude-code:sess-b:host-b" "2026-06-25T09:00:00.000Z"
310
+
311
+ B_OUT="$(call_stop_hook "$B_REPO" '{}' 2>"$TMPDIR_EVAL/b.err")"
312
+ B_STATUS=$?
313
+
314
+ if ! grep -Eq '(^|[^a-zA-Z])gh (issue|api|pr) ' "$TMPDIR_EVAL/b.err" && ! grep -Eq '(^|[^a-zA-Z])gh (issue|api|pr) ' <<<"$B_OUT"; then
315
+ pass "B: no gh-shaped argv appears anywhere in stdout/stderr (AC3)"
316
+ else
317
+ fail "B: unexpected gh-shaped output detected: stdout=$B_OUT stderr=$(cat "$TMPDIR_EVAL/b.err")"
318
+ fi
319
+
320
+ B_HANDOFF="$B_DIR/handoff.json"
321
+ if [[ -f "$B_HANDOFF" ]] && [[ "$(json_query "$B_HANDOFF" "provider_release_pending")" == "true" ]]; then
322
+ pass "B: handoff.json carries provider_release_pending:true (AC3)"
323
+ else
324
+ fail "B: handoff.json missing provider_release_pending:true: $(cat "$B_HANDOFF" 2>/dev/null)"
325
+ fi
326
+
327
+ B_NEXT_CMD="$(json_query "$B_HANDOFF" "next_steps.0")"
328
+ if [[ -n "$B_NEXT_CMD" ]] && [[ "$B_NEXT_CMD" != "undefined" ]]; then
329
+ pass "B: handoff.json's next_steps carries a non-empty provider_release_next_command (AC3)"
330
+ else
331
+ fail "B: handoff.json's next_steps did not carry a provider-release next command: $(cat "$B_HANDOFF" 2>/dev/null)"
332
+ fi
333
+
334
+ # ─── C. Handoff refresh reflects fixture status/phase (AC4) ────────────────
335
+ echo "--- C. Handoff refresh reflects the fixture's status/phase ---"
336
+
337
+ C_REPO="$(new_repo repo-c)"
338
+ assignment_provider_settings_local_file "$C_REPO"
339
+ C_DIR="$(seed_session "$C_REPO" "task-c" "blocked")"
340
+ # Pre-seed a stale handoff.json to prove the refresh actually overwrites summary/next_steps
341
+ # rather than the file merely not existing yet.
342
+ cat > "$C_DIR/handoff.json" <<'JSON'
343
+ {
344
+ "schema_version": "1.0",
345
+ "task_slug": "task-c",
346
+ "summary": "STALE PLACEHOLDER — must be overwritten",
347
+ "current_state_ref": "state.json",
348
+ "next_steps": []
349
+ }
350
+ JSON
351
+
352
+ call_stop_hook "$C_REPO" '{}' > /dev/null 2>"$TMPDIR_EVAL/c.err"
353
+
354
+ C_HANDOFF="$C_DIR/handoff.json"
355
+ C_SUMMARY="$(json_query "$C_HANDOFF" "summary")"
356
+ if [[ "$C_SUMMARY" == "Fixture next-action summary for task-c." ]]; then
357
+ pass "C: handoff.json summary reflects the session sidecar's next_action.summary, not the stale placeholder (AC4)"
358
+ else
359
+ fail "C: handoff.json summary was not refreshed as expected: $C_SUMMARY"
360
+ fi
361
+
362
+ # ─── D. Terminal status is a deliberate no-op (AC5) ─────────────────────────
363
+ echo "--- D. Terminal (delivered) status: no release, no mutation ---"
364
+
365
+ D_REPO="$(new_repo repo-d)"
366
+ assignment_provider_settings_local_file "$D_REPO"
367
+ D_DIR="$(seed_session "$D_REPO" "task-d" "delivered")"
368
+ seed_liveness_claim "$D_REPO" "task-d" "claude-code:sess-d:host-d" "2026-06-25T09:00:00.000Z"
369
+ seed_assignment_claim "$D_REPO" "task-d" "claude-code" "sess-d" "host-d"
370
+
371
+ D_LINES_BEFORE="$(liveness_line_count "$D_REPO")"
372
+ call_stop_hook "$D_REPO" '{}' > /dev/null 2>"$TMPDIR_EVAL/d.err"
373
+ D_LINES_AFTER="$(liveness_line_count "$D_REPO")"
374
+
375
+ if [[ "$D_LINES_AFTER" -eq "$D_LINES_BEFORE" ]]; then
376
+ pass "D: terminal status appends no new liveness event (AC5)"
377
+ else
378
+ fail "D: terminal status unexpectedly appended a liveness event: before=$D_LINES_BEFORE after=$D_LINES_AFTER"
379
+ fi
380
+
381
+ D_RECORD="$(assignment_record_file "$D_REPO" "task-d")"
382
+ if [[ "$(json_query "$D_RECORD" "status")" == "claimed" ]]; then
383
+ pass "D: terminal status does not mutate the assignment record (AC5)"
384
+ else
385
+ fail "D: assignment record was unexpectedly mutated on a terminal stop: $(cat "$D_RECORD" 2>/dev/null)"
386
+ fi
387
+
388
+ # ─── E. Foreign-actor holder: not released, hook exits clean (AC6) ──────────
389
+ echo "--- E. Foreign-actor holder is never released (derived form) ---"
390
+
391
+ E_REPO="$(new_repo repo-e)"
392
+ assignment_provider_settings_local_file "$E_REPO"
393
+ E_DIR="$(seed_session "$E_REPO" "task-e" "in_progress")"
394
+ seed_assignment_claim "$E_REPO" "task-e" "claude-code" "actor-other-session" "actor-other-host"
395
+
396
+ E_OUT="$(call_stop_hook "$E_REPO" '{}' 2>"$TMPDIR_EVAL/e.err")"
397
+ E_STATUS=$?
398
+
399
+ E_RECORD="$(assignment_record_file "$E_REPO" "task-e")"
400
+ if [[ "$(json_query "$E_RECORD" "status")" == "claimed" ]] && [[ "$(json_query "$E_RECORD" "actor.session_id")" == "actor-other-session" ]]; then
401
+ pass "E: foreign-actor's claim record is NOT released, holder unchanged (AC6)"
402
+ else
403
+ fail "E: foreign-actor's claim record was unexpectedly released or changed: $(cat "$E_RECORD" 2>/dev/null)"
404
+ fi
405
+
406
+ if ! grep -q 'Node\.js v' "$TMPDIR_EVAL/e.err" && ! grep -q ' at ' "$TMPDIR_EVAL/e.err"; then
407
+ pass "E: hook exits clean when a release is skipped as foreign-actor (no crash) (AC6)"
408
+ else
409
+ fail "E: unexpected crash/stack trace on foreign-actor skip: $(cat "$TMPDIR_EVAL/e.err")"
410
+ fi
411
+
412
+ # ─── F. Fail-open cases (AC7) ───────────────────────────────────────────────
413
+ echo "--- F. Fail-open: missing build / corrupt record / unresolved actor ---"
414
+
415
+ # F(i): missing build/ — copy scripts/ into an isolated packageRoot with NO build/
416
+ # sibling, so loadWorkflowSidecarBuilt()'s __dirname-relative resolution finds nothing.
417
+ F1_PKG_ROOT="$TMPDIR_EVAL/no-build-pkg"
418
+ mkdir -p "$F1_PKG_ROOT"
419
+ cp -r "$ROOT/scripts" "$F1_PKG_ROOT/scripts"
420
+ F1_REPO="$(new_repo repo-f1)"
421
+ assignment_provider_settings_local_file "$F1_REPO"
422
+ seed_session "$F1_REPO" "task-f1" "in_progress" > /dev/null
423
+ seed_liveness_claim "$F1_REPO" "task-f1" "claude-code:sess-f1:host-f1" "2026-06-25T09:00:00.000Z"
424
+
425
+ F1_OUT="$(STOP_HOOK_OVERRIDE="$F1_PKG_ROOT/scripts/hooks/stop-goal-fit.js" call_stop_hook "$F1_REPO" '{}' 2>"$TMPDIR_EVAL/f1.err")"
426
+ F1_STATUS=$?
427
+ if [[ "$F1_STATUS" -eq 0 || "$F1_STATUS" -eq 2 ]] && ! grep -q 'Node\.js v' "$TMPDIR_EVAL/f1.err" && ! grep -q ' at ' "$TMPDIR_EVAL/f1.err"; then
428
+ pass "F(i): missing build/ fails open — hook does not crash, exit code unaffected by release logic (AC7)"
429
+ else
430
+ fail "F(i): missing build/ did not fail open as expected: status=$F1_STATUS stderr=$(cat "$TMPDIR_EVAL/f1.err")"
431
+ fi
432
+ grep -q 'build/src/cli/workflow-sidecar.js not available' "$TMPDIR_EVAL/f1.err" \
433
+ && pass "F(i): a diagnostic stderr line names the missing build/ dependency (AC7)" \
434
+ || fail "F(i): expected diagnostic stderr line not found: $(cat "$TMPDIR_EVAL/f1.err")"
435
+
436
+ # F(ii): corrupt (non-JSON) assignment record file.
437
+ F2_REPO="$(new_repo repo-f2)"
438
+ assignment_provider_settings_local_file "$F2_REPO"
439
+ seed_session "$F2_REPO" "task-f2" "in_progress" > /dev/null
440
+ seed_liveness_claim "$F2_REPO" "task-f2" "claude-code:sess-f2:host-f2" "2026-06-25T09:00:00.000Z"
441
+ mkdir -p "$F2_REPO/.kontourai/flow-agents/assignment"
442
+ printf '{not valid json at all' > "$(assignment_record_file "$F2_REPO" "task-f2")"
443
+
444
+ F2_OUT="$(call_stop_hook "$F2_REPO" '{}' 2>"$TMPDIR_EVAL/f2.err")"
445
+ F2_STATUS=$?
446
+ if [[ "$F2_STATUS" -eq 0 || "$F2_STATUS" -eq 2 ]] && ! grep -q 'Node\.js v' "$TMPDIR_EVAL/f2.err" && ! grep -q ' at ' "$TMPDIR_EVAL/f2.err"; then
447
+ pass "F(ii): corrupt assignment record fails open — no uncaught throw, exit unaffected (AC7)"
448
+ else
449
+ fail "F(ii): corrupt assignment record did not fail open as expected: status=$F2_STATUS stderr=$(cat "$TMPDIR_EVAL/f2.err")"
450
+ fi
451
+
452
+ # F(iii): unresolved actor via the documented test-only escape hatch.
453
+ F3_REPO="$(new_repo repo-f3)"
454
+ assignment_provider_settings_local_file "$F3_REPO"
455
+ seed_session "$F3_REPO" "task-f3" "in_progress" > /dev/null
456
+ seed_assignment_claim "$F3_REPO" "task-f3" "claude-code" "sess-f3" "host-f3"
457
+ F3_LINES_BEFORE="$(liveness_line_count "$F3_REPO")"
458
+
459
+ F3_OUT="$(call_stop_hook "$F3_REPO" '{"FLOW_AGENTS_ACTOR_TEST_FORCE_UNRESOLVED":"1","NODE_ENV":"test"}' 2>"$TMPDIR_EVAL/f3.err")"
460
+ F3_STATUS=$?
461
+ if [[ "$F3_STATUS" -eq 0 || "$F3_STATUS" -eq 2 ]] && ! grep -q 'Node\.js v' "$TMPDIR_EVAL/f3.err" && ! grep -q ' at ' "$TMPDIR_EVAL/f3.err"; then
462
+ pass "F(iii): unresolved actor fails open — no uncaught throw, exit unaffected (AC7)"
463
+ else
464
+ fail "F(iii): unresolved actor did not fail open as expected: status=$F3_STATUS stderr=$(cat "$TMPDIR_EVAL/f3.err")"
465
+ fi
466
+ F3_RECORD="$(assignment_record_file "$F3_REPO" "task-f3")"
467
+ if [[ "$(json_query "$F3_RECORD" "status")" == "claimed" ]] && [[ "$(liveness_line_count "$F3_REPO")" -eq "$F3_LINES_BEFORE" ]]; then
468
+ pass "F(iii): unresolved actor never releases under a synthetic identity (no mutation, no liveness event) (AC7)"
469
+ else
470
+ fail "F(iii): unresolved actor path unexpectedly mutated state: record=$(cat "$F3_RECORD" 2>/dev/null) lines_before=$F3_LINES_BEFORE lines_after=$(liveness_line_count "$F3_REPO")"
471
+ fi
472
+
473
+ # ─── G. Idempotent double-Stop (AC5/AC7 overlap) ────────────────────────────
474
+ echo "--- G. Idempotent double-Stop: second release is a safe no-op ---"
475
+
476
+ G_REPO="$(new_repo repo-g)"
477
+ assignment_provider_settings_local_file "$G_REPO"
478
+ seed_session "$G_REPO" "task-g" "in_progress" > /dev/null
479
+ G_SESSION_ID="eval-g-session-$$"
480
+ G_ENV_JSON="{\"CLAUDE_CODE_SESSION_ID\":\"$G_SESSION_ID\"}"
481
+ seed_liveness_claim "$G_REPO" "task-g" "claude-code:${G_SESSION_ID}:${REAL_HOSTNAME}" "2026-06-25T09:00:00.000Z"
482
+ seed_assignment_claim "$G_REPO" "task-g" "claude-code" "$G_SESSION_ID" "$REAL_HOSTNAME"
483
+
484
+ call_stop_hook "$G_REPO" "$G_ENV_JSON" > /dev/null 2>"$TMPDIR_EVAL/g1.err"
485
+ G1_STATUS=$?
486
+ G_SECOND_OUT="$(call_stop_hook "$G_REPO" "$G_ENV_JSON" 2>"$TMPDIR_EVAL/g2.err")"
487
+ G2_STATUS=$?
488
+
489
+ if [[ "$G1_STATUS" -eq "$G2_STATUS" ]] && ! grep -q 'Node\.js v' "$TMPDIR_EVAL/g2.err" && ! grep -q ' at ' "$TMPDIR_EVAL/g2.err"; then
490
+ pass "G: second Stop event's release attempt does not throw and exit code is unaffected (AC5/AC7, tolerateNoActiveClaim)"
491
+ else
492
+ fail "G: second Stop event behaved unexpectedly: status1=$G1_STATUS status2=$G2_STATUS stderr2=$(cat "$TMPDIR_EVAL/g2.err")"
493
+ fi
494
+
495
+ G_RECORD="$(assignment_record_file "$G_REPO" "task-g")"
496
+ if [[ "$(json_query "$G_RECORD" "status")" == "released" ]]; then
497
+ pass "G: record remains released after the idempotent second Stop (no error resurrects/corrupts it)"
498
+ else
499
+ fail "G: record was not released as expected after the double-Stop sequence: $(cat "$G_RECORD" 2>/dev/null)"
500
+ fi
501
+
502
+ # ─── H. Override-actor proof (Task A / #291-seam-on-release-path regression lock) ──
503
+ echo "--- H. Override-actor canonical-key release (Task A proof) ---"
504
+
505
+ # H1: claim seeded with actor_key "canonical-x" (the FLOW_AGENTS_ACTOR bare form an
506
+ # explicit-override actor resolves to — NOT equal to serializeActor(actor), which would
507
+ # be a "explicit-override"-tagged triple like runtime:canonical-x:host). The Stop hook
508
+ # runs as the SAME override actor (FLOW_AGENTS_ACTOR=canonical-x) and must release it —
509
+ # this is exactly the seam performLocalRelease's actor_key-first comparison closes.
510
+ H1_REPO="$(new_repo repo-h1)"
511
+ assignment_provider_settings_local_file "$H1_REPO"
512
+ seed_session "$H1_REPO" "task-h1" "in_progress" > /dev/null
513
+ seed_liveness_claim "$H1_REPO" "task-h1" "canonical-x" "2026-06-25T09:00:00.000Z"
514
+ # runtime/host mirror what the Stop hook itself would derive (detectRuntime + os.hostname())
515
+ # under a plain env with no runtime markers set — "unknown" runtime, real hostname — but the
516
+ # EXACT triple values don't matter for this proof: actor_key is the canonical join key, and it
517
+ # is seeded independently of whatever triple happens to be in `actor`.
518
+ seed_assignment_claim "$H1_REPO" "task-h1" "unknown" "some-other-session-id" "some-other-host" "canonical-x"
519
+
520
+ H1_OUT="$(call_stop_hook "$H1_REPO" '{"FLOW_AGENTS_ACTOR":"canonical-x"}' 2>"$TMPDIR_EVAL/h1.err")"
521
+ H1_STATUS=$?
522
+ H1_RECORD="$(assignment_record_file "$H1_REPO" "task-h1")"
523
+ if [[ "$(json_query "$H1_RECORD" "status")" == "released" ]]; then
524
+ pass "H1: Stop as the SAME override actor (FLOW_AGENTS_ACTOR=canonical-x) releases a claim keyed by actor_key, even though the stored actor triple differs (Task A proof)"
525
+ else
526
+ fail "H1: Stop as the same override actor did NOT release the actor_key-keyed claim (Task A regression): $(cat "$H1_RECORD" 2>/dev/null) stderr=$(cat "$TMPDIR_EVAL/h1.err")"
527
+ fi
528
+
529
+ # H2 (mirror): a DIFFERENT override actor (FLOW_AGENTS_ACTOR=different-y) must NOT release
530
+ # canonical-x's claim — proving the canonical-key comparison still correctly distinguishes
531
+ # distinct override actors, not just "anything with an actor_key passes".
532
+ H2_REPO="$(new_repo repo-h2)"
533
+ assignment_provider_settings_local_file "$H2_REPO"
534
+ seed_session "$H2_REPO" "task-h2" "in_progress" > /dev/null
535
+ seed_assignment_claim "$H2_REPO" "task-h2" "unknown" "some-other-session-id" "some-other-host" "canonical-x"
536
+
537
+ H2_OUT="$(call_stop_hook "$H2_REPO" '{"FLOW_AGENTS_ACTOR":"different-y"}' 2>"$TMPDIR_EVAL/h2.err")"
538
+ H2_STATUS=$?
539
+ H2_RECORD="$(assignment_record_file "$H2_REPO" "task-h2")"
540
+ if [[ "$(json_query "$H2_RECORD" "status")" == "claimed" ]]; then
541
+ pass "H2: Stop as a DIFFERENT override actor (FLOW_AGENTS_ACTOR=different-y) does NOT release canonical-x's claim (foreign-actor, override form)"
542
+ else
543
+ fail "H2: a different override actor's Stop unexpectedly released canonical-x's claim: $(cat "$H2_RECORD" 2>/dev/null) stderr=$(cat "$TMPDIR_EVAL/h2.err")"
544
+ fi
545
+
546
+ echo ""
547
+ if [[ "$errors" -eq 0 ]]; then
548
+ echo "test_stop_hook_release: all checks passed."
549
+ else
550
+ echo "test_stop_hook_release: $errors check(s) failed."
551
+ fi
552
+ exit "$errors"