@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,361 @@
1
+ 'use strict';
2
+ /**
3
+ * liveness-heartbeat.js — shared pure-CJS tool-activity liveness heartbeat
4
+ *
5
+ * Zero external dependencies (only Node core: fs, path). Consumed by:
6
+ * - scripts/hooks/{claude,codex,opencode,pi}-telemetry-hook.js (CJS, direct require)
7
+ *
8
+ * Purpose (issue #288): ride the one hook event every supported runtime
9
+ * already fires per tool call (postToolUse) to keep a liveness claim fresh
10
+ * via ordinary tool activity, not just workflow phase transitions. Throttled
11
+ * to >=`resolveHeartbeatThrottleSeconds(env)` (default 60s) per subject+actor,
12
+ * derived from the existing actor-keyed `liveness/events.jsonl` stream tail —
13
+ * deliberately NOT a new mutable throttle-state file, since a shared
14
+ * last-heartbeat-timestamp file keyed by anything less specific than the
15
+ * actor would reintroduce the last-writer-wins race ADR 0021 exists to close
16
+ * (see plan artifact for kontourai-flow-agents-288). Each actor only ever
17
+ * contends with its own prior writes, so deriving the throttle straight from
18
+ * the actor-scoped stream has no shared-write race.
19
+ *
20
+ * Hot-path ordering + bounded read (F3, #288 fix iteration 1, sec-MED +
21
+ * cr-MED): steps run enabled? -> current.json active_slug present? ->
22
+ * resolveActor() -> bounded tail read, in that order, so a repo with liveness
23
+ * disabled or no active session never pays the `resolveActor()` process-
24
+ * ancestry `ps` spawn cost.
25
+ *
26
+ * Orphan-heartbeat invariant (F8(ii), #288 fix iteration 2): a heartbeat must
27
+ * never be treated as its own evidence of a claim. The reviewer-reproduced
28
+ * defect was that a bare heartbeat sitting in the bounded tail (with no claim
29
+ * event anywhere nearby) was accepted as sufficient "claim evidence" to keep
30
+ * emitting further heartbeats — perpetuating a phantom holder that was never
31
+ * legitimately claimed. The fix splits the two decisions this module makes:
32
+ *
33
+ * 1. THROTTLE decision — stays bounded-tail only (cheap, unchanged). The
34
+ * tail is a suffix of an append-only stream, so when it contains ANY
35
+ * event for the (subjectId, actor) pair, that event is necessarily the
36
+ * pair's true most-recent entry (nothing for this pair could exist
37
+ * later in the file without also being in the tail). A release found
38
+ * there refuses immediately; a fresh (within-throttle) event there
39
+ * throttles immediately. Neither outcome ever pays for a full read.
40
+ *
41
+ * 2. EMIT decision — requires actual claim evidence (an event whose
42
+ * `type === 'claim'`) for the pair, not merely "some matching event
43
+ * exists". That evidence is read from the tail when the tail already
44
+ * contains a `claim` event for the pair (zero extra I/O); otherwise
45
+ * exactly ONE full read runs to confirm one way or the other (paid at
46
+ * most once per throttle window per session, never on throttled
47
+ * checks). No claim found anywhere -> skip with reason 'no-claim'. This
48
+ * also neutralizes pre-existing orphan heartbeats already sitting in
49
+ * old streams (a stream containing only heartbeats for a pair, with no
50
+ * claim ever recorded, now always resolves to 'no-claim').
51
+ *
52
+ * Fails open: never throws, never blocks the calling hook, stderr diagnostic
53
+ * on error only (mirrors the #287 fail-open convention already used by
54
+ * actor-identity.js / liveness-read.js consumers).
55
+ *
56
+ * Exports:
57
+ * maybeEmitHeartbeat({ cwd, env, now }) → { emitted: boolean, reason?: string,
58
+ * conflict?: { actor: string, lastAt: string, ttlSeconds: number } }
59
+ *
60
+ * Mid-turn conflict detection (issue #320, AC3/AC5): every call already reads a bounded
61
+ * tail of the liveness stream to throttle/emit our OWN heartbeat. That same in-memory tail
62
+ * (or, on the rare full-read fallback path, that full read) is additionally passed through
63
+ * `freshHolders()` (./liveness-read) to detect another actor's fresh claim on OUR OWN held
64
+ * subject — zero added I/O. The optional `conflict` field is attached whenever we've
65
+ * confirmed we hold a live claim on the subject (regardless of whether our own heartbeat
66
+ * emits or throttles this call) and some other actor's most recent event for the subject is
67
+ * strictly newer than OUR OWN last recorded event for it prior to this call — a
68
+ * stream-derived episode watermark, not a wall-clock timer, so a still-fresh conflicting
69
+ * claim already seen on a prior call never re-fires; only a genuinely newer event from the
70
+ * conflicting actor does (our own heartbeat write, once appended, becomes the new watermark
71
+ * for the next call). Never treated as evidence for OUR OWN claim/throttle decisions above —
72
+ * this only ADDS a field, it never changes the existing `emitted`/`reason` semantics.
73
+ */
74
+
75
+ const fs = require('fs');
76
+ const path = require('path');
77
+
78
+ const { isLivenessEnabled, resolveHeartbeatThrottleSeconds } = require('./liveness-policy');
79
+ const { livenessStreamFile, appendLivenessEvent } = require('./liveness-write');
80
+ const { readLivenessEvents, readLivenessEventsTail, freshHolders } = require('./liveness-read');
81
+ const { resolveActor, isUnresolvedActor, sanitizeSegment } = require('./actor-identity');
82
+ const { flowAgentsArtifactRoot } = require('./local-artifact-paths');
83
+
84
+ /**
85
+ * Resolve a caller-supplied `now` (Date, ISO string, or omitted) to epoch ms.
86
+ * Falls back to `Date.now()` for anything that does not parse cleanly.
87
+ *
88
+ * @param {Date|string|undefined} now
89
+ * @returns {number}
90
+ */
91
+ function resolveNowMs(now) {
92
+ if (now instanceof Date) {
93
+ const ms = now.getTime();
94
+ return Number.isFinite(ms) ? ms : Date.now();
95
+ }
96
+ if (typeof now === 'string' && now.trim()) {
97
+ const ms = Date.parse(now);
98
+ return Number.isFinite(ms) ? ms : Date.now();
99
+ }
100
+ return Date.now();
101
+ }
102
+
103
+ /**
104
+ * Read `current.json`'s `active_slug` from the given artifact root, sanitized through the same
105
+ * charset+cap restriction as actor-identity.js's `sanitizeSegment` (F5, #288 fix iteration 1,
106
+ * sec-LOW: `current.json` is a local file that could be hand-edited or otherwise hostile —
107
+ * `active_slug` must never be trusted verbatim before it is used as a JSONL grouping key /
108
+ * emitted `subjectId` or compared against event data). Tolerates a missing file or malformed
109
+ * JSON (returns "").
110
+ *
111
+ * @param {string} root
112
+ * @returns {string}
113
+ */
114
+ function readActiveSlug(root) {
115
+ let raw = '';
116
+ try {
117
+ raw = fs.readFileSync(path.join(root, 'current.json'), 'utf8');
118
+ } catch {
119
+ return '';
120
+ }
121
+ try {
122
+ const parsed = JSON.parse(raw);
123
+ const activeSlug = (parsed && parsed.active_slug) || '';
124
+ if (!activeSlug) return '';
125
+ // sanitizeSegment falls back to the literal "unknown" for an all-stripped input; that
126
+ // fallback is not a legitimate slug, so treat it as "no active slug" here rather than
127
+ // matching against a subject that was never really claimed.
128
+ const sanitized = sanitizeSegment(activeSlug);
129
+ return sanitized === 'unknown' ? '' : sanitized;
130
+ } catch {
131
+ return '';
132
+ }
133
+ }
134
+
135
+ /**
136
+ * Filter a list of parsed liveness events down to the ones matching a given
137
+ * (subjectId, actor) pair. Shared by the bounded tail read and the full-read
138
+ * fallback so both filter identically.
139
+ *
140
+ * @param {object[]} events
141
+ * @param {string} slug
142
+ * @param {string} actor
143
+ * @returns {object[]}
144
+ */
145
+ function filterMatchingPair(events, slug, actor) {
146
+ return events.filter(
147
+ (e) => e && typeof e === 'object' && e.subjectId === slug && e.actor === actor
148
+ );
149
+ }
150
+
151
+ /**
152
+ * Detect another actor's fresher claim on the SAME subject as our own, using an
153
+ * already-loaded events array — the bounded tail in the common case, or the full-read
154
+ * fallback array in the rare case the EMIT decision already paid for one — never an
155
+ * additional `fs` call (issue #320, AC3). Reuses the canonical `freshHolders()` grouping/
156
+ * freshness rules from `./liveness-read` (the same helper `workflow-steering.js`'s ambient
157
+ * per-turn digest already relies on) instead of re-deriving freshness logic here.
158
+ *
159
+ * Episode-throttled per AC5: a holder is only reported when its `lastAt` is strictly newer
160
+ * than `ourLastAt` (our own last recorded event for this subject, captured BEFORE this call
161
+ * potentially appends its own heartbeat) — an unchanged, still-fresh conflicting claim already
162
+ * surfaced on a prior call never re-fires; only a genuinely new event from the conflicting
163
+ * actor does. This is a stream-derived watermark comparison, never a wall-clock timer. When
164
+ * more than one other actor qualifies, the single most-recently-updated one is reported.
165
+ *
166
+ * Known, disclosed limitation (mirrors this module's own orphan-heartbeat caveat above): if
167
+ * the conflicting actor's own `claim` event (carrying `ttlSeconds`) has scrolled outside the
168
+ * 64KB tail window, `freshHolders` defaults that holder's `ttlSeconds` to 1800s using only the
169
+ * heartbeat events visible in the tail — the same accepted-and-disclosed class of limitation
170
+ * the module header already names for the caller's OWN pair.
171
+ *
172
+ * @param {object[]} events
173
+ * @param {string} slug
174
+ * @param {string} actor
175
+ * @param {number} nowMs
176
+ * @param {string} ourLastAt
177
+ * @returns {{actor: string, lastAt: string, ttlSeconds: number}|undefined}
178
+ */
179
+ function computeConflict(events, slug, actor, nowMs, ourLastAt) {
180
+ const ourLastAtMs = Date.parse(ourLastAt);
181
+ const others = freshHolders(events, slug, actor, nowMs).filter((h) => {
182
+ const holderAtMs = Date.parse(h.lastAt);
183
+ return Number.isFinite(holderAtMs) && Number.isFinite(ourLastAtMs) && holderAtMs > ourLastAtMs;
184
+ });
185
+ if (!others.length) return undefined;
186
+ const winner = others.reduce((a, b) => (Date.parse(b.lastAt) > Date.parse(a.lastAt) ? b : a));
187
+ // Sanitize at this single choke point (issue #320 fix iteration 1, sec-CRITICAL/HIGH F1/F2):
188
+ // `winner.actor`/`winner.lastAt` are read straight off the multi-writer append-only
189
+ // liveness/events.jsonl stream, which ANY process can append to (a second agent's shell, a
190
+ // hand-edit) bypassing the CLI write-side `sanitizeSegment` entirely. These values flow
191
+ // UNSANITIZED into agent-facing channels (hookSpecificOutput.additionalContext / stderr) in
192
+ // all four wrapper hooks below — the exact #287 injection class in a new, more directly
193
+ // agent-facing channel. Sanitizing once here, at the sole place `conflict` is constructed,
194
+ // means every caller (all four wrappers) inherits clean values with no per-wrapper defense
195
+ // to forget. `actor` reuses the same allowlist+64-char-cap `sanitizeSegment` the write side
196
+ // already applies to legitimate actors (a no-op on them); `lastAt` is structurally
197
+ // eliminated by re-serializing through Date.parse/toISOString — a timestamp can never carry
198
+ // control chars once canonicalized this way. `ttlSeconds` is already a Number — left as-is.
199
+ const winnerLastAtMs = Date.parse(winner.lastAt);
200
+ const sanitizedLastAt = Number.isFinite(winnerLastAtMs) ? new Date(winnerLastAtMs).toISOString() : '';
201
+ return { actor: sanitizeSegment(winner.actor), lastAt: sanitizedLastAt, ttlSeconds: winner.ttlSeconds };
202
+ }
203
+
204
+ /**
205
+ * Attach an optional `conflict` field to a result object, omitting the key entirely when no
206
+ * conflict was detected (rather than an explicit `conflict: undefined`), so callers can rely
207
+ * on a plain `if (result.conflict)` check.
208
+ *
209
+ * @param {object} result
210
+ * @param {{actor: string, lastAt: string, ttlSeconds: number}|undefined} conflict
211
+ * @returns {object}
212
+ */
213
+ function attachConflictField(result, conflict) {
214
+ return conflict ? { ...result, conflict } : result;
215
+ }
216
+
217
+ /**
218
+ * Emit a throttled tool-activity liveness heartbeat for the resolved
219
+ * actor/subject, if (and only if) a fresh, unreleased claim already exists.
220
+ * Never creates a new claim. Fails open — always resolves to a result
221
+ * object, never throws.
222
+ *
223
+ * See the module header for the full F8(ii) throttle/emit split. Summary:
224
+ * - enabled? -> slug present? -> resolveActor() -> bounded tail read, in
225
+ * that order (F3), so a disabled repo or one with no active session
226
+ * never pays the resolveActor() ancestry `ps` spawn cost.
227
+ * - THROTTLE decision reads only the bounded tail (never a full read):
228
+ * a release found there refuses; a fresh event there throttles.
229
+ * - EMIT decision requires an actual `claim` event for the pair: taken
230
+ * from the tail when already present there, otherwise exactly one full
231
+ * read confirms (or refutes) it. No claim anywhere -> 'no-claim'.
232
+ * - CONFLICT check (issue #320, AC3/AC5) reuses whichever events array (tail, or the rare
233
+ * full-read fallback) already confirmed OUR OWN live claim — zero added I/O — and passes
234
+ * it through `freshHolders()` to find another actor's claim on the SAME subject. Only
235
+ * surfaced when that other actor's most recent event is strictly newer than our own last
236
+ * recorded event for the subject prior to this call (a stream-derived watermark, not a
237
+ * wall-clock timer), so it fires once per genuinely new conflicting event, independent of
238
+ * whether our own heartbeat emits or throttles this call.
239
+ *
240
+ * @param {{cwd?: string, env?: NodeJS.ProcessEnv, now?: Date|string}} [opts]
241
+ * @returns {{emitted: boolean, reason?: string, conflict?: {actor: string, lastAt: string, ttlSeconds: number}}}
242
+ */
243
+ function maybeEmitHeartbeat(opts = {}) {
244
+ const { cwd = process.cwd(), env = process.env } = opts || {};
245
+ try {
246
+ if (!isLivenessEnabled(env)) {
247
+ return { emitted: false, reason: 'disabled' };
248
+ }
249
+
250
+ const root = flowAgentsArtifactRoot(cwd);
251
+ const slug = readActiveSlug(root);
252
+ if (!slug) {
253
+ return { emitted: false, reason: 'no-current' };
254
+ }
255
+
256
+ const { actor } = resolveActor(env);
257
+ if (isUnresolvedActor(actor)) {
258
+ return { emitted: false, reason: 'actor-unresolved' };
259
+ }
260
+
261
+ const streamPath = livenessStreamFile(root);
262
+ const nowMs = resolveNowMs(opts.now);
263
+ const throttleMs = resolveHeartbeatThrottleSeconds(env) * 1000;
264
+
265
+ // ─── 1. THROTTLE decision — bounded tail only, never a full read (F8(ii)) ──
266
+ // Capture the unfiltered tail buffer once (issue #320, AC3) so the CONFLICT check below
267
+ // can reuse the exact same in-memory array on every return path — zero additional `fs`
268
+ // calls beyond what the throttle/emit decisions already pay for.
269
+ const tailEvents = readLivenessEventsTail(streamPath);
270
+ let matching = filterMatchingPair(tailEvents, slug, actor);
271
+ let last = matching.length ? matching[matching.length - 1] : null;
272
+ if (last) {
273
+ if (last.type === 'release') {
274
+ return { emitted: false, reason: 'released' };
275
+ }
276
+ const lastAtMs = Date.parse(last.at);
277
+ if (Number.isFinite(lastAtMs) && nowMs - lastAtMs < throttleMs) {
278
+ // CONFLICT check (issue #320, AC3/AC5): only attached here when the tail ALREADY
279
+ // shows a genuine `claim` for our own pair — zero extra I/O, never a full read on
280
+ // this hot, most-common return path (preserving the "neither outcome ever pays for a
281
+ // full read" invariant documented above). When our own claim history has scrolled
282
+ // outside the tail window, this rare edge case simply omits the conflict field on
283
+ // THIS call rather than paying for a read; a subsequent call (once genuinely out of
284
+ // our own throttle window, or once the EMIT decision's full-read fallback runs) will
285
+ // confirm it as usual.
286
+ const ourClaimVisibleInTail = matching.some((e) => e.type === 'claim');
287
+ const conflict = ourClaimVisibleInTail
288
+ ? computeConflict(tailEvents, slug, actor, nowMs, last.at)
289
+ : undefined;
290
+ return attachConflictField({ emitted: false, reason: 'throttled' }, conflict);
291
+ }
292
+ }
293
+
294
+ // ─── 2. EMIT decision — requires actual claim evidence (F8(ii)) ───────────
295
+ // A bare heartbeat (or a release we've already ruled out above) is never sufficient
296
+ // evidence on its own that a claim was ever made — that assumption is exactly the
297
+ // orphan-heartbeat defect this closes. Prefer evidence already visible in the tail
298
+ // (zero extra I/O); only pay for one full read when the tail didn't already settle it
299
+ // (either it had no matching events at all, or it had some but none was a claim).
300
+ let hasClaim = matching.some((e) => e.type === 'claim');
301
+ // `holderEvents` is whichever events array (tail, or the full read below) already
302
+ // confirmed our own claim — reused by the CONFLICT check (issue #320, AC3) below with no
303
+ // additional `fs` call of its own.
304
+ let holderEvents = tailEvents;
305
+ if (!hasClaim) {
306
+ const fullEvents = readLivenessEvents(streamPath);
307
+ holderEvents = fullEvents;
308
+ matching = filterMatchingPair(fullEvents, slug, actor);
309
+ last = matching.length ? matching[matching.length - 1] : null;
310
+ if (!last) {
311
+ return { emitted: false, reason: 'no-claim' };
312
+ }
313
+ if (last.type === 'release') {
314
+ return { emitted: false, reason: 'released' };
315
+ }
316
+ hasClaim = matching.some((e) => e.type === 'claim');
317
+ if (!hasClaim) {
318
+ return { emitted: false, reason: 'no-claim' };
319
+ }
320
+ // The tail was entirely empty for this pair (the only case reachable here with the
321
+ // throttle decision above never having run against `last`) — re-check the throttle
322
+ // window now that the full read has supplied the true last event.
323
+ const lastAtMs = Date.parse(last.at);
324
+ if (Number.isFinite(lastAtMs) && nowMs - lastAtMs < throttleMs) {
325
+ return attachConflictField(
326
+ { emitted: false, reason: 'throttled' },
327
+ computeConflict(holderEvents, slug, actor, nowMs, last.at)
328
+ );
329
+ }
330
+ }
331
+
332
+ // ─── 3. CONFLICT check — same in-memory buffer as above, zero added I/O (issue #320,
333
+ // AC3/AC5) ──────────────────────────────────────────────────────────────────────────
334
+ // Reached only once `hasClaim` is confirmed true (a live, unreleased claim on `slug`) and
335
+ // we are about to emit our own heartbeat. `last.at` here is OUR OWN last recorded event
336
+ // for this subject BEFORE this call's write below — the watermark AC5's stream-derived
337
+ // episode throttle compares against, so an unchanged, still-fresh conflicting claim seen
338
+ // on a prior call never re-fires; only a genuinely newer event from the conflicting actor
339
+ // does, because our own heartbeat write below becomes the new watermark for the next call.
340
+ const conflict = computeConflict(holderEvents, slug, actor, nowMs, last.at);
341
+
342
+ const nowIso = new Date(nowMs).toISOString();
343
+ appendLivenessEvent(root, {
344
+ type: 'heartbeat',
345
+ subjectId: slug,
346
+ actor,
347
+ at: nowIso,
348
+ source: 'tool-activity',
349
+ });
350
+ return attachConflictField({ emitted: true }, conflict);
351
+ } catch (err) {
352
+ try {
353
+ process.stderr.write(`[liveness-heartbeat] skipped: ${err.message}\n`);
354
+ } catch {
355
+ /* best-effort diagnostic only */
356
+ }
357
+ return { emitted: false, reason: 'error' };
358
+ }
359
+ }
360
+
361
+ module.exports = { maybeEmitHeartbeat };
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+ /**
3
+ * liveness-policy.js — shared pure-CJS liveness policy predicates
4
+ *
5
+ * Zero external dependencies. Consumed by:
6
+ * - scripts/hooks/lib/liveness-heartbeat.js (CJS, direct require)
7
+ * - build/src/cli/workflow-sidecar.js (ESM compiled, via createRequire)
8
+ *
9
+ * Purpose (issue #288): the ONE definition of the liveness on/off predicate,
10
+ * the claim TTL default, and the heartbeat throttle default, so
11
+ * `livenessEnabled()`/the claim-path TTL literal in workflow-sidecar.ts and
12
+ * the new tool-activity heartbeat (liveness-heartbeat.js) can never disagree
13
+ * about what "enabled" or "default TTL" means — the same consume-never-fork
14
+ * discipline already used for actor resolution (actor-identity.js) and
15
+ * liveness-stream reads (liveness-read.js).
16
+ *
17
+ * Default-on / opt-out (#288 supersedes #166's opt-in default per ADR 0021
18
+ * §3): presence is ambient unless explicitly disabled via one of
19
+ * `off|0|false|no|disabled` (case-insensitive, whitespace-trimmed, and with
20
+ * zero-width/format characters — U+200B/U+200C/U+200D/U+FEFF — stripped
21
+ * before comparison, F4 #288 fix iteration 1, sec-LOW: `"off\u200B"` must
22
+ * still disable, not silently stay enabled by comparing unequal to the
23
+ * `"off"` token). Every other value — including unset/empty — is treated as
24
+ * enabled.
25
+ *
26
+ * Exports:
27
+ * DEFAULT_TTL_SECONDS = 1800
28
+ * DEFAULT_HEARTBEAT_THROTTLE_SECONDS = 60
29
+ * isLivenessEnabled(env) → boolean
30
+ * resolveTtlSeconds(env) → number
31
+ * resolveHeartbeatThrottleSeconds(env) → number
32
+ */
33
+
34
+ /** Explicit opt-out tokens (case-insensitive, trimmed). Everything else — including
35
+ * unset/empty — is enabled. */
36
+ const DISABLED_TOKENS = new Set(['off', '0', 'false', 'no', 'disabled']);
37
+
38
+ /**
39
+ * Zero-width/format characters (U+200B ZERO WIDTH SPACE, U+200C ZERO WIDTH NON-JOINER,
40
+ * U+200D ZERO WIDTH JOINER, U+FEFF ZERO WIDTH NO-BREAK SPACE / BOM) that render invisibly but
41
+ * would otherwise make an opt-out token compare unequal to its canonical form (F4, #288 fix
42
+ * iteration 1, sec-LOW).
43
+ */
44
+ const ZERO_WIDTH_CHARS_RE = /[\u200B-\u200D\uFEFF]/g;
45
+
46
+ /** Default liveness claim TTL, in seconds (30 minutes). */
47
+ const DEFAULT_TTL_SECONDS = 1800;
48
+
49
+ /** Default minimum spacing between tool-activity heartbeats for one subject+actor, in seconds. */
50
+ const DEFAULT_HEARTBEAT_THROTTLE_SECONDS = 60;
51
+
52
+ /**
53
+ * Default-on / opt-out predicate for `FLOW_AGENTS_LIVENESS`. Takes `env` as an explicit
54
+ * param (mirroring actor-identity.js's style) rather than reading `process.env` internally,
55
+ * so it stays unit-testable.
56
+ *
57
+ * @param {NodeJS.ProcessEnv} [env] Environment to inspect (default process.env)
58
+ * @returns {boolean} false only when FLOW_AGENTS_LIVENESS is an explicit off-token; true otherwise
59
+ */
60
+ function isLivenessEnabled(env = process.env) {
61
+ env = env || {};
62
+ const value = String(env.FLOW_AGENTS_LIVENESS ?? '')
63
+ .replace(ZERO_WIDTH_CHARS_RE, '')
64
+ .trim()
65
+ .toLowerCase();
66
+ return !DISABLED_TOKENS.has(value);
67
+ }
68
+
69
+ /** Strict positive-integer literal: one or more ASCII digits, nothing else (F7, #287/#288 fix
70
+ * iterations: rejects hex (`0x10`), exponential (`1e3`), decimal, signed, and whitespace-padded
71
+ * coercions that JS's `Number()` would otherwise silently accept). */
72
+ const STRICT_POSITIVE_INT_RE = /^[0-9]+$/;
73
+
74
+ /**
75
+ * Parse a positive-integer env override, falling back to a default when the raw value is
76
+ * missing, non-numeric, or not strictly positive. Uses a strict `/^[0-9]+$/` literal match
77
+ * (F7) rather than `Number()` coercion, so inputs like `"0x10"` (16) or `"1e3"` (1000) — valid
78
+ * per `Number()` but not a plain positive-integer literal — fall back to the default instead of
79
+ * silently being accepted.
80
+ *
81
+ * @param {*} rawValue
82
+ * @param {number} fallback
83
+ * @returns {number}
84
+ */
85
+ function parsePositiveIntOr(rawValue, fallback) {
86
+ const trimmed = String(rawValue ?? '').trim();
87
+ if (!STRICT_POSITIVE_INT_RE.test(trimmed)) return fallback;
88
+ const parsed = Number(trimmed);
89
+ if (!Number.isFinite(parsed) || parsed <= 0) return fallback;
90
+ return Math.floor(parsed);
91
+ }
92
+
93
+ /**
94
+ * Resolve the liveness claim TTL, in seconds: `FLOW_AGENTS_LIVENESS_TTL_SECONDS` if set to a
95
+ * positive number, else DEFAULT_TTL_SECONDS.
96
+ *
97
+ * @param {NodeJS.ProcessEnv} [env] Environment to inspect (default process.env)
98
+ * @returns {number}
99
+ */
100
+ function resolveTtlSeconds(env = process.env) {
101
+ env = env || {};
102
+ return parsePositiveIntOr(env.FLOW_AGENTS_LIVENESS_TTL_SECONDS, DEFAULT_TTL_SECONDS);
103
+ }
104
+
105
+ /**
106
+ * Resolve the heartbeat throttle window, in seconds: minimum spacing between tool-activity
107
+ * heartbeats for one subject+actor pair. `FLOW_AGENTS_LIVENESS_HEARTBEAT_THROTTLE_SECONDS` if
108
+ * set to a positive number, else DEFAULT_HEARTBEAT_THROTTLE_SECONDS.
109
+ *
110
+ * @param {NodeJS.ProcessEnv} [env] Environment to inspect (default process.env)
111
+ * @returns {number}
112
+ */
113
+ function resolveHeartbeatThrottleSeconds(env = process.env) {
114
+ env = env || {};
115
+ return parsePositiveIntOr(
116
+ env.FLOW_AGENTS_LIVENESS_HEARTBEAT_THROTTLE_SECONDS,
117
+ DEFAULT_HEARTBEAT_THROTTLE_SECONDS
118
+ );
119
+ }
120
+
121
+ module.exports = {
122
+ DEFAULT_TTL_SECONDS,
123
+ DEFAULT_HEARTBEAT_THROTTLE_SECONDS,
124
+ isLivenessEnabled,
125
+ resolveTtlSeconds,
126
+ resolveHeartbeatThrottleSeconds,
127
+ };
@@ -3,11 +3,13 @@
3
3
  * liveness-read.js — shared pure-CJS liveness freshness helper
