@ijfw/memory-server 1.4.4 → 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 (232) 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 +1016 -17
  129. package/src/cross-project-search.js +195 -9
  130. package/src/dashboard-client-waves.html +304 -0
  131. package/src/dashboard-client.html +5 -1
  132. package/src/dashboard-server.js +73 -0
  133. package/src/deploy-alerts.js +150 -0
  134. package/src/design/iframe-bridge.js +242 -0
  135. package/src/design-companion.js +144 -0
  136. package/src/dispatch/checkpoint-cli.js +97 -0
  137. package/src/dispatch/colon-syntax.js +81 -1
  138. package/src/dispatch/extension.js +26 -2
  139. package/src/dispatch/registry-cli.js +4 -1
  140. package/src/dispatch/wave-cli.js +201 -6
  141. package/src/dispatch/worktree-cli.js +40 -0
  142. package/src/dispatch-planner.js +97 -2
  143. package/src/dream/runner.mjs +47 -11
  144. package/src/dream/stage-runner.js +40 -0
  145. package/src/dream/state-file.js +102 -0
  146. package/src/extension-installer.js +70 -24
  147. package/src/extension-quota-tracker.js +4 -2
  148. package/src/extension-registry.js +289 -35
  149. package/src/feedback-detector.js +26 -0
  150. package/src/fs-lock.js +259 -7
  151. package/src/gate-result.js +95 -1
  152. package/src/hero-line.js +86 -5
  153. package/src/intent-router.js +35 -0
  154. package/src/lib/a11y-contract.js +117 -0
  155. package/src/lib/atomic-io.js +29 -8
  156. package/src/lib/cache-keepalive.js +150 -0
  157. package/src/lib/jsonl-rotation.js +104 -0
  158. package/src/lib/lighthouse-pillar.js +121 -0
  159. package/src/lib/llm-call.js +121 -0
  160. package/src/lib/playwright-baseline.js +205 -0
  161. package/src/lib/rekor-bridge.js +221 -0
  162. package/src/lib/repo-map.js +392 -0
  163. package/src/lib/shasum-verify.js +164 -0
  164. package/src/lib/sketches-gc.js +132 -0
  165. package/src/lib/tmp-suffix.js +62 -0
  166. package/src/lib/ui-review-runner.js +554 -0
  167. package/src/lib/uispec-drift.js +301 -0
  168. package/src/lib/uispec-intake.js +381 -0
  169. package/src/lib/worktree-guards.js +118 -0
  170. package/src/lib/worktree-recovery.js +100 -0
  171. package/src/memory/auto-linker.js +152 -0
  172. package/src/memory/benchmark.js +498 -0
  173. package/src/memory/dedup.js +126 -0
  174. package/src/memory/embedding-cache.js +136 -0
  175. package/src/memory/fact-extractor.js +168 -0
  176. package/src/memory/fts5.js +65 -1
  177. package/src/memory/migrations/004-bitemporal.js +91 -0
  178. package/src/memory/migrations/005-vector-cache.js +61 -0
  179. package/src/memory/migrations/006-obsidian-graph.js +46 -0
  180. package/src/memory/migrations/007-skill-telemetry.js +24 -0
  181. package/src/memory/migrations/008-write-provenance.js +41 -0
  182. package/src/memory/obsidian-parser.js +91 -0
  183. package/src/memory/query-dataview.js +86 -0
  184. package/src/memory/search.js +10 -0
  185. package/src/memory/temporal.js +529 -0
  186. package/src/memory/tokenize.js +10 -0
  187. package/src/memory-facts-handler.js +37 -0
  188. package/src/memory-feedback.js +260 -2
  189. package/src/model-refresh.js +292 -0
  190. package/src/observability/cost-anomaly.js +166 -0
  191. package/src/observability/evaluator-checkpoint-contract.js +117 -0
  192. package/src/observability/trace-id.js +163 -0
  193. package/src/orchestrator/agents-md-blackboard.js +152 -0
  194. package/src/orchestrator/checkpoint-contract.md +140 -0
  195. package/src/orchestrator/debug-trident.js +570 -0
  196. package/src/orchestrator/merge-block-aware.js +350 -0
  197. package/src/orchestrator/plan-checker.js +475 -0
  198. package/src/orchestrator/post-done-runner.js +249 -0
  199. package/src/orchestrator/review.js +38 -3
  200. package/src/orchestrator/runtime-loop.js +430 -0
  201. package/src/orchestrator/skill-telemetry-sink.js +29 -0
  202. package/src/orchestrator/skill-telemetry.js +37 -0
  203. package/src/orchestrator/state-events.js +459 -0
  204. package/src/orchestrator/state-sdk.js +1764 -0
  205. package/src/orchestrator/status-protocol.js +84 -17
  206. package/src/orchestrator/subagent-telemetry.js +452 -0
  207. package/src/orchestrator/termination.js +160 -0
  208. package/src/orchestrator/verification-gate.js +200 -16
  209. package/src/orchestrator/wave-state.js +332 -23
  210. package/src/orchestrator/worktree-provision.js +77 -0
  211. package/src/override-use-registry.js +111 -5
  212. package/src/receipts.js +36 -4
  213. package/src/recovery/checkpoint.js +56 -3
  214. package/src/recovery/code-fixer.js +656 -0
  215. package/src/recovery/truncation.js +317 -0
  216. package/src/redactor.js +75 -6
  217. package/src/runtime-mediator.js +15 -0
  218. package/src/sanitizer.js +10 -0
  219. package/src/search-hybrid.js +139 -0
  220. package/src/server.js +603 -59
  221. package/src/swarm/worktree.js +27 -4
  222. package/src/swarm-config.js +94 -17
  223. package/src/team/domain-templates/book.json +51 -0
  224. package/src/team/domain-templates/business.json +41 -0
  225. package/src/team/domain-templates/content.json +50 -0
  226. package/src/team/domain-templates/design.json +44 -0
  227. package/src/team/domain-templates/research.json +41 -0
  228. package/src/team/domain-templates/software.json +40 -0
  229. package/src/team/generator.js +278 -3
  230. package/src/team/modify.js +203 -0
  231. package/src/team/schemas.js +48 -0
  232. package/src/update-apply.js +19 -3
