@ijfw/memory-server 1.4.4 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (245) hide show
  1. package/bin/ijfw-memorize +14 -7
  2. package/fixtures/team/book.json +6 -6
  3. package/fixtures/team/business.json +146 -20
  4. package/fixtures/team/content.json +6 -6
  5. package/fixtures/team/design.json +148 -20
  6. package/fixtures/team/mixed.json +206 -27
  7. package/fixtures/team/research.json +146 -20
  8. package/fixtures/team/software.json +148 -20
  9. package/fixtures/truncation-corpus/_generate-corpus.js +367 -0
  10. package/fixtures/truncation-corpus/fx-01-clean-exit-01/events.jsonl +2 -0
  11. package/fixtures/truncation-corpus/fx-01-clean-exit-01/intent-journal.jsonl +2 -0
  12. package/fixtures/truncation-corpus/fx-01-clean-exit-01/meta.json +18 -0
  13. package/fixtures/truncation-corpus/fx-01-clean-exit-01/target/.ijfw/state/workflow.json +1 -0
  14. package/fixtures/truncation-corpus/fx-01-clean-exit-02/events.jsonl +2 -0
  15. package/fixtures/truncation-corpus/fx-01-clean-exit-02/intent-journal.jsonl +2 -0
  16. package/fixtures/truncation-corpus/fx-01-clean-exit-02/meta.json +18 -0
  17. package/fixtures/truncation-corpus/fx-01-clean-exit-02/target/.ijfw/state/workflow.json +1 -0
  18. package/fixtures/truncation-corpus/fx-01-clean-exit-03/events.jsonl +2 -0
  19. package/fixtures/truncation-corpus/fx-01-clean-exit-03/intent-journal.jsonl +2 -0
  20. package/fixtures/truncation-corpus/fx-01-clean-exit-03/meta.json +18 -0
  21. package/fixtures/truncation-corpus/fx-01-clean-exit-03/target/.ijfw/state/workflow.json +1 -0
  22. package/fixtures/truncation-corpus/fx-01-clean-exit-04/events.jsonl +2 -0
  23. package/fixtures/truncation-corpus/fx-01-clean-exit-04/intent-journal.jsonl +2 -0
  24. package/fixtures/truncation-corpus/fx-01-clean-exit-04/meta.json +18 -0
  25. package/fixtures/truncation-corpus/fx-01-clean-exit-04/target/.ijfw/state/workflow.json +1 -0
  26. package/fixtures/truncation-corpus/fx-01-clean-exit-05/events.jsonl +2 -0
  27. package/fixtures/truncation-corpus/fx-01-clean-exit-05/intent-journal.jsonl +2 -0
  28. package/fixtures/truncation-corpus/fx-01-clean-exit-05/meta.json +18 -0
  29. package/fixtures/truncation-corpus/fx-01-clean-exit-05/target/.ijfw/state/workflow.json +1 -0
  30. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/events.jsonl +1 -0
  31. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/intent-journal.jsonl +3 -0
  32. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/meta.json +18 -0
  33. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/snapshots/v-midO-1-advance.json +11 -0
  34. package/fixtures/truncation-corpus/fx-02-mid-overwrite-01/target/.ijfw/state/workflow.json +1 -0
  35. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/events.jsonl +1 -0
  36. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/intent-journal.jsonl +3 -0
  37. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/meta.json +18 -0
  38. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/snapshots/v-midO-2-advance.json +11 -0
  39. package/fixtures/truncation-corpus/fx-02-mid-overwrite-02/target/.ijfw/state/workflow.json +1 -0
  40. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/events.jsonl +1 -0
  41. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/intent-journal.jsonl +3 -0
  42. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/meta.json +18 -0
  43. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/snapshots/v-midO-3-advance.json +11 -0
  44. package/fixtures/truncation-corpus/fx-02-mid-overwrite-03/target/.ijfw/state/workflow.json +1 -0
  45. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/events.jsonl +1 -0
  46. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/intent-journal.jsonl +3 -0
  47. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/meta.json +18 -0
  48. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/snapshots/v-midO-4-advance.json +11 -0
  49. package/fixtures/truncation-corpus/fx-02-mid-overwrite-04/target/.ijfw/state/workflow.json +1 -0
  50. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/events.jsonl +1 -0
  51. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/intent-journal.jsonl +3 -0
  52. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/meta.json +18 -0
  53. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/snapshots/v-midO-5-advance.json +11 -0
  54. package/fixtures/truncation-corpus/fx-02-mid-overwrite-05/target/.ijfw/state/workflow.json +1 -0
  55. package/fixtures/truncation-corpus/fx-03-mid-append-01/events.jsonl +1 -0
  56. package/fixtures/truncation-corpus/fx-03-mid-append-01/intent-journal.jsonl +3 -0
  57. package/fixtures/truncation-corpus/fx-03-mid-append-01/meta.json +18 -0
  58. package/fixtures/truncation-corpus/fx-03-mid-append-01/target/.ijfw/blackboard/decisions.jsonl +1 -0
  59. package/fixtures/truncation-corpus/fx-03-mid-append-02/events.jsonl +1 -0
  60. package/fixtures/truncation-corpus/fx-03-mid-append-02/intent-journal.jsonl +3 -0
  61. package/fixtures/truncation-corpus/fx-03-mid-append-02/meta.json +18 -0
  62. package/fixtures/truncation-corpus/fx-03-mid-append-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  63. package/fixtures/truncation-corpus/fx-03-mid-append-03/events.jsonl +1 -0
  64. package/fixtures/truncation-corpus/fx-03-mid-append-03/intent-journal.jsonl +3 -0
  65. package/fixtures/truncation-corpus/fx-03-mid-append-03/meta.json +18 -0
  66. package/fixtures/truncation-corpus/fx-03-mid-append-03/target/.ijfw/blackboard/decisions.jsonl +1 -0
  67. package/fixtures/truncation-corpus/fx-03-mid-append-04/events.jsonl +1 -0
  68. package/fixtures/truncation-corpus/fx-03-mid-append-04/intent-journal.jsonl +3 -0
  69. package/fixtures/truncation-corpus/fx-03-mid-append-04/meta.json +18 -0
  70. package/fixtures/truncation-corpus/fx-03-mid-append-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  71. package/fixtures/truncation-corpus/fx-03-mid-append-05/events.jsonl +1 -0
  72. package/fixtures/truncation-corpus/fx-03-mid-append-05/intent-journal.jsonl +3 -0
  73. package/fixtures/truncation-corpus/fx-03-mid-append-05/meta.json +18 -0
  74. package/fixtures/truncation-corpus/fx-03-mid-append-05/target/.ijfw/blackboard/decisions.jsonl +1 -0
  75. package/fixtures/truncation-corpus/fx-04-no-events-01/events.jsonl +0 -0
  76. package/fixtures/truncation-corpus/fx-04-no-events-01/intent-journal.jsonl +1 -0
  77. package/fixtures/truncation-corpus/fx-04-no-events-01/meta.json +18 -0
  78. package/fixtures/truncation-corpus/fx-04-no-events-01/snapshots/v-noEv-1-set-phase.json +11 -0
  79. package/fixtures/truncation-corpus/fx-04-no-events-01/target/.ijfw/state/workflow.json +1 -0
  80. package/fixtures/truncation-corpus/fx-04-no-events-02/events.jsonl +0 -0
  81. package/fixtures/truncation-corpus/fx-04-no-events-02/intent-journal.jsonl +1 -0
  82. package/fixtures/truncation-corpus/fx-04-no-events-02/meta.json +18 -0
  83. package/fixtures/truncation-corpus/fx-04-no-events-02/snapshots/v-noEv-2-set-phase.json +11 -0
  84. package/fixtures/truncation-corpus/fx-04-no-events-02/target/.ijfw/state/workflow.json +1 -0
  85. package/fixtures/truncation-corpus/fx-04-no-events-03/events.jsonl +0 -0
  86. package/fixtures/truncation-corpus/fx-04-no-events-03/intent-journal.jsonl +1 -0
  87. package/fixtures/truncation-corpus/fx-04-no-events-03/meta.json +18 -0
  88. package/fixtures/truncation-corpus/fx-04-no-events-03/snapshots/v-noEv-3-set-phase.json +11 -0
  89. package/fixtures/truncation-corpus/fx-04-no-events-03/target/.ijfw/state/workflow.json +1 -0
  90. package/fixtures/truncation-corpus/fx-04-no-events-04/events.jsonl +0 -0
  91. package/fixtures/truncation-corpus/fx-04-no-events-04/intent-journal.jsonl +1 -0
  92. package/fixtures/truncation-corpus/fx-04-no-events-04/meta.json +18 -0
  93. package/fixtures/truncation-corpus/fx-04-no-events-04/snapshots/v-noEv-4-set-phase.json +11 -0
  94. package/fixtures/truncation-corpus/fx-04-no-events-04/target/.ijfw/state/workflow.json +1 -0
  95. package/fixtures/truncation-corpus/fx-04-no-events-05/events.jsonl +0 -0
  96. package/fixtures/truncation-corpus/fx-04-no-events-05/intent-journal.jsonl +1 -0
  97. package/fixtures/truncation-corpus/fx-04-no-events-05/meta.json +18 -0
  98. package/fixtures/truncation-corpus/fx-04-no-events-05/snapshots/v-noEv-5-set-phase.json +11 -0
  99. package/fixtures/truncation-corpus/fx-04-no-events-05/target/.ijfw/state/workflow.json +1 -0
  100. package/fixtures/truncation-corpus/fx-05-error-terminated-01/events.jsonl +2 -0
  101. package/fixtures/truncation-corpus/fx-05-error-terminated-01/intent-journal.jsonl +3 -0
  102. package/fixtures/truncation-corpus/fx-05-error-terminated-01/meta.json +18 -0
  103. package/fixtures/truncation-corpus/fx-05-error-terminated-01/snapshots/v-errT-1-partial.json +11 -0
  104. package/fixtures/truncation-corpus/fx-05-error-terminated-01/target/.ijfw/state/workflow.json +1 -0
  105. package/fixtures/truncation-corpus/fx-05-error-terminated-02/events.jsonl +2 -0
  106. package/fixtures/truncation-corpus/fx-05-error-terminated-02/intent-journal.jsonl +3 -0
  107. package/fixtures/truncation-corpus/fx-05-error-terminated-02/meta.json +18 -0
  108. package/fixtures/truncation-corpus/fx-05-error-terminated-02/target/.ijfw/blackboard/decisions.jsonl +1 -0
  109. package/fixtures/truncation-corpus/fx-05-error-terminated-03/events.jsonl +2 -0
  110. package/fixtures/truncation-corpus/fx-05-error-terminated-03/intent-journal.jsonl +3 -0
  111. package/fixtures/truncation-corpus/fx-05-error-terminated-03/meta.json +18 -0
  112. package/fixtures/truncation-corpus/fx-05-error-terminated-03/snapshots/v-errT-3-partial.json +11 -0
  113. package/fixtures/truncation-corpus/fx-05-error-terminated-03/target/.ijfw/state/workflow.json +1 -0
  114. package/fixtures/truncation-corpus/fx-05-error-terminated-04/events.jsonl +2 -0
  115. package/fixtures/truncation-corpus/fx-05-error-terminated-04/intent-journal.jsonl +3 -0
  116. package/fixtures/truncation-corpus/fx-05-error-terminated-04/meta.json +18 -0
  117. package/fixtures/truncation-corpus/fx-05-error-terminated-04/target/.ijfw/blackboard/decisions.jsonl +1 -0
  118. package/fixtures/truncation-corpus/fx-05-error-terminated-05/events.jsonl +2 -0
  119. package/fixtures/truncation-corpus/fx-05-error-terminated-05/intent-journal.jsonl +3 -0
  120. package/fixtures/truncation-corpus/fx-05-error-terminated-05/meta.json +18 -0
  121. package/fixtures/truncation-corpus/fx-05-error-terminated-05/snapshots/v-errT-5-partial.json +11 -0
  122. package/fixtures/truncation-corpus/fx-05-error-terminated-05/target/.ijfw/state/workflow.json +1 -0
  123. package/package.json +6 -3
  124. package/src/active-extension-writer.js +144 -64
  125. package/src/api-client.js +43 -5
  126. package/src/audit-roster.js +80 -5
  127. package/src/blackboard.js +298 -6
  128. package/src/cli-run.js +33 -5
  129. package/src/codex-agents.js +96 -5
  130. package/src/cost/aggregator.js +39 -9
  131. package/src/cost/pricing.js +57 -0
  132. package/src/cost/readers/gemini.js +1 -1
  133. package/src/cross-audit-chunker.js +189 -0
  134. package/src/cross-dispatcher.js +124 -21
  135. package/src/cross-orchestrator-cli.js +754 -159
  136. package/src/cross-orchestrator.js +1065 -17
  137. package/src/cross-project-search.js +195 -9
  138. package/src/dashboard-client-waves.html +304 -0
  139. package/src/dashboard-client.html +5 -1
  140. package/src/dashboard-server.js +73 -0
  141. package/src/deploy-alerts.js +150 -0
  142. package/src/design/iframe-bridge.js +242 -0
  143. package/src/design-companion.js +144 -0
  144. package/src/dispatch/checkpoint-cli.js +97 -0
  145. package/src/dispatch/colon-syntax.js +81 -1
  146. package/src/dispatch/extension.js +26 -2
  147. package/src/dispatch/registry-cli.js +4 -1
  148. package/src/dispatch/wave-cli.js +201 -6
  149. package/src/dispatch/worktree-cli.js +40 -0
  150. package/src/dispatch-planner.js +97 -2
  151. package/src/dream/runner.mjs +47 -11
  152. package/src/dream/stage-runner.js +40 -0
  153. package/src/dream/state-file.js +102 -0
  154. package/src/extension-installer.js +70 -24
  155. package/src/extension-quota-tracker.js +4 -2
  156. package/src/extension-registry.js +289 -35
  157. package/src/feedback-detector.js +26 -0
  158. package/src/fs-lock.js +259 -7
  159. package/src/gate-result.js +95 -1
  160. package/src/hardware-signer.js +4 -2
  161. package/src/hero-line.js +86 -5
  162. package/src/intent-router.js +35 -0
  163. package/src/lib/a11y-contract.js +117 -0
  164. package/src/lib/atomic-io.js +29 -8
  165. package/src/lib/cache-keepalive.js +150 -0
  166. package/src/lib/jsonl-rotation.js +104 -0
  167. package/src/lib/lighthouse-pillar.js +121 -0
  168. package/src/lib/llm-call.js +121 -0
  169. package/src/lib/playwright-baseline.js +205 -0
  170. package/src/lib/rekor-bridge.js +221 -0
  171. package/src/lib/repo-map.js +392 -0
  172. package/src/lib/shasum-verify.js +164 -0
  173. package/src/lib/sketches-gc.js +132 -0
  174. package/src/lib/tmp-suffix.js +62 -0
  175. package/src/lib/ui-review-runner.js +595 -0
  176. package/src/lib/uispec-drift.js +301 -0
  177. package/src/lib/uispec-intake.js +381 -0
  178. package/src/lib/worktree-guards.js +118 -0
  179. package/src/lib/worktree-recovery.js +100 -0
  180. package/src/memory/auto-linker.js +267 -0
  181. package/src/memory/benchmark.js +498 -0
  182. package/src/memory/dedup.js +126 -0
  183. package/src/memory/embedding-cache.js +136 -0
  184. package/src/memory/fact-extractor.js +168 -0
  185. package/src/memory/fts5.js +65 -1
  186. package/src/memory/migration-runner.js +6 -1
  187. package/src/memory/migrations/004-bitemporal.js +91 -0
  188. package/src/memory/migrations/005-vector-cache.js +61 -0
  189. package/src/memory/migrations/006-obsidian-graph.js +46 -0
  190. package/src/memory/migrations/007-skill-telemetry.js +24 -0
  191. package/src/memory/migrations/008-write-provenance.js +41 -0
  192. package/src/memory/migrations/009-obsidian-backfill.js +50 -0
  193. package/src/memory/obsidian-parser.js +152 -0
  194. package/src/memory/query-dataview.js +86 -0
  195. package/src/memory/search.js +46 -15
  196. package/src/memory/temporal.js +529 -0
  197. package/src/memory/tokenize.js +10 -0
  198. package/src/memory-facts-handler.js +37 -0
  199. package/src/memory-feedback.js +260 -2
  200. package/src/model-refresh.js +292 -0
  201. package/src/observability/cost-anomaly.js +166 -0
  202. package/src/observability/evaluator-checkpoint-contract.js +117 -0
  203. package/src/observability/trace-id.js +163 -0
  204. package/src/orchestrator/agents-md-blackboard.js +152 -0
  205. package/src/orchestrator/checkpoint-contract.md +140 -0
  206. package/src/orchestrator/debug-trident-trigger.js +374 -0
  207. package/src/orchestrator/debug-trident.js +570 -0
  208. package/src/orchestrator/merge-block-aware.js +350 -0
  209. package/src/orchestrator/plan-checker.js +475 -0
  210. package/src/orchestrator/post-done-runner.js +277 -0
  211. package/src/orchestrator/review.js +38 -3
  212. package/src/orchestrator/skill-telemetry-sink.js +29 -0
  213. package/src/orchestrator/skill-telemetry.js +37 -0
  214. package/src/orchestrator/state-events.js +459 -0
  215. package/src/orchestrator/state-sdk.js +1932 -0
  216. package/src/orchestrator/status-protocol.js +84 -17
  217. package/src/orchestrator/subagent-telemetry.js +471 -0
  218. package/src/orchestrator/termination.js +160 -0
  219. package/src/orchestrator/verification-gate.js +200 -16
  220. package/src/orchestrator/wave-state.js +332 -23
  221. package/src/orchestrator/worktree-provision.js +77 -0
  222. package/src/override-resolver.js +5 -3
  223. package/src/override-use-registry.js +111 -5
  224. package/src/receipts.js +36 -4
  225. package/src/recovery/checkpoint.js +56 -3
  226. package/src/recovery/code-fixer.js +961 -0
  227. package/src/recovery/truncation.js +317 -0
  228. package/src/redactor.js +75 -6
  229. package/src/runtime-mediator.js +15 -1
  230. package/src/sanitizer.js +10 -0
  231. package/src/search-hybrid.js +139 -0
  232. package/src/server.js +795 -112
  233. package/src/swarm/worktree.js +27 -4
  234. package/src/swarm-config.js +102 -17
  235. package/src/team/domain-templates/book.json +51 -0
  236. package/src/team/domain-templates/business.json +44 -0
  237. package/src/team/domain-templates/content.json +50 -0
  238. package/src/team/domain-templates/design.json +44 -0
  239. package/src/team/domain-templates/research.json +44 -0
  240. package/src/team/domain-templates/software.json +40 -0
  241. package/src/team/generator.js +440 -3
  242. package/src/team/modify.js +203 -0
  243. package/src/team/schemas.js +48 -0
  244. package/src/update-apply.js +19 -3
  245. package/src/dashboard-charts.js +0 -239
