@ijfw/memory-server 1.4.4 → 1.5.1

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 (245) hide show
  1. package/bin/ijfw-memorize +14 -7
  2. package/fixtures/team/book.json +6 -6
  3. package/fixtures/team/business.json +146 -20
  4. package/fixtures/team/content.json +6 -6
  5. package/fixtures/team/design.json +148 -20
  6. package/fixtures/team/mixed.json +206 -27
  7. package/fixtures/team/research.json +146 -20
  8. package/fixtures/team/software.json +148 -20
  9. package/fixtures/truncation-corpus/_generate-corpus.js +367 -0
  10. package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
  11. package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
  12. package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
  13. package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
  14. package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
  15. package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
  16. package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
  17. package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
  18. package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
  19. package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
  20. package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
  21. package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
  22. package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
  23. package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
  24. package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
  25. package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
  26. package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
  27. package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
  28. package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
  29. package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
  30. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
  31. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
  32. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
  33. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
  34. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
  35. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
  36. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
  37. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
  38. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
  39. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
  40. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
  41. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
  42. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
  43. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
  44. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
  45. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
  46. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
  47. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
  48. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
  49. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
  50. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
  51. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
  52. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
  53. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
  54. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
  55. package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
  56. package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
  57. package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
  58. package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
  59. package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
  60. package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
  61. package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
  62. package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  63. package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
  64. package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
  65. package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
  66. package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
  67. package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
  68. package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
  69. package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
  70. package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  71. package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
  72. package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
  73. package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
  74. package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
  75. package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
  76. package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
  77. package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
  78. package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
  79. package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
  80. package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
  81. package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
  82. package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
  83. package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
  84. package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
  85. package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
  86. package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
  87. package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
  88. package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
  89. package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
  90. package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
  91. package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
  92. package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
  93. package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
  94. package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
  95. package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
  96. package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
  97. package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
  98. package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
  99. package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
  100. package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
  101. package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
  102. package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
  103. package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
  104. package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
  105. package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
  106. package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
  107. package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
  108. package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  109. package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
  110. package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
  111. package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
  112. package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
  113. package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
  114. package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
  115. package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
  116. package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
  117. package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  118. package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
  119. package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
  120. package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
  121. package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
  122. package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
  123. package/package.json +6 -3
  124. package/src/active-extension-writer.js +144 -64
  125. package/src/api-client.js +43 -5
  126. package/src/audit-roster.js +80 -5
  127. package/src/blackboard.js +298 -6
  128. package/src/cli-run.js +33 -5
  129. package/src/codex-agents.js +96 -5
  130. package/src/cost/aggregator.js +39 -9
  131. package/src/cost/pricing.js +57 -0
  132. package/src/cost/readers/gemini.js +1 -1
  133. package/src/cross-audit-chunker.js +189 -0
  134. package/src/cross-dispatcher.js +124 -21
  135. package/src/cross-orchestrator-cli.js +754 -159
  136. package/src/cross-orchestrator.js +1065 -17
  137. package/src/cross-project-search.js +195 -9
  138. package/src/dashboard-client-waves.html +304 -0
  139. package/src/dashboard-client.html +5 -1
  140. package/src/dashboard-server.js +73 -0
  141. package/src/deploy-alerts.js +150 -0
  142. package/src/design/iframe-bridge.js +242 -0
  143. package/src/design-companion.js +144 -0
  144. package/src/dispatch/checkpoint-cli.js +97 -0
  145. package/src/dispatch/colon-syntax.js +81 -1
  146. package/src/dispatch/extension.js +26 -2
  147. package/src/dispatch/registry-cli.js +4 -1
  148. package/src/dispatch/wave-cli.js +201 -6
  149. package/src/dispatch/worktree-cli.js +40 -0
  150. package/src/dispatch-planner.js +97 -2
  151. package/src/dream/runner.mjs +47 -11
  152. package/src/dream/stage-runner.js +40 -0
  153. package/src/dream/state-file.js +102 -0
  154. package/src/extension-installer.js +70 -24
  155. package/src/extension-quota-tracker.js +4 -2
  156. package/src/extension-registry.js +289 -35
  157. package/src/feedback-detector.js +26 -0
  158. package/src/fs-lock.js +259 -7
  159. package/src/gate-result.js +95 -1
  160. package/src/hardware-signer.js +4 -2
  161. package/src/hero-line.js +86 -5
  162. package/src/intent-router.js +35 -0
  163. package/src/lib/a11y-contract.js +117 -0
  164. package/src/lib/atomic-io.js +29 -8
  165. package/src/lib/cache-keepalive.js +150 -0
  166. package/src/lib/jsonl-rotation.js +104 -0
  167. package/src/lib/lighthouse-pillar.js +121 -0
  168. package/src/lib/llm-call.js +121 -0
  169. package/src/lib/playwright-baseline.js +205 -0
  170. package/src/lib/rekor-bridge.js +221 -0
  171. package/src/lib/repo-map.js +392 -0
  172. package/src/lib/shasum-verify.js +164 -0
  173. package/src/lib/sketches-gc.js +132 -0
  174. package/src/lib/tmp-suffix.js +62 -0
  175. package/src/lib/ui-review-runner.js +595 -0
  176. package/src/lib/uispec-drift.js +301 -0
  177. package/src/lib/uispec-intake.js +381 -0
  178. package/src/lib/worktree-guards.js +118 -0
  179. package/src/lib/worktree-recovery.js +100 -0
  180. package/src/memory/auto-linker.js +267 -0
  181. package/src/memory/benchmark.js +498 -0
  182. package/src/memory/dedup.js +126 -0
  183. package/src/memory/embedding-cache.js +136 -0
  184. package/src/memory/fact-extractor.js +168 -0
  185. package/src/memory/fts5.js +65 -1
  186. package/src/memory/migration-runner.js +6 -1
  187. package/src/memory/migrations/004-bitemporal.js +91 -0
  188. package/src/memory/migrations/005-vector-cache.js +61 -0
  189. package/src/memory/migrations/006-obsidian-graph.js +46 -0
  190. package/src/memory/migrations/007-skill-telemetry.js +24 -0
  191. package/src/memory/migrations/008-write-provenance.js +41 -0
  192. package/src/memory/migrations/009-obsidian-backfill.js +50 -0
  193. package/src/memory/obsidian-parser.js +152 -0
  194. package/src/memory/query-dataview.js +86 -0
  195. package/src/memory/search.js +46 -15
  196. package/src/memory/temporal.js +529 -0
  197. package/src/memory/tokenize.js +10 -0
  198. package/src/memory-facts-handler.js +37 -0
  199. package/src/memory-feedback.js +260 -2
  200. package/src/model-refresh.js +292 -0
  201. package/src/observability/cost-anomaly.js +166 -0
  202. package/src/observability/evaluator-checkpoint-contract.js +117 -0
  203. package/src/observability/trace-id.js +163 -0
  204. package/src/orchestrator/agents-md-blackboard.js +152 -0
  205. package/src/orchestrator/checkpoint-contract.md +140 -0
  206. package/src/orchestrator/debug-trident-trigger.js +374 -0
  207. package/src/orchestrator/debug-trident.js +570 -0
  208. package/src/orchestrator/merge-block-aware.js +350 -0
  209. package/src/orchestrator/plan-checker.js +475 -0
  210. package/src/orchestrator/post-done-runner.js +277 -0
  211. package/src/orchestrator/review.js +38 -3
  212. package/src/orchestrator/skill-telemetry-sink.js +29 -0
  213. package/src/orchestrator/skill-telemetry.js +37 -0
  214. package/src/orchestrator/state-events.js +459 -0
  215. package/src/orchestrator/state-sdk.js +1932 -0
  216. package/src/orchestrator/status-protocol.js +84 -17
  217. package/src/orchestrator/subagent-telemetry.js +471 -0
  218. package/src/orchestrator/termination.js +160 -0
  219. package/src/orchestrator/verification-gate.js +200 -16
  220. package/src/orchestrator/wave-state.js +332 -23
  221. package/src/orchestrator/worktree-provision.js +77 -0
  222. package/src/override-resolver.js +5 -3
  223. package/src/override-use-registry.js +111 -5
  224. package/src/receipts.js +36 -4
  225. package/src/recovery/checkpoint.js +56 -3
  226. package/src/recovery/code-fixer.js +961 -0
  227. package/src/recovery/truncation.js +317 -0
  228. package/src/redactor.js +75 -6
  229. package/src/runtime-mediator.js +15 -1
  230. package/src/sanitizer.js +10 -0
  231. package/src/search-hybrid.js +139 -0
  232. package/src/server.js +795 -112
  233. package/src/swarm/worktree.js +27 -4
  234. package/src/swarm-config.js +102 -17
  235. package/src/team/domain-templates/book.json +51 -0
  236. package/src/team/domain-templates/business.json +44 -0
  237. package/src/team/domain-templates/content.json +50 -0
  238. package/src/team/domain-templates/design.json +44 -0
  239. package/src/team/domain-templates/research.json +44 -0
  240. package/src/team/domain-templates/software.json +40 -0
  241. package/src/team/generator.js +440 -3
  242. package/src/team/modify.js +203 -0
  243. package/src/team/schemas.js +48 -0
  244. package/src/update-apply.js +19 -3
  245. package/src/dashboard-charts.js +0 -239