package/src/fs-lock.js CHANGED
@@ -11,15 +11,37 @@
11
11
  * (single retry after rm).
12
12
  *
13
13
  * Closes SEC-H-01 (cross-process race) from v1.4.3 cross-audit round 1.
14
+ *
15
+ * v1.5.0 T3 — heartbeat-refreshed locks. The fixed-30s stale window wrongly
16
+ * reclaimed locks held by *live* long-running verbs. `withFsLock` now accepts
17
+ * a `heartbeatMs` option: while `fn` runs, the holder refreshes `holder.json`
18
+ * (and the lock dir mtime) on that interval, so a concurrent caller's stale
19
+ * check sees a *recent* `acquired_at` and keeps waiting. A genuinely dead
20
+ * holder stops refreshing → its lock still ages past `staleMs` and becomes
21
+ * reclaimable. The heartbeat interval is always cleared on release (success
22
+ * or throw), so a leaked timer can never touch a recreated lock dir.
23
+ *
24
+ * v1.5.0 T3 also exports `canonicalLockOrder` — the STATE-SDK-CONTRACT §3
25
+ * lock-hierarchy sort. It is the single source of truth for the coarse-to-fine
26
+ * acquire-order so the state-SDK's `_withLocks` cannot deadlock.
14
27
  */
15
28
 
16
- import { mkdir, writeFile, readFile, rm, stat } from 'node:fs/promises';
17
- import { join, dirname } from 'node:path';
29
+ import {
30
+ mkdir, writeFile, readFile, rm, stat, utimes,
31
+ } from 'node:fs/promises';
32
+ import { join, dirname, basename } from 'node:path';
18
33
 
19
34
  const DEFAULT_ACQUIRE_TIMEOUT_MS = 5000;
20
35
  const DEFAULT_STALE_MS = 30000;
21
36
  const BACKOFF_START_MS = 25;
22
37
  const BACKOFF_MAX_MS = 250;
38
+ /**
39
+ * Default heartbeat interval. The refresh cadence must be comfortably shorter
40
+ * than any `staleMs` a caller picks, so a live holder always renews the lock
41
+ * before a concurrent caller's stale check fires. Callers needing a smaller
42
+ * `staleMs` (the state-SDK uses ~10s) pass an explicit `heartbeatMs`.
43
+ */
44
+ const DEFAULT_HEARTBEAT_MS = 5000;
23
45
 