4
4
  *
5
5
  * Zero external dependencies. Consumed by:
6
- * - scripts/hooks/workflow-steering.js (CJS, direct require)
7
- * - build/src/cli/workflow-sidecar.js (ESM compiled, via createRequire)
6
+ * - scripts/hooks/workflow-steering.js (CJS, direct require)
7
+ * - scripts/hooks/lib/liveness-heartbeat.js (CJS, direct require)
8
+ * - build/src/cli/workflow-sidecar.js (ESM compiled, via createRequire)
8
9
  *
9
10
  * Exports:
10
11
  * readLivenessEvents(streamPath) → AnyObj[] (tolerates malformed lines)
12
+ * readLivenessEventsTail(streamPath, tailBytes?) → AnyObj[] (bounded I/O; see below)
11
13
  * freshHolders(events, slug, selfActor, nowMs) → holder[]
12
14
  *
13
15
  * freshHolders returns, for each actor (other than selfActor) with a
@@ -26,6 +28,32 @@
26
28
 
27
29
  const fs = require('fs');
28
30
 
31
+ /** Default bounded-tail read size, in bytes (F3, #288 fix iteration 1). */
32
+ const DEFAULT_TAIL_BYTES = 64 * 1024;
33
+
34
+ /**
35
+ * Parse a raw newline-delimited JSONL blob into an array of parsed event objects.
36
+ * Tolerates blank lines and malformed lines (silently skips both). Shared by
37
+ * readLivenessEvents (full read) and readLivenessEventsTail (bounded read) so both
38
+ * paths parse identically.
39
+ *
40
+ * @param {string} raw
41
+ * @returns {object[]}
42
+ */
43
+ function parseEventLines(raw) {
44
+ const out = [];
45
+ for (const line of raw.split('\n')) {
46
+ const trimmed = line.trim();
47
+ if (!trimmed) continue;
48
+ try {
49
+ out.push(JSON.parse(trimmed));
50
+ } catch {
51
+ /* skip malformed line */
52
+ }
53
+ }
54
+ return out;
55
+ }
56
+
29
57
  /**
30
58
  * Read a liveness JSONL stream from the given path.
31
59
  * Tolerates missing file (returns []) and malformed lines (silently skips).
@@ -40,17 +68,82 @@ function readLivenessEvents(streamPath) {
40
68
  } catch {
41
69
  return [];
42
70
  }
43
- const out = [];
44
- for (const line of raw.split('\n')) {
45
- const trimmed = line.trim();
46
- if (!trimmed) continue;
47
- try {
48
- out.push(JSON.parse(trimmed));
49
- } catch {
50
- /* skip malformed line */
71
+ return parseEventLines(raw);
72
+ }
73
+
74
+ /**
75
+ * Read only the last `tailBytes` (default 64KB) of a liveness JSONL stream, newline-aligned
76
+ * so a partial line at the truncation boundary is dropped rather than mis-parsed, instead of
77
+ * reading the whole file (F3, #288 fix iteration 1, sec-MED + cr-MED: `maybeEmitHeartbeat` in
78
+ * liveness-heartbeat.js previously full-read `events.jsonl` on every tool call — 374ms measured
79
+ * at an 80MB stream — a cost paid on the hot `postToolUse` path). Bounded I/O regardless of
80
+ * total stream size: at most `tailBytes` bytes are ever read off disk.
81
+ *
82
+ * Because the stream is append-only, the tail of the file is always its most recent portion —
83
+ * so if a given `(subjectId, actor)` pair's most recent event lies within the tail window, this
84
+ * function returns it correctly every time; only an event pair whose *every* occurrence lies
85
+ * entirely before the tail window is invisible here (the caller's job, not this function's, is
86
+ * to fall back to `readLivenessEvents` in that rare case — see liveness-heartbeat.js).
87
+ *
88
+ * Tolerates a missing file (returns []) exactly like readLivenessEvents.
89
+ *
90
+ * @param {string} streamPath
91
+ * @param {number} [tailBytes] Defaults to 64KB (DEFAULT_TAIL_BYTES).
92
+ * @returns {object[]}
93
+ */
94
+ function readLivenessEventsTail(streamPath, tailBytes = DEFAULT_TAIL_BYTES) {
95
+ let fd;
96
+ try {
97
+ const size = fs.statSync(streamPath).size;
98
+ if (size <= 0) return [];
99
+ const start = size > tailBytes ? size - tailBytes : 0;
100
+ const length = size - start;
101
+ const buffer = Buffer.alloc(length);
102
+ fd = fs.openSync(streamPath, 'r');
103
+ // F9 (#288 fix iteration 2, LOW): a single fs.readSync() call is not guaranteed to fill the
104
+ // whole requested length in one shot (short reads are a documented POSIX possibility, and can
105
+ // also occur here if the file is truncated/rotated between the statSync() above and this read).
106
+ // Loop until either the full requested `length` has been read or EOF is hit (bytesRead === 0).
107
+ let bytesReadTotal = 0;
108
+ while (bytesReadTotal < length) {
109
+ const bytesRead = fs.readSync(fd, buffer, bytesReadTotal, length - bytesReadTotal, start + bytesReadTotal);
110
+ if (bytesRead <= 0) break; // EOF before the requested tail length was fully read
111
+ bytesReadTotal += bytesRead;
112
+ }
113
+ if (bytesReadTotal < length) {
114
+ // Short read: decoding a partially-filled buffer risks silently dropping or mis-parsing the
115
+ // last (possibly truncated) record. Never decode zero/partial bytes here — degrade to a full
116
+ // readFileSync of the same path instead, which re-reads the file from scratch and is immune
117
+ // to this race entirely.
118
+ try {
119
+ fs.closeSync(fd);
120
+ } catch {
121
+ /* best-effort close only */
122
+ }
123
+ fd = undefined;
124
+ return readLivenessEvents(streamPath);
125
+ }
126
+ let raw = buffer.toString('utf8');
127
+ if (start > 0) {
128
+ // Newline-aligned: the read may begin mid-line (the byte at `start` is not
129
+ // necessarily the start of a JSON record) — drop the possibly-truncated partial
130
+ // first line rather than risk mis-parsing (or worse, silently accepting) a
131
+ // corrupted fragment.
132
+ const newlineIndex = raw.indexOf('\n');
133
+ raw = newlineIndex === -1 ? '' : raw.slice(newlineIndex + 1);
134
+ }
135
+ return parseEventLines(raw);
136
+ } catch {
137
+ return [];
138
+ } finally {
139
+ if (fd !== undefined) {
140
+ try {
141
+ fs.closeSync(fd);
142
+ } catch {
143
+ /* best-effort close only */
144
+ }
51
145
  }
52
146
  }
53
- return out;
54
147
  }
