@kontourai/flow-agents 3.0.0 → 3.1.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 (131) hide show
  1. package/.github/workflows/ci.yml +8 -0
  2. package/CHANGELOG.md +15 -0
  3. package/CONTEXT.md +67 -1
  4. package/README.md +4 -0
  5. package/build/src/cli/assignment-provider.d.ts +143 -0
  6. package/build/src/cli/assignment-provider.js +106 -49
  7. package/build/src/cli/workflow-sidecar.d.ts +2 -2
  8. package/build/src/cli/workflow-sidecar.js +392 -35
  9. package/build/src/lib/flow-resolver.d.ts +12 -6
  10. package/build/src/lib/flow-resolver.js +30 -14
  11. package/build/src/tools/validate-source-tree.js +2 -1
  12. package/context/contracts/assignment-provider-contract.md +9 -0
  13. package/context/contracts/probe-docs-write-contract.md +187 -0
  14. package/context/scripts/hooks/config-protection.js +14 -1
  15. package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
  16. package/context/scripts/hooks/stop-goal-fit.js +4 -2
  17. package/context/scripts/hooks/workflow-steering.js +42 -0
  18. package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
  19. package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
  20. package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
  21. package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
  22. package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
  23. package/docs/adr/0006-typescript-first-source-policy.md +2 -0
  24. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  25. package/docs/adr/0007-skill-audit.md +2 -0
  26. package/docs/adr/0008-kit-operation-boundary.md +2 -0
  27. package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
  28. package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
  29. package/docs/adr/0011-mcp-posture.md +2 -0
  30. package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
  31. package/docs/adr/0013-context-lifecycle.md +2 -0
  32. package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
  33. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
  34. package/docs/adr/0016-three-hard-boundary-model.md +2 -0
  35. package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
  36. package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
  37. package/docs/adr/0019-kit-dependency-ownership.md +2 -0
  38. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
  39. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
  40. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +2 -0
  41. package/docs/adr/README.md +49 -0
  42. package/docs/adr/index.md +34 -0
  43. package/docs/decisions/agent-coordination.md +20 -0
  44. package/docs/decisions/anti-gaming-trust-security.md +20 -0
  45. package/docs/decisions/context-lifecycle.md +18 -0
  46. package/docs/decisions/core-domain-kit-boundary.md +18 -0
  47. package/docs/decisions/flow-flow-agents-boundary.md +18 -0
  48. package/docs/decisions/flow-kit.md +20 -0
  49. package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
  50. package/docs/decisions/graph-knowledge-provider.md +63 -0
  51. package/docs/decisions/hook-core-kit-boundary.md +18 -0
  52. package/docs/decisions/index.md +19 -0
  53. package/docs/decisions/kit-dependency-ownership.md +18 -0
  54. package/docs/decisions/kit-operation-boundary.md +18 -0
  55. package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
  56. package/docs/decisions/kontour-resource-contract.md +18 -0
  57. package/docs/decisions/mcp-posture.md +18 -0
  58. package/docs/decisions/three-hard-boundary-model.md +18 -0
  59. package/docs/decisions/trust-reconcile.md +20 -0
  60. package/docs/decisions/typescript-source-policy.md +48 -0
  61. package/docs/decisions/workflow-enforcement.md +18 -0
  62. package/docs/decisions/workflow-trust-state.md +20 -0
  63. package/docs/fixture-ownership.md +1 -1
  64. package/docs/workflow-usage-guide.md +1 -1
  65. package/evals/ci/run-baseline.sh +4 -0
  66. package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
  67. package/evals/integration/test_current_json_per_actor.sh +516 -0
  68. package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
  69. package/evals/integration/test_gate_lockdown.sh +10 -0
  70. package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
  71. package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
  72. package/evals/run.sh +4 -0
  73. package/evals/static/test_knowledge_providers.sh +13 -4
  74. package/evals/static/test_workflow_skills.sh +15 -2
  75. package/kits/builder/skills/deliver/SKILL.md +17 -0
  76. package/kits/builder/skills/design-probe/SKILL.md +37 -0
  77. package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
  78. package/kits/knowledge/adapters/default-store/index.js +92 -4
  79. package/kits/knowledge/adapters/obsidian-store/index.js +69 -4
  80. package/kits/knowledge/adapters/shared/codec.js +141 -0
  81. package/kits/knowledge/docs/README.md +121 -2
  82. package/kits/knowledge/docs/store-contract.md +112 -4
  83. package/kits/knowledge/evals/contract-suite/suite.test.js +176 -0
  84. package/kits/knowledge/flows/promote.flow.json +84 -0
  85. package/kits/knowledge/kit.json +15 -0
  86. package/kits/knowledge/promote/distill.js +96 -0
  87. package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
  88. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
  89. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
  90. package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
  91. package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
  92. package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
  93. package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
  94. package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
  95. package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
  96. package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
  97. package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
  98. package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
  99. package/kits/knowledge/promote/fixtures/session/state.json +19 -0
  100. package/kits/knowledge/promote/health.js +137 -0
  101. package/kits/knowledge/promote/index.js +176 -0
  102. package/kits/knowledge/promote/ingest.js +103 -0
  103. package/kits/knowledge/promote/lib.js +132 -0
  104. package/kits/knowledge/promote/link.js +84 -0
  105. package/kits/knowledge/promote/promote.test.js +174 -0
  106. package/kits/knowledge/providers/conformance/suite.test.js +18 -0
  107. package/kits/knowledge/providers/index.js +1 -0
  108. package/kits/knowledge/providers/neo4j/connection.js +121 -0
  109. package/kits/knowledge/providers/neo4j/cypher.js +190 -0
  110. package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
  111. package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
  112. package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
  113. package/kits/knowledge/providers/neo4j/index.js +280 -0
  114. package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
  115. package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
  116. package/kits/knowledge/providers/neo4j/sync.js +235 -0
  117. package/package.json +4 -2
  118. package/scripts/README.md +1 -0
  119. package/scripts/freeze-adrs.mjs +364 -0
  120. package/scripts/hooks/config-protection.js +14 -1
  121. package/scripts/hooks/evidence-capture.js +4 -1
  122. package/scripts/hooks/lib/config-protection-remedies.js +9 -0
  123. package/scripts/hooks/lib/current-pointer.js +123 -0
  124. package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
  125. package/scripts/hooks/stop-goal-fit.js +4 -2
  126. package/scripts/hooks/workflow-steering.js +42 -0
  127. package/scripts/statusline/flow-agents-statusline.js +3 -1
  128. package/src/cli/assignment-provider.ts +137 -55
  129. package/src/cli/workflow-sidecar.ts +430 -33
  130. package/src/lib/flow-resolver.ts +35 -14
  131. package/src/tools/validate-source-tree.ts +2 -1
