@ijfw/memory-server 1.4.3 → 1.5.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 (233) hide show
  1. package/fixtures/truncation-corpus/_generate-corpus.js +367 -0
  2. package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
  3. package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
  4. package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
  5. package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
  6. package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
  7. package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
  8. package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
  9. package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
  10. package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
  11. package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
  12. package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
  13. package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
  14. package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
  15. package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
  16. package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
  17. package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
  18. package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
  19. package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
  20. package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
  21. package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
  22. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
  23. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
  24. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
  25. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
  26. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
  27. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
  28. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
  29. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
  30. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
  31. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
  32. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
  33. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
  34. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
  35. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
  36. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
  37. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
  38. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
  39. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
  40. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
  41. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
  42. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
  43. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
  44. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
  45. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
  46. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
  47. package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
  48. package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
  49. package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
  50. package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
  51. package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
  52. package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
  53. package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
  54. package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  55. package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
  56. package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
  57. package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
  58. package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
  59. package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
  60. package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
  61. package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
  62. package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  63. package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
  64. package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
  65. package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
  66. package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
  67. package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
  68. package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
  69. package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
  70. package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
  71. package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
  72. package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
  73. package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
  74. package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
  75. package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
  76. package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
  77. package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
  78. package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
  79. package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
  80. package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
  81. package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
  82. package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
  83. package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
  84. package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
  85. package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
  86. package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
  87. package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
  88. package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
  89. package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
  90. package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
  91. package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
  92. package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
  93. package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
  94. package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
  95. package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
  96. package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
  97. package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
  98. package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
  99. package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
  100. package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  101. package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
  102. package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
  103. package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
  104. package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
  105. package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
  106. package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
  107. package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
  108. package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
  109. package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  110. package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
  111. package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
  112. package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
  113. package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
  114. package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
  115. package/package.json +1 -1
  116. package/src/active-extension-writer.js +144 -64
  117. package/src/api-client.js +43 -5
  118. package/src/audit-roster.js +80 -5
  119. package/src/blackboard.js +298 -6
  120. package/src/cli-run.js +33 -5
  121. package/src/codex-agents.js +96 -5
  122. package/src/cost/aggregator.js +39 -9
  123. package/src/cost/pricing.js +57 -0
  124. package/src/cost/readers/gemini.js +1 -1
  125. package/src/cross-audit-chunker.js +189 -0
  126. package/src/cross-dispatcher.js +124 -21
  127. package/src/cross-orchestrator-cli.js +550 -14
  128. package/src/cross-orchestrator.js +1171 -10
  129. package/src/cross-project-search.js +195 -9
  130. package/src/dashboard-client-planning.html +273 -0
  131. package/src/dashboard-client-waves.html +304 -0
  132. package/src/dashboard-client.html +17 -2
  133. package/src/dashboard-server.js +152 -0
  134. package/src/deploy-alerts.js +150 -0
  135. package/src/design/iframe-bridge.js +242 -0
  136. package/src/design-companion.js +144 -0
  137. package/src/dispatch/checkpoint-cli.js +97 -0
  138. package/src/dispatch/colon-syntax.js +81 -1
  139. package/src/dispatch/extension.js +27 -1
  140. package/src/dispatch/registry-cli.js +4 -1
  141. package/src/dispatch/wave-cli.js +323 -0
  142. package/src/dispatch/worktree-cli.js +40 -0
  143. package/src/dispatch-planner.js +97 -2
  144. package/src/dream/runner.mjs +47 -11
  145. package/src/dream/stage-runner.js +40 -0
  146. package/src/dream/state-file.js +102 -0
  147. package/src/extension-installer.js +70 -24
  148. package/src/extension-quota-tracker.js +4 -2
  149. package/src/extension-registry.js +289 -35
  150. package/src/feedback-detector.js +26 -0
  151. package/src/fs-lock.js +259 -7
  152. package/src/gate-result.js +95 -1
  153. package/src/hero-line.js +86 -5
  154. package/src/intent-router.js +35 -0
  155. package/src/lib/a11y-contract.js +117 -0
  156. package/src/lib/atomic-io.js +29 -8
  157. package/src/lib/cache-keepalive.js +150 -0
  158. package/src/lib/jsonl-rotation.js +104 -0
  159. package/src/lib/lighthouse-pillar.js +121 -0
  160. package/src/lib/llm-call.js +121 -0
  161. package/src/lib/playwright-baseline.js +205 -0
  162. package/src/lib/rekor-bridge.js +221 -0
  163. package/src/lib/repo-map.js +392 -0
  164. package/src/lib/shasum-verify.js +164 -0
  165. package/src/lib/sketches-gc.js +132 -0
  166. package/src/lib/tmp-suffix.js +62 -0
  167. package/src/lib/ui-review-runner.js +554 -0
  168. package/src/lib/uispec-drift.js +301 -0
  169. package/src/lib/uispec-intake.js +381 -0
  170. package/src/lib/worktree-guards.js +118 -0
  171. package/src/lib/worktree-recovery.js +100 -0
  172. package/src/memory/auto-linker.js +152 -0
  173. package/src/memory/benchmark.js +498 -0
  174. package/src/memory/dedup.js +126 -0
  175. package/src/memory/embedding-cache.js +136 -0
  176. package/src/memory/fact-extractor.js +168 -0
  177. package/src/memory/fts5.js +65 -1
  178. package/src/memory/migrations/004-bitemporal.js +91 -0
  179. package/src/memory/migrations/005-vector-cache.js +61 -0
  180. package/src/memory/migrations/006-obsidian-graph.js +46 -0
  181. package/src/memory/migrations/007-skill-telemetry.js +24 -0
  182. package/src/memory/migrations/008-write-provenance.js +41 -0
  183. package/src/memory/obsidian-parser.js +91 -0
  184. package/src/memory/query-dataview.js +86 -0
  185. package/src/memory/search.js +10 -0
  186. package/src/memory/temporal.js +529 -0
  187. package/src/memory/tokenize.js +10 -0
  188. package/src/memory-facts-handler.js +37 -0
  189. package/src/memory-feedback.js +260 -2
  190. package/src/model-refresh.js +292 -0
  191. package/src/observability/cost-anomaly.js +166 -0
  192. package/src/observability/evaluator-checkpoint-contract.js +117 -0
  193. package/src/observability/trace-id.js +163 -0
  194. package/src/orchestrator/agents-md-blackboard.js +152 -0
  195. package/src/orchestrator/checkpoint-contract.md +140 -0
  196. package/src/orchestrator/debug-trident.js +570 -0
  197. package/src/orchestrator/merge-block-aware.js +350 -0
  198. package/src/orchestrator/plan-checker.js +475 -0
  199. package/src/orchestrator/post-done-runner.js +249 -0
  200. package/src/orchestrator/review.js +136 -0
  201. package/src/orchestrator/runtime-loop.js +430 -0
  202. package/src/orchestrator/skill-telemetry-sink.js +29 -0
  203. package/src/orchestrator/skill-telemetry.js +37 -0
  204. package/src/orchestrator/state-events.js +459 -0
  205. package/src/orchestrator/state-sdk.js +1764 -0
  206. package/src/orchestrator/status-protocol.js +235 -0
  207. package/src/orchestrator/subagent-telemetry.js +452 -0
  208. package/src/orchestrator/termination.js +160 -0
  209. package/src/orchestrator/verification-gate.js +281 -0
  210. package/src/orchestrator/wave-state.js +564 -0
  211. package/src/orchestrator/worktree-provision.js +77 -0
  212. package/src/override-use-registry.js +111 -5
  213. package/src/receipts.js +36 -4
  214. package/src/recovery/checkpoint.js +56 -3
  215. package/src/recovery/code-fixer.js +656 -0
  216. package/src/recovery/truncation.js +317 -0
  217. package/src/redactor.js +75 -6
  218. package/src/runtime-mediator.js +15 -0
  219. package/src/sanitizer.js +10 -0
  220. package/src/search-hybrid.js +139 -0
  221. package/src/server.js +603 -59
  222. package/src/swarm/worktree.js +27 -4
  223. package/src/swarm-config.js +113 -12
  224. package/src/team/domain-templates/book.json +51 -0
  225. package/src/team/domain-templates/business.json +41 -0
  226. package/src/team/domain-templates/content.json +50 -0
  227. package/src/team/domain-templates/design.json +44 -0
  228. package/src/team/domain-templates/research.json +41 -0
  229. package/src/team/domain-templates/software.json +40 -0
  230. package/src/team/generator.js +278 -3
  231. package/src/team/modify.js +203 -0
  232. package/src/team/schemas.js +48 -0
  233. package/src/update-apply.js +19 -3
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`);
@@ -1,7 +1,33 @@
1
- import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
- import { join, resolve } from 'node:path';
1
+ import { existsSync, mkdirSync, readFileSync, realpathSync } from 'node:fs';
2
+ import { isAbsolute, join, relative, resolve } from 'node:path';
3
3
  import { writeAtomic } from './lib/atomic-io.js';
4
4
 
5
+ // F-FUN-7 (audit-MED-teams-#8): role-type → Codex tool allowlist. Honors
6
+ // the principle of least authority: research roles read only, review roles
7
+ // read+edit, software/lead/qa get the full toolbelt, book/content roles get
8
+ // Read/Write/Edit (they author durable text artifacts). Unknown role types
9
+ // fall back to the conservative software set so a missing entry never
10
+ // silently downgrades a charter we already accepted.
11
+ export const ROLE_TOOL_ALLOWLIST = Object.freeze({
12
+ research: ['Read'],
13
+ review: ['Read', 'Edit'],
14
+ software: ['Read', 'Write', 'Edit', 'Bash'],
15
+ lead: ['Read', 'Write', 'Edit', 'Bash'],
16
+ qa: ['Read', 'Write', 'Edit', 'Bash'],
17
+ book: ['Read', 'Write', 'Edit'],
18
+ content: ['Read', 'Write', 'Edit'],
19
+ design: ['Read', 'Write', 'Edit'],
20
+ business: ['Read', 'Write', 'Edit'],
21
+ education: ['Read', 'Write', 'Edit'],
22
+ operations: ['Read', 'Write', 'Edit', 'Bash'],
23
+ });
24
+
25
+ export function toolsForRoleType(roleType) {
26
+ const fallback = ROLE_TOOL_ALLOWLIST.software;
27
+ if (!roleType || typeof roleType !== 'string') return fallback;
28
+ return ROLE_TOOL_ALLOWLIST[roleType] || fallback;
29
+ }
30
+
5
31
  export function renderCodexAgentToml(role, bundle = {}) {
6
32
  assertRole(role);
7
33
  const charter = bundle.charter || bundle;
@@ -23,6 +49,12 @@ export function renderCodexAgentToml(role, bundle = {}) {
23
49
  lines.push(`model_reasoning_effort = ${tomlString(codexConfig.model_reasoning_effort.trim())}`);
24
50
  }
25
51
 
52
+ // F-FUN-7 (audit-MED-teams-#8): emit a role-type-keyed tools allowlist. The
53
+ // Codex agent runtime honors this as the per-agent toolbelt; research roles
54
+ // get read-only, software/lead/qa get the full set, etc.
55
+ const tools = toolsForRoleType(role.role_type);
56
+ lines.push(`tools = ${tomlStringArray(tools)}`);
57
+
26
58
  lines.push(`developer_instructions = ${tomlMultiline(instructions)}`);
27
59
  return `${lines.join('\n')}\n`;
28
60
  }
@@ -37,21 +69,75 @@ export function syncCodexAgents(projectRoot = process.cwd(), options = {}) {
37
69
  const agentsDir = join(root, '.codex', 'agents');
38
70
  mkdirSync(agentsDir, { recursive: true, mode: 0o700 });
39
71
 
72
+ // F-SEC-3 (audit-MED-teams-#12): symlink-realpath containment gate. If the
73
+ // .codex/agents/ target was swapped for a symlink that points outside the
74
+ // project root, writing into it would let a hostile project escape its
75
+ // own boundary. realpathSync resolves the link, and we require the
76
+ // resolved path to live inside the realpath'd project root.
77
+ const containment = assertAgentsDirContained(root, agentsDir);
78
+ if (!containment.ok) {
79
+ return { ok: false, error: containment.error, agentsDir, agentFiles: [], detail: containment.detail };
80
+ }
81
+ const safeAgentsDir = containment.realpath;
82
+
40
83
  const agentFiles = [];
84
+ let skipped = 0;
41
85
  for (const role of bundle.charter.roles) {
42
- const agentPath = join(agentsDir, `${codexAgentFilename(role.name)}.toml`);
43
- writeAtomic(agentPath, renderCodexAgentToml(role, bundle), { mode: 0o600 });
86
+ const agentPath = join(safeAgentsDir, `${codexAgentFilename(role.name)}.toml`);
87
+ const rendered = renderCodexAgentToml(role, bundle);
88
+ // F-SPD-3 (audit-MED-teams-#11): content-hash skip. Reading the existing
89
+ // file is cheap (TOML agent files are small) and avoids touching mtime
90
+ // when nothing changed -- which in turn keeps downstream watchers and
91
+ // Codex-side hot-reloaders from doing redundant work.
92
+ if (existsSync(agentPath)) {
93
+ let existing = '';
94
+ try { existing = readFileSync(agentPath, 'utf8'); } catch { existing = ''; }
95
+ if (existing === rendered) {
96
+ agentFiles.push(agentPath);
97
+ skipped += 1;
98
+ continue;
99
+ }
100
+ }
101
+ writeAtomic(agentPath, rendered, { mode: 0o600 });
44
102
  agentFiles.push(agentPath);
45
103
  }
46
104
 
47
105
  return {
48
106
  ok: true,
49
- agentsDir,
107
+ agentsDir: safeAgentsDir,
50
108
  agentFiles,
51
109
  count: agentFiles.length,
110
+ skipped,
52
111
  };
53
112
  }
54
113
 
114
+ // F-SEC-3: contain `.codex/agents/` to the project root. Returns the
115
+ // realpath-resolved agents dir so callers write through the resolved path
116
+ // (defence-in-depth: if a future writer re-uses `agentsDir` we still touch
117
+ // the same vetted location).
118
+ function assertAgentsDirContained(projectRoot, agentsDir) {
119
+ let resolvedRoot;
120
+ let resolvedAgents;
121
+ try {
122
+ resolvedRoot = realpathSync(projectRoot);
123
+ } catch (err) {
124
+ return { ok: false, error: 'project-realpath-failed', detail: String(err?.message || err) };
125
+ }
126
+ try {
127
+ resolvedAgents = realpathSync(agentsDir);
128
+ } catch (err) {
129
+ // Newly-created dir may not yet have a resolvable realpath if a parent
130
+ // is a symlink; fall back to the un-resolved path for containment but
131
+ // verify the parent is contained.
132
+ return { ok: false, error: 'agents-realpath-failed', detail: String(err?.message || err) };
133
+ }
134
+ const rel = relative(resolvedRoot, resolvedAgents);
135
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) {
136
+ return { ok: false, error: 'agents-escapes-project', detail: `agentsDir=${resolvedAgents} root=${resolvedRoot}` };
137
+ }
138
+ return { ok: true, realpath: resolvedAgents };
139
+ }
140
+
55
141
  function readTeamBundle(root) {
56
142
  const charterPath = join(root, '.ijfw', 'team', 'charter.json');
57
143
  if (!existsSync(charterPath)) return null;
@@ -168,6 +254,11 @@ function tomlString(value) {
168
254
  return JSON.stringify(String(value));
169
255
  }
170
256
 
257
+ function tomlStringArray(values) {
258
+ const items = list(values).map((v) => tomlString(String(v)));
259
+ return `[${items.join(', ')}]`;
260
+ }
261
+
171
262
  function tomlMultiline(value) {
172
263
  return `"""\n${String(value).replace(/"""/g, '\\"\\"\\"')}\n"""`;
173
264
  }
@@ -124,29 +124,59 @@ export function buildCostReport(days, observations = []) {
124
124
  };
125
125
  }
126
126
 
127
+ // v1.5.0 audit-LOW-tok-L3: memoize buildBreakdown per (dim, days, bucket).
128
+ // Dashboards call this endpoint repeatedly on a 10s tick across four
129
+ // dimensions; each call re-reads ~3 platforms of transcript JSON. Within
130
+ // a 60-second time bucket the result is functionally identical, so cache
131
+ // it. TTL is short enough that newly-arriving turns surface within one
132
+ // dashboard refresh interval.
133
+ const BREAKDOWN_CACHE_TTL_MS = 60_000;
134
+ const _breakdownCache = new Map();
135
+
136
+ function _bucketKey(dim, days, now = Date.now()) {
137
+ const bucket = Math.floor(now / BREAKDOWN_CACHE_TTL_MS);
138
+ return `${dim}|${days ?? 'all'}|${bucket}`;
139
+ }
140
+
141
+ // Test helper: clear the memoization cache.
142
+ export function _resetBreakdownCache() { _breakdownCache.clear(); }
143
+
127
144
  /**
128
145
  * Build a breakdown grouped by a dimension.
129
146
  * dim: 'platform' | 'session' | 'model' | 'tool'
130
147
  */
131
148
  export function buildBreakdown(dim, days, _observations = []) {
149
+ const key = _bucketKey(dim, days);
150
+ const cached = _breakdownCache.get(key);
151
+ if (cached) return cached;
152
+
132
153
  const raw = readAllTurns(days);
133
154
  const turns = annotateCosts(raw);
134
155
 
135
156
  const groups = {};
136
157
  for (const t of turns) {
137
- const key = t[dim] || 'unknown';
138
- if (!groups[key]) groups[key] = { key, cost_usd: 0, theoretical_cost_usd: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, count: 0 };
139
- groups[key].cost_usd += t.cost_usd;
140
- groups[key].theoretical_cost_usd += t.theoretical_cost_usd || 0;
141
- groups[key].input_tokens += t.input_tokens || 0;
142
- groups[key].output_tokens += t.output_tokens || 0;
143
- groups[key].cache_read_tokens += t.cache_read_tokens || 0;
144
- groups[key].count++;
158
+ const k = t[dim] || 'unknown';
159
+ if (!groups[k]) groups[k] = { key: k, cost_usd: 0, theoretical_cost_usd: 0, input_tokens: 0, output_tokens: 0, cache_read_tokens: 0, count: 0 };
160
+ groups[k].cost_usd += t.cost_usd;
161
+ groups[k].theoretical_cost_usd += t.theoretical_cost_usd || 0;
162
+ groups[k].input_tokens += t.input_tokens || 0;
163
+ groups[k].output_tokens += t.output_tokens || 0;
164
+ groups[k].cache_read_tokens += t.cache_read_tokens || 0;
165
+ groups[k].count++;
145
166
  }
146
167
 
147
168
  // Sort by theoretical cost so Max-session breakdowns still rank by usage
148
169
  // intensity even when cost_usd is uniformly zero.
149
- return Object.values(groups).sort((a, b) => b.theoretical_cost_usd - a.theoretical_cost_usd);
170
+ const result = Object.values(groups).sort((a, b) => b.theoretical_cost_usd - a.theoretical_cost_usd);
171
+
172
+ // Bound the cache: keep at most 32 entries (4 dims * 8 window/bucket combos).
173
+ // Eviction is opportunistic on insert -- O(1) amortised.
174
+ if (_breakdownCache.size >= 32) {
175
+ const firstKey = _breakdownCache.keys().next().value;
176
+ if (firstKey !== undefined) _breakdownCache.delete(firstKey);
177
+ }
178
+ _breakdownCache.set(key, result);
179
+ return result;
150
180
  }
151
181
 
152
182
  /**