@kontourai/flow-agents 2.2.0 → 2.4.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 (250) hide show
  1. package/.github/CODEOWNERS +8 -0
  2. package/.github/workflows/ci.yml +32 -0
  3. package/AGENTS.md +4 -4
  4. package/CHANGELOG.md +46 -0
  5. package/CONTEXT.md +14 -0
  6. package/README.md +9 -3
  7. package/agents/dev.json +1 -1
  8. package/agents/tool-code-reviewer.json +1 -1
  9. package/agents/tool-planner.json +3 -3
  10. package/agents/tool-verifier.json +3 -3
  11. package/build/src/cli/console-learning-projection.js +3 -2
  12. package/build/src/cli/init.js +104 -21
  13. package/build/src/cli/kit.js +37 -6
  14. package/build/src/cli/pull-work-provider.js +1 -1
  15. package/build/src/cli/usage-feedback.js +3 -3
  16. package/build/src/cli/validate-hook-influence.js +1 -0
  17. package/build/src/cli/validate-workflow-artifacts.js +22 -6
  18. package/build/src/cli/workflow-artifact-cleanup-audit.js +36 -2
  19. package/build/src/cli/workflow-sidecar.d.ts +14 -7
  20. package/build/src/cli/workflow-sidecar.js +792 -69
  21. package/build/src/flow-kit/validate.d.ts +23 -0
  22. package/build/src/flow-kit/validate.js +67 -1
  23. package/build/src/index.d.ts +1 -1
  24. package/build/src/index.js +1 -1
  25. package/build/src/lib/flow-resolver.d.ts +6 -4
  26. package/build/src/lib/flow-resolver.js +125 -58
  27. package/build/src/lib/local-artifact-root.d.ts +14 -2
  28. package/build/src/lib/local-artifact-root.js +22 -5
  29. package/build/src/lib/workflow-learning-projection.js +2 -2
  30. package/build/src/runtime-adapters.d.ts +12 -0
  31. package/build/src/runtime-adapters.js +56 -16
  32. package/build/src/tools/build-universal-bundles.js +49 -16
  33. package/build/src/tools/generate-context-map.js +10 -8
  34. package/build/src/tools/validate-source-tree.js +23 -1
  35. package/console.telemetry.json +22 -0
  36. package/context/contracts/artifact-contract.md +38 -7
  37. package/context/contracts/builder-kit-workflow-state-contract.md +3 -2
  38. package/context/contracts/decision-registry-contract.md +263 -0
  39. package/context/contracts/delivery-contract.md +1 -1
  40. package/context/contracts/planning-contract.md +2 -1
  41. package/context/contracts/review-contract.md +5 -3
  42. package/context/contracts/verification-contract.md +9 -1
  43. package/context/contracts/work-item-contract.md +1 -1
  44. package/context/deferred/parallelization.md +1 -1
  45. package/context/gate-awareness.md +4 -4
  46. package/context/scripts/git-status.sh +2 -2
  47. package/context/scripts/hooks/config-protection.js +502 -2
  48. package/context/scripts/hooks/lib/config-protection-remedies.js +69 -0
  49. package/context/scripts/hooks/run-hook.js +14 -1
  50. package/context/scripts/hooks/stop-goal-fit.js +1140 -99
  51. package/context/scripts/hooks/workflow-steering.js +212 -6
  52. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  53. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +35 -0
  54. package/docs/adr/0019-kit-dependency-ownership.md +62 -0
  55. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +290 -0
  56. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +221 -0
  57. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +283 -0
  58. package/docs/agent-system-guidebook.md +3 -3
  59. package/docs/agent-usage-feedback-loop.md +6 -6
  60. package/docs/context-map.md +9 -7
  61. package/docs/decisions/decision-records.md +57 -0
  62. package/docs/decisions/index.md +14 -0
  63. package/docs/decisions/promotion-gate.md +52 -0
  64. package/docs/developer-architecture.md +1 -1
  65. package/docs/fixture-ownership.md +3 -0
  66. package/docs/flow-kit-repository-contract.md +5 -5
  67. package/docs/getting-started.md +12 -2
  68. package/docs/integrations/harness-install.md +2 -1
  69. package/docs/kit-authoring-guide.md +64 -5
  70. package/docs/knowledge-kit.md +5 -1
  71. package/docs/learnings/2026-07-improvement-program.md +203 -0
  72. package/docs/migrations.md +6 -5
  73. package/docs/north-star.md +2 -2
  74. package/docs/operating-layers.md +4 -3
  75. package/docs/repository-structure.md +5 -5
  76. package/docs/skills-map.md +8 -8
  77. package/docs/spec/runtime-hook-surface.md +19 -15
  78. package/docs/spikes/graph-provider-2026-07.md +155 -0
  79. package/docs/standards-register.md +7 -7
  80. package/docs/trust-anchor-adoption.md +105 -4
  81. package/docs/work-item-adapters.md +1 -1
  82. package/docs/workflow-artifact-lifecycle.md +72 -9
  83. package/docs/workflow-eval-strategy.md +3 -3
  84. package/docs/workflow-shared-contracts.md +1 -1
  85. package/docs/workflow-usage-guide.md +254 -21
  86. package/evals/acceptance/DEMO-false-completion.md +20 -11
  87. package/evals/acceptance/prove-capture-teeth-declared.sh +15 -15
  88. package/evals/acceptance/prove-capture-teeth.sh +11 -11
  89. package/evals/acceptance/test_kiro_harness.sh +1 -1
  90. package/evals/ci/antigaming-suite.sh +5 -0
  91. package/evals/ci/run-baseline.sh +65 -1
  92. package/evals/fixtures/flow-kit-repository/invalid-bad-dependency/flows/review.flow.json +26 -0
  93. package/evals/fixtures/flow-kit-repository/invalid-bad-dependency/kit.json +13 -0
  94. package/evals/fixtures/flow-kit-repository/valid-with-dependency/flows/review.flow.json +26 -0
  95. package/evals/fixtures/flow-kit-repository/valid-with-dependency/kit.json +13 -0
  96. package/evals/fixtures/hook-influence/cases.json +32 -0
  97. package/evals/fixtures/pull-work-provider/github-issues.json +6 -6
  98. package/evals/fixtures/trust-reconcile-exploits/fabricated-attestation.json +59 -0
  99. package/evals/fixtures/trust-reconcile-exploits/no-label-bypass.json +36 -0
  100. package/evals/fixtures/trust-reconcile-exploits/skip-assumed-bypass.json +59 -0
  101. package/evals/fixtures/trust-reconcile-exploits/status-misassertion.json +48 -0
  102. package/evals/fixtures/trust-reconcile-exploits/waived-command-check.json +44 -0
  103. package/evals/fixtures/trust-reconcile-mixed-bundle/mixed-bundle.json +159 -0
  104. package/evals/fixtures/trust-reconcile-ws3/ws3-bundle.json +920 -0
  105. package/evals/integration/test_actor_identity.sh +254 -0
  106. package/evals/integration/test_builder_step_producers.sh +33 -23
  107. package/evals/integration/test_bundle_install.sh +22 -16
  108. package/evals/integration/test_bundle_lifecycle.sh +11 -11
  109. package/evals/integration/test_captured_fail_reconciliation.sh +50 -50
  110. package/evals/integration/test_checkpoint_signing.sh +43 -0
  111. package/evals/integration/test_claim_lookup.sh +4 -4
  112. package/evals/integration/test_codex_hook_resolution.sh +114 -0
  113. package/evals/integration/test_command_log_concurrency.sh +8 -8
  114. package/evals/integration/test_command_log_fork_classification.sh +3 -3
  115. package/evals/integration/test_command_log_integrity.sh +16 -16
  116. package/evals/integration/test_console_learning_projection.sh +1 -1
  117. package/evals/integration/test_dual_emit_flow_step.sh +145 -0
  118. package/evals/integration/test_enforcer_expects_driven.sh +12 -12
  119. package/evals/integration/test_evidence_capture_hook.sh +14 -13
  120. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  121. package/evals/integration/test_flow_agents_statusline.sh +2 -2
  122. package/evals/integration/test_flow_kit_install_git.sh +52 -0
  123. package/evals/integration/test_flow_kit_repository.sh +2 -0
  124. package/evals/integration/test_flowdef_session_activation.sh +6 -6
  125. package/evals/integration/test_flowdef_union_floor_regression.sh +429 -0
  126. package/evals/integration/test_gate_bypass_chain.sh +52 -40
  127. package/evals/integration/test_gate_lockdown.sh +97 -73
  128. package/evals/integration/test_gate_review_inquiry_records.sh +3 -3
  129. package/evals/integration/test_goal_fit_escape_hatch.sh +10 -10
  130. package/evals/integration/test_goal_fit_ghost_session.sh +104 -0
  131. package/evals/integration/test_goal_fit_hook.sh +32 -32
  132. package/evals/integration/test_goal_fit_rederive.sh +9 -9
  133. package/evals/integration/test_hook_category_behaviors.sh +103 -0
  134. package/evals/integration/test_hook_influence_cases.sh +1 -0
  135. package/evals/integration/test_install_merge.sh +262 -1
  136. package/evals/integration/test_kit_identity_trust.sh +6 -6
  137. package/evals/integration/test_liveness_conflict_injection.sh +587 -0
  138. package/evals/integration/test_liveness_heartbeat.sh +677 -0
  139. package/evals/integration/test_liveness_verdict.sh +394 -0
  140. package/evals/integration/test_local_flow_kit_install.sh +15 -0
  141. package/evals/integration/test_phase_map_and_gate_claim.sh +63 -11
  142. package/evals/integration/test_promote_gate.sh +178 -0
  143. package/evals/integration/test_publish_delivery.sh +2 -2
  144. package/evals/integration/test_pull_work_liveness_preflight.sh +386 -0
  145. package/evals/integration/test_pull_work_provider.sh +2 -2
  146. package/evals/integration/test_reconcile_soundness.sh +4 -4
  147. package/evals/integration/test_resolvefirststep_security.sh +22 -0
  148. package/evals/integration/test_runtime_adapter_activation.sh +87 -18
  149. package/evals/integration/test_session_resume_roundtrip.sh +147 -0
  150. package/evals/integration/test_sidecar_field_preservation.sh +249 -0
  151. package/evals/integration/test_trust_reconcile.sh +2 -2
  152. package/evals/integration/test_trust_reconcile_manifest.sh +79 -0
  153. package/evals/integration/test_trust_reconcile_mixed_bundle.sh +96 -0
  154. package/evals/integration/test_trust_reconcile_negatives.sh +151 -0
  155. package/evals/integration/test_usage_feedback_global.sh +5 -5
  156. package/evals/integration/test_validate_artifacts_portability.sh +60 -0
  157. package/evals/integration/test_verify_cli.sh +2 -2
  158. package/evals/integration/test_veritas_governance_kit.sh +117 -0
  159. package/evals/integration/test_workflow_artifact_cleanup_audit.sh +26 -1
  160. package/evals/integration/test_workflow_artifacts.sh +33 -0
  161. package/evals/integration/test_workflow_sidecar_writer.sh +723 -74
  162. package/evals/integration/test_workflow_steering_hook.sh +175 -5
  163. package/evals/lib/codex-provider.sh +1 -1
  164. package/evals/lib/node.sh +19 -1
  165. package/evals/run.sh +22 -0
  166. package/evals/static/test_decisions.sh +150 -0
  167. package/evals/static/test_flowdef_codeowners_coverage.sh +56 -0
  168. package/evals/static/test_package.sh +19 -10
  169. package/evals/static/test_universal_bundles.sh +48 -3
  170. package/evals/static/test_workflow_skills.sh +41 -8
  171. package/kits/builder/flows/build.flow.json +3 -74
  172. package/kits/builder/flows/publish-learn.flow.json +90 -0
  173. package/kits/builder/kit.json +11 -0
  174. package/kits/builder/skills/builder-shape/SKILL.md +1 -1
  175. package/kits/builder/skills/deliver/SKILL.md +13 -11
  176. package/kits/builder/skills/design-probe/SKILL.md +4 -4
  177. package/kits/builder/skills/evidence-gate/SKILL.md +26 -1
  178. package/kits/builder/skills/execute-plan/SKILL.md +2 -2
  179. package/kits/builder/skills/fix-bug/SKILL.md +2 -0
  180. package/kits/builder/skills/gate-review/SKILL.md +6 -6
  181. package/kits/builder/skills/idea-to-backlog/SKILL.md +1 -1
  182. package/kits/builder/skills/learning-review/SKILL.md +7 -7
  183. package/kits/builder/skills/pickup-probe/SKILL.md +5 -4
  184. package/kits/builder/skills/plan-work/SKILL.md +27 -5
  185. package/kits/builder/skills/pull-work/SKILL.md +80 -5
  186. package/kits/builder/skills/release-readiness/SKILL.md +4 -4
  187. package/kits/builder/skills/review-work/SKILL.md +24 -1
  188. package/kits/builder/skills/tdd-workflow/SKILL.md +2 -0
  189. package/kits/builder/skills/verify-work/SKILL.md +8 -1
  190. package/kits/catalog.json +6 -0
  191. package/kits/veritas-governance/adapter/readiness-to-trust-bundle.mjs +178 -0
  192. package/kits/veritas-governance/docs/README.md +75 -0
  193. package/kits/veritas-governance/fixtures/readiness/not-ready.readiness-report.json +4645 -0
  194. package/kits/veritas-governance/fixtures/readiness/ready.readiness-report.json +4403 -0
  195. package/kits/veritas-governance/flows/readiness-check.flow.json +35 -0
  196. package/kits/veritas-governance/kit.json +16 -0
  197. package/package.json +8 -4
  198. package/packaging/README.md +2 -2
  199. package/packaging/conformance/fixtures/config-protection--allow-read-state-json.json +20 -0
  200. package/packaging/conformance/fixtures/config-protection--block-write-state-json.json +20 -0
  201. package/packaging/conformance/fixtures/stop-goal-fit--block-bundle-disputed-claim.json +1 -1
  202. package/packaging/conformance/fixtures/stop-goal-fit--block-capture-contradicts-claimed-pass.json +1 -1
  203. package/packaging/manifest.json +9 -10
  204. package/schemas/decision-record.schema.json +109 -0
  205. package/schemas/workflow-evidence.schema.json +1 -1
  206. package/schemas/workflow-state.schema.json +5 -0
  207. package/scripts/README.md +8 -3
  208. package/scripts/check-content-boundary.cjs +1 -1
  209. package/scripts/check-decisions.cjs +356 -0
  210. package/scripts/ci/derive-claim-status.mjs +74 -0
  211. package/scripts/ci/trust-reconcile.js +436 -79
  212. package/scripts/git-status.sh +2 -2
  213. package/scripts/hooks/claude-telemetry-hook.js +37 -3
  214. package/scripts/hooks/codex-telemetry-hook.js +36 -2
  215. package/scripts/hooks/config-protection.js +102 -30
  216. package/scripts/hooks/evidence-capture.js +3 -3
  217. package/scripts/hooks/lib/actor-identity.js +368 -0
  218. package/scripts/hooks/lib/config-protection-remedies.js +69 -0
  219. package/scripts/hooks/lib/liveness-heartbeat.js +361 -0
  220. package/scripts/hooks/lib/liveness-policy.js +127 -0
  221. package/scripts/hooks/lib/liveness-read.js +105 -12
  222. package/scripts/hooks/lib/liveness-write.js +46 -0
  223. package/scripts/hooks/lib/local-artifact-paths.js +12 -6
  224. package/scripts/hooks/opencode-telemetry-hook.js +27 -1
  225. package/scripts/hooks/pi-telemetry-hook.js +27 -1
  226. package/scripts/hooks/stop-goal-fit.js +157 -26
  227. package/scripts/hooks/workflow-steering.js +77 -3
  228. package/scripts/install-codex-home.sh +138 -38
  229. package/scripts/statusline/flow-agents-statusline.js +2 -2
  230. package/src/cli/console-learning-projection.ts +3 -2
  231. package/src/cli/init.ts +101 -21
  232. package/src/cli/kit.ts +37 -6
  233. package/src/cli/public-api.test.mjs +58 -6
  234. package/src/cli/pull-work-provider.ts +1 -1
  235. package/src/cli/trust-bundle-policy-order.test.mjs +87 -0
  236. package/src/cli/usage-feedback.ts +3 -3
  237. package/src/cli/validate-hook-influence.ts +1 -0
  238. package/src/cli/validate-workflow-artifacts.ts +22 -6
  239. package/src/cli/veritas-readiness-adapter.test.mjs +267 -0
  240. package/src/cli/workflow-artifact-cleanup-audit.ts +35 -2
  241. package/src/cli/workflow-sidecar.ts +795 -71
  242. package/src/flow-kit/validate.ts +74 -1
  243. package/src/index.ts +6 -2
  244. package/src/lib/flow-resolver.ts +123 -55
  245. package/src/lib/local-artifact-root.ts +24 -5
  246. package/src/lib/workflow-learning-projection.ts +2 -2
  247. package/src/runtime-adapters.ts +61 -15
  248. package/src/tools/build-universal-bundles.ts +49 -16
  249. package/src/tools/generate-context-map.ts +10 -8
  250. package/src/tools/validate-source-tree.ts +23 -1