@@ -0,0 +1,435 @@
1
+ #!/usr/bin/env bash
2
+ # test_ensure_session_ownership_guard.sh — ensure-session's pre-entry ownership guard (#291).
3
+ #
4
+ # Exercises `enforceEnsureSessionOwnership()` (src/cli/workflow-sidecar.ts) against a temp
5
+ # ARTIFACT_ROOT, reusing #290's local-file assignment ⋈ liveness join
6
+ # (`computeEffectiveState`/`performLocalClaim`/`performLocalSupersede`, exported in
7
+ # src/cli/assignment-provider.ts). Fully deterministic: two fixed test-actor identities
8
+ # (--actor eval-actor-a-session / eval-actor-b-session), no network, no `gh` process. Follows
9
+ # test_assignment_provider_local_file.sh's pass/fail/json_query idiom exactly.
10
+ #
11
+ # Plan sections (see .kontourai/flow-agents/kontourai-flow-agents-291/
12
+ # kontourai-flow-agents-291--plan-work.md, Wave 3 "Ownership-guard eval"):
13
+ # 1. Actor A ensure-session on a free subject → exit 0; a durable local-file claim is
14
+ # established (AC5).
15
+ # 2. Actor B ensure-session on the SAME subject while A's claim is fresh (a real liveness event
16
+ # is manufactured so the join classifies it `held`, not merely `reclaimable`) → exit nonzero,
17
+ # naming A's holder identity (AC1); plus two hostile-string sanitization variants (AC9): a
18
+ # crafted hostile LIVENESS event (liveness-only holder, no assignment record) and the
19
+ # `hostile-effective-state.json` fixture via --effective-state-json (github-style hostile
20
+ # assignee string).
21
+ # 3. Actor A re-running ensure-session on its OWN subject → exit 0, no refusal (AC4).
22
+ # 4. A manufactured stale claim (real assignment record + a liveness event whose `at` is well
23
+ # past its ttlSeconds) → refuses without --supersede-stale (naming the flag), succeeds with
24
+ # --supersede-stale (AC2, both halves; audit-trail supersede entry confirmed).
25
+ # 5. A human-held fixture (assignment record with actor.human set) → any actor's ensure-session
26
+ # → exit nonzero, ask-first remediation, no auto-reclaim (AC3).
27
+ # 6. Concurrency: two REAL, concurrently-launched ensure-session processes on the same fresh
28
+ # subject (background `&` + `wait`, genuine OS-process concurrency, not a sequential
29
+ # simulation) → exactly one becomes the confirmed holder; the assignment record stays valid,
30
+ # single-holder, single-audit-entry JSON (no corrupted/partial write, no double claim) (AC6).
31
+ set -uo pipefail
32
+
33
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
34
+ source "$ROOT/evals/lib/node.sh"
35
+
36
+ CLI="$ROOT/build/src/cli.js"
37
+ FIXTURES="$ROOT/evals/fixtures/assignment-provider"
38
+ HOSTILE_EFFECTIVE_STATE_FIXTURE="$FIXTURES/hostile-effective-state.json"
39
+ ACTOR_IDENTITY_HELPER="$ROOT/scripts/hooks/lib/actor-identity.js"
40
+
41
+ TMPDIR_EVAL="$(mktemp -d)"
42
+ trap 'rm -rf "$TMPDIR_EVAL"' EXIT
43
+ ARTIFACT_ROOT="$TMPDIR_EVAL/artifact-root"
44
+
45
+ errors=0
46
+ pass() { echo " ✓ $1"; }
47
+ fail() { echo " ✗ $1"; errors=$((errors + 1)); }
48
+
49
+ json_query() {
50
+ node -e 'const fs=require("fs"); let cur=JSON.parse(fs.readFileSync(process.argv[1],"utf8")); for (const part of process.argv[2].split(".")) cur=part==="length" ? cur.length : (Array.isArray(cur) ? cur[Number(part)] : cur[part]); console.log(cur);' "$1" "$2"
51
+ }
52
+
53
+ # actor_key_from_record <assignment-record.json> — recomputes the SAME holderActorKey
54
+ # computeEffectiveState (src/cli/assignment-provider.ts) itself derives: record.actor_key when
55
+ # present (F1 fix, fix-plan iteration 1 — the canonical resolveActor().actor string
56
+ # performLocalClaim/performLocalSupersede now persist), else serializeActor(record.actor) as a
57
+ # back-compat fallback for any pre-F1 record with no actor_key field. The eval never hand-derives
58
+ # (and risks drifting from) the actual holder-key format ensure-session's guard produces for an
59
+ # explicit --actor override.
60
+ actor_key_from_record() {
61
+ node -e '
62
+ const fs = require("fs");
63
+ const { serializeActor } = require(process.argv[1]);
64
+ const rec = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
65
+ process.stdout.write(rec.actor_key || serializeActor(rec.actor));
66
+ ' "$ACTOR_IDENTITY_HELPER" "$1"
67
+ }
68
+
69
+ # append_liveness_event <root> <subjectId> <actor> <at-iso> [ttlSeconds]
70
+ append_liveness_event() {
71
+ local root="$1" subject="$2" actor="$3" at="$4" ttl="${5:-1800}"
72
+ mkdir -p "$root/liveness"
73
+ node -e '
74
+ const fs = require("fs");
75
+ const evt = { type: "claim", subjectId: process.argv[1], actor: process.argv[2], at: process.argv[3], ttlSeconds: Number(process.argv[4]) };
76
+ fs.appendFileSync(process.argv[5], JSON.stringify(evt) + "\n");
77
+ ' "$subject" "$actor" "$at" "$ttl" "$root/liveness/events.jsonl"
78
+ }
79
+
80
+ if [[ ! -f "$CLI" ]]; then
81
+ echo "build/src/cli.js not found — run 'npm run build' first" >&2
82
+ exit 1
83
+ fi
84
+ flow_agents_build_ts || { echo "build failed" >&2; exit 1; }
85
+
86
+ echo "=== ensure-session ownership guard (#291) ==="
87
+
88
+ # ─── 1. Actor A claims a free subject via ensure-session (AC5) ─────────────────────────────
89
+ echo "--- 1. free subject: ensure-session establishes a durable claim (AC5) ---"
90
+
91
+ SUBJECT_WORK_ITEM="kontourai/flow-agents#9101"
92
+ SLUG="kontourai-flow-agents-9101"
93
+
94
+ if flow_agents_node "workflow-sidecar" ensure-session \
95
+ --artifact-root "$ARTIFACT_ROOT" \
96
+ --work-item "$SUBJECT_WORK_ITEM" \
97
+ --actor eval-actor-a-session \
98
+ --source-request "Actor A claims a free subject." \
99
+ --summary "Actor A establishes the first claim on a free subject." \
100
+ >"$TMPDIR_EVAL/a-ensure.out" 2>"$TMPDIR_EVAL/a-ensure.err"; then
101
+ pass "actor A's ensure-session on a free subject exits 0 (AC5)"
102
+ else
103
+ fail "actor A's ensure-session on a free subject unexpectedly failed: $(cat "$TMPDIR_EVAL/a-ensure.out" "$TMPDIR_EVAL/a-ensure.err")"
104
+ fi
105
+
106
+ node "$CLI" assignment-provider status \
107
+ --provider local-file --artifact-root "$ARTIFACT_ROOT" --subject-id "$SLUG" \
108
+ > "$TMPDIR_EVAL/status-after-a.json"
109
+ [[ "$(json_query "$TMPDIR_EVAL/status-after-a.json" "assignment.record.status")" == "claimed" ]] && pass "assignment-provider status confirms a durable claim record exists for actor A (AC5)" || fail "assignment-provider status did not show a durable claim record for actor A: $(cat "$TMPDIR_EVAL/status-after-a.json")"
110
+ [[ "$(json_query "$TMPDIR_EVAL/status-after-a.json" "assignment.assignee")" == *"eval-actor-a-session"* ]] && pass "assignment-provider status reports actor A as the claim holder (AC5)" || fail "assignment-provider status did not report actor A as holder: $(cat "$TMPDIR_EVAL/status-after-a.json")"
111
+
112
+ A_RECORD="$ARTIFACT_ROOT/assignment/$SLUG.json"
113
+ [[ -f "$A_RECORD" ]] && pass "ensure-session's free-branch claim wrote assignment/$SLUG.json (AC5)" || fail "ensure-session's free-branch claim did not write assignment/$SLUG.json"
114
+ ACTOR_KEY_A="$(actor_key_from_record "$A_RECORD")"
115
+ [[ -n "$ACTOR_KEY_A" ]] && pass "actor A's serialized actor key was recovered from the on-disk record" || fail "could not recover actor A's serialized actor key from the on-disk record"
116
+
117
+ # ─── 2. Actor B refused on A's fresh claim (AC1), plus two AC9 sanitization variants ────────
118
+ echo "--- 2. fresh other-actor claim refuses entry (AC1); hostile-string sanitization (AC9) ---"
119
+
120
+ NOW_ISO="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
121
+ append_liveness_event "$ARTIFACT_ROOT" "$SLUG" "$ACTOR_KEY_A" "$NOW_ISO" 1800
122
+
123
+ if flow_agents_node "workflow-sidecar" ensure-session \
124
+ --artifact-root "$ARTIFACT_ROOT" \
125
+ --work-item "$SUBJECT_WORK_ITEM" \
126
+ --actor eval-actor-b-session \
127
+ --source-request "Actor B attempts to enter A's fresh claim." \
128
+ --summary "Actor B should be refused." \
129
+ >"$TMPDIR_EVAL/b-ensure.out" 2>"$TMPDIR_EVAL/b-ensure.err"; then
130
+ fail "actor B's ensure-session on A's fresh-held subject should have exited nonzero (AC1)"
131
+ else
132
+ pass "actor B's ensure-session on A's fresh-held subject exits nonzero (AC1)"
133
+ fi
134
+ grep -qF "eval-actor-a-session" "$TMPDIR_EVAL/b-ensure.err" && pass "refusal message names actor A's holder identity (AC1)" || fail "refusal message did not name actor A's holder identity: $(cat "$TMPDIR_EVAL/b-ensure.err")"
135
+ grep -qiF "takeover" "$TMPDIR_EVAL/b-ensure.err" && pass "refusal message offers remediation (pick different work or consider a takeover) (AC1)" || fail "refusal message lacked remediation text: $(cat "$TMPDIR_EVAL/b-ensure.err")"
136
+
137
+ B_DIR="$ARTIFACT_ROOT/$SLUG"
138
+ # A's session dir already legitimately exists (A owns it, from section 1) — a refused entry must
139
+ # never leave any trace of actor B's identity in A's own sidecar.
140
+ if grep -qF "eval-actor-b-session" "$B_DIR/state.json" 2>/dev/null; then
141
+ fail "actor B's refused entry unexpectedly left a trace in A's state.json"
142
+ else
143
+ pass "actor B's refused entry left no trace in A's state.json"
144
+ fi
145
+
146
+ # AC9 variant 1: a crafted HOSTILE liveness event (no assignment record at all — the
147
+ # "liveness_claim_present_assignment_lagging" join row) — proves the guard sanitizes a holder
148
+ # actor string sourced directly from an attacker-postable liveness stream, not merely from a
149
+ # well-formed assignment record.
150
+ HOSTILE_LIVENESS_SLUG="ensure-guard-hostile-liveness"
151
+ HOSTILE_LIVENESS_ACTOR=$'hostile-liveness-actor\x1b[31;1mFAKE\x07-holder'
152
+ append_liveness_event "$ARTIFACT_ROOT" "$HOSTILE_LIVENESS_SLUG" "$HOSTILE_LIVENESS_ACTOR" "$NOW_ISO" 1800
153
+
154
+ if flow_agents_node "workflow-sidecar" ensure-session \
155
+ --artifact-root "$ARTIFACT_ROOT" \
156
+ --task-slug "$HOSTILE_LIVENESS_SLUG" \
157
+ --actor eval-actor-hostile-reader \
158
+ --source-request "Reader against a hostile liveness-only holder." \
159
+ --summary "Should refuse, sanitized." \
160
+ >"$TMPDIR_EVAL/hostile-liveness.out" 2>"$TMPDIR_EVAL/hostile-liveness.err"; then
161
+ fail "ensure-session against a hostile liveness-only holder should have refused (AC1, AC9)"
162
+ else
163
+ pass "ensure-session against a hostile liveness-only holder refuses (AC1 liveness_claim_present_assignment_lagging row, AC9)"
164
+ fi
165
+ if grep -qF $'\x1b[31;1m' "$TMPDIR_EVAL/hostile-liveness.err" || grep -qF $'\x07' "$TMPDIR_EVAL/hostile-liveness.err"; then
166
+ fail "hostile liveness-event holder actor's raw ANSI/control bytes leaked into the refusal message (AC9)"
167
+ else
168
+ pass "hostile liveness-event holder actor's raw ANSI/control bytes never leak into the refusal message (AC9)"
169
+ fi
170
+ grep -qF "hostile-liveness-actor" "$TMPDIR_EVAL/hostile-liveness.err" && pass "the sanitized (non-control-byte) portion of the hostile holder actor string still appears in the refusal message (AC9)" || fail "sanitized portion of the hostile holder actor string was unexpectedly dropped entirely: $(cat "$TMPDIR_EVAL/hostile-liveness.err")"
171
+
172
+ # AC9 variant 2: hostile-effective-state.json (github-style hostile assignee string) via
173
+ # --effective-state-json — the escape hatch for non-local-file providers (Conflict #5).
174
+ HOSTILE_EFFECTIVE_SLUG="ensure-guard-hostile-effective-state"
175
+ if flow_agents_node "workflow-sidecar" ensure-session \
176
+ --artifact-root "$ARTIFACT_ROOT" \
177
+ --task-slug "$HOSTILE_EFFECTIVE_SLUG" \
178
+ --actor eval-actor-hostile-reader-2 \
179
+ --effective-state-json "$HOSTILE_EFFECTIVE_STATE_FIXTURE" \
180
+ --source-request "Reader against a hostile precomputed effective-state fixture." \
181
+ --summary "Should refuse, sanitized." \
182
+ >"$TMPDIR_EVAL/hostile-effective.out" 2>"$TMPDIR_EVAL/hostile-effective.err"; then
183
+ fail "ensure-session --effective-state-json against the hostile fixture should have refused (AC3, AC9)"
184
+ else
185
+ pass "ensure-session --effective-state-json against the hostile fixture refuses (human-held, AC3, AC9)"
186
+ fi
187
+ if grep -qF $'\x1b[31;1m' "$TMPDIR_EVAL/hostile-effective.err" || grep -qF $'\x07' "$TMPDIR_EVAL/hostile-effective.err"; then
188
+ fail "hostile --effective-state-json assignee's raw ANSI/control bytes leaked into the refusal message (AC9)"
189
+ else
190
+ pass "hostile --effective-state-json assignee's raw ANSI/control bytes never leak into the refusal message (AC9)"
191
+ fi
192
+ grep -qF "hostile-assignee" "$TMPDIR_EVAL/hostile-effective.err" && pass "the sanitized portion of the hostile --effective-state-json assignee string still appears in the refusal message (AC9)" || fail "sanitized portion of the hostile --effective-state-json assignee string was unexpectedly dropped entirely: $(cat "$TMPDIR_EVAL/hostile-effective.err")"
193
+
194
+ # ─── 3. Actor A re-entering its own subject succeeds (AC4) ─────────────────────────────────
195
+ echo "--- 3. self re-entry succeeds with no spurious refusal (AC4) ---"
196
+
197
+ if flow_agents_node "workflow-sidecar" ensure-session \
198
+ --artifact-root "$ARTIFACT_ROOT" \
199
+ --work-item "$SUBJECT_WORK_ITEM" \
200
+ --actor eval-actor-a-session \
201
+ --source-request "Actor A resumes its own session." \
202
+ --summary "Actor A resumes." \
203
+ >"$TMPDIR_EVAL/a-reentry.out" 2>"$TMPDIR_EVAL/a-reentry.err"; then
204
+ pass "actor A re-running ensure-session on its own fresh claim succeeds (AC4)"
205
+ else
206
+ fail "actor A's self re-entry unexpectedly refused: $(cat "$TMPDIR_EVAL/a-reentry.out" "$TMPDIR_EVAL/a-reentry.err")"
207
+ fi
208
+
209
+ # ─── 3b. F1 (fix-plan iteration 1, HIGH): cross-tool self-recognition for an EXPLICIT-OVERRIDE
210
+ # actor (FLOW_AGENTS_ACTOR / --actor). This is the reviewer's exact live repro: a claim
211
+ # established via ensure-session under a bare override value must be recognized as self by a
212
+ # DIFFERENT tool invocation (`assignment-provider status --self-actor <bare value>`), and a fresh
213
+ # liveness heartbeat for that same bare value must join against the claim record too. Before the
214
+ # fix, computeEffectiveState keyed everything on serializeActor(record.actor) — a TRIPLE
215
+ # (`explicit-override:<value>:<host>`) for an override actor — while every other tool (including
216
+ # --self-actor here) uses the BARE value, so this cross-tool check failed even though the guard's
217
+ # OWN internal self-check passed (it always re-derives the same wrapped key on both sides).
218
+ echo "--- 3b. cross-tool self-recognition for an explicit-override actor: assignment-provider status --self-actor recognizes an ensure-session claim (F1, fix-plan iteration 1) ---"
219
+
220
+ CANONICAL_WORK_ITEM="kontourai/flow-agents#9103"
221
+ CANONICAL_SLUG="kontourai-flow-agents-9103"
222
+ CANONICAL_ACTOR="canonical-x"
223
+
224
+ if FLOW_AGENTS_ACTOR="$CANONICAL_ACTOR" flow_agents_node "workflow-sidecar" ensure-session \
225
+ --artifact-root "$ARTIFACT_ROOT" \
226
+ --work-item "$CANONICAL_WORK_ITEM" \
227
+ --source-request "canonical-x claims a free subject via FLOW_AGENTS_ACTOR (no --actor flag)." \
228
+ --summary "canonical-x establishes the first claim." \
229
+ >"$TMPDIR_EVAL/canonical-ensure.out" 2>"$TMPDIR_EVAL/canonical-ensure.err"; then
230
+ pass "FLOW_AGENTS_ACTOR=canonical-x ensure-session on a free subject exits 0 (F1 setup)"
231
+ else
232
+ fail "FLOW_AGENTS_ACTOR=canonical-x ensure-session unexpectedly failed: $(cat "$TMPDIR_EVAL/canonical-ensure.out" "$TMPDIR_EVAL/canonical-ensure.err")"
233
+ fi
234
+
235
+ CANONICAL_RECORD="$ARTIFACT_ROOT/assignment/$CANONICAL_SLUG.json"
236
+ [[ -f "$CANONICAL_RECORD" ]] && pass "canonical-x's claim record exists on disk (F1 setup)" || fail "canonical-x's claim record was not written"
237
+ CANONICAL_ACTOR_KEY_FIELD="$(json_query "$CANONICAL_RECORD" "actor_key")"
238
+ if [[ "$CANONICAL_ACTOR_KEY_FIELD" == "$CANONICAL_ACTOR" ]]; then
239
+ pass "the claim record's actor_key is the canonical BARE value 'canonical-x', not a serialized explicit-override triple (F1)"
240
+ else
241
+ fail "the claim record's actor_key was not the expected bare canonical value: got '$CANONICAL_ACTOR_KEY_FIELD'"
242
+ fi
243
+
244
+ # (a) assignment-provider status --self-actor canonical-x (the BARE value, exactly what
245
+ # `liveness whoami` / `liveness claim --actor` / pull-work's --self-actor would all use) must
246
+ # recognize this claim as self -- effective_state:held, reason:self_is_holder, NOT reclaimable.
247
+ node "$CLI" assignment-provider status \
248
+ --provider local-file --artifact-root "$ARTIFACT_ROOT" --subject-id "$CANONICAL_SLUG" \
249
+ --liveness-events-json <(echo '[]') \
250
+ --self-actor "$CANONICAL_ACTOR" \
251
+ > "$TMPDIR_EVAL/status-canonical-self.json"
252
+ CANONICAL_SELF_STATE="$(json_query "$TMPDIR_EVAL/status-canonical-self.json" "effective.effective_state")"
253
+ CANONICAL_SELF_REASON="$(json_query "$TMPDIR_EVAL/status-canonical-self.json" "effective.reason")"
254
+ if [[ "$CANONICAL_SELF_STATE" == "held" && "$CANONICAL_SELF_REASON" == "self_is_holder" ]]; then
255
+ pass "assignment-provider status --self-actor canonical-x (bare value) recognizes the ensure-session claim as self: held/self_is_holder, not reclaimable (F1 -- the reviewer's exact repro, now fixed)"
256
+ else
257
+ fail "assignment-provider status --self-actor canonical-x did NOT recognize the claim as self: effective_state=$CANONICAL_SELF_STATE reason=$CANONICAL_SELF_REASON (expected held/self_is_holder): $(cat "$TMPDIR_EVAL/status-canonical-self.json")"
258
+ fi
259
+
260
+ # (b) symmetric case: a FRESH liveness heartbeat for canonical-x (the bare value, exactly what a
261
+ # real heartbeat/liveness-claim event would record as `actor`) must join against the SAME record
262
+ # via fresh_liveness_heartbeat, with a DIFFERENT (or no) --self-actor -- proving the join itself
263
+ # matches on the canonical actor_key, not merely the redundant self-check.
264
+ CANONICAL_HEARTBEAT_NOW="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
265
+ append_liveness_event "$ARTIFACT_ROOT" "$CANONICAL_SLUG" "$CANONICAL_ACTOR" "$CANONICAL_HEARTBEAT_NOW" 1800
266
+
267
+ node "$CLI" assignment-provider status \
268
+ --provider local-file --artifact-root "$ARTIFACT_ROOT" --subject-id "$CANONICAL_SLUG" \
269
+ --liveness-stream "$ARTIFACT_ROOT/liveness/events.jsonl" \
270
+ --self-actor "eval-actor-some-other-reader" \
271
+ > "$TMPDIR_EVAL/status-canonical-heartbeat.json"
272
+ CANONICAL_HEARTBEAT_STATE="$(json_query "$TMPDIR_EVAL/status-canonical-heartbeat.json" "effective.effective_state")"
273
+ CANONICAL_HEARTBEAT_REASON="$(json_query "$TMPDIR_EVAL/status-canonical-heartbeat.json" "effective.reason")"
274
+ if [[ "$CANONICAL_HEARTBEAT_STATE" == "held" && "$CANONICAL_HEARTBEAT_REASON" == "fresh_liveness_heartbeat" ]]; then
275
+ pass "a fresh liveness heartbeat for canonical-x (bare value) joins against the ensure-session claim via fresh_liveness_heartbeat, under a DIFFERENT self-actor (F1 -- proves the assignment <-> liveness join itself matches, not just the self-check)"
276
+ else
277
+ fail "the fresh liveness heartbeat for canonical-x did not join against the claim: effective_state=$CANONICAL_HEARTBEAT_STATE reason=$CANONICAL_HEARTBEAT_REASON (expected held/fresh_liveness_heartbeat): $(cat "$TMPDIR_EVAL/status-canonical-heartbeat.json")"
278
+ fi
279
+
280
+ # ─── 4. Stale (reclaimable) claim: refuse without the flag, succeed with it (AC2) ──────────
281
+ echo "--- 4. reclaimable (stale) claim: refuse without --supersede-stale, succeed with it (AC2) ---"
282
+
283
+ STALE_SLUG="ensure-guard-stale-takeover"
284
+ flow_agents_node "workflow-sidecar" ensure-session \
285
+ --artifact-root "$ARTIFACT_ROOT" \
286
+ --task-slug "$STALE_SLUG" \
287
+ --actor eval-actor-stale-holder \
288
+ --source-request "Establish the original (soon-to-be-stale) claim." \
289
+ --summary "Original holder." \
290
+ >"$TMPDIR_EVAL/stale-setup.out" 2>"$TMPDIR_EVAL/stale-setup.err"
291
+ STALE_RECORD="$ARTIFACT_ROOT/assignment/$STALE_SLUG.json"
292
+ ACTOR_KEY_STALE_HOLDER="$(actor_key_from_record "$STALE_RECORD")"
293
+ # A liveness event whose `at` is well past ttlSeconds (1800s) relative to real wall-clock now —
294
+ # matches evals/fixtures/assignment-provider/liveness-stale.json's "well in the past" convention.
295
+ append_liveness_event "$ARTIFACT_ROOT" "$STALE_SLUG" "$ACTOR_KEY_STALE_HOLDER" "2026-06-01T10:00:00Z" 1800
296
+
297
+ if flow_agents_node "workflow-sidecar" ensure-session \
298
+ --artifact-root "$ARTIFACT_ROOT" \
299
+ --task-slug "$STALE_SLUG" \
300
+ --actor eval-actor-b-session \
301
+ --source-request "Actor B attempts a stale takeover without --supersede-stale." \
302
+ --summary "Should refuse, naming the flag." \
303
+ >"$TMPDIR_EVAL/stale-refuse.out" 2>"$TMPDIR_EVAL/stale-refuse.err"; then
304
+ fail "ensure-session on a reclaimable (stale) subject without --supersede-stale should have refused (AC2)"
305
+ else
306
+ pass "ensure-session on a reclaimable (stale) subject without --supersede-stale refuses (AC2)"
307
+ fi
308
+ grep -qF -- "--supersede-stale" "$TMPDIR_EVAL/stale-refuse.err" && pass "stale-claim refusal names --supersede-stale explicitly (AC2)" || fail "stale-claim refusal did not name --supersede-stale: $(cat "$TMPDIR_EVAL/stale-refuse.err")"
309
+
310
+ if flow_agents_node "workflow-sidecar" ensure-session \
311
+ --artifact-root "$ARTIFACT_ROOT" \
312
+ --task-slug "$STALE_SLUG" \
313
+ --actor eval-actor-b-session \
314
+ --supersede-stale \
315
+ --source-request "Actor B takes over the stale claim explicitly." \
316
+ --summary "Explicit takeover." \
317
+ >"$TMPDIR_EVAL/stale-supersede.out" 2>"$TMPDIR_EVAL/stale-supersede.err"; then
318
+ pass "ensure-session --supersede-stale on a reclaimable (stale) subject succeeds (AC2)"
319
+ else
320
+ fail "ensure-session --supersede-stale on a reclaimable (stale) subject unexpectedly failed: $(cat "$TMPDIR_EVAL/stale-supersede.out" "$TMPDIR_EVAL/stale-supersede.err")"
321
+ fi
322
+
323
+ node "$CLI" assignment-provider status \
324
+ --provider local-file --artifact-root "$ARTIFACT_ROOT" --subject-id "$STALE_SLUG" \
325
+ > "$TMPDIR_EVAL/status-after-supersede.json"
326
+ [[ "$(json_query "$TMPDIR_EVAL/status-after-supersede.json" "assignment.assignee")" == *"eval-actor-b-session"* ]] && pass "assignment-provider status confirms actor B is the new holder after --supersede-stale takeover (AC2)" || fail "assignment-provider status did not confirm actor B as the new holder: $(cat "$TMPDIR_EVAL/status-after-supersede.json")"
327
+ [[ "$(json_query "$TMPDIR_EVAL/status-after-supersede.json" "assignment.record.audit_trail.length")" == "2" ]] && pass "supersede takeover appended a second audit_trail entry (claim + supersede) (AC2)" || fail "supersede takeover did not append a second audit_trail entry: $(cat "$TMPDIR_EVAL/status-after-supersede.json")"
328
+ [[ "$(json_query "$TMPDIR_EVAL/status-after-supersede.json" "assignment.record.audit_trail.1.transition")" == "supersede" ]] && pass "audit trail records the supersede transition from the stale-claim takeover (AC2)" || fail "audit trail did not record a supersede transition: $(cat "$TMPDIR_EVAL/status-after-supersede.json")"
329
+
330
+ # ─── 5. Human-held: never auto-reclaims (AC3) ──────────────────────────────────────────────
331
+ echo "--- 5. human-held subject: ask-first refusal, never auto-reclaims (AC3) ---"
332
+
333
+ HUMAN_SLUG="ensure-guard-human-held"
334
+ mkdir -p "$ARTIFACT_ROOT/assignment"
335
+ cat > "$ARTIFACT_ROOT/assignment/$HUMAN_SLUG.json" <<HUMANEOF
336
+ {
337
+ "schema_version": "1.0",
338
+ "role": "AssignmentClaimRecord",
339
+ "subject_id": "$HUMAN_SLUG",
340
+ "actor": {
341
+ "runtime": "github",
342
+ "session_id": "n-a",
343
+ "host": "n-a",
344
+ "human": "alice-human"
345
+ },
346
+ "claimed_at": "2026-06-15T09:00:00Z",
347
+ "ttl_seconds": 1800,
348
+ "branch": "main",
349
+ "artifact_dir": "$HUMAN_SLUG",
350
+ "status": "claimed",
351
+ "audit_trail": [
352
+ { "at": "2026-06-15T09:00:00Z", "transition": "claim", "from_actor": null, "to_actor": { "runtime": "github", "session_id": "n-a", "host": "n-a", "human": "alice-human" }, "reason": "human assignment" }
353
+ ]
354
+ }
355
+ HUMANEOF
356
+ HUMAN_RECORD_BEFORE="$(cat "$ARTIFACT_ROOT/assignment/$HUMAN_SLUG.json")"
357
+
358
+ if flow_agents_node "workflow-sidecar" ensure-session \
359
+ --artifact-root "$ARTIFACT_ROOT" \
360
+ --task-slug "$HUMAN_SLUG" \
361
+ --actor eval-actor-c-session \
362
+ --source-request "Any actor attempts entry on a human-held subject." \
363
+ --summary "Should refuse, ask-first." \
364
+ >"$TMPDIR_EVAL/human-held.out" 2>"$TMPDIR_EVAL/human-held.err"; then
365
+ fail "ensure-session on a human-held subject should have refused (AC3)"
366
+ else
367
+ pass "ensure-session on a human-held subject refuses (AC3)"
368
+ fi
369
+ grep -qiF "human" "$TMPDIR_EVAL/human-held.err" && pass "human-held refusal message references the human assignment (AC3)" || fail "human-held refusal message did not reference the human assignment: $(cat "$TMPDIR_EVAL/human-held.err")"
370
+ grep -qiF "confirm" "$TMPDIR_EVAL/human-held.err" && pass "human-held refusal message asks for confirmation before proceeding, never auto-reclaims (AC3)" || fail "human-held refusal message lacked ask-first remediation: $(cat "$TMPDIR_EVAL/human-held.err")"
371
+ [[ ! -d "$ARTIFACT_ROOT/$HUMAN_SLUG" ]] && pass "human-held refusal never created a session directory (no auto-reclaim side effect) (AC3)" || fail "human-held refusal unexpectedly created a session directory"
372
+ HUMAN_RECORD_AFTER="$(cat "$ARTIFACT_ROOT/assignment/$HUMAN_SLUG.json")"
373
+ [[ "$HUMAN_RECORD_BEFORE" == "$HUMAN_RECORD_AFTER" ]] && pass "human-held assignment record is byte-identical after the refused attempt (never auto-reclaimed) (AC3)" || fail "human-held assignment record was mutated by a refused ensure-session attempt"
374
+
375
+ # ─── 6. Concurrency: exactly one holder, no corrupted/partial record (AC6) ─────────────────
376
+ echo "--- 6. genuine concurrent ensure-session race: exactly one holder wins (AC6) ---"
377
+
378
+ RACE_SLUG="ensure-guard-race"
379
+ (
380
+ flow_agents_node "workflow-sidecar" ensure-session \
381
+ --artifact-root "$ARTIFACT_ROOT" \
382
+ --task-slug "$RACE_SLUG" \
383
+ --actor eval-race-actor-a \
384
+ --source-request "Race participant A." \
385
+ --summary "Race participant A." \
386
+ >"$TMPDIR_EVAL/race-a.out" 2>"$TMPDIR_EVAL/race-a.err"
387
+ echo $? > "$TMPDIR_EVAL/race-a.rc"
388
+ ) &
389
+ RACE_PID_A=$!
390
+ (
391
+ flow_agents_node "workflow-sidecar" ensure-session \
392
+ --artifact-root "$ARTIFACT_ROOT" \
393
+ --task-slug "$RACE_SLUG" \
394
+ --actor eval-race-actor-b \
395
+ --source-request "Race participant B." \
396
+ --summary "Race participant B." \
397
+ >"$TMPDIR_EVAL/race-b.out" 2>"$TMPDIR_EVAL/race-b.err"
398
+ echo $? > "$TMPDIR_EVAL/race-b.rc"
399
+ ) &
400
+ RACE_PID_B=$!
401
+ wait "$RACE_PID_A" "$RACE_PID_B"
402
+
403
+ RACE_RC_A="$(cat "$TMPDIR_EVAL/race-a.rc")"
404
+ RACE_RC_B="$(cat "$TMPDIR_EVAL/race-b.rc")"
405
+ if { [[ "$RACE_RC_A" -eq 0 && "$RACE_RC_B" -ne 0 ]] || [[ "$RACE_RC_A" -ne 0 && "$RACE_RC_B" -eq 0 ]]; }; then
406
+ pass "genuine concurrent ensure-session race: exactly one process wins (rc_a=$RACE_RC_A rc_b=$RACE_RC_B) (AC6)"
407
+ else
408
+ fail "genuine concurrent ensure-session race: exactly one process should win (rc_a=$RACE_RC_A rc_b=$RACE_RC_B) (AC6)"
409
+ fi
410
+
411
+ RACE_RECORD="$ARTIFACT_ROOT/assignment/$RACE_SLUG.json"
412
+ if node -e 'JSON.parse(require("fs").readFileSync(process.argv[1], "utf8"))' "$RACE_RECORD" 2>/dev/null; then
413
+ pass "race winner's on-disk assignment record is valid, uncorrupted JSON (AC6)"
414
+ else
415
+ fail "race winner's on-disk assignment record is missing or corrupted JSON (AC6)"
416
+ fi
417
+ node "$CLI" assignment-provider status \
418
+ --provider local-file --artifact-root "$ARTIFACT_ROOT" --subject-id "$RACE_SLUG" \
419
+ > "$TMPDIR_EVAL/status-race.json"
420
+ [[ "$(json_query "$TMPDIR_EVAL/status-race.json" "assignment.record.audit_trail.length")" == "1" ]] && pass "race winner's on-disk record has exactly one audit_trail entry (no lost/merged/double claim) (AC6)" || fail "race winner's on-disk record does not have exactly one audit_trail entry: $(cat "$TMPDIR_EVAL/status-race.json")"
421
+
422
+ if [[ "$RACE_RC_A" -eq 0 ]]; then
423
+ EXPECTED_RACE_HOLDER="eval-race-actor-a"
424
+ else
425
+ EXPECTED_RACE_HOLDER="eval-race-actor-b"
426
+ fi
427
+ [[ "$(json_query "$TMPDIR_EVAL/status-race.json" "assignment.assignee")" == *"$EXPECTED_RACE_HOLDER"* ]] && pass "on-disk record holder matches the process that actually exited 0 (no silent overwrite by the loser) (AC6)" || fail "on-disk record holder does not match the winning process: $(cat "$TMPDIR_EVAL/status-race.json")"
428
+
429
+ echo ""
430
+ if [[ "$errors" -eq 0 ]]; then
431
+ echo "test_ensure_session_ownership_guard: all checks passed."
432
+ else
433
+ echo "test_ensure_session_ownership_guard: $errors check(s) failed."
434
+ fi
435
+ exit "$errors"
@@ -829,6 +829,13 @@ mkdir -p "$ISO_DIR/repo/.kontourai/flow-agents/surftest"
829
829
  mkdir -p "$ISO_DIR/lib"