@@ -21,12 +21,17 @@
21
21
  */
22
22
 
23
23
  import { createPublicKey, createHash, verify as cryptoVerify } from 'node:crypto';
24
- import { readFile, writeFile, mkdir, rename } from 'node:fs/promises';
24
+ import { readFile, writeFile, mkdir, rename, readdir, stat, unlink } from 'node:fs/promises';
25
25
  import { homedir } from 'node:os';
26
26
  import { join, dirname } from 'node:path';
27
27
  import https from 'node:https';
28
28
 
29
29
  import { withFsLock } from './fs-lock.js';
30
+ import {
31
+ submitToRekor as _submitToRekor,
32
+ verifyRekorEntry as _verifyRekorEntry,
33
+ hasRekorClient as _hasRekorClient,
34
+ } from './lib/rekor-bridge.js';
30
35
 
31
36
  // ---------------------------------------------------------------------------
32
37
  // Embedded meta-key — compiled-in trust root for registry signature verification.
@@ -133,7 +138,12 @@ function sortKeysDeep(v) {
133
138
  function registryCanonicalBytes(registry) {
134
139
  const shallow = {};
135
140
  for (const k of Object.keys(registry)) {
136
- if (k === 'signature') continue;
141
+ // Exclude `signature` (carries the sig itself) and `rekor` (added AFTER
142
+ // signing as a transparency-log anchor — v1.5.0 audit-H5.7). Both must
143
+ // be excluded so sign-time and verify-time produce byte-identical input.
144
+ // Backcompat: pre-v1.5.0 registries have no `rekor` field, so this is
145
+ // a no-op for them.
146
+ if (k === 'signature' || k === 'rekor') continue;
137
147
  shallow[k] = registry[k];
138
148
  }
139
149
  return Buffer.from(JSON.stringify(sortKeysDeep(shallow)), 'utf8');
@@ -312,6 +322,27 @@ export function verifyRegistry(body, opts = {}) {
312
322
  return { valid: false, registry: null, reason: 'revoked must be an array' };
313
323
  }
314
324
 
325
+ // v1.5.0 audit-H5.7: when an embedded `rekor` anchor is present, validate
326
+ // its shape here (sync, applies to ALL signature paths including seed-mode).
327
+ // The actual network cross-check happens in `crossCheckRekor` (async).
328
+ if (parsed.rekor !== undefined && parsed.rekor !== null) {
329
+ const r = parsed.rekor;
330
+ if (
331
+ typeof r !== 'object' ||
332
+ Array.isArray(r) ||
333
+ typeof r.uuid !== 'string' ||
334
+ r.uuid.length === 0 ||
335
+ typeof r.logIndex !== 'number' ||
336
+ typeof r.integratedTime !== 'number'
337
+ ) {
338
+ return {
339
+ valid: false,
340
+ registry: null,
341
+ reason: 'rekor field malformed (expected {uuid, logIndex, integratedTime})',
342
+ };
343
+ }
344
+ }
345
+
315
346
  // Signature verification — null signature is only accepted in seed/bootstrap mode.
316
347
  if (parsed.signature === null) {
317
348
  const allowSeed = opts.allowSeed === true || process.env.IJFW_ALLOW_SEED_REGISTRY === '1';
@@ -360,6 +391,50 @@ export function verifyRegistry(body, opts = {}) {
360
391
  return { valid: true, registry: parsed, reason: 'ok' };
361
392
  }
362
393
 
394
+ /**
395
+ * v1.5.0 audit-H5.7: optional Rekor transparency-log cross-check.
396
+ *
397
+ * Run AFTER `verifyRegistry` returns valid:true. When the registry has an
398
+ * embedded `rekor` field AND a Rekor client is available locally, this
399
+ * looks the entry up in the live log and confirms its payload hash matches
400
+ * the registry we just verified locally.
401
+ *
402
+ * Decision matrix:
403
+ * - no `rekor` field → returns { ok: true, reason: 'no rekor anchor' }
404
+ * (backcompat — registries signed before this
405
+ * lift still verify on Ed25519 alone).
406
+ * - no Rekor client available → returns { ok: true, reason: 'no rekor client' }
407
+ * (offline clients accept Ed25519 alone).
408
+ * - entry mismatch → returns { ok: false, reason: 'rekor payload mismatch — REJECT' }
409
+ * Caller MUST reject the registry; this is the
410
+ * detection signal for meta-key compromise.
411
+ * - entry matches → returns { ok: true, reason: 'rekor cross-check ok' }
412
+ *
413
+ * @param {object} registry verified registry object (from verifyRegistry)
414
+ * @returns {Promise<{ ok: boolean, reason: string }>}
415
+ */
416
+ export async function crossCheckRekor(registry) {
417
+ if (!registry || typeof registry !== 'object' || registry.rekor === undefined || registry.rekor === null) {
418
+ return { ok: true, reason: 'no rekor anchor' };
419
+ }
420
+ const hasClient = await _hasRekorClient();
421
+ if (!hasClient) {
422
+ return { ok: true, reason: 'no rekor client' };
423
+ }
424
+ const bytes = registryCanonicalBytes(registry);
425
+ const result = await _verifyRekorEntry({ uuid: registry.rekor.uuid, payload: bytes });
426
+ if (result === null) {
427
+ // Lookup failed (network error, malformed response). Be conservative but
428
+ // backcompat-friendly: accept on Ed25519 alone — same posture as offline.
429
+ return { ok: true, reason: 'rekor lookup unavailable — accepting on ed25519' };
430
+ }
431
+ if (result === false) {
432
+ return { ok: false, reason: 'rekor payload mismatch — REJECT' };
433
+ }
434
+ return { ok: true, reason: 'rekor cross-check ok' };
435
+ }
436
+
437
+
363
438
  // ---------------------------------------------------------------------------
364
439
  // loadRegistrySources — read ~/.ijfw/registries.json or fall back to single-source.
365
440
  // ---------------------------------------------------------------------------
@@ -628,13 +703,104 @@ async function atomicWriteJson(filePath, payload) {
628
703
  await rename(tmp, filePath);
629
704
  }
630
705
 
706
+ // ---------------------------------------------------------------------------
707
+ // Federated per-source cache quota (v1.5.0 audit M10 / F-PRF-2).
708
+ //
709
+ // Per-source caches live at `~/.ijfw/state/registry-cache-<name>.json`. With
710
+ // the 1 MiB MAX_REGISTRY_BYTES per-body cap and N federated sources, the disk
711
+ // footprint can grow unbounded as users add/remove sources over time (orphan
712
+ // caches for deactivated sources are never cleaned up). We enforce two caps:
713
+ //
714
+ // - IJFW_FEDERATED_CACHE_MAX_SOURCES (default 32) — file count
715
+ // - IJFW_FEDERATED_CACHE_MAX_BYTES (default 64 MiB) — total bytes
716
+ //
717
+ // On overflow we LRU-evict (mtime ascending) until we're under both caps.
718
+ // Eviction skips the file we're about to write so a fresh write never
719
+ // self-evicts; this is a soft contract for the caller — the next overflow
720
+ // pass will re-evaluate.
721
+ // ---------------------------------------------------------------------------
722
+
723
+ const REGISTRY_CACHE_PREFIX = 'registry-cache-';
724
+ const REGISTRY_CACHE_SUFFIX = '.json';
725
+ const DEFAULT_FEDERATED_CACHE_MAX_SOURCES = 32;
726
+ const DEFAULT_FEDERATED_CACHE_MAX_BYTES = 64 * 1024 * 1024;
727
+
728
+ function envIntCap(name, fallback) {
729
+ const raw = process.env[name];
730
+ if (typeof raw !== 'string' || raw.length === 0) return fallback;
731
+ const n = Number.parseInt(raw, 10);
732
+ if (!Number.isFinite(n) || n <= 0) return fallback;
733
+ return n;
734
+ }
735
+
736
+ async function listSourceCacheFiles() {
737
+ const dir = ijfwStateDir();
738
+ let names;
739
+ try {
740
+ names = await readdir(dir);
741
+ } catch {
742
+ return [];
743
+ }
744
+ const out = [];
745
+ for (const name of names) {
746
+ if (!name.startsWith(REGISTRY_CACHE_PREFIX)) continue;
747
+ if (!name.endsWith(REGISTRY_CACHE_SUFFIX)) continue;
748
+ // Skip the legacy single-source cache (no source name infix).
749
+ if (name === 'registry-cache.json') continue;
750
+ const filePath = join(dir, name);
751
+ let st;
752
+ try {
753
+ st = await stat(filePath);
754
+ } catch {
755
+ continue;
756
+ }
757
+ if (!st.isFile()) continue;
758
+ out.push({ path: filePath, name, size: st.size, mtimeMs: st.mtimeMs });
759
+ }
760
+ return out;
761
+ }
762
+
763
+ /**
764
+ * Enforce the federated cache quota by evicting oldest-mtime cache files until
765
+ * both the file-count and total-bytes caps are satisfied. `protectPath` is
766
+ * excluded from eviction so a freshly-written cache survives its own pass.
767
+ *
768
+ * Returns the list of evicted file paths (for observability / tests).
769
+ */
770
+ export async function enforceFederatedCacheQuota({ protectPath = null } = {}) {
771
+ const maxSources = envIntCap('IJFW_FEDERATED_CACHE_MAX_SOURCES', DEFAULT_FEDERATED_CACHE_MAX_SOURCES);
772
+ const maxBytes = envIntCap('IJFW_FEDERATED_CACHE_MAX_BYTES', DEFAULT_FEDERATED_CACHE_MAX_BYTES);
773
+ const files = await listSourceCacheFiles();
774
+ if (files.length === 0) return [];
775
+
776
+ files.sort((a, b) => a.mtimeMs - b.mtimeMs); // oldest first
777
+ let totalBytes = files.reduce((acc, f) => acc + f.size, 0);
778
+ let count = files.length;
779
+ const evicted = [];
780
+
781
+ for (const f of files) {
782
+ if (count <= maxSources && totalBytes <= maxBytes) break;
783
+ if (protectPath && f.path === protectPath) continue;
784
+ try {
785
+ await unlink(f.path);
786
+ evicted.push(f.path);
787
+ totalBytes -= f.size;
788
+ count -= 1;
789
+ } catch {
790
+ // Best-effort: failure to unlink is non-fatal; the next pass retries.
791
+ }
792
+ }
793
+ return evicted;
794
+ }
795
+
631
796
  /**
632
797
  * Mutate the per-source cache inside an exclusive fs-lock.
633
798
  * @param {object} source — entry from loadRegistrySources()
634
799
  * @param {(cache: object) => object|Promise<object>} mutator
635
800
  */
636
801
  export async function withSourceCache(source, mutator) {
637
- return withFsLock(perSourceLockPath(source.name), async () => {
802
+ const targetPath = perSourceCachePath(source.name);
803
+ const result = await withFsLock(perSourceLockPath(source.name), async () => {
638
804
  const { cache, corrupt, reason } = await readSourceCache(source);
639
805
  if (corrupt) {
640
806
  // The mutator still gets a fresh empty cache to write into. We surface
@@ -642,16 +808,26 @@ export async function withSourceCache(source, mutator) {
642
808
  const next = await mutator({ ...cache, _corruptReason: reason });
643
809
  if (next && typeof next === 'object') {
644
810
  delete next._corruptReason;
645
- await atomicWriteJson(perSourceCachePath(source.name), next);
811
+ await atomicWriteJson(targetPath, next);
646
812
  }
647
813
  return { corrupt: true, reason };
648
814
  }
649
815
  const next = await mutator(cache);
650
816
  if (next && typeof next === 'object') {
651
- await atomicWriteJson(perSourceCachePath(source.name), next);
817
+ await atomicWriteJson(targetPath, next);
652
818
  }
653
819
  return { corrupt: false };
654
820
  });
821
+ // v1.5.0 audit M10: enforce the federated cache quota AFTER the write
822
+ // releases its per-source lock. We exclude the just-written file so a fresh
823
+ // write never self-evicts. Failures here are non-fatal — caller already has
824
+ // a consistent on-disk cache.
825
+ try {
826
+ await enforceFederatedCacheQuota({ protectPath: targetPath });
827
+ } catch {
828
+ // Silent: quota is a hygiene knob, not a correctness gate.
829
+ }
830
+ return result;
655
831
  }
656
832
 
657
833
  // ---------------------------------------------------------------------------
@@ -969,20 +1145,38 @@ export async function refreshTrustFromAllRegistries(opts = {}) {
969
1145
 
970
1146
  const appliedSources = [];
971
1147
 
972
- for (const source of sources) {
1148
+ // v1.5.0 audit M9 (F-SPD-3): parallelise the per-source pipeline. Previously
1149
+ // this was a sequential `for await` loop — with the 10s fetch timeout and N
1150
+ // federated sources, worst-case wait was N×10s (50s for 5 sources). The
1151
+ // network-bound and cache-IO phases for each source are independent, so we
1152
+ // fan out with Promise.all and process results in priority order afterward
1153
+ // (preserves deterministic warning order + applyMultiRegistry input order).
1154
+ //
1155
+ // Per-source failures are caught inside the worker so one slow/throwing
1156
+ // source can never block the others; the worker returns a result envelope
1157
+ // with `error` set, and the post-merge loop converts it into the same
1158
+ // skip-with-warning shape the old sequential code produced.
1159
+ async function processSource(source) {
973
1160
  const fetchImpl = typeof opts.fetchImpl === 'function'
974
1161
  ? (url, part) => opts.fetchImpl(url, source, part)
975
1162
  : null;
976
1163
 
977
1164
  let cache;
978
1165
  let corruptReason = null;
979
- const cacheRead = await readSourceCache(source);
980
- cache = cacheRead.cache;
981
- if (cacheRead.corrupt) {
982
- corruptReason = cacheRead.reason;
983
- const msg = `[ijfw] WARNING: cache for source '${source.name}' corrupt (${cacheRead.reason}) — ignored; falling back to network`;
984
- process.stderr.write(msg + '\n');
985
- warnings.push(msg);
1166
+ let workerWarnings = [];
1167
+ try {
1168
+ const cacheRead = await readSourceCache(source);
1169
+ cache = cacheRead.cache;
1170
+ if (cacheRead.corrupt) {
1171
+ corruptReason = cacheRead.reason;
1172
+ workerWarnings.push(`[ijfw] WARNING: cache for source '${source.name}' corrupt (${cacheRead.reason}) — ignored; falling back to network`);
1173
+ }
1174
+ } catch (err) {
1175
+ // Defensive: readSourceCache should always return; if it throws treat as
1176
+ // corrupt so we still fetch network and try to recover.
1177
+ cache = emptySourceCache(source);
1178
+ corruptReason = `read_throw:${err.message}`;
1179
+ workerWarnings.push(`[ijfw] WARNING: cache for source '${source.name}' read threw (${err.message}) — falling back to network`);
986
1180
  }
987
1181
 
988
1182
  const now = Date.now();
@@ -994,35 +1188,72 @@ export async function refreshTrustFromAllRegistries(opts = {}) {
994
1188
  let mergedRegistry = null;
995
1189
  let fetchError = null;
996
1190
 
997
- // Always fetch the full registry when we want publishers (the response is
998
- // the source of truth for both parts). When ONLY revocation is stale we
999
- // still issue a fetch (server returns the same JSON; CDN can cache
1000
- // separately if it cares about ?part=revoked).
1001
1191
  if (wantPublishers || wantRevocation) {
1002
1192
  const part = wantPublishers ? 'all' : 'revoked';
1003
- const fetched = await fetchRegistry(source.url, { fetchImpl, part });
1004
- if (!fetched.ok) {
1005
- fetchError = fetched.error;
1006
- } else {
1007
- const verified = verifyRegistry(fetched.body, {
1008
- metaKeyPem: source.meta_key_pem,
1009
- allowSeed: opts.allowSeed,
1010
- });
1011
- if (!verified.valid) {
1012
- fetchError = `verify failed: ${verified.reason}`;
1193
+ try {
1194
+ const fetched = await fetchRegistry(source.url, { fetchImpl, part });
1195
+ if (!fetched.ok) {
1196
+ fetchError = fetched.error;
1013
1197
  } else {
1014
- if (verified.warnings) {
1015
- for (const w of verified.warnings) {
1016
- const wMsg = `[ijfw] WARNING (source=${source.name}): ${w}`;
1017
- process.stderr.write(wMsg + '\n');
1018
- warnings.push(wMsg);
1198
+ const verified = verifyRegistry(fetched.body, {
1199
+ metaKeyPem: source.meta_key_pem,
1200
+ allowSeed: opts.allowSeed,
1201
+ });
1202
+ if (!verified.valid) {
1203
+ fetchError = `verify failed: ${verified.reason}`;
1204
+ } else {
1205
+ if (verified.warnings) {
1206
+ for (const w of verified.warnings) {
1207
+ workerWarnings.push(`[ijfw] WARNING (source=${source.name}): ${w}`);
1208
+ }
1019
1209
  }
1210
+ mergedRegistry = verified.registry;
1020
1211
  }
1021
- mergedRegistry = verified.registry;
1022
1212
  }
1213
+ } catch (err) {
1214
+ // Worker-level catch — if fetchRegistry/verifyRegistry throws we
1215
+ // surface it as a regular fetch error so the cache-fallback path
1216
+ // below can run, instead of poisoning the whole Promise.all batch.
1217
+ fetchError = `fetch threw: ${err.message}`;
1023
1218
  }
1024
1219
  }
1025
1220
 
1221
+ return {
1222
+ source,
1223
+ cache,
1224
+ corruptReason,
1225
+ wantPublishers,
1226
+ wantRevocation,
1227
+ mergedRegistry,
1228
+ fetchError,
1229
+ workerWarnings,
1230
+ };
1231
+ }
1232
+
1233
+ // Promise.all — N×10s worst case collapses to ~10s. safe-by-construction:
1234
+ // every worker catches its own errors and returns a result envelope.
1235
+ const processed = await Promise.all(sources.map((s) => processSource(s)));
1236
+
1237
+ // Post-merge: walk results in original priority order so warnings + cache
1238
+ // writes + appliedSources accumulate deterministically. This is the same
1239
+ // sequence the old `for` loop produced; only the network/cache I/O phase
1240
+ // moved to parallel.
1241
+ for (const result of processed) {
1242
+ const {
1243
+ source,
1244
+ cache,
1245
+ corruptReason,
1246
+ wantPublishers,
1247
+ mergedRegistry,
1248
+ fetchError,
1249
+ workerWarnings,
1250
+ } = result;
1251
+
1252
+ for (const w of workerWarnings) {
1253
+ process.stderr.write(w + '\n');
1254
+ warnings.push(w);
1255
+ }
1256
+
1026
1257
  // Decide what cache to write + which registry to apply.
1027
1258
  if (mergedRegistry) {
1028
1259
  // Update cache.
@@ -1245,9 +1476,32 @@ export async function signRegistry(registryPath, opts = {}) {
1245
1476
 
1246
1477
  registry.updated_at = new Date().toISOString();
1247
1478
  delete registry.signature;
1479
+ delete registry.rekor;
1248
1480
  const bytes = registryCanonicalBytes(registry);
1249
1481
  const sigBuf = cryptoSign(null, bytes, privKey);
1250
- registry.signature = `ed25519:${sigBuf.toString('base64')}`;
1482
+ const signature = `ed25519:${sigBuf.toString('base64')}`;
1483
+ registry.signature = signature;
1484
+
1485
+ // v1.5.0 audit-H5.7: anchor the signed registry to Sigstore Rekor when a
1486
+ // client is available. The Rekor entry attests to {payload, signature,
1487
+ // publicKey} — downstream verifiers cross-check the embedded uuid against
1488
+ // the live log so a meta-key swap is detectable. Graceful no-op when the
1489
+ // peer dep is absent.
1490
+ try {
1491
+ const publicKeyPem = createPublicKey(privKey)
1492
+ .export({ type: 'spki', format: 'pem' })
1493
+ .toString();
1494
+ const rekorEntry = await _submitToRekor({
1495
+ payload: bytes,
1496
+ signature,
1497
+ publicKey: publicKeyPem,
1498
+ });
1499
+ if (rekorEntry !== null) {
1500
+ registry.rekor = rekorEntry;
1501
+ }
1502
+ } catch {
1503
+ // submitToRekor never throws by contract; this guard is belt-and-braces.
1504
+ }
1251
1505
 
1252
1506
  try {
1253
1507
  await writeFile(abs, JSON.stringify(registry, null, 2) + '\n', 'utf8');
@@ -1255,7 +1509,7 @@ export async function signRegistry(registryPath, opts = {}) {
1255
1509
  return { ok: false, error: `write failed: ${err.message}` };
1256
1510
  }
1257
1511
 
1258
- return { ok: true };
1512
+ return { ok: true, rekor: registry.rekor || null };
1259
1513
  }
1260
1514
 
1261
1515
  export async function verifyRegistryFile(registryPath) {
@@ -30,8 +30,34 @@ const PATTERNS = [
30
30
  { kind: 'rule', re: /\b(?:every time|each time|whenever|any time)\b/i },
31
31
  ];
32
32
 
33
+ // v1.5.0 audit-LOW-memory-#15: negation / sarcasm guards.
34
+ // When any of these phrases appears, suppress all feedback detection for the
35
+ // prompt — the user has signalled that the literal phrase shouldn't be taken
36
+ // as feedback (retraction, joke, hypothetical). High precision is more
37
+ // valuable than recall here; a false positive promotes a bogus rule into
38
+ // long-term memory which is expensive to unlearn.
39
+ const NEGATION_PATTERNS = [
40
+ /\bnever ?mind\s+(?:that|this|it)?\b/i,
41
+ /\bnvm\b/i,
42
+ /\bactually,?\s+wait\b/i,
43
+ /\bsarcasm:\s*yes\b/i,
44
+ /\b\/s\b/i, // tumblr/reddit sarcasm tag
45
+ /\bjust kidding\b/i,
46
+ /\bscratch that\b/i,
47
+ /\bdisregard (?:that|this|it|the (?:above|last))\b/i,
48
+ ];
49
+
50
+ function isNegatedOrSarcastic(prompt) {
51
+ for (const re of NEGATION_PATTERNS) {
52
+ if (re.test(prompt)) return true;
53
+ }
54
+ return false;
55
+ }
56
+
33
57
  export function detectFeedback(prompt) {
34
58
  if (typeof prompt !== 'string' || !prompt) return [];
59
+ // Suppress detection when negation/sarcasm signal present.
60
+ if (isNegatedOrSarcastic(prompt)) return [];
35
61
  const hits = [];
36
62
  for (const { kind, re } of PATTERNS) {
37
63
  const m = prompt.match(re);