55
148
 
56
149
  /**
@@ -110,4 +203,4 @@ function freshHolders(events, slug, selfActor, nowMs) {
110
203
  return result;
111
204
  }
112
205
 
113
- module.exports = { readLivenessEvents, freshHolders };
206
+ module.exports = { readLivenessEvents, readLivenessEventsTail, freshHolders };
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+ /**
3
+ * liveness-write.js — shared pure-CJS liveness stream writer
4
+ *
5
+ * Zero external dependencies. Consumed by:
6
+ * - scripts/hooks/lib/liveness-heartbeat.js (CJS, direct require)
7
+ * - build/src/cli/workflow-sidecar.js (ESM compiled, via createRequire)
8
+ *
9
+ * Purpose (issue #288): the ONE writer for `liveness/events.jsonl`, lifted
10
+ * verbatim from `src/cli/workflow-sidecar.ts`'s inline `livenessStreamFile`/
11
+ * `appendLivenessEvent` so both the CLI and the hook wrappers share one
12
+ * implementation (mirroring the existing `liveness-read.js`/`actor-identity.js`
13
+ * sharing pattern) instead of forking the append shape a second time.
14
+ *
15
+ * Exports:
16
+ * livenessStreamFile(root) → string (absolute path to liveness/events.jsonl)
17
+ * appendLivenessEvent(root, evt) → void (mkdir -p parent, append one JSON line)
18
+ */
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+
23
+ /**
24
+ * Resolve the path to the shared liveness event stream for a given artifact root.
25
+ *
26
+ * @param {string} root Artifact root (e.g. `.kontourai/flow-agents`)
27
+ * @returns {string} `<root>/liveness/events.jsonl`
28
+ */
29
+ function livenessStreamFile(root) {
30
+ return path.join(root, 'liveness', 'events.jsonl');
31
+ }
32
+
33
+ /**
34
+ * Append one liveness event to the shared stream, creating the parent directory if needed.
35
+ *
36
+ * @param {string} root Artifact root (e.g. `.kontourai/flow-agents`)
37
+ * @param {object} evt Event object (written as one JSON line, newline-terminated)
38
+ * @returns {void}
39
+ */
40
+ function appendLivenessEvent(root, evt) {
41
+ const file = livenessStreamFile(root);
42
+ fs.mkdirSync(path.dirname(file), { recursive: true });
43
+ fs.appendFileSync(file, `${JSON.stringify(evt)}\n`);
44
+ }
45
+
46
+ module.exports = { livenessStreamFile, appendLivenessEvent };