830
830
  cp "$GATE" "$ISO_DIR/stop-goal-fit.js"
831
831
  cp "$ROOT/scripts/hooks/lib/local-artifact-paths.js" "$ISO_DIR/lib/"
832
+ # #291: stop-goal-fit.js now also requires scripts/hooks/lib/actor-identity.js and
833
+ # scripts/hooks/lib/current-pointer.js (the per-actor current.json compat-shim read helper)
834
+ # -- both must be mirrored into this isolated copy too, or the isolated gate crashes on
835
+ # MODULE_NOT_FOUND before it ever reaches the surface-unavailable fail-closed path this
836
+ # section is testing.
837
+ cp "$ROOT/scripts/hooks/lib/actor-identity.js" "$ISO_DIR/lib/"
838
+ cp "$ROOT/scripts/hooks/lib/current-pointer.js" "$ISO_DIR/lib/"
832
839
  printf '# Repo\n' > "$ISO_DIR/repo/AGENTS.md"
833
840
  # Non-terminal session (execution phase, in_progress status)
834
841
  printf '%s' '{"schema_version":"1.0","task_slug":"surftest","status":"in_progress","phase":"execution","updated_at":"2026-06-27T00:00:00Z","next_action":{"status":"in_progress","summary":"running"}}' \
@@ -875,6 +882,9 @@ mkdir -p "$ISO2_DIR/repo/.kontourai/flow-agents/lowtest"
875
882
  mkdir -p "$ISO2_DIR/lib"