@@ -12,6 +12,38 @@
12
12
  // gets filtered as "self."
13
13
 
14
14
  import { spawnSync } from 'node:child_process';
15
+ import { getLatestModel } from './model-refresh.js';
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // v1.5.0 F5 -- audit-rotation v0 schema (schema-only; runtime ships in v1.6.0)
19
+ // ---------------------------------------------------------------------------
20
+ //
21
+ // We are NOT shipping rotation logic in v1.5.0. We are shipping the schema
22
+ // commitment so callers / future code have a stable contract to encode
23
+ // against. Auto-rotation flips on in v1.6.0 once we have telemetry to
24
+ // decide rotation policy (cost-weighted, win-rate-weighted, round-robin).
25
+ //
26
+ // Contract:
27
+ // - ROTATION_SCHEMA_VERSION = 1: any future schema change bumps this
28
+ // integer; consumers MUST refuse to apply policy from a higher version
29
+ // than they support.
30
+ // - defaultRotationPolicy = 'manual': v0 behavior is "no rotation;
31
+ // caller (or operator) picks the auditor explicitly via pickAuditors
32
+ // `only:` or default-priority strategy." Other policy values reserved
33
+ // for v1.6.0: 'round-robin', 'cost-weighted', 'win-rate-weighted'.
34
+ //
35
+ // Shape (reserved; not consumed yet):
36
+ // {
37
+ // schema: ROTATION_SCHEMA_VERSION,
38
+ // policy: defaultRotationPolicy,
39
+ // window_days: 7, // reserved -- look-back for win-rate
40
+ // min_picks_per_auditor: 1, // reserved -- floor on usage
41
+ // last_rotated: <ISO>, // reserved -- persistence anchor
42
+ // }
43
+ //
44
+ /** @typedef {{ schema: number, policy: string, window_days?: number, min_picks_per_auditor?: number, last_rotated?: string }} RotationPolicy */
45
+ export const ROTATION_SCHEMA_VERSION = 1;
46
+ export const defaultRotationPolicy = 'manual';
15
47
 