@@ -0,0 +1,677 @@
1
+ #!/usr/bin/env bash
2
+ # test_liveness_heartbeat.sh — integration eval for the shared tool-activity
3
+ # liveness heartbeat (issue #288, Wave 3 Task 3.2 of the plan artifact at
4
+ # .kontourai/flow-agents/kontourai-flow-agents-288/kontourai-flow-agents-288--plan-work.md).
5
+ #
6
+ # Covers, per the plan and AC3/AC4/AC5/AC8:
7
+ # A. Policy semantics (scripts/hooks/lib/liveness-policy.js):
8
+ # default-on when unset/empty/arbitrary value; explicit opt-out for each
9
+ # of off|0|false|no|disabled (case-insensitive, whitespace-trimmed).
10
+ # B. maybeEmitHeartbeat({cwd, env, now}) throttle boundary, called directly
11
+ # via a stdin-piped `node -` script with an injected `now` for
12
+ # deterministic <60s throttled / >=60s emits proof (the plan's
13
+ # preferred, deterministic option (a)).
14
+ # C. maybeEmitHeartbeat skip reasons: no-current (no active_slug at all),
15
+ # no-claim (active_slug set but no prior claim event for the resolved
16
+ # actor), released (actor's last event is a release), disabled (explicit
17
+ # opt-out wins even with a fresh claim present), actor-unresolved (wins
18
+ # even with a fresh claim present for a different, resolvable actor).
19
+ # D. One end-to-end wrapper invocation (claude-telemetry-hook.js with a
20
+ # PostToolUse fixture) proving a heartbeat is appended for a fresh claim,
21
+ # plus a fail-open proof when the sidecar snapshot file is corrupted
22
+ # (malformed JSON).
23
+ # E. Telemetry-toggle independence (AC5): TELEMETRY_ENABLED=false still
24
+ # heartbeats — liveness heartbeats do not read/depend on that gate.
25
+ # F. Cross-runtime smoke: codex-telemetry-hook.js (distinct event-name path:
26
+ # raw eventType passed straight through, no canonicalEvent() mapping).
27
+ # G. Wrapper-level throttle smoke: two back-to-back real-clock invocations
28
+ # append exactly one heartbeat (the deterministic node stdin-script
29
+ # proof in B is the precise boundary test; this is the end-to-end
30
+ # companion the plan also asks for).
31
+ #
32
+ # Deterministic, no model spend, self-cleaning (mktemp -d + trap EXIT).
33
+ # Usage: bash evals/integration/test_liveness_heartbeat.sh
34
+
35
+ set -uo pipefail
36
+
37
+ ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
38
+ LIB_DIR="$ROOT/scripts/hooks/lib"
39
+ POLICY_MODULE="$LIB_DIR/liveness-policy.js"
40
+ HEARTBEAT_MODULE="$LIB_DIR/liveness-heartbeat.js"
41
+ READ_MODULE="$LIB_DIR/liveness-read.js"
42
+ ACTOR_MODULE="$LIB_DIR/actor-identity.js"
43
+ CLAUDE_HOOK="$ROOT/scripts/hooks/claude-telemetry-hook.js"
44
+ CODEX_HOOK="$ROOT/scripts/hooks/codex-telemetry-hook.js"
45
+
46
+ for m in "$POLICY_MODULE" "$HEARTBEAT_MODULE" "$READ_MODULE" "$ACTOR_MODULE" "$CLAUDE_HOOK" "$CODEX_HOOK"; do
47
+ if [[ ! -f "$m" ]]; then
48
+ echo "liveness heartbeat eval skipped: $m does not exist yet." >&2
49
+ exit 1
50
+ fi
51
+ done
52
+
53
+ TMPDIR_EVAL="$(mktemp -d)"
54
+ trap 'rm -rf "$TMPDIR_EVAL"' EXIT
55
+
56
+ errors=0
57
+ _pass() { echo " ✓ $1"; }
58
+ _fail() { echo " ✗ $1"; errors=$((errors + 1)); }
59
+
60
+ echo "=== Liveness heartbeat integration (#288) ==="
61
+
62
+ # ─── Fixture helpers ────────────────────────────────────────────────────────
63
+
64
+ SNAPSHOT_FILENAME="current.json"
65
+
66
+ # new_scratch — fresh scratch directory under the eval's own tmpdir.
67
+ new_scratch() {
68
+ mktemp -d "$TMPDIR_EVAL/scratch-XXXXXX"
69
+ }
70
+
71
+ # seed_claim <scratch_root> <slug> <actor> <at_iso> [ttl_seconds]
72
+ # Seeds <root>/.kontourai/flow-agents/<SNAPSHOT_FILENAME> (active_slug) and a
73
+ # single `claim` event in liveness/events.jsonl for <actor>, matching the
74
+ # exact shape workflow-sidecar.ts's liveness writer produces
75
+ # (subjectId/actor/at/ttlSeconds).
76
+ seed_claim() {
77
+ local root="$1" slug="$2" actor="$3" at="$4" ttl="${5:-1800}"
78
+ local artifact_root="$root/.kontourai/flow-agents"
79
+ mkdir -p "$artifact_root/liveness"
80
+ printf '{"active_slug":"%s"}\n' "$slug" >"$artifact_root/$SNAPSHOT_FILENAME"
81
+ printf '{"type":"claim","subjectId":"%s","actor":"%s","at":"%s","ttlSeconds":%s}\n' \
82
+ "$slug" "$actor" "$at" "$ttl" >"$artifact_root/liveness/events.jsonl"
83
+ }
84
+
85
+ # append_release <scratch_root> <slug> <actor> <at_iso>
86
+ append_release() {
87
+ local root="$1" slug="$2" actor="$3" at="$4"
88
+ local stream="$root/.kontourai/flow-agents/liveness/events.jsonl"
89
+ printf '{"type":"release","subjectId":"%s","actor":"%s","at":"%s"}\n' "$slug" "$actor" "$at" >>"$stream"
90
+ }
91
+
92
+ stream_file() {
93
+ printf '%s' "$1/.kontourai/flow-agents/liveness/events.jsonl"
94
+ }
95
+
96
+ stream_line_count() {
97
+ local f
98
+ f="$(stream_file "$1")"
99
+ [[ -f "$f" ]] && wc -l <"$f" | tr -d ' ' || echo 0
100
+ }
101
+
102
+ # iso_offset_ms <delta_ms> — real-clock ISO timestamp `delta_ms` from now (used
103
+ # to seed a claim that is definitely past the heartbeat throttle window).
104
+ iso_offset_ms() {
105
+ DELTA_MS="$1" node - <<'NODE'
106
+ process.stdout.write(new Date(Date.now() + Number(process.env.DELTA_MS)).toISOString());
107
+ NODE
108
+ }
109
+
110
+ # is_valid_json_stdin <string> — true if the given string parses as JSON.
111
+ is_valid_json() {
112
+ printf '%s' "$1" | node - <<'NODE'
113
+ let raw = '';
114
+ process.stdin.on('data', c => (raw += c));
115
+ process.stdin.on('end', () => {
116
+ try {
117
+ JSON.parse(raw);
118
+ process.exit(0);
119
+ } catch {
120
+ process.exit(1);
121
+ }
122
+ });
123
+ NODE
124
+ }
125
+
126
+ # call_heartbeat <scratch_root> <env_json> [now_iso]
127
+ # Calls maybeEmitHeartbeat({cwd, env, now}) directly via a stdin-piped `node -`
128
+ # script (env passed through the process environment, not argv) and prints
129
+ # its JSON result (single line, stable key order per liveness-heartbeat.js).
130
+ call_heartbeat() {
131
+ local root="$1" env_json="$2" now="${3:-}"
132
+ MODULE_ARG="$HEARTBEAT_MODULE" ROOT_ARG="$root" ENV_JSON_ARG="$env_json" NOW_ARG="$now" \
133
+ node - <<'NODE'
134
+ const { maybeEmitHeartbeat } = require(process.env.MODULE_ARG);
135
+ const env = JSON.parse(process.env.ENV_JSON_ARG);
136
+ const opts = { cwd: process.env.ROOT_ARG, env };
137
+ if (process.env.NOW_ARG) opts.now = process.env.NOW_ARG;
138
+ process.stdout.write(JSON.stringify(maybeEmitHeartbeat(opts)));
139
+ NODE
140
+ }
141
+
142
+ # ─── A. Policy semantics (liveness-policy.js) ───────────────────────────────
143
+ echo "--- A. Policy semantics ---"
144
+
145
+ if MODULE_ARG="$POLICY_MODULE" node - <<'NODE'
146
+ const { isLivenessEnabled } = require(process.env.MODULE_ARG);
147
+ if (isLivenessEnabled({}) !== true) throw new Error("isLivenessEnabled({}) (unset) must default to true");
148
+ if (isLivenessEnabled({ FLOW_AGENTS_LIVENESS: "" }) !== true) throw new Error("isLivenessEnabled with empty string must default to true");
149
+ if (isLivenessEnabled({ FLOW_AGENTS_LIVENESS: "on" }) !== true) throw new Error("isLivenessEnabled('on') must be enabled (any non-off-token enables)");
150
+ if (isLivenessEnabled({ FLOW_AGENTS_LIVENESS: "1" }) !== true) throw new Error("isLivenessEnabled('1') must be enabled (any non-off-token enables)");
151
+ NODE
152
+ then
153
+ _pass "isLivenessEnabled defaults to true when unset/empty and for arbitrary non-off values (AC1)"
154
+ else
155
+ _fail "isLivenessEnabled did not default-on as expected"
156
+ fi
157
+
158
+ for token in off OFF Off " off " 0 false False FALSE no No NO disabled Disabled " disabled "; do
159
+ if MODULE_ARG="$POLICY_MODULE" TOKEN_ARG="$token" node - <<'NODE'
160
+ const { isLivenessEnabled } = require(process.env.MODULE_ARG);
161
+ const token = process.env.TOKEN_ARG;
162
+ const out = isLivenessEnabled({ FLOW_AGENTS_LIVENESS: token });
163
+ if (out !== false) throw new Error(`isLivenessEnabled did not disable for token ${JSON.stringify(token)}: got ${out}`);
164
+ NODE
165
+ then
166
+ _pass "isLivenessEnabled honors opt-out token $(printf '%q' "$token") (AC1)"
167
+ else
168
+ _fail "isLivenessEnabled did not honor opt-out token $(printf '%q' "$token")"
169
+ fi
170
+ done
171
+
172
+ # F4 (#288 fix iteration 1, sec-LOW): zero-width/format characters (U+200B/U+200C/U+200D/U+FEFF)
173
+ # must be stripped before the opt-out token compare — 'off<ZWSP>' must still disable. Codepoints
174
+ # are constructed entirely inside the node script (via String.fromCodePoint on a decimal
175
+ # codepoint passed through the env) to avoid ever embedding an invisible/BOM character literally
176
+ # in this shell script's own source text.
177
+ for zw_spec in "ZWSP:0x200B:before" "ZWJ:0x200D:middle" "ZWNJ:0x200C:middle" "BOM:0xFEFF:before"; do
178
+ zw_name="${zw_spec%%:*}"
179
+ zw_rest="${zw_spec#*:}"
180
+ zw_codepoint="${zw_rest%%:*}"
181
+ zw_position="${zw_rest#*:}"
182
+ if ZW_CODEPOINT_ARG="$zw_codepoint" ZW_POSITION_ARG="$zw_position" MODULE_ARG="$POLICY_MODULE" node - <<'NODE'
183
+ const { isLivenessEnabled } = require(process.env.MODULE_ARG);
184
+ const zwChar = String.fromCodePoint(Number(process.env.ZW_CODEPOINT_ARG));
185
+ const token = process.env.ZW_POSITION_ARG === 'before' ? `${zwChar}off` : `o${zwChar}ff`;
186
+ const out = isLivenessEnabled({ FLOW_AGENTS_LIVENESS: token });
187
+ if (out !== false) throw new Error(`isLivenessEnabled did not disable for a zero-width-embedded token: got ${out}`);
188
+ NODE
189
+ then
190
+ _pass "isLivenessEnabled strips zero-width/format chars ($zw_name) before comparing the opt-out token (F4)"
191
+ else
192
+ _fail "isLivenessEnabled did not strip zero-width/format chars ($zw_name) before comparing the opt-out token"
193
+ fi
194
+ done
195
+
196
+ # F7 (#288 fix iteration 1, cr-LOW): parsePositiveIntOr uses a strict /^[0-9]+$/ literal match —
197
+ # non-integer-literal coercions Number() would otherwise accept (hex "0x10", exponential "1e3")
198
+ # must fall back to the default, not be silently accepted as 16 / 1000.
199
+ if MODULE_ARG="$POLICY_MODULE" node - <<'NODE'
200
+ const { resolveTtlSeconds, resolveHeartbeatThrottleSeconds, DEFAULT_TTL_SECONDS, DEFAULT_HEARTBEAT_THROTTLE_SECONDS } = require(process.env.MODULE_ARG);
201
+ const cases = [
202
+ ["FLOW_AGENTS_LIVENESS_TTL_SECONDS", "0x10", resolveTtlSeconds, DEFAULT_TTL_SECONDS],
203
+ ["FLOW_AGENTS_LIVENESS_TTL_SECONDS", "1e3", resolveTtlSeconds, DEFAULT_TTL_SECONDS],
204
+ ["FLOW_AGENTS_LIVENESS_HEARTBEAT_THROTTLE_SECONDS", "0x10", resolveHeartbeatThrottleSeconds, DEFAULT_HEARTBEAT_THROTTLE_SECONDS],
205
+ ["FLOW_AGENTS_LIVENESS_HEARTBEAT_THROTTLE_SECONDS", "1e3", resolveHeartbeatThrottleSeconds, DEFAULT_HEARTBEAT_THROTTLE_SECONDS],
206
+ ];
207
+ for (const [envVar, rawValue, resolver, expectedDefault] of cases) {
208
+ const got = resolver({ [envVar]: rawValue });
209
+ if (got !== expectedDefault) {
210
+ throw new Error(`${envVar}=${rawValue} should fall back to default ${expectedDefault}, got ${got}`);
211
+ }
212
+ }
213
+ // A genuine strict positive-integer literal must still parse correctly.
214
+ if (resolveTtlSeconds({ FLOW_AGENTS_LIVENESS_TTL_SECONDS: "300" }) !== 300) {
215
+ throw new Error("resolveTtlSeconds('300') should resolve to 300");
216
+ }
217
+ NODE
218
+ then
219
+ _pass "parsePositiveIntOr rejects hex (0x10) / exponential (1e3) coercions and falls back to defaults (F7)"
220
+ else
221
+ _fail "parsePositiveIntOr did not reject hex/exponential coercions as expected"
222
+ fi
223
+
224
+ # ─── B. maybeEmitHeartbeat throttle boundary (direct, injected `now`) ───────
225
+ echo "--- B. Throttle boundary (deterministic, injected now) ---"
226
+
227
+ B_ROOT="$(new_scratch)"
228
+ seed_claim "$B_ROOT" "b-subj" "agent-b" "2026-06-25T12:00:00.000Z" 1800
229
+
230
+ B_59S="$(call_heartbeat "$B_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-b"}' "2026-06-25T12:00:59.000Z")"
231
+ if [[ "$B_59S" == '{"emitted":false,"reason":"throttled"}' ]] && [[ "$(stream_line_count "$B_ROOT")" -eq 1 ]]; then
232
+ _pass "maybeEmitHeartbeat throttles at 59s since last event (< 60s, no append) (AC3)"
233
+ else
234
+ _fail "maybeEmitHeartbeat did not throttle at 59s: result=$B_59S lines=$(stream_line_count "$B_ROOT")"
235
+ fi
236
+
237
+ B_60S="$(call_heartbeat "$B_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-b"}' "2026-06-25T12:01:00.000Z")"
238
+ if [[ "$B_60S" == '{"emitted":true}' ]] && [[ "$(stream_line_count "$B_ROOT")" -eq 2 ]]; then
239
+ _pass "maybeEmitHeartbeat emits at exactly 60s since last event (>= 60s boundary) (AC3)"
240
+ else
241
+ _fail "maybeEmitHeartbeat did not emit at the 60s boundary: result=$B_60S lines=$(stream_line_count "$B_ROOT")"
242
+ fi
243
+
244
+ # Chained throttle: the newly-appended heartbeat is now the "last matching event"; an
245
+ # immediate follow-up call (0s later) must throttle against IT, not the original claim.
246
+ B_CHAIN="$(call_heartbeat "$B_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-b"}' "2026-06-25T12:01:00.000Z")"
247
+ if [[ "$B_CHAIN" == '{"emitted":false,"reason":"throttled"}' ]] && [[ "$(stream_line_count "$B_ROOT")" -eq 2 ]]; then
248
+ _pass "maybeEmitHeartbeat throttle re-anchors to the most recent heartbeat, not the original claim (AC3)"
249
+ else
250
+ _fail "maybeEmitHeartbeat throttle did not re-anchor to the most recent heartbeat: result=$B_CHAIN lines=$(stream_line_count "$B_ROOT")"
251
+ fi
252
+
253
+ # F2 (#288 fix iteration 1, cr-HIGH coverage gap): FLOW_AGENTS_LIVENESS_HEARTBEAT_THROTTLE_SECONDS
254
+ # moves the throttle boundary to a custom value (5s here, not the 60s default) — proves the env
255
+ # var is actually consulted, not just the hard-coded default.
256
+ B2_ROOT="$(new_scratch)"
257
+ seed_claim "$B2_ROOT" "b2-subj" "agent-b2" "2026-06-25T12:00:00.000Z" 1800
258
+
259
+ B2_4S="$(call_heartbeat "$B2_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-b2","FLOW_AGENTS_LIVENESS_HEARTBEAT_THROTTLE_SECONDS":"5"}' "2026-06-25T12:00:04.000Z")"
260
+ if [[ "$B2_4S" == '{"emitted":false,"reason":"throttled"}' ]] && [[ "$(stream_line_count "$B2_ROOT")" -eq 1 ]]; then
261
+ _pass "maybeEmitHeartbeat honors a custom FLOW_AGENTS_LIVENESS_HEARTBEAT_THROTTLE_SECONDS=5: throttles at 4s (F2, AC3)"
262
+ else
263
+ _fail "maybeEmitHeartbeat did not throttle at 4s under a custom 5s throttle: result=$B2_4S lines=$(stream_line_count "$B2_ROOT")"
264
+ fi
265
+
266
+ B2_5S="$(call_heartbeat "$B2_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-b2","FLOW_AGENTS_LIVENESS_HEARTBEAT_THROTTLE_SECONDS":"5"}' "2026-06-25T12:00:05.000Z")"
267
+ if [[ "$B2_5S" == '{"emitted":true}' ]] && [[ "$(stream_line_count "$B2_ROOT")" -eq 2 ]]; then
268
+ _pass "maybeEmitHeartbeat honors a custom FLOW_AGENTS_LIVENESS_HEARTBEAT_THROTTLE_SECONDS=5: emits at the 5s boundary, not the 60s default (F2, AC3)"
269
+ else
270
+ _fail "maybeEmitHeartbeat did not emit at the custom 5s boundary: result=$B2_5S lines=$(stream_line_count "$B2_ROOT")"
271
+ fi
272
+
273
+ # The default (no env override) at the SAME 5s elapsed must still throttle (60s default) —
274
+ # proves the custom-value boundary shift in B2 is real, not a fixture artifact.
275
+ B2_DEFAULT_ROOT="$(new_scratch)"
276
+ seed_claim "$B2_DEFAULT_ROOT" "b2d-subj" "agent-b2d" "2026-06-25T12:00:00.000Z" 1800
277
+ B2_DEFAULT="$(call_heartbeat "$B2_DEFAULT_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-b2d"}' "2026-06-25T12:00:05.000Z")"
278
+ if [[ "$B2_DEFAULT" == '{"emitted":false,"reason":"throttled"}' ]] && [[ "$(stream_line_count "$B2_DEFAULT_ROOT")" -eq 1 ]]; then
279
+ _pass "maybeEmitHeartbeat default throttle (60s) still applies at 5s elapsed with no env override (F2 control case)"
280
+ else
281
+ _fail "maybeEmitHeartbeat default throttle did not hold at 5s elapsed: result=$B2_DEFAULT lines=$(stream_line_count "$B2_DEFAULT_ROOT")"
282
+ fi
283
+
284
+ # ─── C. Skip reasons: no-current / no-claim / released / disabled / actor-unresolved ──
285
+ echo "--- C. Skip reasons ---"
286
+
287
+ # C1: the sidecar snapshot is missing entirely -> no-current, no stream file created.
288
+ C1_ROOT="$(new_scratch)"
289
+ mkdir -p "$C1_ROOT/.kontourai/flow-agents"
290
+ C1_RESULT="$(call_heartbeat "$C1_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-c1"}')"
291
+ if [[ "$C1_RESULT" == '{"emitted":false,"reason":"no-current"}' ]] && [[ ! -f "$(stream_file "$C1_ROOT")" ]]; then
292
+ _pass "maybeEmitHeartbeat returns no-current when the sidecar snapshot / active_slug is absent (AC4)"
293
+ else
294
+ _fail "maybeEmitHeartbeat did not report no-current for a missing sidecar snapshot: result=$C1_RESULT"
295
+ fi
296
+
297
+ # C2: the sidecar snapshot names a slug with no prior events at all -> no-claim.
298
+ C2_ROOT="$(new_scratch)"
299
+ mkdir -p "$C2_ROOT/.kontourai/flow-agents"
300
+ printf '{"active_slug":"c2-subj"}\n' >"$C2_ROOT/.kontourai/flow-agents/$SNAPSHOT_FILENAME"
301
+ C2_RESULT="$(call_heartbeat "$C2_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-c2"}')"
302
+ if [[ "$C2_RESULT" == '{"emitted":false,"reason":"no-claim"}' ]]; then
303
+ _pass "maybeEmitHeartbeat returns no-claim when the named subject has no prior claim for the actor (AC4)"
304
+ else
305
+ _fail "maybeEmitHeartbeat did not report no-claim for an unclaimed subject: result=$C2_RESULT"
306
+ fi
307
+
308
+ # C3: a released claim (actor's last event is release) -> released, never re-claims.
309
+ C3_ROOT="$(new_scratch)"
310
+ seed_claim "$C3_ROOT" "c3-subj" "agent-c3" "2026-06-25T11:00:00.000Z" 1800
311
+ append_release "$C3_ROOT" "c3-subj" "agent-c3" "2026-06-25T11:05:00.000Z"
312
+ C3_RESULT="$(call_heartbeat "$C3_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-c3"}' "2026-06-25T11:10:00.000Z")"
313
+ if [[ "$C3_RESULT" == '{"emitted":false,"reason":"released"}' ]] && [[ "$(stream_line_count "$C3_ROOT")" -eq 2 ]]; then
314
+ _pass "maybeEmitHeartbeat returns released when the actor's last event for the subject is a release (AC4)"
315
+ else
316
+ _fail "maybeEmitHeartbeat did not report released after a release event: result=$C3_RESULT lines=$(stream_line_count "$C3_ROOT")"
317
+ fi
318
+
319
+ # C4: explicit disabled wins even with a fresh, unreleased claim present.
320
+ C4_ROOT="$(new_scratch)"
321
+ seed_claim "$C4_ROOT" "c4-subj" "agent-c4" "2026-06-25T11:00:00.000Z" 1800
322
+ C4_RESULT="$(call_heartbeat "$C4_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-c4","FLOW_AGENTS_LIVENESS":"off"}' "2026-06-25T11:01:00.000Z")"
323
+ if [[ "$C4_RESULT" == '{"emitted":false,"reason":"disabled"}' ]] && [[ "$(stream_line_count "$C4_ROOT")" -eq 1 ]]; then
324
+ _pass "maybeEmitHeartbeat returns disabled and wins over a fresh claim (AC1, AC4)"
325
+ else
326
+ _fail "maybeEmitHeartbeat did not report disabled with a fresh claim present: result=$C4_RESULT lines=$(stream_line_count "$C4_ROOT")"
327
+ fi
328
+
329
+ # C5: actor-unresolved wins even with a fresh claim present for a DIFFERENT, resolvable
330
+ # actor (proves the actor-unresolved check runs before the claim lookup, per the plan's
331
+ # step ordering, not merely "no claim for the unresolved actor").
332
+ C5_ROOT="$(new_scratch)"
333
+ seed_claim "$C5_ROOT" "c5-subj" "agent-c5-other" "2026-06-25T11:00:00.000Z" 1800
334
+ C5_RESULT="$(call_heartbeat "$C5_ROOT" '{"FLOW_AGENTS_ACTOR_TEST_FORCE_UNRESOLVED":"1","NODE_ENV":"test"}' "2026-06-25T11:01:00.000Z")"
335
+ if [[ "$C5_RESULT" == '{"emitted":false,"reason":"actor-unresolved"}' ]] && [[ "$(stream_line_count "$C5_ROOT")" -eq 1 ]]; then
336
+ _pass "maybeEmitHeartbeat returns actor-unresolved and wins over a fresh claim held by another actor (AC4)"
337
+ else
338
+ _fail "maybeEmitHeartbeat did not report actor-unresolved: result=$C5_RESULT lines=$(stream_line_count "$C5_ROOT")"
339
+ fi
340
+
341
+ # ─── D. End-to-end wrapper invocation (claude-telemetry-hook.js) ───────────
342
+ echo "--- D. End-to-end wrapper (claude-telemetry-hook.js) ---"
343
+
344
+ POST_TOOL_USE_PAYLOAD='{"hook_event_name":"PostToolUse","tool_name":"Bash","tool_input":{"command":"echo hi"},"tool_response":{"stdout":"hi"}}'
345
+
346
+ # D1: a fresh (well past the 60s throttle, real clock) claim exists for the resolved
347
+ # actor -> exactly one heartbeat event is appended by the wrapper's postToolUse path.
348
+ D1_ROOT="$(new_scratch)"
349
+ D1_STALE_AT="$(iso_offset_ms -300000)"
350
+ seed_claim "$D1_ROOT" "d1-subj" "agent-hb" "$D1_STALE_AT" 1800
351
+ D1_OUT="$(cd "$D1_ROOT" && TELEMETRY_ENABLED=false FLOW_AGENTS_ACTOR=agent-hb \
352
+ node "$CLAUDE_HOOK" PostToolUse dev <<<"$POST_TOOL_USE_PAYLOAD")"
353
+ D1_STATUS=$?
354
+ if [[ "$D1_STATUS" -eq 0 ]] \
355
+ && is_valid_json "$D1_OUT" \
356
+ && [[ "$(stream_line_count "$D1_ROOT")" -eq 2 ]] \
357
+ && grep -q '"type":"heartbeat"' "$(stream_file "$D1_ROOT")"; then
358
+ _pass "claude-telemetry-hook.js PostToolUse appends exactly one heartbeat for a fresh claim (AC3)"
359
+ else
360
+ _fail "claude-telemetry-hook.js PostToolUse did not append a heartbeat as expected: status=$D1_STATUS out=$D1_OUT lines=$(stream_line_count "$D1_ROOT")"
361
+ fi
362
+
363
+ # D2: a corrupted sidecar snapshot (malformed JSON) fails open — wrapper still exits 0
364
+ # with its normal stdout contract, and no heartbeat is fabricated from unparseable state.
365
+ D2_ROOT="$(new_scratch)"
366
+ mkdir -p "$D2_ROOT/.kontourai/flow-agents/liveness"
367
+ printf '{not valid json' >"$D2_ROOT/.kontourai/flow-agents/$SNAPSHOT_FILENAME"
368
+ D2_OUT="$(cd "$D2_ROOT" && TELEMETRY_ENABLED=false FLOW_AGENTS_ACTOR=agent-hb \
369
+ node "$CLAUDE_HOOK" PostToolUse dev <<<"$POST_TOOL_USE_PAYLOAD" 2>"$TMPDIR_EVAL/d2.err")"
370
+ D2_STATUS=$?
371
+ if [[ "$D2_STATUS" -eq 0 ]] \
372
+ && is_valid_json "$D2_OUT" \
373
+ && [[ "$(stream_line_count "$D2_ROOT")" -eq 0 ]]; then
374
+ _pass "claude-telemetry-hook.js fails open on a corrupted sidecar snapshot (exit 0, no heartbeat fabricated) (AC3)"
375
+ else
376
+ _fail "claude-telemetry-hook.js did not fail open on a corrupted sidecar snapshot: status=$D2_STATUS out=$D2_OUT lines=$(stream_line_count "$D2_ROOT") stderr=$(cat "$TMPDIR_EVAL/d2.err")"
377
+ fi
378
+
379
+ # ─── E. Telemetry-toggle independence (AC5) ─────────────────────────────────
380
+ echo "--- E. Telemetry-toggle independence ---"
381
+
382
+ E_ROOT="$(new_scratch)"
383
+ E_STALE_AT="$(iso_offset_ms -300000)"
384
+ seed_claim "$E_ROOT" "e-subj" "agent-hb" "$E_STALE_AT" 1800
385
+ E_OUT="$(cd "$E_ROOT" && TELEMETRY_ENABLED=false FLOW_AGENTS_ACTOR=agent-hb \
386
+ node "$CLAUDE_HOOK" PostToolUse dev <<<"$POST_TOOL_USE_PAYLOAD")"
387
+ E_STATUS=$?
388
+ if [[ "$E_STATUS" -eq 0 ]] && [[ "$(stream_line_count "$E_ROOT")" -eq 2 ]] \
389
+ && grep -q '"type":"heartbeat"' "$(stream_file "$E_ROOT")"; then
390
+ _pass "TELEMETRY_ENABLED=false still heartbeats — liveness is decoupled from the telemetry toggle (AC5)"
391
+ else
392
+ _fail "heartbeat did not fire with TELEMETRY_ENABLED=false: status=$E_STATUS out=$E_OUT lines=$(stream_line_count "$E_ROOT")"
393
+ fi
394
+
395
+ # ─── F. Cross-runtime smoke (codex-telemetry-hook.js) ───────────────────────
396
+ echo "--- F. Cross-runtime smoke (codex-telemetry-hook.js) ---"
397
+
398
+ CODEX_PAYLOAD='{"hook_event_name":"PostToolUse","tool_name":"shell","tool_input":{"command":"echo hi"}}'
399
+ F_ROOT="$(new_scratch)"
400
+ F_STALE_AT="$(iso_offset_ms -300000)"
401
+ seed_claim "$F_ROOT" "f-subj" "agent-hb" "$F_STALE_AT" 1800
402
+ F_OUT="$(cd "$F_ROOT" && TELEMETRY_ENABLED=false FLOW_AGENTS_ACTOR=agent-hb \
403
+ node "$CODEX_HOOK" PostToolUse dev <<<"$CODEX_PAYLOAD")"
404
+ F_STATUS=$?
405
+ if [[ "$F_STATUS" -eq 0 ]] && [[ "$(stream_line_count "$F_ROOT")" -eq 2 ]] \
406
+ && grep -q '"type":"heartbeat"' "$(stream_file "$F_ROOT")"; then
407
+ _pass "codex-telemetry-hook.js PostToolUse appends a heartbeat for a fresh claim (cross-runtime, AC3)"
408
+ else
409
+ _fail "codex-telemetry-hook.js PostToolUse did not append a heartbeat as expected: status=$F_STATUS out=$F_OUT lines=$(stream_line_count "$F_ROOT")"
410
+ fi
411
+
412
+ # ─── G. Wrapper-level throttle smoke (two back-to-back real-clock calls) ───
413
+ echo "--- G. Wrapper-level throttle smoke ---"
414
+
415
+ G_ROOT="$(new_scratch)"
416
+ G_STALE_AT="$(iso_offset_ms -300000)"
417
+ seed_claim "$G_ROOT" "g-subj" "agent-hb" "$G_STALE_AT" 1800
418
+ (cd "$G_ROOT" && TELEMETRY_ENABLED=false FLOW_AGENTS_ACTOR=agent-hb node "$CLAUDE_HOOK" PostToolUse dev <<<"$POST_TOOL_USE_PAYLOAD" >/dev/null)
419
+ (cd "$G_ROOT" && TELEMETRY_ENABLED=false FLOW_AGENTS_ACTOR=agent-hb node "$CLAUDE_HOOK" PostToolUse dev <<<"$POST_TOOL_USE_PAYLOAD" >/dev/null)
420
+ if [[ "$(stream_line_count "$G_ROOT")" -eq 2 ]]; then
421
+ _pass "two back-to-back wrapper invocations append exactly one heartbeat (real-clock throttle smoke, AC3)"
422
+ else
423
+ _fail "two back-to-back wrapper invocations did not throttle to one heartbeat: lines=$(stream_line_count "$G_ROOT") content=$(cat "$(stream_file "$G_ROOT")" 2>/dev/null)"
424
+ fi
425
+
426
+ # ─── H. Bounded tail read (F3, #288 fix iteration 1, sec-MED + cr-MED) ──────
427
+ echo "--- H. Bounded tail read (F3) ---"
428
+
429
+ # build_large_liveness_stream <root> <slug> <actor> <claim_at_iso> <include_recent_hb:true|false> [hb_at_iso]
430
+ # Seeds current.json's active_slug and a >64KB liveness/events.jsonl: ~900 unrelated filler
431
+ # lines (~85KB), then the target CLAIM event (now provably >64KB from EOF once the rest of the
432
+ # file is written), then ~900 more filler lines (~85KB), then optionally one target HEARTBEAT
433
+ # event as the very last line (provably within the last 64KB — it IS the last line). Margins are
434
+ # ~20KB+ on both sides of the 64KB boundary, comfortably clear of any off-by-one ambiguity.
435
+ build_large_liveness_stream() {
436
+ local root="$1" slug="$2" actor="$3" claim_at="$4" include_hb="${5:-false}" hb_at="${6:-}"
437
+ local artifact_root="$root/.kontourai/flow-agents"
438
+ mkdir -p "$artifact_root/liveness"
439
+ printf '{"active_slug":"%s"}\n' "$slug" >"$artifact_root/$SNAPSHOT_FILENAME"
440
+ SLUG_ARG="$slug" ACTOR_ARG="$actor" CLAIM_AT_ARG="$claim_at" INCLUDE_HB_ARG="$include_hb" \
441
+ HB_AT_ARG="$hb_at" STREAM_ARG="$artifact_root/liveness/events.jsonl" node - <<'NODE'
442
+ const fs = require('fs');
443
+ const filler = (i) =>
444
+ JSON.stringify({
445
+ type: 'heartbeat',
446
+ subjectId: `filler-subj-${i}`,
447
+ actor: `filler-actor-${i}`,
448
+ at: '2020-01-01T00:00:00.000Z',
449
+ });
450
+ const lines = [];
451
+ for (let i = 0; i < 900; i++) lines.push(filler(i));
452
+ lines.push(
453
+ JSON.stringify({
454
+ type: 'claim',
455
+ subjectId: process.env.SLUG_ARG,
456
+ actor: process.env.ACTOR_ARG,
457
+ at: process.env.CLAIM_AT_ARG,
458
+ ttlSeconds: 1800,
459
+ })
460
+ );
461
+ for (let i = 900; i < 1800; i++) lines.push(filler(i));
462
+ if (process.env.INCLUDE_HB_ARG === 'true') {
463
+ lines.push(
464
+ JSON.stringify({
465
+ type: 'heartbeat',
466
+ subjectId: process.env.SLUG_ARG,
467
+ actor: process.env.ACTOR_ARG,
468
+ at: process.env.HB_AT_ARG,
469
+ })
470
+ );
471
+ }
472
+ fs.writeFileSync(process.env.STREAM_ARG, lines.join('\n') + '\n');
473
+ NODE
474
+ }
475
+
476
+ # call_heartbeat_instrumented <scratch_root> <env_json> [now_iso]
477
+ # Same as call_heartbeat, but monkey-patches fs.readFileSync to count calls against the
478
+ # liveness/events.jsonl stream specifically (readLivenessEvents, the FULL-read path) —
479
+ # readLivenessEventsTail uses fs.statSync/openSync/readSync/closeSync, never readFileSync, so
480
+ # any nonzero count here proves the full-read fallback executed. Prints
481
+ # {"result": <maybeEmitHeartbeat result>, "fullReadCalls": <n>}.
482
+ call_heartbeat_instrumented() {
483
+ local root="$1" env_json="$2" now="${3:-}"
484
+ MODULE_ARG="$HEARTBEAT_MODULE" ROOT_ARG="$root" ENV_JSON_ARG="$env_json" NOW_ARG="$now" \
485
+ node - <<'NODE'
486
+ const fs = require('fs');
487
+ const path = require('path');
488
+ const originalReadFileSync = fs.readFileSync;
489
+ const streamSuffix = path.join('liveness', 'events.jsonl');
490
+ let fullReadCalls = 0;
491
+ fs.readFileSync = function (p, ...rest) {
492
+ if (typeof p === 'string' && p.endsWith(streamSuffix)) fullReadCalls += 1;
493
+ return originalReadFileSync.call(fs, p, ...rest);
494
+ };
495
+ const { maybeEmitHeartbeat } = require(process.env.MODULE_ARG);
496
+ const env = JSON.parse(process.env.ENV_JSON_ARG);
497
+ const opts = { cwd: process.env.ROOT_ARG, env };
498
+ if (process.env.NOW_ARG) opts.now = process.env.NOW_ARG;
499
+ const result = maybeEmitHeartbeat(opts);
500
+ process.stdout.write(JSON.stringify({ result, fullReadCalls }));
501
+ NODE
502
+ }
503
+
504
+ # H1: claim is old (beyond the tail window) and a heartbeat for the SAME pair sits inside the
505
+ # tail, but that heartbeat is itself still within the throttle window of `now` -> the THROTTLE
506
+ # decision is bounded-tail-only (F8(ii): unchanged from F3) and refuses without ever needing to
507
+ # confirm claim evidence, so this must cost zero full reads.
508
+ H1_ROOT="$(new_scratch)"
509
+ build_large_liveness_stream "$H1_ROOT" "h1-subj" "agent-h1" "2026-06-25T09:00:00.000Z" true "2026-06-25T11:59:30.000Z"
510
+ H1_OUT="$(call_heartbeat_instrumented "$H1_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-h1"}' "2026-06-25T12:00:00.000Z")"
511
+ if [[ "$H1_OUT" == '{"result":{"emitted":false,"reason":"throttled"},"fullReadCalls":0}' ]]; then
512
+ _pass "maybeEmitHeartbeat throttle decision uses the bounded tail alone (no full read), even though the original claim has scrolled out of it (F3, F8(ii))"
513
+ else
514
+ _fail "maybeEmitHeartbeat did not take the bounded-only throttle path as expected: $H1_OUT"
515
+ fi
516
+
517
+ # H1B (F8(ii), #288 fix iteration 2): same shape as H1, but the in-tail heartbeat is old enough
518
+ # that the throttle window has elapsed — this is now an EMIT-time decision. The tail alone does
519
+ # NOT contain a `claim` event for the pair (only the heartbeat), so a bare heartbeat is no longer
520
+ # accepted as sufficient claim evidence on its own (that was the reviewer-reproduced orphan-
521
+ # heartbeat defect): exactly one full read must run to confirm the claim genuinely exists earlier
522
+ # in the true history, and only then does the heartbeat emit.
523
+ H1B_ROOT="$(new_scratch)"
524
+ build_large_liveness_stream "$H1B_ROOT" "h1b-subj" "agent-h1b" "2026-06-25T09:00:00.000Z" true "2026-06-25T11:55:00.000Z"
525
+ H1B_OUT="$(call_heartbeat_instrumented "$H1B_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-h1b"}' "2026-06-25T12:00:00.000Z")"
526
+ if [[ "$H1B_OUT" == '{"result":{"emitted":true},"fullReadCalls":1}' ]]; then
527
+ _pass "maybeEmitHeartbeat's EMIT decision pays exactly one full read to confirm claim evidence when only a heartbeat (no claim) is visible in the tail, and still emits once confirmed (F8(ii))"
528
+ else
529
+ _fail "maybeEmitHeartbeat did not fall back to a confirming full read for the claim-out-of-tail emit case: $H1B_OUT"
530
+ fi
531
+
532
+ # H2: the pair is entirely ABSENT from the tail (claim only, no subsequent heartbeat for this
533
+ # pair, and the claim itself has scrolled out of the last 64KB) -> the bounded read alone finds
534
+ # nothing, so exactly one full read must run as a fallback (fullReadCalls === 1), and the result
535
+ # must still be correct (the full read finds the old claim and emits, since it is well past the
536
+ # throttle window).
537
+ H2_ROOT="$(new_scratch)"
538
+ build_large_liveness_stream "$H2_ROOT" "h2-subj" "agent-h2" "2026-06-25T09:00:00.000Z" false
539
+ H2_OUT="$(call_heartbeat_instrumented "$H2_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-h2"}' "2026-06-25T12:00:00.000Z")"
540
+ if [[ "$H2_OUT" == '{"result":{"emitted":true},"fullReadCalls":1}' ]]; then
541
+ _pass "maybeEmitHeartbeat falls back to exactly one full read when the pair is entirely absent from the bounded tail, and still emits correctly (F3)"
542
+ else
543
+ _fail "maybeEmitHeartbeat did not fall back to a full read as expected: $H2_OUT"
544
+ fi
545
+
546
+ # H3 control: a SMALL stream (well under 64KB) behaves identically to before this change — the
547
+ # bounded tail read covers the whole file (including the claim event itself), so no fallback is
548
+ # ever needed (fullReadCalls === 0).
549
+ H3_ROOT="$(new_scratch)"
550
+ seed_claim "$H3_ROOT" "h3-subj" "agent-h3" "2026-06-25T09:00:00.000Z" 1800
551
+ H3_OUT="$(call_heartbeat_instrumented "$H3_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-h3"}' "2026-06-25T12:00:00.000Z")"
552
+ if [[ "$H3_OUT" == '{"result":{"emitted":true},"fullReadCalls":0}' ]]; then
553
+ _pass "maybeEmitHeartbeat never falls back to a full read for a small (< 64KB) stream (F3 regression control)"
554
+ else
555
+ _fail "maybeEmitHeartbeat behaved unexpectedly for a small stream: $H3_OUT"
556
+ fi
557
+
558
+ # build_large_liveness_stream_release <root> <slug> <actor> <claim_at_iso> <release_at_iso>
559
+ # Like build_large_liveness_stream, but seeds a claim FOLLOWED (many filler lines later) by a
560
+ # release for the same pair, with >64KB of filler both before the claim, between claim and
561
+ # release, and after the release — so the pair is entirely absent from the bounded tail and a
562
+ # full-read fallback is required to discover it at all.
563
+ build_large_liveness_stream_release() {
564
+ local root="$1" slug="$2" actor="$3" claim_at="$4" release_at="$5"
565
+ local artifact_root="$root/.kontourai/flow-agents"
566
+ mkdir -p "$artifact_root/liveness"
567
+ printf '{"active_slug":"%s"}
568
+ ' "$slug" >"$artifact_root/$SNAPSHOT_FILENAME"
569
+ SLUG_ARG="$slug" ACTOR_ARG="$actor" CLAIM_AT_ARG="$claim_at" RELEASE_AT_ARG="$release_at" STREAM_ARG="$artifact_root/liveness/events.jsonl" node - <<'NODE'
570
+ const fs = require('fs');
571
+ const filler = (i) =>
572
+ JSON.stringify({
573
+ type: 'heartbeat',
574
+ subjectId: `filler-subj-${i}`,
575
+ actor: `filler-actor-${i}`,
576
+ at: '2020-01-01T00:00:00.000Z',
577
+ });
578
+ const lines = [];
579
+ for (let i = 0; i < 900; i++) lines.push(filler(i));
580
+ lines.push(
581
+ JSON.stringify({
582
+ type: 'claim',
583
+ subjectId: process.env.SLUG_ARG,
584
+ actor: process.env.ACTOR_ARG,
585
+ at: process.env.CLAIM_AT_ARG,
586
+ ttlSeconds: 1800,
587
+ })
588
+ );
589
+ for (let i = 900; i < 1800; i++) lines.push(filler(i));
590
+ lines.push(
591
+ JSON.stringify({
592
+ type: 'release',
593
+ subjectId: process.env.SLUG_ARG,
594
+ actor: process.env.ACTOR_ARG,
595
+ at: process.env.RELEASE_AT_ARG,
596
+ })
597
+ );
598
+ for (let i = 1800; i < 2700; i++) lines.push(filler(i));
599
+ fs.writeFileSync(process.env.STREAM_ARG, lines.join('\n') + '\n');
600
+ NODE
601
+ }
602
+
603
+ # H4 (F8(ii), #288 fix iteration 2): release-most-recent still refuses even when BOTH the claim
604
+ # and the release have scrolled out of the bounded tail entirely, forcing the full-read fallback
605
+ # to be the one that discovers the pair's history — the fallback must still honor "release wins",
606
+ # not treat the earlier claim as license to emit.
607
+ H4_ROOT="$(new_scratch)"
608
+ build_large_liveness_stream_release "$H4_ROOT" "h4-subj" "agent-h4" "2026-06-25T09:00:00.000Z" "2026-06-25T10:00:00.000Z"
609
+ H4_OUT="$(call_heartbeat_instrumented "$H4_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-h4"}' "2026-06-25T12:00:00.000Z")"
610
+ if [[ "$H4_OUT" == '{"result":{"emitted":false,"reason":"released"},"fullReadCalls":1}' ]]; then
611
+ _pass "maybeEmitHeartbeat's full-read fallback still refuses on a most-recent release, even when both claim and release are absent from the bounded tail (F8(ii))"
612
+ else
613
+ _fail "maybeEmitHeartbeat did not honor a most-recent release discovered via the full-read fallback: $H4_OUT"
614
+ fi
615
+
616
+ # ─── I. Hostile active_slug never emits raw (F5, #288 fix iteration 1, sec-LOW) ─────
617
+ echo "--- I. Hostile active_slug sanitization (F5) ---"
618
+
619
+ I_ROOT="$(new_scratch)"
620
+ mkdir -p "$I_ROOT/.kontourai/flow-agents/liveness"
621
+ I_RAW_SLUG='evil;rm -rf /hb-subj'
622
+ I_SANITIZED_SLUG="$(ACTOR_MODULE_ARG="$ACTOR_MODULE" RAW_ARG="$I_RAW_SLUG" node - <<'NODE'
623
+ const { sanitizeSegment } = require(process.env.ACTOR_MODULE_ARG);
624
+ process.stdout.write(sanitizeSegment(process.env.RAW_ARG));
625
+ NODE
626
+ )"
627
+ printf '{"active_slug":"%s"}\n' "$I_RAW_SLUG" >"$I_ROOT/.kontourai/flow-agents/$SNAPSHOT_FILENAME"
628
+ printf '{"type":"claim","subjectId":"%s","actor":"agent-hb-i","at":"2026-06-25T11:00:00.000Z","ttlSeconds":1800}\n' \
629
+ "$I_SANITIZED_SLUG" >"$(stream_file "$I_ROOT")"
630
+ I_RESULT="$(call_heartbeat "$I_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-hb-i"}' "2026-06-25T11:05:00.000Z")"
631
+ I_STREAM_CONTENT="$(cat "$(stream_file "$I_ROOT")" 2>/dev/null)"
632
+ if [[ "$I_RESULT" == '{"emitted":true}' ]] \
633
+ && echo "$I_STREAM_CONTENT" | grep -qF "\"subjectId\":\"$I_SANITIZED_SLUG\"" \
634
+ && ! echo "$I_STREAM_CONTENT" | grep -qF ';rm -rf'; then
635
+ _pass "maybeEmitHeartbeat sanitizes a hostile active_slug (charset+cap, mirroring sanitizeSegment) before use — the emitted heartbeat's subjectId is never the raw hostile value (F5)"
636
+ else
637
+ _fail "maybeEmitHeartbeat did not sanitize a hostile active_slug as expected: result=$I_RESULT sanitized=$I_SANITIZED_SLUG stream=$I_STREAM_CONTENT"
638
+ fi
639
+
640
+ # ─── J. Orphan-heartbeat invariant (F8, #288 fix iteration 2) ──────────────
641
+ echo "--- J. Orphan-heartbeat invariant (F8) ---"
642
+
643
+ # seed_heartbeat_only <scratch_root> <slug> <actor> <at_iso>
644
+ # Seeds a liveness stream containing ONLY a heartbeat event for (slug, actor) — no claim was
645
+ # ever recorded. Reproduces the reviewer-found defect precondition: a direct CLI
646
+ # `liveness heartbeat` with no prior claim writes exactly this shape.
647
+ seed_heartbeat_only() {
648
+ local root="$1" slug="$2" actor="$3" at="$4"
649
+ local artifact_root="$root/.kontourai/flow-agents"
650
+ mkdir -p "$artifact_root/liveness"
651
+ printf '{"active_slug":"%s"}\n' "$slug" >"$artifact_root/$SNAPSHOT_FILENAME"
652
+ printf '{"type":"heartbeat","subjectId":"%s","actor":"%s","at":"%s"}\n' \
653
+ "$slug" "$actor" "$at" >"$artifact_root/liveness/events.jsonl"
654
+ }
655
+
656
+ # J1: an orphan heartbeat (no claim ever recorded for the pair), old enough to be past the
657
+ # throttle window, must resolve to no-claim — NOT be mistaken for its own claim evidence (the
658
+ # exact defect reproduced by the reviewer: "maybeEmitHeartbeat's bounded-tail path then treats it
659
+ # as claim evidence and perpetuates a phantom holder"). The tail alone has no `claim` event for
660
+ # the pair, so exactly one full read runs to confirm — and still finds none.
661
+ J1_ROOT="$(new_scratch)"
662
+ seed_heartbeat_only "$J1_ROOT" "j1-subj" "agent-j1" "2026-06-25T09:00:00.000Z"
663
+ J1_OUT="$(call_heartbeat_instrumented "$J1_ROOT" '{"FLOW_AGENTS_ACTOR":"agent-j1"}' "2026-06-25T12:00:00.000Z")"
664
+ if [[ "$J1_OUT" == '{"result":{"emitted":false,"reason":"no-claim"},"fullReadCalls":1}' ]]; then
665
+ _pass "maybeEmitHeartbeat treats a pre-existing orphan heartbeat (no claim ever recorded) as no-claim, neutralizing a phantom holder instead of perpetuating it (F8(ii))"
666
+ else
667
+ _fail "maybeEmitHeartbeat did not neutralize an orphan heartbeat as expected: $J1_OUT"
668
+ fi
669
+
670
+ echo ""
671
+ if [[ "$errors" -eq 0 ]]; then
672
+ echo "Liveness heartbeat integration passed."
673
+ exit 0
674
+ fi
675
+
676
+ echo "Liveness heartbeat integration failed: $errors issue(s)."
677
+ exit 1