876
883
  cp "$GATE" "$ISO2_DIR/stop-goal-fit.js"
877
884
  cp "$ROOT/scripts/hooks/lib/local-artifact-paths.js" "$ISO2_DIR/lib/"
885
+ # #291: same rationale as ISO_DIR above -- mirror the two new scripts/hooks/lib dependencies.
886
+ cp "$ROOT/scripts/hooks/lib/actor-identity.js" "$ISO2_DIR/lib/"
887
+ cp "$ROOT/scripts/hooks/lib/current-pointer.js" "$ISO2_DIR/lib/"
878
888
  printf '# Repo\n' > "$ISO2_DIR/repo/AGENTS.md"
879
889
  printf '%s' '{"schema_version":"1.0","task_slug":"lowtest","status":"in_progress","phase":"execution","updated_at":"2026-06-27T00:00:00Z","next_action":{"status":"in_progress","summary":"running"}}' \
880
890
  > "$ISO2_DIR/repo/.kontourai/flow-agents/lowtest/state.json"
@@ -151,7 +151,12 @@ if flow_agents_node "$WRITER" ensure-session \
151
151
  --title "Parity Check" \
152
152
  --summary "resolve-slug parity fixture." \
153
153
  --timestamp "2026-07-01T00:00:00Z" >"$TMPDIR_EVAL/b-ensure.out" 2>"$TMPDIR_EVAL/b-ensure.err"; then