24
46
  export class FsLockBusyError extends Error {
25
47
  constructor(lockPath, timeoutMs) {
@@ -88,9 +110,40 @@ async function tryAcquireOnce(lockPath) {
88
110
  }
89
111
 
90
112
  /**
91
- * withFsLock(lockPath, fn, { staleMs, acquireTimeoutMs })
113
+ * Refresh a held lock's freshness anchor. Rewrites `holder.json` with a fresh
114
+ * `acquired_at` and bumps the lock directory's own mtime — both are anchors the
115
+ * stale check consults, so refreshing both keeps the holder.json path AND the
116
+ * R12-M-01 mtime-fallback path in agreement. Best-effort: a transient failure
117
+ * (e.g. the dir is mid-release) is swallowed; the next tick retries.
118
+ */
119
+ async function refreshHolder(lockPath, holder) {
120
+ const now = Date.now();
121
+ holder.acquired_at = now;
122
+ try {
123
+ await writeFile(
124
+ join(lockPath, 'holder.json'),
125
+ JSON.stringify(holder),
126
+ 'utf8',
127
+ );
128
+ } catch {
129
+ // Lock dir may be mid-release. Harmless — next tick retries, or the
130
+ // interval is about to be cleared.
131
+ }
132
+ try {
133
+ const d = new Date(now);
134
+ await utimes(lockPath, d, d);
135
+ } catch {
136
+ // Same rationale as above.
137
+ }
138
+ }
139
+
140
+ /**
141
+ * withFsLock(lockPath, fn, { staleMs, acquireTimeoutMs, heartbeatMs })
92
142
  *
93
- * See module docstring for contract details.
143
+ * See module docstring for contract details. `heartbeatMs` (v1.5.0 T3): while
144
+ * `fn` runs, refresh the lock's freshness anchor on this interval so a live
145
+ * long-running holder is never wrongly reclaimed as stale. Pass `0` to disable
146
+ * the heartbeat (legacy fixed-window behaviour). Default: 5000ms.
94
147
  */
95
148
  export async function withFsLock(lockPath, fn, opts = {}) {
96
149
  const staleMs =
@@ -99,6 +152,10 @@ export async function withFsLock(lockPath, fn, opts = {}) {
99
152
  typeof opts.acquireTimeoutMs === 'number'
100
153
  ? opts.acquireTimeoutMs
101
154
  : DEFAULT_ACQUIRE_TIMEOUT_MS;
155
+ const heartbeatMs =
156
+ typeof opts.heartbeatMs === 'number'
157
+ ? opts.heartbeatMs
158
+ : DEFAULT_HEARTBEAT_MS;
102
159
 
103
160
  // Ensure the lock's parent directory exists. `tryAcquireOnce` uses
104
161
  // `mkdir(lockPath, { recursive: false })` which fails with ENOENT when any
@@ -119,12 +176,16 @@ export async function withFsLock(lockPath, fn, opts = {}) {
119
176
  let staleRecoveryUsed = false;
120
177
  let backoff = BACKOFF_START_MS;
121
178
 
179
+ // The holder object for THIS acquisition — the heartbeat refreshes it in
180
+ // place so its `acquired_at` stays current while `fn` runs.
181
+ let heldHolder = null;
182
+
122
183
  // Acquire loop. We try mkdir; if EEXIST, decide between waiting and stale
123
184
  // recovery; otherwise propagate the error.
124
185
  // eslint-disable-next-line no-constant-condition
125
186
  while (true) {
126
187
  try {
127
- await tryAcquireOnce(lockPath);
188
+ heldHolder = await tryAcquireOnce(lockPath);
128
189
  break;
129
190
  } catch (err) {
130
191
  if (err && err.code !== 'EEXIST') {
@@ -161,7 +222,7 @@ export async function withFsLock(lockPath, fn, opts = {}) {
161
222
  throw new FsLockStaleError(lockPath, rmErr);
162
223
  }
163
224
  try {
164
- await tryAcquireOnce(lockPath);
225
+ heldHolder = await tryAcquireOnce(lockPath);
165
226
  break;
166
227
  } catch (retryErr) {
167
228
  if (retryErr && retryErr.code === 'EEXIST') {
@@ -184,13 +245,33 @@ export async function withFsLock(lockPath, fn, opts = {}) {
184
245
  }
185
246
  }
186
247
 
187
- // Lock acquired — run fn and ALWAYS release.
248
+ // Lock acquired — start the heartbeat, run fn, and ALWAYS clear + release.
249
+ //
250
+ // The heartbeat keeps a *live* long-running holder's lock fresh so a
251
+ // concurrent caller never wrongly stale-reclaims it. The interval is cleared
252
+ // in the `finally` before the lock dir is removed, so a leaked timer can
253
+ // never touch a recreated lock dir. `unref()` ensures the interval cannot
254
+ // keep the process alive on its own.
255
+ let heartbeat = null;
256
+ if (heartbeatMs > 0 && heldHolder) {
257
+ heartbeat = setInterval(() => {
258
+ // Fire-and-forget — refreshHolder swallows its own transient errors.
259
+ refreshHolder(lockPath, heldHolder);
260
+ }, heartbeatMs);
261
+ if (typeof heartbeat.unref === 'function') heartbeat.unref();
262
+ }
263
+
188
264
  let fnResult;
189
265
  let fnError;
190
266
  try {
191
267
  fnResult = await fn();
192
268
  } catch (err) {
193
269
  fnError = err;
270
+ } finally {
271
+ if (heartbeat) {
272
+ clearInterval(heartbeat);
273
+ heartbeat = null;
274
+ }
194
275
  }
195
276
 
196
277
  try {
@@ -203,3 +284,174 @@ export async function withFsLock(lockPath, fn, opts = {}) {
203
284
  if (fnError !== undefined) throw fnError;
204
285
  return fnResult;
205
286
  }
287
+
288
+ // ===========================================================================
289
+ // v1.5.0 T3 — STATE-SDK-CONTRACT §3 canonical lock-hierarchy ordering.
290
+ //
291
+ // `canonicalLockOrder(targets)` sorts an arbitrary set of physical state-file
292
+ // paths into the exact coarse-to-fine acquire-order from §3 of
293
+ // `.planning/v150-gap-closure/STATE-SDK-CONTRACT.md`. A verb touching N files
294
+ // acquires its locks in this order and releases in reverse — because the order
295
+ // is total and deterministic, no two verbs can form a lock-ordering cycle, so
296
+ // the state-SDK is deadlock-free by construction.
297
+ //
298
+ // This lives in fs-lock.js (not state-sdk.js) so the ordering rule sits next
299
+ // to the lock primitive it governs — one module owns "how locks behave".
300
+ // ===========================================================================
301
+
302
+ /**
303
+ * §3 tier table. Each entry: a tier number (1-based, the §3 list position) and
304
+ * a `match(path)` predicate. The FIRST matching entry wins, so more-specific
305
+ * patterns are listed where ambiguity could arise (none currently overlap).
306
+ *
307
+ * `sub(path)` extracts the same-tier discriminator (`waveId` / `subId`) so
308
+ * multiple files at one tier sort deterministically by their natural ascending
309
+ * order — §3 "sub-orders them by the natural ascending sort of the
310
+ * discriminator". `null` when a tier has no discriminator.
311
+ */
312
+ const LOCK_TIERS = [
313
+ // #1 — intent journal (always first)
314
+ { tier: 1, match: (p) => /[\\/]\.ijfw[\\/]state[\\/]intent-journal\.jsonl$/.test(p) },
315
+ // #2 — workflow phase state
316
+ { tier: 2, match: (p) => /[\\/]\.ijfw[\\/]state[\\/]workflow\.json$/.test(p) },
317
+ // #3 — wave index
318
+ { tier: 3, match: (p) => /[\\/]\.ijfw[\\/]state[\\/]waves\.json$/.test(p) },
319
+ // #4 — per-wave STATE.md (sub-ordered by waveId)
320
+ {
321
+ tier: 4,
322
+ match: (p) => /[\\/]\.ijfw[\\/]wave-[^\\/]+[\\/]STATE\.md$/.test(p),
323
+ sub: (p) => {
324
+ const m = /[\\/]\.ijfw[\\/]wave-([^\\/]+)[\\/]STATE\.md$/.exec(p);
325
+ return m ? m[1] : null;
326
+ },
327
+ },
328
+ // #5 — per-subagent checkpoint (sub-ordered by subId, then waveId)
329
+ {
330
+ tier: 5,
331
+ match: (p) => /[\\/]\.ijfw[\\/]wave-[^\\/]+[\\/]subagent-[^\\/]+\.checkpoint\.json$/.test(p),
332
+ sub: (p) => {
333
+ const m = /[\\/]\.ijfw[\\/]wave-([^\\/]+)[\\/]subagent-([^\\/]+)\.checkpoint\.json$/.exec(p);
334
+ // Discriminator: "<subId> <waveId>" — subId is the primary key per
335
+ // §3, waveId breaks ties when one verb touches the same subId in two
336
+ // waves (no current verb does, but the order stays total).
337
+ return m ? `${m[2]} ${m[1]}` : null;
338
+ },
339
+ },
340
+ // #6 — generated roster
341
+ { tier: 6, match: (p) => /[\\/]\.ijfw[\\/]team[\\/]workflow\.json$/.test(p) },
342
+ // #7 — decision / blocker append log
343
+ { tier: 7, match: (p) => /[\\/]\.ijfw[\\/]blackboard[\\/]decisions\.jsonl$/.test(p) },
344
+ // #8 — AGENTS.md blackboard rollup
345
+ { tier: 8, match: (p) => /(^|[\\/])AGENTS\.md$/.test(p) },
346
+ // #9 — Trident convergence telemetry
347
+ { tier: 9, match: (p) => /[\\/]\.ijfw[\\/]telemetry[\\/]convergence\.json$/.test(p) },
348
+ // #10 — per-subagent event log (sub-ordered by subId, then waveId)
349
+ {
350
+ tier: 10,
351
+ match: (p) => /[\\/]\.ijfw[\\/]wave-[^\\/]+[\\/]events-[^\\/]+\.jsonl$/.test(p),
352
+ sub: (p) => {
353
+ const m = /[\\/]\.ijfw[\\/]wave-([^\\/]+)[\\/]events-([^\\/]+)\.jsonl$/.exec(p);
354
+ return m ? `${m[2]} ${m[1]}` : null;
355
+ },
356
+ },
357
+ // #11 — homedir active-extension state (always last — different fs root)
358
+ { tier: 11, match: (p) => /[\\/]\.ijfw[\\/]state[\\/]active-extension\.json$/.test(p) },
359
+ ];
360
+
361
+ /**
362
+ * Natural ascending comparison for same-tier discriminators: numeric runs
363
+ * compare numerically (so `W2 < W10`), non-numeric runs compare
364
+ * lexicographically. Makes `wave-W1, wave-W2, wave-W10` sort in human order.
365
+ */
366
+ function naturalCompare(a, b) {
367
+ if (a == null && b == null) return 0;
368
+ if (a == null) return -1;
369
+ if (b == null) return 1;
370
+ const ra = String(a).match(/\d+|\D+/g) || [];
371
+ const rb = String(b).match(/\d+|\D+/g) || [];
372
+ const n = Math.min(ra.length, rb.length);
373
+ for (let i = 0; i < n; i += 1) {
374
+ const sa = ra[i];
375
+ const sb = rb[i];
376
+ const na = /^\d+$/.test(sa);
377
+ const nb = /^\d+$/.test(sb);
378
+ if (na && nb) {
379
+ const d = Number(sa) - Number(sb);
380
+ if (d !== 0) return d < 0 ? -1 : 1;
381
+ } else if (sa !== sb) {
382
+ return sa < sb ? -1 : 1;
383
+ }
384
+ }
385
+ if (ra.length !== rb.length) return ra.length < rb.length ? -1 : 1;
386
+ return 0;
387
+ }
388
+
389
+ /** Classify one path -> its §3 tier descriptor. Unknown paths land at tier 99. */
390
+ function classify(path) {
391
+ for (const t of LOCK_TIERS) {
392
+ if (t.match(path)) {
393
+ return { tier: t.tier, sub: t.sub ? t.sub(path) : null };
394
+ }
395
+ }
396
+ // An unknown path is acquired AFTER every known tier — still deterministic
397
+ // (sorts by path string), so a future/typo path can never wedge the order.
398
+ return { tier: 99, sub: null };
399
+ }
400
+
401
+ /**
402
+ * canonicalLockOrder(targets) — return `targets` sorted into the §3 canonical
403
+ * coarse-to-fine acquire-order, de-duplicated. The total order is:
404
+ *
405
+ * 1. tier number ascending (§3 list position #1 … #11)
406
+ * 2. within a tier, the natural ascending sort of the discriminator
407
+ * (`waveId` / `subId`) — §3 same-tier sub-ordering
408
+ * 3. final tie-break on the full path string (keeps the order total even
409
+ * for paths a tier cannot otherwise distinguish)
410
+ *
411
+ * Pure + idempotent: `canonicalLockOrder(canonicalLockOrder(x))` deep-equals
412
+ * `canonicalLockOrder(x)`. Callers are NOT trusted to pre-sort — the state-SDK
413
+ * always routes its lock-target list through here (defense in depth).
414
+ *
415
+ * @param {string[]} targets physical state-file paths (any order, may repeat)
416
+ * @returns {string[]} the §3-ordered, de-duplicated path list
417
+ */
418
+ export function canonicalLockOrder(targets) {
419
+ if (!Array.isArray(targets)) {
420
+ throw new TypeError('canonicalLockOrder: targets must be a string[]');
421
+ }
422
+ const seen = new Set();
423
+ const decorated = [];
424
+ for (const path of targets) {
425
+ if (typeof path !== 'string' || path.length === 0) {
426
+ throw new TypeError(
427
+ `canonicalLockOrder: every target must be a non-empty string (got ${JSON.stringify(path)})`,
428
+ );
429
+ }
430
+ if (seen.has(path)) continue;
431
+ seen.add(path);
432
+ decorated.push({ path, ...classify(path) });
433
+ }
434
+ decorated.sort((a, b) => {
435
+ if (a.tier !== b.tier) return a.tier - b.tier;
436
+ const s = naturalCompare(a.sub, b.sub);
437
+ if (s !== 0) return s;
438
+ if (a.path === b.path) return 0;
439
+ return a.path < b.path ? -1 : 1;
440
+ });
441
+ return decorated.map((d) => d.path);
442
+ }
443
+
444
+ /**
445
+ * lockPathFor(targetPath) — the dotfile-sibling lock path for a target, per
446
+ * STATE-SDK-CONTRACT §1 ("Lock files are the dotfile sibling of each target",
447
+ * e.g. `.ijfw/state/workflow.json` -> `.ijfw/state/.workflow.json.lock`).
448
+ *
449
+ * @param {string} targetPath a physical state file path
450
+ * @returns {string} the lock directory path to pass to `withFsLock`
451
+ */
452
+ export function lockPathFor(targetPath) {
453
+ if (typeof targetPath !== 'string' || targetPath.length === 0) {
454
+ throw new TypeError('lockPathFor: targetPath must be a non-empty string');
455
+ }
456
+ return join(dirname(targetPath), `.${basename(targetPath)}.lock`);
457
+ }
@@ -14,7 +14,7 @@
14
14
  * - Receipt writes MUST NOT throw — the gate's hot path is the priority.
15
15
  */
16
16
 
17
- import { mkdir, writeFile } from 'node:fs/promises';
17
+ import { mkdir, writeFile, readdir, stat, unlink, appendFile } from 'node:fs/promises';
18
18
  import { basename, dirname, join } from 'node:path';
19
19
 
20
20
  import {
@@ -155,6 +155,18 @@ export async function makeReceipt(gateResult, opts = {}) {
155
155
  await mkdir(dirname(receiptPath), { recursive: true });
156
156
  const body = JSON.stringify(gateResult, null, 2) + '\n';
157
157
  await writeFile(receiptPath, body, 'utf8');
158
+
159
+ // v1.5.0 audit MED #9 (memory-engine.md F-PRF-2): bounded LRU on the
160
+ // gate-receipts dir. Without eviction this directory grew unbounded
161
+ // (one file per gate run forever); on long-lived projects this hit
162
+ // hundreds of MB and slowed the memory-feedback scan in
163
+ // ijfw_memory_prelude. We keep the newest N=1000 *.json files and
164
+ // append the rest to .archive.jsonl (one JSON line per old receipt),
165
+ // then unlink the originals. Atomic-ish: we write the archive line
166
+ // before unlinking so a crash mid-eviction leaves the data preserved
167
+ // in the archive *and* the receipt -- worst case is a single dup
168
+ // entry in .archive.jsonl, never data loss.
169
+ await evictOldReceipts(dirname(receiptPath));
158
170
  } catch (err) {
159
171
  // Fire-and-forget: log and move on. The gate hot path must not fail.
160
172
  const msg = err && err.message ? err.message : String(err);
@@ -174,6 +186,88 @@ export async function makeReceipt(gateResult, opts = {}) {
174
186
  // filesystem call.
175
187
  const RECEIPT_GATE_ID_PATTERN = /^[a-z][a-z0-9-]+$/;
176
188
 
189
+ // v1.5.0 audit MED #9 -- bounded LRU constants. Pulled out so tests can
190
+ // dial the cap down to keep the test fixtures cheap.
191
+ export const RECEIPTS_KEEP = 1000;
192
+ export const RECEIPTS_ARCHIVE = '.archive.jsonl';
193
+
194
+ /**
195
+ * evictOldReceipts(dir, opts) -- bounded LRU on the gate-receipts directory.
196
+ *
197
+ * Keeps the newest `keep` *.json files; older files are appended to
198
+ * `.archive.jsonl` as JSONL and then unlinked. Never throws -- the gate's
199
+ * hot path is the priority and a logging-channel directory should not
200
+ * propagate I/O errors into the gate result.
201
+ *
202
+ * @param {string} dir
203
+ * @param {{keep?: number}} [opts]
204
+ * @returns {Promise<{evicted: number}>}
205
+ */
206
+ export async function evictOldReceipts(dir, opts = {}) {
207
+ const keep = Number.isFinite(opts.keep) && opts.keep > 0 ? opts.keep | 0 : RECEIPTS_KEEP;
208
+ try {
209
+ let entries;
210
+ try {
211
+ entries = await readdir(dir);
212
+ } catch {
213
+ return { evicted: 0 };
214
+ }
215
+ const jsonFiles = entries.filter((f) => f.endsWith('.json'));
216
+ if (jsonFiles.length <= keep) return { evicted: 0 };
217
+
218
+ // Stat each candidate; sort by mtime descending (newest first).
219
+ const stamped = [];
220
+ for (const f of jsonFiles) {
221
+ const full = join(dir, f);
222
+ try {
223
+ const s = await stat(full);
224
+ stamped.push({ full, name: f, mtimeMs: s.mtimeMs });
225
+ } catch {
226
+ // Cannot stat -- treat as oldest so it gets evicted/cleaned up.
227
+ stamped.push({ full, name: f, mtimeMs: 0 });
228
+ }
229
+ }
230
+ stamped.sort((a, b) => b.mtimeMs - a.mtimeMs);
231
+ const toEvict = stamped.slice(keep);
232
+ if (toEvict.length === 0) return { evicted: 0 };
233
+
234
+ const archivePath = join(dir, RECEIPTS_ARCHIVE);
235
+ let evicted = 0;
236
+ for (const victim of toEvict) {
237
+ try {
238
+ // Read + append to archive *before* unlinking so a mid-eviction crash
239
+ // leaves the receipt either intact OR in the archive (never lost).
240
+ let body;
241
+ try {
242
+ const { readFile } = await import('node:fs/promises');
243
+ body = await readFile(victim.full, 'utf8');
244
+ } catch {
245
+ // Already gone -- skip without inflating the evicted counter.
246
+ continue;
247
+ }
248
+ // Normalize: archive entries are one JSON object per line. The
249
+ // receipt body is pretty-printed JSON -- collapse via parse/stringify.
250
+ let archiveLine;
251
+ try {
252
+ archiveLine = JSON.stringify(JSON.parse(body)) + '\n';
253
+ } catch {
254
+ // Malformed body -- preserve raw bytes inside a wrapper line so
255
+ // operators can still inspect what was there.
256
+ archiveLine = JSON.stringify({ raw: body, evicted_from: victim.name }) + '\n';
257
+ }
258
+ await appendFile(archivePath, archiveLine, 'utf8');
259
+ await unlink(victim.full);
260
+ evicted++;
261
+ } catch {
262
+ // Per-file failures don't abort the eviction -- next call retries.
263
+ }
264
+ }
265
+ return { evicted };
266
+ } catch {
267
+ return { evicted: 0 };
268
+ }
269
+ }
270
+
177
271
  async function resolveProjectType(projectRoot) {
178
272
  try {
179
273
  const root = typeof projectRoot === 'string' && projectRoot.length > 0
package/src/hero-line.js CHANGED
@@ -2,6 +2,8 @@
2
2
  // Codex U1 caveat: delta is NEVER fabricated. If real data is insufficient,
3
3
  // the delta suffix is omitted entirely.
4
4
 
5
+ import { getPricing, getPricesTable } from './cost/pricing.js';
6
+
5
7
  // Format duration in whole seconds (or ms if <1000ms total).
6
8
  function fmtDuration(ms) {
7
9
  if (ms < 1000) return `${Math.round(ms)}ms`;
@@ -29,8 +31,85 @@ function countFindings(f) {
29
31
  return { total: consensus + contested + unique, consensus };
30
32
  }
31
33
 
32
- // Anthropic cache-read savings rate: full input $3/M, cache-read $0.30/M -> $2.70/M saved.
33
- const CACHE_SAVINGS_PER_TOKEN = 2.70 / 1_000_000;
34
+ // Anthropic cache-read savings rate FALLBACK (Sonnet): full input $3/M,
35
+ // cache-read $0.30/M -> $2.70/M saved. Used when a receipt has no model id
36
+ // (or its model is unknown). Per-receipt rates come from cost/pricing.js so
37
+ // Opus/Haiku users see the correct dollar figure.
38
+ const SONNET_FALLBACK_SAVINGS_PER_TOKEN = 2.70 / 1_000_000;
39
+
40
+ // Known-model detector: mirrors pricing.js's match-then-fuzzy-then-family
41
+ // logic but answers a yes/no question instead of returning a price entry.
42
+ // We need this because getPricing() silently falls back to Sonnet for unknown
43
+ // ids, so we cannot tell "Opus matched" from "garbage -> Sonnet fallback" by
44
+ // looking at the returned rate alone.
45
+ function isKnownModel(modelId) {
46
+ if (!modelId || typeof modelId !== 'string') return false;
47
+ let table;
48
+ try {
49
+ table = getPricesTable()?.models || {};
50
+ } catch {
51
+ return false;
52
+ }
53
+ const id = modelId.toLowerCase().trim();
54
+ if (table[id] || table[modelId]) return true;
55
+ for (const key of Object.keys(table)) {
56
+ const k = key.toLowerCase();
57
+ if (k.startsWith(id) || id.startsWith(k)) return true;
58
+ }
59
+ // Family prefixes (must mirror pricing.js fallbacks table).
60
+ const familyPrefixes = [
61
+ 'claude-opus-4', 'claude-sonnet-4', 'claude-haiku-4',
62
+ 'claude-3-5-sonnet', 'claude-3-5-haiku', 'claude-3-opus',
63
+ 'gpt-5', 'gpt-4o', 'o3', 'o4', 'gemini-2', 'gemini-1.5',
64
+ ];
65
+ for (const prefix of familyPrefixes) {
66
+ if (id.includes(prefix)) return true;
67
+ }
68
+ return false;
69
+ }
70
+
71
+ // Compute per-token cache-read savings for a given model id by consulting
72
+ // the vendored pricing table. Returns { perToken, isFallback, model }.
73
+ // Falls back to Sonnet rate when the model is missing/unknown.
74
+ function cacheSavingsForModel(modelId) {
75
+ if (!modelId || typeof modelId !== 'string') {
76
+ return { perToken: SONNET_FALLBACK_SAVINGS_PER_TOKEN, isFallback: true, model: modelId || null };
77
+ }
78
+ if (!isKnownModel(modelId)) {
79
+ return { perToken: SONNET_FALLBACK_SAVINGS_PER_TOKEN, isFallback: true, model: modelId };
80
+ }
81
+ try {
82
+ const p = getPricing(modelId);
83
+ const perToken = Math.max(0, (p?.in || 0) - (p?.cache_read || 0));
84
+ if (perToken > 0) {
85
+ return { perToken, isFallback: false, model: modelId };
86
+ }
87
+ return { perToken: SONNET_FALLBACK_SAVINGS_PER_TOKEN, isFallback: true, model: modelId };
88
+ } catch {
89
+ return { perToken: SONNET_FALLBACK_SAVINGS_PER_TOKEN, isFallback: true, model: modelId };
90
+ }
91
+ }
92
+
93
+ // One-time-per-session stderr advisory for unknown-model fallbacks.
94
+ // Reset only by reloading the module, which matches "per session" semantics
95
+ // for the CLI entrypoint and MCP server.
96
+ let _unknownModelWarned = false;
97
+ export function _resetUnknownModelWarningForTests() {
98
+ _unknownModelWarned = false;
99
+ }
100
+ function warnUnknownModelOnce(modelId) {
101
+ if (_unknownModelWarned) return;
102
+ _unknownModelWarned = true;
103
+ try {
104
+ const label = modelId ? `"${modelId}"` : '(missing)';
105
+ process.stderr.write(
106
+ `[ijfw hero-line] unknown model ${label} on receipt -- falling back to Sonnet cache-savings rate. ` +
107
+ `Set receipt.model to a known id for accurate dollar figures.\n`
108
+ );
109
+ } catch {
110
+ // stderr write failures are non-fatal.
111
+ }
112
+ }
34
113
 
35
114
  // renderHeroLine(receipts, sessions?)
36
115
  // receipts -- array of cross-runs.jsonl records
@@ -53,7 +132,7 @@ export function renderHeroLine(receipts, sessions = []) {
53
132
  let totalConsensus = 0;
54
133
  let receiptsInputTokens = 0;
55
134
  let hasReceiptsTokens = true;
56
- let totalCacheReadTokens = 0;
135
+ let totalCacheSavings = 0; // dollars, computed per-receipt using its model
57
136
 
58
137
  for (const r of receipts) {
59
138
  if (Array.isArray(r.auditors)) {
@@ -72,7 +151,9 @@ export function renderHeroLine(receipts, sessions = []) {
72
151
  }
73
152
  const crt = r.cache_stats?.cache_read_input_tokens;
74
153
  if (typeof crt === 'number' && crt > 0) {
75
- totalCacheReadTokens += crt;
154
+ const { perToken, isFallback } = cacheSavingsForModel(r.model);
155
+ if (isFallback) warnUnknownModelOnce(r.model);
156
+ totalCacheSavings += crt * perToken;
76
157
  }
77
158
  }
78
159
 
@@ -81,7 +162,7 @@ export function renderHeroLine(receipts, sessions = []) {
81
162
 
82
163
  // Cache savings suffix (10D.4): append only when cache reads produced a
83
164
  // visible saving (>= $0.01). A sub-cent figure reads as anti-value.
84
- const rawSaved = totalCacheReadTokens * CACHE_SAVINGS_PER_TOKEN;
165
+ const rawSaved = totalCacheSavings;
85
166
  const cacheSuffix = rawSaved >= 0.01
86
167
  ? ` (prompt cache hit -- ~$${rawSaved.toFixed(2)} saved)`
87
168
  : '';
@@ -179,6 +179,41 @@ function adaptProjectScaleNudge(prompt) {
179
179
  return `This sounds like a project. Want me to ${verb} with you? I'll ask a few questions, do some research, and come back with recommendations.`;
180
180
  }
181
181
 
182
+ /**
183
+ * detectIntent — deterministic keyword → skill router.
184
+ *
185
+ * Resolution order (v1.5.0 audit-LOW-work-L6, documented for op-clarity):
186
+ *
187
+ * 1. **Priority (DESC).** Each intent entry declares a numeric `priority`.
188
+ * Higher numbers beat lower numbers. Reserved bands:
189
+ * - 10 : cross-* analytic intents (cross-research / cross-critique /
190
+ * cross-audit). These are explicit operator commands and should
191
+ * override any prose-pattern match.
192
+ * - 8 : brainstorm (primary workflow entry point).
193
+ * - 7 : project-scale (length-based fallback for brainstorm).
194
+ * - 5 : ship / review / remember / recall / handoff / mode (default
195
+ * band for everyday skills).
196
+ * - 1 : critique (broad-pattern; intentionally lowest so it never
197
+ * steals a more specific intent).
198
+ * Adding a new intent? Pick the band, don't invent a new one.
199
+ *
200
+ * 2. **Match length (DESC).** If two intents tie on priority, the one
201
+ * whose regex matched a longer substring wins. Rationale: longer match
202
+ * = more specific signal. (Skills that use `check()` instead of regex
203
+ * patterns report matchLen=0 and lose this tiebreak by design — their
204
+ * `check()` already encodes the specificity.)
205
+ *
206
+ * 3. **Declaration order (ASC).** Final stable tiebreak: the intent listed
207
+ * earlier in the INTENTS array wins. This makes the resolution fully
208
+ * deterministic — no Map/Set ordering surprises across Node versions.
209
+ *
210
+ * Bypass: a prompt with a leading `*` or containing `ijfw off` returns null
211
+ * without entering the resolver. This is the documented escape hatch for
212
+ * users who want to suppress all routing for one prompt.
213
+ *
214
+ * @param {string} prompt
215
+ * @returns {{intent: string, skill: string, nudge: string} | null}
216
+ */
182
217
  export function detectIntent(prompt) {
183
218
  if (typeof prompt !== 'string' || !prompt) return null;
184
219
  // Skip if user explicitly bypasses (leading * or `ijfw off`).