16
48
  export const ROSTER = [
17
49
  {
@@ -19,7 +51,22 @@ export const ROSTER = [
19
51
  family: 'openai',
20
52
  model: '',
21
53
  name: 'Codex CLI',
54
+ // Prompt-via-stdin path. Proven working 2026-05-18 with codex-cli 0.130.0.
22
55
  invoke: 'codex exec --skip-git-repo-check --sandbox read-only -c approval_policy="never" -c mcp_servers.ijfw-memory.enabled=false -',
56
+ // Dedicated review subcommand path. Use when an audit target is a git ref
57
+ // (HEAD~N, branch name, or commit SHA). The -c mcp_servers.ijfw-memory.enabled=false
58
+ // override is LOAD-BEARING: without it, codex review hangs indefinitely on
59
+ // the ijfw_memory_prelude MCP tool autostart (cycle: codex spawns IJFW MCP
60
+ // server, prelude tool waits on a response, IJFW MCP server is itself the
61
+ // child of the codex session). Verified 2026-05-18, codex-cli 0.130.0.
62
+ // {REF} is the substitution token the caller swaps for the base git ref.
63
+ reviewInvoke: 'codex review --base {REF} -c approval_policy="never" -c mcp_servers.ijfw-memory.enabled=false',
64
+ // 8 min default per-auditor budget for review work. codex review against
65
+ // HEAD~5 with MCP disabled completed in ~75s during S7 reproduction;
66
+ // larger diffs and reasoning-heavy targets need headroom. The existing
67
+ // PROVIDER_TIMEOUT_MS['codex'] in cross-orchestrator.js is 120s (2 min),
68
+ // which is fine for exec-mode quick prompts but too tight for review.
69
+ timeoutMs: 8 * 60 * 1000,
23
70
  note: 'Different training lineage; fast on review tasks. The - flag reads prompt from stdin. --skip-git-repo-check bypasses the trusted-directory gate added in codex-cli 0.118.0. --sandbox read-only blocks file WRITES on the host (verified Codex 0.122.0: `echo > /tmp/x` returns `operation not permitted`); it does NOT block shell exec or subprocess launching -- a `read-only` sandbox can still run `ls`, `curl`, or `gemini`. The defense against codex going meta and shelling out to other auditors is the prompt-layer "Operating constraints" block in cross-dispatcher.js buildRequest, not the sandbox flag. The model layer additionally refuses to read explicitly-secret files like ~/.ssh/id_rsa or ~/.codex/auth.json even when prompt-injected to do so. The visibility surface in cross-orchestrator-cli.js cmdCross catches any residual silent failure. approval_policy="never" auto-approves without an interactive prompt. mcp_servers.ijfw-memory.enabled=false disables IJFW MCP for this session because Codex in `codex exec` mode under a non-bypass sandbox auto-cancels MCP tool calls -- the cancellation noise wastes tokens and the audit does not need IJFW memory recall (the brief contains the full target inline).',
24
71
  // CODEX_SESSION_ID is set by codex itself when running INSIDE a codex
25
72
  // session; CODEX_HOME is a config-path env var that's set whenever codex
@@ -28,7 +75,11 @@ export const ROSTER = [
28
75
  // installed but where the caller is something else (Claude Code, Cursor,
29
76
  // etc.). Surface noted by carrmjw during the qwen roster review (#11).
30
77
  detect: (env) => Boolean(env.CODEX_SESSION_ID) || /codex/i.test(env._ || ''),
31
- apiFallback: { provider: 'openai', model: 'gpt-4o-mini', authEnv: 'OPENAI_API_KEY', endpoint: 'https://api.openai.com/v1/chat/completions' },
78
+ // model is resolved at call-time via model-refresh.js (24h-cached probe of
79
+ // /v1/models). Hardcoded value below is the offline fallback only.
80
+ get apiFallback() {
81
+ return { provider: 'openai', model: getLatestModel('openai'), authEnv: 'OPENAI_API_KEY', endpoint: 'https://api.openai.com/v1/chat/completions' };
82
+ },
32
83
  },
33
84
  {
34
85
  id: 'gemini',
@@ -38,7 +89,10 @@ export const ROSTER = [
38
89
  invoke: 'gemini',
39
90
  note: 'Strong on security + architectural patterns. Auto-detects piped stdin for headless mode.',
40
91
  detect: (env) => Boolean(env.GEMINI_CLI || env.GOOGLE_CLOUD_PROJECT_GEMINI) || /gemini-cli/i.test(env._ || ''),
41
- apiFallback: { provider: 'google', model: 'gemini-2.0-flash', authEnv: 'GEMINI_API_KEY', endpoint: 'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent' },
92
+ // model is resolved at call-time via model-refresh.js (24h-cached probe).
93
+ get apiFallback() {
94
+ return { provider: 'google', model: getLatestModel('google'), authEnv: 'GEMINI_API_KEY', endpoint: 'https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent' };
95
+ },
42
96
  },
43
97
  {
44
98
  id: 'qwen',
@@ -108,7 +162,10 @@ export const ROSTER = [
108
162
  invoke: 'claude -p',
109
163
  note: 'Anthropic; useful when you want a second Claude pass in a fresh session.',
110
164
  detect: (env) => Boolean(env.CLAUDECODE || env.CLAUDE_CODE_ENTRYPOINT || env.CLAUDE_PLUGIN_ROOT),
111
- apiFallback: { provider: 'anthropic', model: 'claude-haiku-4-5-20251001', authEnv: 'ANTHROPIC_API_KEY', endpoint: 'https://api.anthropic.com/v1/messages' },
165
+ // model is resolved at call-time via model-refresh.js (24h-cached probe).
166
+ get apiFallback() {
167
+ return { provider: 'anthropic', model: getLatestModel('anthropic'), authEnv: 'ANTHROPIC_API_KEY', endpoint: 'https://api.anthropic.com/v1/messages' };
168
+ },
112
169
  },
113
170
  ];
114
171
 
@@ -122,9 +179,27 @@ export function detectSelf(env = process.env) {
122
179
 
123
180
  // Probe whether the auditor's CLI is on PATH. Cached per process.
124
181
  // Exported so tests can prime the cache for deterministic behavior.
182
+ //
183
+ // v1.5.0 audit-LOW-trident-L1: cache entries now carry a timestamp and
184
+ // expire after 5 minutes. A long-running orchestrator session that installs
185
+ // an auditor mid-session (e.g. `npm install -g @google/gemini-cli`) was
186
+ // otherwise stuck with the stale "not installed" verdict for the rest of
187
+ // the process lifetime. 5min is comfortably longer than a typical Trident
188
+ // fan-out (which probes every auditor up front) but short enough that a
189
+ // mid-session install is detected on the next round.
125
190
  export const _installedCache = new Map();
191
+ const INSTALLED_CACHE_TTL_MS = 5 * 60 * 1000;
126
192
  export function isInstalled(id) {
127
- if (_installedCache.has(id)) return _installedCache.get(id);
193
+ const cached = _installedCache.get(id);
194
+ if (cached !== undefined) {
195
+ // Legacy entries (primed by tests as a raw boolean) are honoured
196
+ // forever — tests rely on that contract. New entries carry {value, ts}.
197
+ if (typeof cached === 'boolean') return cached;
198
+ if (cached && typeof cached === 'object' && cached.ts + INSTALLED_CACHE_TTL_MS > Date.now()) {
199
+ return cached.value;
200
+ }
201
+ // expired — fall through to re-probe
202
+ }
128
203
  const entry = ROSTER.find(e => e.id === id);
129
204
  if (!entry) return false;
130
205
  // First word of invoke is the binary; the rest are args.
@@ -133,7 +208,7 @@ export function isInstalled(id) {
133
208
  // works reliably across macOS + Linux. spawnSync exit code = 0 → present.
134
209
  const r = spawnSync('bash', ['-lc', `command -v ${JSON.stringify(bin)} >/dev/null 2>&1`], { timeout: 2000 });
135
210
  const installed = r.status === 0;
136
- _installedCache.set(id, installed);
211
+ _installedCache.set(id, { value: installed, ts: Date.now() });
137
212
  return installed;
138
213
  }
139
214
 
package/src/blackboard.js CHANGED
@@ -4,12 +4,28 @@
4
4
  // small and dependency-free: tasks/claims are atomic JSON, notes are append-only
5
5
  // JSONL, and handoff is plain markdown.
6
6
 
7
- import { appendFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
8
8
  import { join, resolve } from 'node:path';
9
9
  import { writeAtomic, readSafe, withLock } from './lib/atomic-io.js';
10
+ import { rotateJsonlIfNeeded } from './lib/jsonl-rotation.js';
10
11
 
11
12
  export const BLACKBOARD_VERSION = 1;
12
13
 
14
+ // F-REL-1 (H5.3): default claim TTL = 30 minutes. Subagents that go silent
15
+ // (the wayland 5/8-subagent failure mode) leave their claims forever
16
+ // without this. Configurable via the `ttlMs` option on evictOrphanedClaims
17
+ // and via the `--ttl-min N` CLI flag on `ijfw swarm evict-orphans`.
18
+ export const DEFAULT_CLAIM_TTL_MS = 30 * 60 * 1000;
19
+
20
+ // v1.5.0 audit-LOW-teams-#16: hard cap on tasks.json + claims.json
21
+ // serialized size. A runaway producer (bug or attacker) could otherwise
22
+ // grow either file unboundedly and starve the project's disk + slow every
23
+ // read of the cache to a crawl. 4MB is comfortably above any legitimate
24
+ // swarm workload (thousands of tasks) but well below "fill up the disk"
25
+ // territory; refusing the write here keeps the previous on-disk state
26
+ // intact (atomic writes only swap on success, so the old file survives).
27
+ export const MAX_BB_FILE_BYTES = 4_000_000;
28
+
13
29
  export function blackboardPaths(projectRoot = process.cwd()) {
14
30
  const root = resolve(projectRoot);
15
31
  const dir = join(root, '.ijfw', 'blackboard');
@@ -60,7 +76,21 @@ function readJson(path, fallback, validator) {
60
76
 
61
77
  function writeJson(path, data) {
62
78
  data.updated_at = nowIso();
63
- return writeAtomic(path, `${JSON.stringify(data, null, 2)}\n`, { mode: 0o600 });
79
+ const serialized = `${JSON.stringify(data, null, 2)}\n`;
80
+ // v1.5.0 audit-LOW-teams-#16: enforce the size cap on the write path
81
+ // only -- existing readers (readBlackboard / pathsOverlap / etc.) are
82
+ // unaffected so a legacy oversized file remains readable. Throwing here
83
+ // preserves the previous on-disk state because writeAtomic only swaps
84
+ // after a successful tmp-write.
85
+ const bytes = Buffer.byteLength(serialized, 'utf8');
86
+ if (bytes > MAX_BB_FILE_BYTES) {
87
+ throw new Error(
88
+ `blackboard: refusing to write ${path} -- serialized size ${bytes} bytes ` +
89
+ `exceeds cap ${MAX_BB_FILE_BYTES} bytes (audit-LOW-teams-#16). Trim ` +
90
+ `tasks/claims (e.g. archive completed tasks) before retrying.`,
91
+ );
92
+ }
93
+ return writeAtomic(path, serialized, { mode: 0o600 });
64
94
  }
65
95
 
66
96
  function readJsonl(path, limit = 5) {
@@ -76,6 +106,10 @@ function readJsonl(path, limit = 5) {
76
106
  }
77
107
 
78
108
  function appendJsonlUnlocked(path, entry) {
109
+ // F-PRF-1 (audit-MED-teams-#10): rotate large JSONL files in place before
110
+ // appending. The rotator is a no-op when the file is under the 4MB
111
+ // threshold, so this stays a hot-path-friendly stat() in the common case.
112
+ try { rotateJsonlIfNeeded(path); } catch { /* rotation is best-effort */ }
79
113
  appendFileSync(path, `${JSON.stringify(entry)}\n`, { encoding: 'utf8', mode: 0o600 });
80
114
  return entry;
81
115
  }
@@ -110,10 +144,53 @@ export function initBlackboard(projectRoot = process.cwd()) {
110
144
  return { ok: true, dir: paths.dir };
111
145
  }
112
146
 
147
+ // F-SPD-2 (audit-MED-teams-#9): mtime cache for readBlackboard. Re-parsing
148
+ // tasks.json + claims.json on every status/listSwarmTasks call shows up in
149
+ // hot-path traces (planner + dispatcher both call this). The cache is keyed
150
+ // on the resolved project dir and remembers the mtimeMs of both JSON files.
151
+ // On a hit we return the previously-parsed JSON shape; on miss we re-parse
152
+ // and refresh the cache. JSONL recent-tails are NOT cached -- they are
153
+ // append-only and the LRU is intentionally narrow.
154
+ const BLACKBOARD_READ_CACHE = new Map();
155
+ const BLACKBOARD_READ_CACHE_MAX = 32;
156
+
157
+ function blackboardFileMtime(path) {
158
+ try {
159
+ return statSync(path).mtimeMs;
160
+ } catch {
161
+ return 0;
162
+ }
163
+ }
164
+
165
+ function getCachedJson(cacheKey, path, mtime, fallback, validator) {
166
+ const cached = BLACKBOARD_READ_CACHE.get(cacheKey);
167
+ if (cached && cached.path === path && cached.mtime === mtime && mtime > 0) {
168
+ return cached.value;
169
+ }
170
+ const value = readJson(path, fallback, validator);
171
+ BLACKBOARD_READ_CACHE.set(cacheKey, { path, mtime, value });
172
+ // Lightweight LRU eviction: drop oldest entry when over cap.
173
+ if (BLACKBOARD_READ_CACHE.size > BLACKBOARD_READ_CACHE_MAX) {
174
+ const firstKey = BLACKBOARD_READ_CACHE.keys().next().value;
175
+ if (firstKey !== undefined) BLACKBOARD_READ_CACHE.delete(firstKey);
176
+ }
177
+ return value;
178
+ }
179
+
180
+ // Exposed for tests + cache invalidation hooks. Clears all memoised entries.
181
+ export function _resetBlackboardReadCache() {
182
+ BLACKBOARD_READ_CACHE.clear();
183
+ }
184
+
113
185
  export function readBlackboard(projectRoot = process.cwd()) {
114
186
  const paths = blackboardPaths(projectRoot);
115
- const tasks = readJson(paths.tasks, defaultTasks, validTasks);
116
- const claims = readJson(paths.claims, defaultClaims, validClaims);
187
+ // F-SPD-2: mtime-keyed memo. When tasks.json + claims.json are unchanged
188
+ // we skip JSON.parse entirely. mtime===0 forces a miss so transient stat
189
+ // failures degrade to the un-cached path safely.
190
+ const tasksMtime = blackboardFileMtime(paths.tasks);
191
+ const claimsMtime = blackboardFileMtime(paths.claims);
192
+ const tasks = getCachedJson(`${paths.root}::tasks`, paths.tasks, tasksMtime, defaultTasks, validTasks);
193
+ const claims = getCachedJson(`${paths.root}::claims`, paths.claims, claimsMtime, defaultClaims, validClaims);
117
194
  return {
118
195
  paths,
119
196
  tasks,
@@ -207,6 +284,36 @@ function commonPrefixBeforeGlob(pattern) {
207
284
  return idx === -1 ? pattern : pattern.slice(0, idx);
208
285
  }
209
286
 
287
+ // v1.5.0 N4.obs M7: explicit path-segment overlap detection.
288
+ //
289
+ // The old prefix check was `right.startsWith(lp)` which falsely overlapped
290
+ // e.g. `src` with `srcfoo`. Real path containment requires either an exact
291
+ // match OR a `/` separator immediately after the shorter prefix (so `src/`
292
+ // is the prefix of `src/foo`, but `src` does NOT contain `srcfoo`).
293
+ //
294
+ // `commonPrefixBeforeGlob` is preserved for glob handling -- it returns the
295
+ // literal head of a glob pattern (`src/*.js` -> `src/`). When that head
296
+ // already ends with `/`, we compare directly; when it doesn't (no glob in
297
+ // the pattern at all), we require a trailing-slash match below.
298
+ //
299
+ // Same-string comparison short-circuits at the top, so `src` vs `src`
300
+ // remains overlap-true.
301
+ function segmentOverlap(prefix, candidate) {
302
+ if (!prefix || !candidate) return false;
303
+ if (prefix === candidate) return true;
304
+ // Treat the prefix as a directory prefix: candidate must start with
305
+ // `prefix` AND the next character must be `/`. This rejects the
306
+ // `srcfoo`-vs-`src` false positive.
307
+ if (prefix.endsWith('/')) {
308
+ // Glob-derived prefix already includes the separator; plain prefix match
309
+ // is the right semantics.
310
+ return candidate === prefix.slice(0, -1) || candidate.startsWith(prefix);
311
+ }
312
+ return candidate.length > prefix.length
313
+ && candidate.startsWith(prefix)
314
+ && candidate.charAt(prefix.length) === '/';
315
+ }
316
+
210
317
  function pathsOverlap(a, b) {
211
318
  if (!a.length || !b.length) return false;
212
319
  for (const left of a) {
@@ -214,8 +321,8 @@ function pathsOverlap(a, b) {
214
321
  if (left === right) return true;
215
322
  const lp = commonPrefixBeforeGlob(left);
216
323
  const rp = commonPrefixBeforeGlob(right);
217
- if (lp && right.startsWith(lp)) return true;
218
- if (rp && left.startsWith(rp)) return true;
324
+ if (segmentOverlap(lp, right)) return true;
325
+ if (segmentOverlap(rp, left)) return true;
219
326
  }
220
327
  }
221
328
  return false;
@@ -236,6 +343,9 @@ export function claimArtifact(projectRoot, input) {
236
343
  const current = readJson(paths.claims, defaultClaims, validClaims).data;
237
344
  const artifactId = String(input.artifact_id || input.artifact || '').trim();
238
345
  const agent = String(input.agent || input.owner || '').trim();
346
+ const ttlMs = Number.isFinite(input.ttlMs) && input.ttlMs > 0
347
+ ? Math.floor(input.ttlMs)
348
+ : DEFAULT_CLAIM_TTL_MS;
239
349
  const next = {
240
350
  id: input.id || `${artifactId}:${agent}`,
241
351
  artifact_id: artifactId,
@@ -243,6 +353,13 @@ export function claimArtifact(projectRoot, input) {
243
353
  paths: normalizePaths(input.paths),
244
354
  status: 'active',
245
355
  claimed_at: nowIso(),
356
+ // F-REL-1: TTL is stored on the claim so per-claim overrides survive
357
+ // a config reload and the evictor doesn't need the original config.
358
+ ttl_ms: ttlMs,
359
+ // heartbeat_at is OPTIONAL -- subagents that don't ping fall back to
360
+ // claimed_at as the freshness anchor. Initialised null so the field
361
+ // always exists in the JSON shape (no schema migration needed).
362
+ heartbeat_at: null,
246
363
  note: input.note ? String(input.note) : undefined,
247
364
  };
248
365
  if (!next.artifact_id) return { ok: false, error: 'artifact-required' };
@@ -265,6 +382,90 @@ export function claimArtifact(projectRoot, input) {
265
382
  }).result ?? { ok: false, error: 'locked' };
266
383
  }
267
384
 
385
+ /**
386
+ * v1.5.0 audit-LOW-teams-#17: bulk-claim API.
387
+ *
388
+ * Acquire claims for N artifacts under ONE lock + ONE writeJson, instead of
389
+ * N round-trips through `claimArtifact`. The dispatcher fanned-out batch case
390
+ * (e.g. wave fan-out reserves 10+ artifacts at once) previously hit the lock
391
+ * 10+ times with serialised disk writes between each.
392
+ *
393
+ * Semantics:
394
+ * - All-or-nothing: any single conflict ABORTS the batch and reports the
395
+ * conflicting artifact_id + the existing claim. No partial commits.
396
+ * - Same conflict rules as claimArtifact (artifact_id equal OR paths
397
+ * overlap, scoped to a different agent).
398
+ * - Per-item agent override: each item may set its own `agent`. When
399
+ * omitted, the top-level `agent` is used.
400
+ *
401
+ * @param {string} projectRoot
402
+ * @param {Array<{artifact_id: string, paths?: string[], agent?: string, ttlMs?: number, note?: string}>} items
403
+ * @param {{agent?: string}} [defaults]
404
+ * @returns {{ok: true, claims: object[]} | {ok: false, error: string, artifact_id?: string, conflicts?: object[]}}
405
+ */
406
+ export function claimArtifacts(projectRoot, items, defaults = {}) {
407
+ const paths = blackboardPaths(projectRoot);
408
+ ensureDir(paths);
409
+ if (!Array.isArray(items) || items.length === 0) {
410
+ return { ok: false, error: 'items-required' };
411
+ }
412
+ return withLock(paths.lock, () => {
413
+ const current = readJson(paths.claims, defaultClaims, validClaims).data;
414
+ const accepted = [];
415
+ // Track NEW claims so they conflict-check against each other (same wave
416
+ // calling claim_a + claim_b where they overlap is still a conflict).
417
+ const pendingClaims = [];
418
+ for (const input of items) {
419
+ const artifactId = String(input.artifact_id || input.artifact || '').trim();
420
+ const agent = String(input.agent || defaults.agent || input.owner || '').trim();
421
+ const ttlMs = Number.isFinite(input.ttlMs) && input.ttlMs > 0
422
+ ? Math.floor(input.ttlMs)
423
+ : DEFAULT_CLAIM_TTL_MS;
424
+ const next = {
425
+ id: input.id || `${artifactId}:${agent}`,
426
+ artifact_id: artifactId,
427
+ agent,
428
+ paths: normalizePaths(input.paths),
429
+ status: 'active',
430
+ claimed_at: nowIso(),
431
+ ttl_ms: ttlMs,
432
+ heartbeat_at: null,
433
+ note: input.note ? String(input.note) : undefined,
434
+ };
435
+ if (!next.artifact_id) return { ok: false, error: 'artifact-required' };
436
+ if (!next.agent) return { ok: false, error: 'owner-required' };
437
+
438
+ // Conflict-check against existing AND already-accepted pending claims.
439
+ const combined = { claims: [...current.claims, ...pendingClaims] };
440
+ const conflicts = claimConflicts(combined, next);
441
+ if (conflicts.length) {
442
+ return { ok: false, error: 'conflict', artifact_id: next.artifact_id, conflicts };
443
+ }
444
+ pendingClaims.push(next);
445
+ accepted.push(next);
446
+ }
447
+ // Drop any prior duplicates (same artifact_id + agent) — matches
448
+ // claimArtifact semantics where a re-claim by the same agent is idempotent.
449
+ for (const next of accepted) {
450
+ current.claims = current.claims.filter(
451
+ (claim) => !(claimArtifactId(claim) === next.artifact_id && claimAgent(claim) === next.agent),
452
+ );
453
+ current.claims.push(next);
454
+ }
455
+ writeJson(paths.claims, current);
456
+ for (const next of accepted) {
457
+ appendJsonlUnlocked(paths.events, blackboardEventEntry({
458
+ type: 'claim.acquired',
459
+ actor: next.agent,
460
+ artifact_ids: [next.artifact_id],
461
+ message: `Claimed ${next.artifact_id} (bulk)`,
462
+ data: { paths: next.paths, bulk: true },
463
+ }));
464
+ }
465
+ return { ok: true, claims: accepted };
466
+ }).result ?? { ok: false, error: 'locked' };
467
+ }
468
+
268
469
  export function releaseClaim(projectRoot, input) {
269
470
  const paths = blackboardPaths(projectRoot);
270
471
  ensureDir(paths);
@@ -295,6 +496,97 @@ export function releaseClaim(projectRoot, input) {
295
496
  }).result ?? { ok: false, error: 'locked' };
296
497
  }
297
498
 
499
+ /**
500
+ * F-REL-1 (H5.3): heartbeat ping. Subagents call this to extend their claim
501
+ * TTL without releasing + reclaiming. Heartbeat is matched by claim id
502
+ * (preferred) or by (artifact_id, agent) tuple. Returns the updated claim
503
+ * so the caller can verify the new heartbeat_at.
504
+ */
505
+ export function updateClaimHeartbeat(projectRoot, input) {
506
+ const paths = blackboardPaths(projectRoot);
507
+ ensureDir(paths);
508
+ return withLock(paths.lock, () => {
509
+ const current = readJson(paths.claims, defaultClaims, validClaims).data;
510
+ const claimId = input.claim_id || input.id ? String(input.claim_id || input.id).trim() : null;
511
+ const artifactId = input.artifact_id || input.artifact ? String(input.artifact_id || input.artifact).trim() : null;
512
+ const agent = input.agent || input.owner ? String(input.agent || input.owner).trim() : null;
513
+ if (!claimId && !(artifactId && agent)) {
514
+ return { ok: false, error: 'claim-or-tuple-required' };
515
+ }
516
+ let updated = null;
517
+ current.claims = current.claims.map((claim) => {
518
+ if (claim.status !== 'active') return claim;
519
+ const matchesById = claimId && claim.id === claimId;
520
+ const matchesByTuple = !claimId && claimArtifactId(claim) === artifactId && claimAgent(claim) === agent;
521
+ if (!matchesById && !matchesByTuple) return claim;
522
+ updated = { ...claim, heartbeat_at: nowIso() };
523
+ return updated;
524
+ });
525
+ if (!updated) return { ok: false, error: 'claim-not-found' };
526
+ writeJson(paths.claims, current);
527
+ return { ok: true, claim: updated };
528
+ }).result ?? { ok: false, error: 'locked' };
529
+ }
530
+
531
+ /**
532
+ * F-REL-1 (H5.3): orphan evictor. Walks active claims, releases any whose
533
+ * freshness anchor (max(claimed_at, heartbeat_at)) is older than ttlMs.
534
+ * Returns evicted claim IDs so the caller can log + report. Default TTL is
535
+ * 30 minutes, matching DEFAULT_CLAIM_TTL_MS.
536
+ *
537
+ * Per-claim ttl_ms (recorded at claim time) is honoured when present; the
538
+ * options.ttlMs is a fallback for legacy claims written before the TTL
539
+ * field existed.
540
+ */
541
+ export function evictOrphanedClaims(projectRoot, options = {}) {
542
+ const paths = blackboardPaths(projectRoot);
543
+ ensureDir(paths);
544
+ const fallbackTtl = Number.isFinite(options.ttlMs) && options.ttlMs > 0
545
+ ? Math.floor(options.ttlMs)
546
+ : DEFAULT_CLAIM_TTL_MS;
547
+ const now = Number.isFinite(options.nowMs) ? Number(options.nowMs) : Date.now();
548
+ return withLock(paths.lock, () => {
549
+ const current = readJson(paths.claims, defaultClaims, validClaims).data;
550
+ const evicted = [];
551
+ current.claims = current.claims.map((claim) => {
552
+ if (claim.status !== 'active') return claim;
553
+ const claimedAt = parseIso(claim.claimed_at);
554
+ const heartbeatAt = parseIso(claim.heartbeat_at);
555
+ const anchor = Math.max(claimedAt || 0, heartbeatAt || 0);
556
+ if (!anchor) return claim; // unparseable timestamps -- leave alone, don't false-evict
557
+ const ttl = Number.isFinite(claim.ttl_ms) && claim.ttl_ms > 0 ? claim.ttl_ms : fallbackTtl;
558
+ if (now - anchor <= ttl) return claim;
559
+ evicted.push({
560
+ id: claim.id,
561
+ artifact_id: claimArtifactId(claim),
562
+ agent: claimAgent(claim),
563
+ age_ms: now - anchor,
564
+ ttl_ms: ttl,
565
+ });
566
+ return { ...claim, status: 'expired', expired_at: nowIso(), eviction_reason: 'ttl-exceeded' };
567
+ });
568
+ if (evicted.length > 0) {
569
+ writeJson(paths.claims, current);
570
+ for (const item of evicted) {
571
+ appendJsonlUnlocked(paths.events, blackboardEventEntry({
572
+ type: 'claim.evicted',
573
+ actor: 'ijfw',
574
+ artifact_ids: [item.artifact_id],
575
+ message: `Evicted orphan claim ${item.id} (age ${Math.round(item.age_ms / 1000)}s > ttl ${Math.round(item.ttl_ms / 1000)}s)`,
576
+ data: { id: item.id, agent: item.agent, age_ms: item.age_ms, ttl_ms: item.ttl_ms },
577
+ }));
578
+ }
579
+ }
580
+ return { ok: true, evicted, evicted_ids: evicted.map((item) => item.id), count: evicted.length };
581
+ }).result ?? { ok: false, error: 'locked' };
582
+ }
583
+
584
+ function parseIso(value) {
585
+ if (!value || typeof value !== 'string') return 0;
586
+ const ms = Date.parse(value);
587
+ return Number.isFinite(ms) ? ms : 0;
588
+ }
589
+
298
590
  export function addBlackboardNote(projectRoot, input) {
299
591
  const paths = blackboardPaths(projectRoot);
300
592
  ensureDir(paths);
package/src/cli-run.js CHANGED
@@ -10,20 +10,34 @@
10
10
  * long-lived MCP server. A 30-line shim that imports dispatchRun directly
11
11
  * keeps the dependency chain trivial: bash -> node -> dispatch/*.js.
12
12
  *
13
+ * v1.5.0 T12 extends this shim with the `state:<verb>` colon-namespace —
14
+ * the CLI face of the state-SDK (contract §0). The same shim now lets
15
+ * external tooling reach `query(verb, payload, ctx)` from bash, e.g.
16
+ * shell-hook state writes (T11) and the e2e-smoke `state:workflow.get` gate.
17
+ *
13
18
  * Usage:
14
19
  * node cli-run.js <namespace>:<command> [--project-root <dir>] [args...]
15
20
  *
16
21
  * Examples:
17
22
  * node cli-run.js domain-manifest:load --project-root /path/to/proj
18
23
  * node cli-run.js extension:deploy-lazy --project-root /path/to/proj
24
+ * node cli-run.js state:workflow.get '{}'
25
+ * node cli-run.js state:workflow.set-phase '{"phase":"build"}'
19
26
  *
20
27
  * Contract:
21
- * - Always exits 0 on a successful dispatch (even when the dispatched
22
- * command reports ok:false -- that's a *result*, not a shim failure).
28
+ * - Prints the JSON-stringified result to stdout.
23
29
  * - Exits 2 on argv-shape errors (missing colon expression).
24
30
  * - Exits 3 on a thrown error inside the dispatcher.
25
- * - Prints the JSON-stringified result to stdout. stderr stays empty on
26
- * the happy path so the session-start log isn't polluted.
31
+ * - For the `state:` namespace: exits 0 on `ok:true`, non-zero on
32
+ * `ok:false` so shell callers can branch on `$?` without re-parsing
33
+ * the JSON. The non-zero exit is paired with a stderr line carrying
34
+ * the result's `error` for log readability.
35
+ * - For every other namespace (compute/index/detect/graph/override/
36
+ * extension/domain-manifest): exits 0 on a successful dispatch even
37
+ * when the dispatched command reports ok:false — that is a *result*,
38
+ * not a shim failure (legacy behaviour preserved).
39
+ * - stderr stays empty on the happy path so the session-start log isn't
40
+ * polluted.
27
41
  *
28
42
  * Discipline:
29
43
  * - Built-in Node only. No new deps.
@@ -80,7 +94,21 @@ async function main() {
80
94
  const result = await dispatchRun(parsed, {
81
95
  projectRoot: projectRoot || process.env.IJFW_PROJECT_DIR || process.cwd(),
82
96
  });
83
- process.stdout.write(JSON.stringify(result == null ? { ok: false, error: 'dispatch returned null (unknown namespace)' } : result) + '\n');
97
+ const payload = result == null
98
+ ? { ok: false, error: 'dispatch returned null (unknown namespace)' }
99
+ : result;
100
+ process.stdout.write(JSON.stringify(payload) + '\n');
101
+ // T12: the `state:` namespace honours `ok:true/false` as the process
102
+ // exit code so bash callers can `if ijfw state:foo ...; then` without
103
+ // re-parsing the JSON. Other namespaces keep the legacy always-0 contract
104
+ // — a dispatched compute:python script with exit_code=1 is a *result*,
105
+ // not a shim failure, and the existing session-start hooks rely on that.
106
+ if (parsed.namespace === 'state' && payload && payload.ok === false) {
107
+ if (payload.error) {
108
+ process.stderr.write(`cli-run: state:${parsed.command || '<verb>'}: ${payload.error}\n`);
109
+ }
110
+ process.exit(1);
111
+ }
84
112
  process.exit(0);
85
113
  } catch (err) {
86
114
  process.stderr.write(`cli-run: dispatch threw: ${err && err.message ? err.message : String(err)}\n`);