154
- B_SESSION_DIR="$(find "$B_ROOT" -mindepth 1 -maxdepth 1 -type d ! -name liveness | head -n1)"
154
+ # #291: ensure-session (when an ambient actor resolves, even without an explicit --actor)
155
+ # now also creates artifact-root-level "assignment/" (the ownership-guard's durable local-file
156
+ # claim record dir) and "current/" (the per-actor current.json projection dir) sibling
157
+ # directories, alongside the pre-existing "liveness/" dir -- all three must be excluded here so
158
+ # this find still lands on the real session slug directory, not a sibling artifact dir.
159
+ B_SESSION_DIR="$(find "$B_ROOT" -mindepth 1 -maxdepth 1 -type d ! -name liveness ! -name assignment ! -name current | head -n1)"
155
160
  B_SLUG_FROM_DIR="$(basename "$B_SESSION_DIR")"
156
161
  B_SLUG_FROM_RESOLVE="$(flow_agents_node "$WRITER" resolve-slug 'owner/repo#42' 2>"$TMPDIR_EVAL/b-resolve.err")"
157
162
  if [[ -n "$B_SESSION_DIR" ]] && [[ "$B_SLUG_FROM_DIR" == "$B_SLUG_FROM_RESOLVE" ]]; then
@@ -530,19 +530,28 @@ fi
530
530
 
531
531
  # AC5 (existing-session continuity): re-running ensure-session against the SAME slug from a
532
532
  # DIFFERENT actor never re-derives or overwrites the already-recorded branch (ADR 0021 §5
533
- # takeover continuity — resume the incumbent's branch, never a parallel one).
533
+ # takeover continuity — resume the incumbent's branch, never a parallel one). #291's
534
+ # ensure-session ownership guard now classifies alpha's still-fresh-assignment/no-liveness
535
+ # claim as `reclaimable` and refuses gamma's entry without an explicit takeover — so this
536
+ # takeover is made explicit via --supersede-stale (ADR 0021 §5's grace-beat/auto-resume
537
+ # protocol is still #294's scope; #291 only wires the explicit, caller-invoked takeover path).
538
+ # The supersede updates the ASSIGNMENT record's actor (assignment/<slug>.json), never
539
+ # state.json — state.json's `branch` field (and the rest of state.json) must stay exactly
540
+ # alpha's, proving the takeover resumes the incumbent's branch rather than reforking one for
541
+ # gamma.
534
542
  if flow_agents_node "$WRITER" ensure-session \
535
543
  --artifact-root "$SESSION_ROOT" \
536
544
  --task-slug branch-derive-a \
537
545
  --actor test-actor-gamma \
546
+ --supersede-stale \
538
547
  --source-request "A takeover by a different actor must not refork the branch." \
539
548
  --summary "Resuming actor gamma should inherit alpha's already-recorded branch." \
540
549
  --timestamp "2026-05-09T00:03:12Z" >"$TMPDIR_EVAL/branch-no-rederive.out" 2>"$TMPDIR_EVAL/branch-no-rederive.err" \
541
550
  && rg -q '"branch": "agent/test-actor-alpha/branch-derive-a"' "$BRANCH_DERIVE_A_DIR/state.json" \
542
551
  && ! rg -q 'test-actor-gamma' "$BRANCH_DERIVE_A_DIR/state.json"; then
543
- _pass "sidecar writer never re-derives an existing session's branch for a later actor (AC5, ADR 0021 §5)"
552
+ _pass "sidecar writer's explicit --supersede-stale takeover (#291) never re-derives or overwrites an existing session's branch for the new actor (AC5, ADR 0021 §5)"
544
553
  else
545
- _fail "sidecar writer re-derived or overwrote an existing session's branch on takeover: $(cat "$TMPDIR_EVAL/branch-no-rederive.out" "$TMPDIR_EVAL/branch-no-rederive.err")"
554
+ _fail "sidecar writer's explicit --supersede-stale takeover re-derived or overwrote an existing session's branch: $(cat "$TMPDIR_EVAL/branch-no-rederive.out" "$TMPDIR_EVAL/branch-no-rederive.err")"
546
555
  fi
547
556
 
548
557
  # ─── #309: init-plan (and advance-state) must never drop an already-recorded branch ────
package/evals/run.sh CHANGED
@@ -203,6 +203,10 @@ run_integration() {
203
203
  echo ""
204
204
  bash "$EVAL_DIR/integration/test_pull_work_assignment_join.sh" || result=1
205
205
  echo ""
206
+ bash "$EVAL_DIR/integration/test_ensure_session_ownership_guard.sh" || result=1
207
+ echo ""
208
+ bash "$EVAL_DIR/integration/test_current_json_per_actor.sh" || result=1
209
+ echo ""
206
210
  bash "$EVAL_DIR/integration/test_liveness_heartbeat.sh" || result=1
207
211
  echo ""
208
212
  bash "$EVAL_DIR/integration/test_pull_work_liveness_preflight.sh" || result=1
@@ -6,18 +6,27 @@
6
6
  # - providers/conformance/suite.test.js (AC3: all three providers conform)
7
7
  # - providers/health/health-pass.test.js (AC1: vault+git-repo reports;
8
8
  # AC2: work-item seeded dupe + broken blocker)
9
+ # - promote/promote.test.js (issue #313 knowledge promote sub-flow:
10
+ # AC1 draft delta+provenance, AC2 contradiction
11
+ # report+merge proposal, AC3 zero external writes)
12
+ # - providers/neo4j/neo4j.test.js (issue #327 neo4j provider, CI-safe: sync
13
+ # idempotency AC1, canonical queries AC3, degradation
14
+ # AC4 — via an injected fake driver, no Docker.
15
+ # Live Neo4j integration.test.js is gated on NEO4J_URI.)
9
16
  # Deterministic, dependency-free, fixture-driven — never touches a real board.
10
17
  set -uo pipefail
11
18
  ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
12
19
  cd "$ROOT_DIR"
13
20
 
14
- echo "── Knowledge store provider conformance + health (node --test) ──"
21
+ echo "── Knowledge store provider conformance + health + promote sub-flow (node --test) ──"
15
22
 
16
23
  if node --test \
17
24
  kits/knowledge/providers/conformance/suite.test.js \
18
- kits/knowledge/providers/health/health-pass.test.js; then
19
- echo " PASS: knowledge store provider conformance + health verbs"
25
+ kits/knowledge/providers/health/health-pass.test.js \
26
+ kits/knowledge/promote/promote.test.js \
27
+ kits/knowledge/providers/neo4j/neo4j.test.js; then
28
+ echo " PASS: knowledge store provider conformance + health verbs + promote sub-flow"
20
29
  else
21
- echo " FAIL: knowledge store provider conformance + health verbs"
30
+ echo " FAIL: knowledge store provider conformance + health verbs + promote sub-flow"
22
31
  exit 1
23
32
  fi
@@ -101,6 +101,8 @@ BACKLOG_PROVIDER_SETTINGS_SCHEMA="$ROOT/schemas/backlog-provider-settings.schema
101
101
  VERIFICATION_CONTRACT="$ROOT/context/contracts/verification-contract.md"
102
102
  REVIEW_CONTRACT="$ROOT/context/contracts/review-contract.md"
103
103
  DELIVERY_CONTRACT="$ROOT/context/contracts/delivery-contract.md"
104
+ DECISION_REGISTRY_CONTRACT="$ROOT/context/contracts/decision-registry-contract.md"
105
+ PROBE_DOCS_WRITE_CONTRACT="$ROOT/context/contracts/probe-docs-write-contract.md"
104
106
  EFFECTIVE_BACKLOG_SETTINGS="$ROOT/src/cli/effective-backlog-settings.ts"
105
107
  PULL_WORK_PROVIDER="$ROOT/src/cli/pull-work-provider.ts"
106
108
  PULL_WORK_PROVIDER_INTEGRATION="$ROOT/evals/integration/test_pull_work_provider.sh"
@@ -188,6 +190,8 @@ require_file "$PLANNING_CONTRACT" "planning contract"
188
190
  require_file "$EXECUTION_CONTRACT" "execution contract"
189
191
  require_file "$SANDBOX_CONTRACT" "sandbox policy contract"
190
192
  require_file "$GOVERNANCE_ADAPTER_CONTRACT" "governance adapter contract"
193
+ require_file "$DECISION_REGISTRY_CONTRACT" "decision registry contract"
194
+ require_file "$PROBE_DOCS_WRITE_CONTRACT" "probe docs-write contract"
191
195
  require_file "$WORK_ITEM_CONTRACT" "work item contract"
192
196
  require_file "$BACKLOG_PROVIDER_SETTINGS" "backlog provider settings"
193
197
  require_file "$BACKLOG_PROVIDER_SETTINGS_SCHEMA" "backlog provider settings schema"
@@ -754,8 +758,17 @@ require_text "$PICKUP_PROBE" 'planned-base drift summary and drift classificatio
754
758
  require_text "$PICKUP_PROBE" 'Update `CONTEXT.md` inline only for glossary-style durable terminology decisions' "pickup-probe limits CONTEXT.md updates"
755
759
  require_text "$PICKUP_PROBE" 'Challenge glossary conflicts immediately' "pickup-probe challenges domain terminology"
756
760
  require_text "$PICKUP_PROBE" 'Create a lazy context file only when a resolved term or workflow concept has no existing home' "pickup-probe creates context lazily"
757
- require_text "$PICKUP_PROBE" 'ADRs sparingly, only when all three are true' "pickup-probe limits ADRs"
758
- require_text "$PICKUP_PROBE" 'hard to reverse, surprising without context, and the result of a real trade-off' "pickup-probe keeps ADR threshold"
761
+ require_text "$PICKUP_PROBE" 'context/contracts/probe-docs-write-contract.md' "pickup-probe references probe docs-write contract"
762
+ require_text "$PICKUP_PROBE" 'Never propose or create a numbered ADR' "pickup-probe never proposes numbered ADRs"
763
+ require_text "$PICKUP_PROBE" 'Record a decision delta at .docs/decisions/<slug>\.md' "pickup-probe records decision delta"
764
+ require_text "$PICKUP_PROBE" 'propose revise-vs-create' "pickup-probe proposes revise-vs-create against the index"
765
+ require_text "$PICKUP_PROBE" 'session-archive. evidence ref' "pickup-probe links transcript provenance"
766
+ require_text "$DESIGN_PROBE" 'context/contracts/probe-docs-write-contract.md' "design-probe references probe docs-write contract"
767
+ require_text "$DESIGN_PROBE" 'Docs-Write: Two-Delta Emission' "design-probe documents two-delta emission"
768
+ require_text "$DESIGN_PROBE" 'Vocabulary delta' "design-probe documents vocabulary delta"
769
+ require_text "$DESIGN_PROBE" 'Decision delta' "design-probe documents decision delta"
770
+ require_text "$DESIGN_PROBE" 'Transcript provenance' "design-probe documents transcript provenance"
771
+ require_text "$DESIGN_PROBE" 'Do not write a numbered ADR' "design-probe never proposes numbered ADRs"
759
772
  require_text "$BUILDER_SHAPE" 'Product-level auto-guidance' "builder-shape guides idea-to-backlog"
760
773
  require_text "$BUILDER_SHAPE" 'do not require them to type `idea-to-backlog`' "builder-shape hides primitive typing"
761
774
  require_text "$PULL" 'probe_status' "pull-work records machine-checkable probe status"
@@ -277,6 +277,23 @@ After CI passes and the work is merged or otherwise accepted:
277
277
  2. Archive the working artifacts under `.kontourai/flow-agents/<slug>/archive/` or keep a stable link to them.
278
278
  3. Record provider records, verification evidence, durable docs targets, accepted gaps, and follow-up routing in durable docs or provider records.
279
279
  4. Promote the relevant plan, decision, evidence, and usage notes into long-lived docs such as `docs/`, `README.md`, or a project decision record.
280
+
281
+ **Assisted promotion path (Knowledge Kit `knowledge.promote` sub-flow).** Rather than
282
+ deciding WHAT to promote by ad-hoc judgement, run the Knowledge Kit promote sub-flow
283
+ (`kits/knowledge/flows/promote.flow.json`, id `knowledge.promote`) over the session
284
+ directory. It ingests the session's plan/evidence/critique/learnings/transcripts,
285
+ distills schema-valid DRAFT decision-registry deltas (per
286
+ `context/contracts/decision-registry-contract.md`), CONTEXT.md vocabulary additions,
287
+ and learning entries, links each to the PR + merge SHA + session archive + touched
288
+ topics, and health-checks the registry for contradictions (proposing merge-repair).
289
+ It is PROPOSALS-ONLY: every output lands under `<session>/proposals/` — it never writes
290
+ docs directly. You apply the drafts you accept, then record what was promoted where with
291
+ the `promote` CLI (below), which stays the recording mechanism. The sub-flow is the
292
+ assisted path; the `promote` claim is the gate. `knowledge.promote` is a true composable
293
+ FlowDefinition (a parent step can `uses_flow: "knowledge.promote"`); it is invoked here
294
+ from the promote step rather than nested as a builder gate because the `promote` CLI —
295
+ not a flow gate — is #312's recording mechanism and the sub-flow's outputs are proposals
296
+ a human/agent applies.
280
297
  5. Link the long-lived doc back to the provider record, archived plan artifact, or accepted evidence when useful so future readers can see why and how the feature was built.
281
298
  6. Confirm `.kontourai/flow-agents/` runtime artifacts remain untracked before merge to `main`.
282
299
  7. **Clean up the workspace once the merge is confirmed.** First verify the merge actually happened from the provider's own record (a merge commit / `mergedAt`) — not a green check or a watcher's exit code. Then honor the `worktree_lifecycle` recorded by `pull-work` (`retain_until: pr_merged`): remove the isolated worktree (`git worktree remove <path>`) and delete the now-merged branch locally and on the remote. Never delete a branch or worktree before the merge is confirmed — a closed-but-unmerged PR or a prematurely deleted branch loses work. The task is not done while it leaves a stale worktree or merged branch behind.