agent-recall-core 3.4.31 → 3.4.36

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 (270) hide show
  1. package/README.md +121 -441
  2. package/dist/digest/store.d.ts.map +1 -1
  3. package/dist/digest/store.js +4 -3
  4. package/dist/digest/store.js.map +1 -1
  5. package/dist/display/board-render.d.ts +61 -0
  6. package/dist/display/board-render.d.ts.map +1 -0
  7. package/dist/display/board-render.js +223 -0
  8. package/dist/display/board-render.js.map +1 -0
  9. package/dist/helpers/alignment-patterns.d.ts +10 -0
  10. package/dist/helpers/alignment-patterns.d.ts.map +1 -1
  11. package/dist/helpers/alignment-patterns.js +5 -1
  12. package/dist/helpers/alignment-patterns.js.map +1 -1
  13. package/dist/helpers/blind-spots.d.ts +86 -0
  14. package/dist/helpers/blind-spots.d.ts.map +1 -0
  15. package/dist/helpers/blind-spots.js +205 -0
  16. package/dist/helpers/blind-spots.js.map +1 -0
  17. package/dist/helpers/conflict-scan.d.ts +10 -0
  18. package/dist/helpers/conflict-scan.d.ts.map +1 -1
  19. package/dist/helpers/conflict-scan.js +3 -3
  20. package/dist/helpers/conflict-scan.js.map +1 -1
  21. package/dist/helpers/semantic-match.d.ts +82 -0
  22. package/dist/helpers/semantic-match.d.ts.map +1 -0
  23. package/dist/helpers/semantic-match.js +229 -0
  24. package/dist/helpers/semantic-match.js.map +1 -0
  25. package/dist/index.d.ts +57 -17
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +60 -14
  28. package/dist/index.js.map +1 -1
  29. package/dist/palace/awareness.d.ts +30 -0
  30. package/dist/palace/awareness.d.ts.map +1 -1
  31. package/dist/palace/awareness.js +65 -2
  32. package/dist/palace/awareness.js.map +1 -1
  33. package/dist/palace/consolidate.d.ts.map +1 -1
  34. package/dist/palace/consolidate.js +17 -0
  35. package/dist/palace/consolidate.js.map +1 -1
  36. package/dist/palace/decay-pass.d.ts +46 -0
  37. package/dist/palace/decay-pass.d.ts.map +1 -0
  38. package/dist/palace/decay-pass.js +87 -0
  39. package/dist/palace/decay-pass.js.map +1 -0
  40. package/dist/palace/insights-index.d.ts.map +1 -1
  41. package/dist/palace/insights-index.js +4 -2
  42. package/dist/palace/insights-index.js.map +1 -1
  43. package/dist/palace/skills.d.ts +41 -2
  44. package/dist/palace/skills.d.ts.map +1 -1
  45. package/dist/palace/skills.js +106 -4
  46. package/dist/palace/skills.js.map +1 -1
  47. package/dist/prompts/consolidation-prompt.d.ts +25 -0
  48. package/dist/prompts/consolidation-prompt.d.ts.map +1 -0
  49. package/dist/prompts/consolidation-prompt.js +91 -0
  50. package/dist/prompts/consolidation-prompt.js.map +1 -0
  51. package/dist/storage/ab-experiment.d.ts +184 -0
  52. package/dist/storage/ab-experiment.d.ts.map +1 -0
  53. package/dist/storage/ab-experiment.js +294 -0
  54. package/dist/storage/ab-experiment.js.map +1 -0
  55. package/dist/storage/archive-prune.d.ts +56 -0
  56. package/dist/storage/archive-prune.d.ts.map +1 -0
  57. package/dist/storage/archive-prune.js +118 -0
  58. package/dist/storage/archive-prune.js.map +1 -0
  59. package/dist/storage/archive-write.d.ts +38 -0
  60. package/dist/storage/archive-write.d.ts.map +1 -0
  61. package/dist/storage/archive-write.js +98 -0
  62. package/dist/storage/archive-write.js.map +1 -0
  63. package/dist/storage/blind-spots-store.d.ts +30 -0
  64. package/dist/storage/blind-spots-store.d.ts.map +1 -0
  65. package/dist/storage/blind-spots-store.js +91 -0
  66. package/dist/storage/blind-spots-store.js.map +1 -0
  67. package/dist/storage/capture-router.d.ts +60 -0
  68. package/dist/storage/capture-router.d.ts.map +1 -0
  69. package/dist/storage/capture-router.js +133 -0
  70. package/dist/storage/capture-router.js.map +1 -0
  71. package/dist/storage/classification.d.ts +36 -0
  72. package/dist/storage/classification.d.ts.map +1 -0
  73. package/dist/storage/classification.js +78 -0
  74. package/dist/storage/classification.js.map +1 -0
  75. package/dist/storage/consolidation-queue.d.ts +39 -0
  76. package/dist/storage/consolidation-queue.d.ts.map +1 -0
  77. package/dist/storage/consolidation-queue.js +120 -0
  78. package/dist/storage/consolidation-queue.js.map +1 -0
  79. package/dist/storage/content-guard.d.ts +74 -0
  80. package/dist/storage/content-guard.d.ts.map +1 -0
  81. package/dist/storage/content-guard.js +179 -0
  82. package/dist/storage/content-guard.js.map +1 -0
  83. package/dist/storage/corrections.d.ts +329 -22
  84. package/dist/storage/corrections.d.ts.map +1 -1
  85. package/dist/storage/corrections.js +788 -58
  86. package/dist/storage/corrections.js.map +1 -1
  87. package/dist/storage/durable-intent.d.ts +49 -0
  88. package/dist/storage/durable-intent.d.ts.map +1 -0
  89. package/dist/storage/durable-intent.js +124 -0
  90. package/dist/storage/durable-intent.js.map +1 -0
  91. package/dist/storage/filelock.d.ts +6 -0
  92. package/dist/storage/filelock.d.ts.map +1 -1
  93. package/dist/storage/filelock.js +6 -1
  94. package/dist/storage/filelock.js.map +1 -1
  95. package/dist/storage/memory-protocol.d.ts +19 -0
  96. package/dist/storage/memory-protocol.d.ts.map +1 -0
  97. package/dist/storage/memory-protocol.js +101 -0
  98. package/dist/storage/memory-protocol.js.map +1 -0
  99. package/dist/storage/paths.d.ts +23 -0
  100. package/dist/storage/paths.d.ts.map +1 -1
  101. package/dist/storage/paths.js +39 -0
  102. package/dist/storage/paths.js.map +1 -1
  103. package/dist/storage/retention.d.ts +23 -0
  104. package/dist/storage/retention.d.ts.map +1 -0
  105. package/dist/storage/retention.js +48 -0
  106. package/dist/storage/retention.js.map +1 -0
  107. package/dist/storage/session.d.ts +2 -2
  108. package/dist/storage/session.d.ts.map +1 -1
  109. package/dist/storage/session.js +11 -1
  110. package/dist/storage/session.js.map +1 -1
  111. package/dist/supabase/config.d.ts +26 -0
  112. package/dist/supabase/config.d.ts.map +1 -1
  113. package/dist/supabase/config.js +11 -0
  114. package/dist/supabase/config.js.map +1 -1
  115. package/dist/supabase/recall-backend.d.ts +1 -0
  116. package/dist/supabase/recall-backend.d.ts.map +1 -1
  117. package/dist/supabase/recall-backend.js +13 -13
  118. package/dist/supabase/recall-backend.js.map +1 -1
  119. package/dist/supabase/sync.d.ts +1 -1
  120. package/dist/supabase/sync.d.ts.map +1 -1
  121. package/dist/supabase/sync.js +85 -3
  122. package/dist/supabase/sync.js.map +1 -1
  123. package/dist/tools-logic/bootstrap.d.ts +15 -0
  124. package/dist/tools-logic/bootstrap.d.ts.map +1 -1
  125. package/dist/tools-logic/bootstrap.js +238 -47
  126. package/dist/tools-logic/bootstrap.js.map +1 -1
  127. package/dist/tools-logic/brief.d.ts +84 -0
  128. package/dist/tools-logic/brief.d.ts.map +1 -0
  129. package/dist/tools-logic/brief.js +193 -0
  130. package/dist/tools-logic/brief.js.map +1 -0
  131. package/dist/tools-logic/check-action.d.ts +9 -0
  132. package/dist/tools-logic/check-action.d.ts.map +1 -1
  133. package/dist/tools-logic/check-action.js +61 -3
  134. package/dist/tools-logic/check-action.js.map +1 -1
  135. package/dist/tools-logic/check.d.ts +7 -0
  136. package/dist/tools-logic/check.d.ts.map +1 -1
  137. package/dist/tools-logic/check.js +24 -2
  138. package/dist/tools-logic/check.js.map +1 -1
  139. package/dist/tools-logic/confidence.d.ts +50 -0
  140. package/dist/tools-logic/confidence.d.ts.map +1 -0
  141. package/dist/tools-logic/confidence.js +70 -0
  142. package/dist/tools-logic/confidence.js.map +1 -0
  143. package/dist/tools-logic/drill-down.d.ts +37 -0
  144. package/dist/tools-logic/drill-down.d.ts.map +1 -0
  145. package/dist/tools-logic/drill-down.js +63 -0
  146. package/dist/tools-logic/drill-down.js.map +1 -0
  147. package/dist/tools-logic/export-corrections.d.ts +63 -0
  148. package/dist/tools-logic/export-corrections.d.ts.map +1 -0
  149. package/dist/tools-logic/export-corrections.js +124 -0
  150. package/dist/tools-logic/export-corrections.js.map +1 -0
  151. package/dist/tools-logic/journal-capture.d.ts.map +1 -1
  152. package/dist/tools-logic/journal-capture.js +4 -1
  153. package/dist/tools-logic/journal-capture.js.map +1 -1
  154. package/dist/tools-logic/journal-write.d.ts.map +1 -1
  155. package/dist/tools-logic/journal-write.js +3 -2
  156. package/dist/tools-logic/journal-write.js.map +1 -1
  157. package/dist/tools-logic/local-archive-backend.d.ts +33 -0
  158. package/dist/tools-logic/local-archive-backend.d.ts.map +1 -0
  159. package/dist/tools-logic/local-archive-backend.js +124 -0
  160. package/dist/tools-logic/local-archive-backend.js.map +1 -0
  161. package/dist/tools-logic/memory-backend.d.ts +74 -0
  162. package/dist/tools-logic/memory-backend.d.ts.map +1 -0
  163. package/dist/tools-logic/memory-backend.js +172 -0
  164. package/dist/tools-logic/memory-backend.js.map +1 -0
  165. package/dist/tools-logic/memory-query.d.ts +10 -3
  166. package/dist/tools-logic/memory-query.d.ts.map +1 -1
  167. package/dist/tools-logic/memory-query.js +31 -7
  168. package/dist/tools-logic/memory-query.js.map +1 -1
  169. package/dist/tools-logic/mirror-builder.d.ts +115 -0
  170. package/dist/tools-logic/mirror-builder.d.ts.map +1 -0
  171. package/dist/tools-logic/mirror-builder.js +348 -0
  172. package/dist/tools-logic/mirror-builder.js.map +1 -0
  173. package/dist/tools-logic/palace-write.d.ts.map +1 -1
  174. package/dist/tools-logic/palace-write.js +3 -2
  175. package/dist/tools-logic/palace-write.js.map +1 -1
  176. package/dist/tools-logic/pipeline-close.d.ts.map +1 -1
  177. package/dist/tools-logic/pipeline-close.js +2 -1
  178. package/dist/tools-logic/pipeline-close.js.map +1 -1
  179. package/dist/tools-logic/pipeline-open.d.ts.map +1 -1
  180. package/dist/tools-logic/pipeline-open.js +2 -1
  181. package/dist/tools-logic/pipeline-open.js.map +1 -1
  182. package/dist/tools-logic/predict-correction.d.ts +47 -0
  183. package/dist/tools-logic/predict-correction.d.ts.map +1 -0
  184. package/dist/tools-logic/predict-correction.js +137 -0
  185. package/dist/tools-logic/predict-correction.js.map +1 -0
  186. package/dist/tools-logic/prior-builder.d.ts +27 -0
  187. package/dist/tools-logic/prior-builder.d.ts.map +1 -0
  188. package/dist/tools-logic/prior-builder.js +90 -0
  189. package/dist/tools-logic/prior-builder.js.map +1 -0
  190. package/dist/tools-logic/recognition-builder.d.ts +122 -0
  191. package/dist/tools-logic/recognition-builder.d.ts.map +1 -0
  192. package/dist/tools-logic/recognition-builder.js +289 -0
  193. package/dist/tools-logic/recognition-builder.js.map +1 -0
  194. package/dist/tools-logic/safety-consolidation.d.ts +104 -0
  195. package/dist/tools-logic/safety-consolidation.d.ts.map +1 -0
  196. package/dist/tools-logic/safety-consolidation.js +256 -0
  197. package/dist/tools-logic/safety-consolidation.js.map +1 -0
  198. package/dist/tools-logic/session-end-reflect.d.ts +18 -0
  199. package/dist/tools-logic/session-end-reflect.d.ts.map +1 -1
  200. package/dist/tools-logic/session-end-reflect.js +69 -30
  201. package/dist/tools-logic/session-end-reflect.js.map +1 -1
  202. package/dist/tools-logic/session-end.d.ts +18 -0
  203. package/dist/tools-logic/session-end.d.ts.map +1 -1
  204. package/dist/tools-logic/session-end.js +189 -34
  205. package/dist/tools-logic/session-end.js.map +1 -1
  206. package/dist/tools-logic/session-start-lite.d.ts +2 -0
  207. package/dist/tools-logic/session-start-lite.d.ts.map +1 -1
  208. package/dist/tools-logic/session-start-lite.js +11 -2
  209. package/dist/tools-logic/session-start-lite.js.map +1 -1
  210. package/dist/tools-logic/session-start.d.ts +81 -2
  211. package/dist/tools-logic/session-start.d.ts.map +1 -1
  212. package/dist/tools-logic/session-start.js +357 -40
  213. package/dist/tools-logic/session-start.js.map +1 -1
  214. package/dist/tools-logic/skill-propose.d.ts +27 -0
  215. package/dist/tools-logic/skill-propose.d.ts.map +1 -0
  216. package/dist/tools-logic/skill-propose.js +66 -0
  217. package/dist/tools-logic/skill-propose.js.map +1 -0
  218. package/dist/tools-logic/skill-recall.d.ts +5 -0
  219. package/dist/tools-logic/skill-recall.d.ts.map +1 -1
  220. package/dist/tools-logic/skill-recall.js +14 -1
  221. package/dist/tools-logic/skill-recall.js.map +1 -1
  222. package/dist/tools-logic/smart-recall.d.ts +17 -0
  223. package/dist/tools-logic/smart-recall.d.ts.map +1 -1
  224. package/dist/tools-logic/smart-recall.js +51 -16
  225. package/dist/tools-logic/smart-recall.js.map +1 -1
  226. package/dist/tools-logic/smart-remember.js +8 -9
  227. package/dist/tools-logic/smart-remember.js.map +1 -1
  228. package/dist/tools-logic/store-doctor.d.ts +71 -0
  229. package/dist/tools-logic/store-doctor.d.ts.map +1 -0
  230. package/dist/tools-logic/store-doctor.js +387 -0
  231. package/dist/tools-logic/store-doctor.js.map +1 -0
  232. package/dist/tools-logic/store-repair.d.ts +64 -0
  233. package/dist/tools-logic/store-repair.d.ts.map +1 -0
  234. package/dist/tools-logic/store-repair.js +243 -0
  235. package/dist/tools-logic/store-repair.js.map +1 -0
  236. package/dist/tools-logic/supersession.d.ts +34 -0
  237. package/dist/tools-logic/supersession.d.ts.map +1 -0
  238. package/dist/tools-logic/supersession.js +103 -0
  239. package/dist/tools-logic/supersession.js.map +1 -0
  240. package/dist/types.d.ts +7 -1
  241. package/dist/types.d.ts.map +1 -1
  242. package/dist/types.js +1 -1
  243. package/dist/vector/embedding.d.ts +8 -0
  244. package/dist/vector/embedding.d.ts.map +1 -1
  245. package/dist/vector/embedding.js +9 -1
  246. package/dist/vector/embedding.js.map +1 -1
  247. package/dist/vector/local-vector-backend.d.ts.map +1 -1
  248. package/dist/vector/local-vector-backend.js +14 -17
  249. package/dist/vector/local-vector-backend.js.map +1 -1
  250. package/package.json +2 -2
  251. package/dist/relevance/injector.d.ts +0 -51
  252. package/dist/relevance/injector.d.ts.map +0 -1
  253. package/dist/relevance/injector.js +0 -121
  254. package/dist/relevance/injector.js.map +0 -1
  255. package/dist/relevance/precision.d.ts +0 -109
  256. package/dist/relevance/precision.d.ts.map +0 -1
  257. package/dist/relevance/precision.js +0 -279
  258. package/dist/relevance/precision.js.map +0 -1
  259. package/dist/relevance/promoter.d.ts +0 -60
  260. package/dist/relevance/promoter.d.ts.map +0 -1
  261. package/dist/relevance/promoter.js +0 -336
  262. package/dist/relevance/promoter.js.map +0 -1
  263. package/dist/relevance/stager.d.ts +0 -61
  264. package/dist/relevance/stager.d.ts.map +0 -1
  265. package/dist/relevance/stager.js +0 -251
  266. package/dist/relevance/stager.js.map +0 -1
  267. package/dist/relevance/surfacer.d.ts +0 -34
  268. package/dist/relevance/surfacer.d.ts.map +0 -1
  269. package/dist/relevance/surfacer.js +0 -333
  270. package/dist/relevance/surfacer.js.map +0 -1
@@ -28,6 +28,33 @@ function correctionsDir(project) {
28
28
  function outcomesPath(project) {
29
29
  return path.join(correctionsDir(project), "_outcomes.jsonl");
30
30
  }
31
+ function rejectedPath(project) {
32
+ return path.join(correctionsDir(project), "_rejected.jsonl");
33
+ }
34
+ /**
35
+ * Gate version stamp — bump whenever isLikelyRealCorrection's accept criteria
36
+ * change so a rejected-log analysis can attribute discard rates to a specific
37
+ * gate revision. Kept in lock-step with the classifier below.
38
+ *
39
+ * v3 (2026-06-21, Loop 8): the gate now scans the FULL correction text (and its
40
+ * decimal-safe sentence fragments) for an actionable marker instead of only the
41
+ * truncated first sentence. Loop 7 proved the first-sentence-slice discarded
42
+ * genuine soft corrections whose directive lived in sentence 2. The NOISE
43
+ * filters (system-fragment / too-short / pure-acknowledgment / doc-header) are
44
+ * unchanged and still run FIRST so the precision floor holds.
45
+ *
46
+ * v4 (2026-06-22, Loop 14): split directive markers into STRONG (accept anywhere)
47
+ * vs WEAK (accept only outside a hedged/reporting frame), closing the round-table's
48
+ * MEDIUM false-accept where tentative filler ("I think we should use it") passed on
49
+ * a bare weak verb. Recall-safe — no fixture correction relies on a hedged weak verb.
50
+ */
51
+ export const GATE_VERSION = "v4-2026-06-22";
52
+ /**
53
+ * Cap for _rejected.jsonl — keep the most-recent N rows so a survivorship-bias
54
+ * probe can never grow the file unbounded on the hot capture path. Rotation is
55
+ * best-effort: a failure to rotate must never throw into writeCorrection.
56
+ */
57
+ const REJECTED_LOG_CAP = 2000;
31
58
  /** Auto-detect severity: p0 if uses strong negation/mandate language, else p1. */
32
59
  function detectSeverity(text) {
33
60
  const p0Patterns = /\bnever\b|\balways\b|\bdon'?t\b|\bdo not\b|\bmust not\b|\bforbid\b|\bprohibit\b/i;
@@ -47,59 +74,212 @@ function defaultWeight(severity) {
47
74
  function todayDate() {
48
75
  return new Date().toISOString().slice(0, 10);
49
76
  }
77
+ /**
78
+ * Atomic JSON write — tmp + rename, mode 0600. Prevents truncation on SIGTERM.
79
+ * Extracted from the three identical inlined copies (writeCorrection /
80
+ * retractCorrection / recordOutcome) so every correction writer shares one
81
+ * durable path. Pure side-effect helper; no behavior change vs the originals.
82
+ */
83
+ function writeRecordAtomic(filepath, record) {
84
+ const tmp = `${filepath}.tmp.${process.pid}.${Date.now()}`;
85
+ fs.writeFileSync(tmp, JSON.stringify(record, null, 2), { encoding: "utf-8", mode: 0o600 });
86
+ fs.renameSync(tmp, filepath);
87
+ }
88
+ /**
89
+ * Beta posterior mean E[Beta(α,β)] with α=heeded+1, β=recurrence+1 (Laplace).
90
+ * Mirrors the canonical `betaUtility` in tools-logic/smart-recall.ts — kept INLINE
91
+ * so the low-level storage layer never imports the recall stack. Returns (0,1):
92
+ * neutral (no evidence) = 0.5; more heeded → higher; more recurrence → lower.
93
+ */
94
+ function betaPosterior(heeded, recurrence) {
95
+ return (heeded + 1) / (heeded + recurrence + 2);
96
+ }
97
+ /** Days after which an untouched correction is considered stale (P4). */
98
+ const STALE_DAYS = 30;
99
+ /**
100
+ * P4: a correction is stale when its most recent touch (last_retrieved, else
101
+ * last_outcome, else its date) is older than STALE_DAYS. Pure — `nowMs` is
102
+ * injectable for tests. INFORMATIONAL ONLY: the corrections room is decay-
103
+ * protected, so this never archives on its own; it surfaces a review candidate.
104
+ */
105
+ export function isStaleCorrection(rec, nowMs = Date.now()) {
106
+ const touch = rec.last_retrieved ?? rec.last_outcome ?? rec.date;
107
+ const t = new Date(touch).getTime();
108
+ if (Number.isNaN(t))
109
+ return false;
110
+ return nowMs - t > STALE_DAYS * 24 * 60 * 60 * 1000;
111
+ }
50
112
  function applyCorrectionDefaults(record, holderDefault) {
113
+ const kind = record.kind ?? "correction";
114
+ const weight = record.weight ?? defaultWeight(record.severity);
51
115
  return {
52
116
  ...record,
53
117
  holder: record.holder ?? holderDefault,
54
- kind: record.kind ?? "correction",
55
- weight: record.weight ?? defaultWeight(record.severity),
118
+ kind,
119
+ weight,
56
120
  active: record.active ?? true,
121
+ // Wave 5: a human correction is authoritative ground truth by default.
122
+ // Non-correction kinds (insight/hunch/fact) are advisory unless explicitly
123
+ // marked authoritative. Honor an explicit value when present.
124
+ authoritative: record.authoritative ?? (kind === "correction"),
125
+ // Consolidation/lifecycle defaults (2026-06-29). Old records lack these;
126
+ // they normalize on read with no migration. proof_confidence seeds from the
127
+ // authority weight so it is meaningful before any outcome has accrued.
128
+ proof_count: record.proof_count ?? 1,
129
+ proof_confidence: record.proof_confidence ?? weight,
130
+ stale: record.stale ?? false,
57
131
  };
58
132
  }
59
133
  // ---------------------------------------------------------------------------
60
134
  // Public API
61
135
  // ---------------------------------------------------------------------------
136
+ /**
137
+ * Decimal-safe sentence splitter. Splits on sentence-ending punctuation
138
+ * (`.`, `!`, `?`, or a newline) ONLY when followed by whitespace or end-of-text
139
+ * — NOT on a bare `.` that sits between digits/word chars. This protects
140
+ * version/model tokens like "Opus 4.7", "v3.4.32", "novada-search" and URLs
141
+ * from being chopped mid-fragment (the exact Loop-7 mis-split that hid the
142
+ * imperative in "Show BOTH Opus 4.7 and 4.8" behind the slice "Show BOTH Opus 4").
143
+ *
144
+ * Returns the FULL text as a single fragment when no sentence boundary is found.
145
+ * Empty fragments are dropped. This is a classifier helper, not a linguistic
146
+ * tokenizer — it deliberately errs toward NOT splitting.
147
+ */
148
+ export function splitSentences(text) {
149
+ // Boundary = one of . ! ? OR a newline, that is followed by whitespace or EOT.
150
+ // A `.` wedged between two non-space chars (4.7, file.md, e.g.) is NOT a boundary.
151
+ const out = [];
152
+ let buf = "";
153
+ for (let i = 0; i < text.length; i++) {
154
+ const ch = text[i];
155
+ buf += ch;
156
+ const next = text[i + 1];
157
+ const isPunct = ch === "." || ch === "!" || ch === "?";
158
+ const isNewline = ch === "\n" || ch === "\r";
159
+ // Sentence boundary: terminal punctuation at end OR followed by whitespace;
160
+ // newline is always a boundary. A `.` between non-whitespace chars is NOT
161
+ // a boundary (next is defined and not whitespace) — keeps decimals intact.
162
+ const atBoundary = isNewline ||
163
+ (isPunct && (next === undefined || /\s/.test(next)));
164
+ if (atBoundary) {
165
+ const frag = buf.trim();
166
+ if (frag)
167
+ out.push(frag);
168
+ buf = "";
169
+ }
170
+ }
171
+ const tail = buf.trim();
172
+ if (tail)
173
+ out.push(tail);
174
+ return out.length > 0 ? out : [text.trim()].filter(Boolean);
175
+ }
176
+ // Directive markers, split by STRENGTH (Loop 14 precision fix). Scanned
177
+ // per-fragment (Loop 8) so a directive in sentence 2+ is seen.
178
+ //
179
+ // STRONG markers signal a behavioral rule even inside prose, so they accept
180
+ // unconditionally. WEAK markers (a bare modal/verb) are genuine in a direct
181
+ // correction ("stop making it full width, it should be inline") but ALSO appear
182
+ // in tentative first-person filler ("I think we should use it") — the MEDIUM
183
+ // false-accept the Loop 14 round-table found. WEAK markers therefore accept only
184
+ // when the fragment is NOT a hedged/reporting frame. This is recall-safe: every
185
+ // genuine fixture correction carries a STRONG marker, a preference shape, or a
186
+ // non-hedged weak verb (verified by scripts/eval/capture-gate-confusion.mjs).
187
+ const STRONG_IMPERATIVE = /\b(never|always|don'?t|do not|must\s+not|must|should\s+not|needs?\s+(to|those|the|a|an|more|all)\b|instead|make\s+sure|remember\s+to|remove\s+all|replace\s+with|default\s+to|keep\s+the|keep\s+\w|show\s+(both|all|the|only))\b/i;
188
+ const WEAK_IMPERATIVE = /\b(should|use|using|stop|avoid|prefer)\b/i;
189
+ // Tentative / reporting frame at the START of a fragment — the speaker is musing
190
+ // or reporting, not issuing a rule. A WEAK marker inside such a frame is NOT a
191
+ // directive. Anchored at ^ so it only catches the OPENER, never a directive
192
+ // sentence that merely follows a hedge.
193
+ const HEDGE_FRAME = /^\s*(i\s+(think|guess|suppose|believe|reckon|feel|will)\b|i'?ll\b|i'?m\s+going\s+to\b|maybe\b|perhaps\b|sounds?\s+good\b|the\s+team\s+(wants?|thinks?|prefers?)\b|we\s+(could|might|may)\b)/i;
194
+ // Preference / corrective-fact statement. Includes user-preference verbs (CJK
195
+ // equivalents) AND the "X not Y" / "wrong … not" corrective-fact shape that
196
+ // carries real intent without an imperative verb (e.g. "Product names are
197
+ // novada-search (not novada-mcp)"). Scanned per-fragment.
198
+ const PREFERENCE_PATTERN = /(\buser\s+(wants?|prefers?|likes?|needs?|agreed|tested|chose|wanted)\b|\bthe\s+user\s+is\b|偏好|喜欢|要求|\bwrong\b[\s\S]{0,60}\bnot\b|\(not\s+[^)]+\)|\bnot\s+\w[\w-]*[,.]?\s+(it'?s|its|use|the\s+\w))/i;
62
199
  /**
63
200
  * Capture-quality gate — rejects context-free fragments, pure acknowledgments,
64
201
  * and text that carries no actionable signal.
65
202
  *
66
- * v2 (2026-06-12): classifies on the RULE field only (context param reserved for
67
- * future use but never classified onprevents long context from bypassing the
68
- * acknowledgment gate).
203
+ * v3 (2026-06-21, Loop 8): the ACTIONABLE-signal scan now runs over the FULL
204
+ * text AND each decimal-safe sentence fragmentaccepting if ANY fragment
205
+ * carries an imperative/modal/preference marker. This fixes the Loop-7 root
206
+ * cause where the gate only ever saw the truncated first sentence
207
+ * (`text.split(/[.\n]/)[0].slice(0,100)`), discarding ~60% of genuine soft
208
+ * corrections whose directive lived in sentence 2 (e.g. "No, that's wrong.
209
+ * Don't use dark backgrounds.") or whose first sentence was chopped by a
210
+ * decimal ("Show BOTH Opus 4.7 and 4.8" → "Show BOTH Opus 4").
211
+ *
212
+ * PRECISION FLOOR: the HARD noise gates run FIRST, on the WHOLE text — these can
213
+ * never be rescued by the actionable scan:
214
+ * 1. too-short (< 12 chars).
215
+ * 2. system/tool fragment: starts with '<', pure number, bare file path.
216
+ * 3. doc/report/transcript header (starts with '#', a report/mission title,
217
+ * or a file:// URL) — pasted artifacts, never a behavioral rule.
218
+ *
219
+ * Then the ACTIONABLE-signal scan runs over the FULL text + each fragment. A
220
+ * text that OPENS like an acknowledgment ("No, that's wrong …") is RESCUED only
221
+ * if a fragment carries a genuine directive (the Loop-7 leak: "No, that's wrong.
222
+ * Don't use dark backgrounds." → fragment 2 "Don't use …" is a real rule).
223
+ *
224
+ * Finally the SOFT acknowledgment gate rejects pure acks that the actionable
225
+ * scan did NOT rescue (bare "ok sure", "no that's not what I meant"). Because it
226
+ * runs AFTER the actionable scan, it can no longer eat a genuine correction that
227
+ * merely opens with "no" — but a content-free ack still has no directive to
228
+ * rescue it, so it is still dropped. The marker set is TIGHT (dropped the v2
229
+ * loose "verb-ish anywhere" path) so a long prose blob with no real directive
230
+ * is NOT re-admitted just because it contains a generic verb.
231
+ *
232
+ * Returns { ok: true } when the text passes, or { ok: false, reason } explaining
233
+ * which gate fired. Callers may surface the reason in a warning.
234
+ */
235
+ /**
236
+ * dropHardNoise — the four hard-noise precision-floor gates extracted so they
237
+ * can be called independently by the two-lane router (both lanes apply the same
238
+ * pre-filter before routing).
239
+ *
240
+ * Returns true = text passes (KEEP — not obviously noise)
241
+ * Returns false = text fails a hard gate (DROP — un-rescuable by actionable scan)
69
242
  *
70
- * Gates (in order):
71
- * 1. Must be >= 12 chars after trim.
72
- * 2. Must NOT be a pure acknowledgment/fragment — applied unconditionally (no
73
- * length cap — v1 60-char cap allowed long-context bypass).
74
- * 3. Reject system/tool fragments: starts with '<', is a pure number, or is a
75
- * bare file path (no spaces, contains path separator, no verb-ish content).
76
- * 4. PASS if any of:
77
- * a) imperative/modal marker present
78
- * b) preference statement ("user wants/prefers/likes/needs", CJK equiv.)
79
- * c) substantive rule: len >= 40 AND >= 2 words of >= 5 chars AND verb-ish
80
- * 5. else reject: "no actionable signal"
243
+ * Identical semantics to the inline gates in isLikelyRealCorrection; this
244
+ * extraction must NOT change gate v4 behaviour (Loops 7/8/14 must stay intact).
81
245
  *
82
- * Returns { ok: true } when the text passes all gates, or { ok: false, reason }
83
- * explaining which gate fired. Callers may surface the reason in a warning.
246
+ * Gate 1 — minimum length (< 12 chars)
247
+ * Gate 2a starts with '<' (system/tool fragment)
248
+ * Gate 2b — pure digits (bare number, no rule content)
249
+ * Gate 2c — bare file path (no spaces, has / or \, no 4+ letter word)
250
+ * Gate 3 — doc/report/transcript header (markdown '#', file://, ⏺, report title)
84
251
  */
252
+ export function dropHardNoise(text) {
253
+ const r = (typeof text === "string" ? text : "").trim();
254
+ // Gate 1 — minimum length
255
+ if (r.length < 12)
256
+ return false;
257
+ // Gate 2a — system/tool fragment
258
+ if (r.startsWith("<"))
259
+ return false;
260
+ // Gate 2b — bare number
261
+ if (/^\d+$/.test(r))
262
+ return false;
263
+ // Gate 2c — bare file path: no spaces, contains / or \, no 4+ letter words
264
+ if (!/\s/.test(r) && /[/\\]/.test(r) && !/\b[a-zA-Z]{4,}\b/.test(r))
265
+ return false;
266
+ // Gate 3 — doc/report/transcript header
267
+ const firstLine = r.split(/\r?\n/, 1)[0]?.trim() ?? "";
268
+ const docHeaderPattern = /^(#{1,6}\s|file:\/\/|⏺|.*\b(test\s+report|status\s+report|local\s+test|mission|protocol|语言风格指南)\b\s*[—\-:])/i;
269
+ if (docHeaderPattern.test(firstLine))
270
+ return false;
271
+ return true;
272
+ }
85
273
  export function isLikelyRealCorrection(rule, _context) {
86
274
  // NOTE: _context is accepted for forward-compat but NEVER classified on.
87
275
  const r = rule.trim();
276
+ // ── HARD NOISE GATES (precision floor) — un-rescuable, run on WHOLE text ───
277
+ // Mirrors dropHardNoise's gates (kept inline for the per-gate reason strings).
88
278
  // Gate 1 — minimum length
89
279
  if (r.length < 12) {
90
280
  return { ok: false, reason: "too short" };
91
281
  }
92
- // Gate 2 — pure acknowledgment / fragment patterns (NO length cap — applied always).
93
- // Matches strings that start with an acknowledgment word and trail with only filler content
94
- // up to 80 extra chars. The 80-char trailing budget covers e.g.
95
- // "Ok good, then we don't change anything. let's focus on novada-mcp" (67 total)
96
- // without catching real rules that happen to open with "ok" or "yes" (those tend to be
97
- // much longer and/or contain definitive nouns + verbs checked in gate 4).
98
- const acknowledgmentPattern = /^(no[,.]?\s*(that'?s\s+wrong[.!]?)?|ok(ay)?\b|good\b|great\b|nice\b|yes\b|yeah\b|right\b|wait\b|hmm+\b|sure\b|thanks?\b)[\s\S]{0,80}$/i;
99
- if (acknowledgmentPattern.test(r)) {
100
- return { ok: false, reason: "pure acknowledgment or fragment — no rule content" };
101
- }
102
- // Gate 3 — system/tool fragments
282
+ // Gate 2 — system/tool fragments
103
283
  if (r.startsWith("<")) {
104
284
  return { ok: false, reason: "system/tool fragment (starts with '<')" };
105
285
  }
@@ -110,27 +290,67 @@ export function isLikelyRealCorrection(rule, _context) {
110
290
  if (!/\s/.test(r) && /[/\\]/.test(r) && !/\b[a-zA-Z]{4,}\b/.test(r)) {
111
291
  return { ok: false, reason: "looks like a bare file path — no rule content" };
112
292
  }
113
- // Gate 4must carry actionable signal (pass if ANY of a/b/c)
114
- // (a) imperative/modal marker
115
- const imperativePattern = /\b(never|always|don'?t|do not|must|should|use|stop|avoid|prefer|instead|make sure|remember to)\b/i;
116
- if (imperativePattern.test(r)) {
293
+ // Gate 3doc / report / transcript header (pasted artifact, not a rule).
294
+ // Loop-7 true-noise "doc/report headers": markdown headers, report/mission
295
+ // titles, file:// URL pastes, and the agent's own "⏺ …" transcript echo.
296
+ // Anchored at the START so a real rule that merely mentions "report"
297
+ // mid-sentence is unaffected. This is what stops the full-text scan from
298
+ // re-admitting a long pasted doc just because its body contains a verb.
299
+ const firstLine = r.split(/\r?\n/, 1)[0]?.trim() ?? "";
300
+ const docHeaderPattern = /^(#{1,6}\s|file:\/\/|⏺|.*\b(test\s+report|status\s+report|local\s+test|mission|protocol|语言风格指南)\b\s*[—\-:])/i;
301
+ if (docHeaderPattern.test(firstLine)) {
302
+ return { ok: false, reason: "doc/report/transcript header — pasted artifact, no rule content" };
303
+ }
304
+ // ── ACTIONABLE-SIGNAL SCAN (v3) — FULL text + each sentence fragment ───────
305
+ // Loop 8 root-cause fix: accept if the FULL text OR ANY decimal-safe fragment
306
+ // carries a directive marker. Fragments come from the WHOLE text (never a
307
+ // truncated slice), so a directive in sentence 2+ is now seen and can RESCUE
308
+ // a text that opens with an acknowledgment.
309
+ const fragments = [r, ...splitSentences(r)];
310
+ // (a) STRONG directive marker in any fragment → accept unconditionally.
311
+ if (fragments.some((f) => STRONG_IMPERATIVE.test(f))) {
117
312
  return { ok: true };
118
313
  }
119
- // (b) preference statement
120
- const preferencePattern = /\b(user\s+(wants?|prefers?|likes?|needs?)|the\s+user\s+is|偏好|喜欢|要求)\b/i;
121
- if (preferencePattern.test(r)) {
314
+ // (a2) WEAK directive marker → accept only in a fragment that is NOT a hedged/
315
+ // reporting frame. Closes the Loop-14 filler-prose false-accept ("I think we
316
+ // should use it") while still accepting a direct weak-verb correction ("stop
317
+ // making it full width") and a directive sentence that merely FOLLOWS a hedge.
318
+ if (fragments.some((f) => WEAK_IMPERATIVE.test(f) && !HEDGE_FRAME.test(f))) {
122
319
  return { ok: true };
123
320
  }
124
- // (c) substantive rule: len >= 40, >= 2 words of >= 5 alphanum chars, has verb-ish token
125
- if (r.length >= 40) {
126
- const longWords = (r.match(/\b[a-zA-Z0-9]{5,}\b/g) ?? []).length;
127
- const verbIsh = /\b(bump|consolidate|release|phase|version|publish|push|format|palette|font|round|warm|side.by.side|bilingual|batch|clean|parse|build|compile|deploy|migrate|export|import|store|handle|return|check|verify|ensure)\b/i;
128
- if (longWords >= 2 && verbIsh.test(r)) {
129
- return { ok: true };
130
- }
321
+ // (b) preference / corrective-fact statement in any fragment
322
+ if (fragments.some((f) => PREFERENCE_PATTERN.test(f))) {
323
+ return { ok: true };
324
+ }
325
+ // ── SOFT ACKNOWLEDGMENT GATE runs AFTER the actionable scan ──────────────
326
+ // Pure acknowledgment / fragment: opens with an ack word and trails with only
327
+ // filler (<=80 extra chars). By this point the actionable scan has already
328
+ // found NO directive, so anything matching here is a genuine content-free ack
329
+ // ("ok sure", "no that's not what I meant", "confirmed"). NO length cap on the
330
+ // anchor — only the trailing budget — matching the v2 behavior for true acks.
331
+ const acknowledgmentPattern = /^(no[,.]?\s*(that'?s\s+wrong[.!]?)?|ok(ay)?\b|good\b|great\b|nice\b|yes\b|yeah\b|right\b|wait\b|hmm+\b|sure\b|thanks?\b|confirmed\b|fair\s+point\b)[\s\S]{0,80}$/i;
332
+ if (acknowledgmentPattern.test(r)) {
333
+ return { ok: false, reason: "pure acknowledgment or fragment — no rule content" };
131
334
  }
132
335
  return { ok: false, reason: "no actionable signal — rule lacks imperative/modal marker, preference statement, or substantive content" };
133
336
  }
337
+ /**
338
+ * P1 consolidation match key. A new correction folds into an existing ACTIVE one
339
+ * only when their rule titles are IDENTICAL after normalization (lowercase, all
340
+ * runs of non-alphanumerics collapsed to a single space, trimmed).
341
+ *
342
+ * Deliberately VERBATIM-only. The dominant duplicate source is the SAME correction
343
+ * captured again across sessions, and exact-match is the ONE gate with ZERO risk
344
+ * of folding two DISTINCT rules into one. Fuzzy/semantic matching is unsafe on the
345
+ * zero-LLM storage path because it cannot tell a duplicate from a contradiction:
346
+ * "use proxy.ts" vs "use middleware.ts" and a "P0" vs "P1" variant differ by a
347
+ * short/numeric token that any local matcher either inflates (char-trigram) or
348
+ * drops (sub-3-char token filter) — so it would wrongly merge them. Paraphrase-
349
+ * level consolidation is left to the optional semantic/LLM path, never here.
350
+ */
351
+ function normalizeRule(rule) {
352
+ return (rule ?? "").toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim();
353
+ }
134
354
  /**
135
355
  * Write a correction to persistent storage.
136
356
  * Auto-detects severity from the rule/context text.
@@ -142,9 +362,28 @@ export function isLikelyRealCorrection(rule, _context) {
142
362
  */
143
363
  export function writeCorrection(project, correction) {
144
364
  // Capture-quality gate — reject noise before touching disk.
145
- // v2: classify on rule field only (context is for future use, never gate-input).
146
- const gate = isLikelyRealCorrection(correction.rule ?? "");
365
+ // v3 (Loop 8): classify on the FULL correction text, not the truncated rule.
366
+ // `rule` is a first-sentence title slice (set by check.ts) that hid the
367
+ // directive when it lived in sentence 2 or after a decimal. The full text
368
+ // lives in `context`. ACCEPT if EITHER the rule OR the context carries a
369
+ // directive — a directive anywhere in the correction is genuine signal. The
370
+ // gate's own HARD noise gates (system-fragment / doc-header / too-short) run
371
+ // on each candidate, so this can't re-admit a long noise blob: a blob that
372
+ // matched a hard gate is rejected regardless of which field it came from.
373
+ const ruleText = (correction.rule ?? "").trim();
374
+ const contextText = (correction.context ?? "").trim();
375
+ const ruleGate = ruleText ? isLikelyRealCorrection(ruleText) : { ok: false, reason: "empty rule" };
376
+ // Only consult context when it adds NEW text (production: context ⊇ rule).
377
+ const contextGate = contextText && contextText !== ruleText
378
+ ? isLikelyRealCorrection(contextText)
379
+ : { ok: false };
380
+ const gate = ruleGate.ok || contextGate.ok ? { ok: true } : ruleGate;
147
381
  if (!gate.ok) {
382
+ // Survivorship-bias probe — record the discarded candidate (FULL rejected
383
+ // text + reason) so soft corrections the palace silently drops become
384
+ // measurable. Best-effort: logRejectedCorrection can NEVER throw here.
385
+ const rejectedText = contextText.length > ruleText.length ? contextText : ruleText;
386
+ logRejectedCorrection(project, rejectedText, gate.reason ?? "rejected");
148
387
  return { written: false, reason: gate.reason };
149
388
  }
150
389
  const dir = correctionsDir(project);
@@ -152,13 +391,41 @@ export function writeCorrection(project, correction) {
152
391
  // Auto-detect severity if not already set
153
392
  const severity = correction.severity ?? detectSeverity(`${correction.rule} ${correction.context}`);
154
393
  const record = applyCorrectionDefaults({ ...correction, severity }, todayDate());
394
+ // ── P1: on-write consolidation (refine-not-overwrite) ─────────────────────
395
+ // Borrow Hindsight's consolidation idea, AR-native: instead of accumulating a
396
+ // new dated file for a re-stated rule, fold it into the most similar ACTIVE
397
+ // correction of the SAME kind and bump that record's proof_count. The matched
398
+ // record keeps its id/date (stable document_id) and absorbs the new tags +
399
+ // higher severity/authority/weight. High-precision LOCAL gate — no key, no
400
+ // network — so this never runs an LLM on the storage hot path.
401
+ const normNew = normalizeRule(record.rule);
402
+ for (const existing of readActiveCorrections(project)) {
403
+ if (existing.id === record.id)
404
+ continue; // never merge into self (same-day re-slug)
405
+ if ((existing.kind ?? "correction") !== (record.kind ?? "correction"))
406
+ continue;
407
+ if (normalizeRule(existing.rule) !== normNew)
408
+ continue;
409
+ const merged = {
410
+ ...existing,
411
+ proof_count: (existing.proof_count ?? 1) + 1,
412
+ merged_from: [...(existing.merged_from ?? []), record.id],
413
+ tags: Array.from(new Set([...(existing.tags ?? []), ...(record.tags ?? [])])),
414
+ // keep the STRONGER signal on every axis
415
+ severity: existing.severity === "p0" || record.severity === "p0" ? "p0" : "p1",
416
+ weight: Math.max(existing.weight ?? 0, record.weight ?? 0),
417
+ authoritative: Boolean(existing.authoritative || record.authoritative),
418
+ last_outcome: new Date().toISOString(),
419
+ };
420
+ const mfile = `${merged.date}-${slugify(merged.rule || merged.id)}.json`;
421
+ writeRecordAtomic(path.join(dir, mfile), merged);
422
+ return { written: true, merged: true, id: merged.id };
423
+ }
155
424
  const filename = `${record.date}-${slugify(record.rule || record.id)}.json`;
156
425
  const filepath = path.join(dir, filename);
157
426
  // Atomic write — tmp + rename, mode 0600
158
- const tmp = `${filepath}.tmp.${process.pid}.${Date.now()}`;
159
- fs.writeFileSync(tmp, JSON.stringify(record, null, 2), { encoding: "utf-8", mode: 0o600 });
160
- fs.renameSync(tmp, filepath);
161
- return { written: true };
427
+ writeRecordAtomic(filepath, record);
428
+ return { written: true, merged: false, id: record.id };
162
429
  }
163
430
  /**
164
431
  * Read all corrections for a project, sorted newest first.
@@ -202,7 +469,7 @@ export function readP0Corrections(project) {
202
469
  * The file is rewritten atomically — never deleted. The record remains in
203
470
  * _outcomes.jsonl history and can be manually reactivated by editing the JSON.
204
471
  */
205
- export function retractCorrection(project, id, reason) {
472
+ export function retractCorrection(project, id, reason, supersededBy) {
206
473
  const dir = correctionsDir(project);
207
474
  // Find the correction record by id
208
475
  const all = readCorrections(project);
@@ -215,26 +482,56 @@ export function retractCorrection(project, id, reason) {
215
482
  active: false,
216
483
  retracted_at: new Date().toISOString(),
217
484
  ...(reason !== undefined ? { retract_reason: reason } : {}),
485
+ // P2: forward pointer to the correction that replaced this one (audit trail).
486
+ ...(supersededBy !== undefined ? { superseded_by: supersededBy } : {}),
218
487
  };
219
488
  const filename = `${updated.date}-${slugify(updated.rule || updated.id)}.json`;
220
489
  const filepath = path.join(dir, filename);
221
490
  // Atomic rewrite — tmp + rename, mode 0600
222
- const tmp = `${filepath}.tmp.${process.pid}.${Date.now()}`;
223
- fs.writeFileSync(tmp, JSON.stringify(updated, null, 2), { encoding: "utf-8", mode: 0o600 });
224
- fs.renameSync(tmp, filepath);
491
+ writeRecordAtomic(filepath, updated);
225
492
  return { success: true, id };
226
493
  }
227
494
  /**
228
495
  * Record an outcome event for a correction (retrieved / heeded / recurred).
229
496
  * Appends to _outcomes.jsonl and also updates the correction JSON's counters
230
497
  * + precision cache. Atomic per-write.
498
+ *
499
+ * C3b invariants:
500
+ * - `recorded_at` (forensic wall-clock timestamp) is stamped on EVERY event,
501
+ * unconditionally — callers cannot suppress or spoof it. The semantic `at`
502
+ * stays caller-controlled (the dream audit backdates it to the audited day).
503
+ * - `not_triggered` is ONLY producible via the dream-audit path: the evidence
504
+ * string MUST start with "dream-audit:". Any other producer throws. This is
505
+ * the core-level enforcement of the single-producer contract (the CLI's
506
+ * `ar outcomes record` is the one caller that adds the prefix).
231
507
  */
232
508
  export function recordOutcome(outcome) {
509
+ // C3b single-producer gate: not_triggered without the dream-audit evidence
510
+ // prefix indicates an unauthorized producer — fail loudly, never silently.
511
+ if (outcome.kind === "not_triggered" &&
512
+ !(outcome.evidence ?? "").startsWith("dream-audit:")) {
513
+ throw new Error(`recordOutcome: kind "not_triggered" is only producible by the dream-audit path — ` +
514
+ `evidence must start with "dream-audit:". Use \`ar outcomes record --kind not_triggered\` ` +
515
+ `(it adds the prefix) instead of calling recordOutcome directly.`);
516
+ }
233
517
  const dir = correctionsDir(outcome.project);
234
518
  ensureDir(dir);
235
- // Append jsonl event (audit trail).
236
- const line = JSON.stringify(outcome) + "\n";
519
+ // Append jsonl event (audit trail). recorded_at is the forensic wall-clock
520
+ // stamp always NOW, regardless of what the caller put in `at`.
521
+ const stamped = { ...outcome, recorded_at: new Date().toISOString() };
522
+ const line = JSON.stringify(stamped) + "\n";
237
523
  fs.appendFileSync(outcomesPath(outcome.project), line, "utf-8");
524
+ // C3: triggered / not_triggered / unknown are LEDGER-ONLY events — they change
525
+ // no per-record counter, so the read-modify-write below would recompute
526
+ // precision/proof_confidence to identical values and rewrite the file for
527
+ // nothing. Early-return after the jsonl append (the authoritative sink):
528
+ // avoids a wasted betaPosterior + atomic rewrite on every check-action call
529
+ // and keeps these hot-path kinds clear of the unlocked-RMW counter race.
530
+ if (outcome.kind === "triggered" ||
531
+ outcome.kind === "not_triggered" ||
532
+ outcome.kind === "unknown") {
533
+ return;
534
+ }
238
535
  // Update the per-correction file's counters.
239
536
  const target = readCorrections(outcome.project).find((r) => r.id === outcome.correction_id);
240
537
  if (!target)
@@ -257,21 +554,365 @@ export function recordOutcome(outcome) {
257
554
  updated.recurrence_count = (updated.recurrence_count ?? 0) + 1;
258
555
  updated.last_outcome = outcome.at;
259
556
  }
557
+ else if (outcome.kind === "predicted") {
558
+ // Wave 5: prediction fired — instrument the predict-the-correction loop.
559
+ updated.predicted_count = (updated.predicted_count ?? 0) + 1;
560
+ updated.last_predicted = outcome.at;
561
+ }
562
+ else if (outcome.kind === "predict_hit") {
563
+ updated.predict_hits = (updated.predict_hits ?? 0) + 1;
564
+ }
260
565
  const r = updated.retrieved_count ?? 0;
261
566
  // Clamp to [0,1]: `retrieved` is guarded 1/day but `heeded` can fire on every
262
567
  // session_end, so raw heeded/retrieved can exceed 1.0 ("150% heeded" is
263
568
  // nonsense). min(1, …) keeps the metric honest. (Root-cause follow-up: apply
264
569
  // the same 1/day guard to heeded as retrieved has, for finer resolution.)
570
+ // NB (Wave 5): `precision` is heeded/retrieved ONLY — predict_* never touch it.
265
571
  updated.precision = r > 0 ? Math.min(1, Number(((updated.heeded_count ?? 0) / r).toFixed(3))) : undefined;
572
+ // Wave 5: predict_precision = predict_hits / predicted_count, kept SEPARATE
573
+ // from `precision`. Undefined until at least one prediction has fired.
574
+ // A predict_hit implies a prior prediction. If data is inconsistent (hits
575
+ // recorded without a matching predicted_count — e.g. migrated/corrupt records),
576
+ // floor the denominator at predict_hits so the metric stays VISIBLE and bounded
577
+ // rather than silently undefined while hits exist.
578
+ const pc = updated.predicted_count ?? 0;
579
+ const ph = updated.predict_hits ?? 0;
580
+ const predictDenom = Math.max(pc, ph);
581
+ updated.predict_precision = predictDenom > 0
582
+ ? Math.min(1, Number((ph / predictDenom).toFixed(3)))
583
+ : undefined;
584
+ // P3: evidence-grounded proof_confidence. With NO outcome evidence yet, keep the
585
+ // authority prior (weight); once heeded/recurrence accrue, move to the Beta
586
+ // posterior so a rule that keeps being honored strengthens and one whose bug
587
+ // keeps recurring weakens. Kept SEPARATE from `precision` (heeded/retrieved) and
588
+ // from `weight` (static authority) — this is the evidence axis.
589
+ const heededC = updated.heeded_count ?? 0;
590
+ const recurC = updated.recurrence_count ?? 0;
591
+ updated.proof_confidence = (heededC + recurC) > 0
592
+ ? Number(betaPosterior(heededC, recurC).toFixed(3))
593
+ : (updated.weight ?? defaultWeight(updated.severity));
266
594
  // Re-write the JSON file atomically (tmp + rename — prevents truncation on SIGTERM).
267
595
  const filename = `${updated.date}-${slugify(updated.rule || updated.id)}.json`;
268
596
  const filepath = path.join(dir, filename);
269
- const tmp = `${filepath}.tmp.${process.pid}.${Date.now()}`;
270
- fs.writeFileSync(tmp, JSON.stringify(updated, null, 2), { encoding: "utf-8", mode: 0o600 });
271
- fs.renameSync(tmp, filepath);
597
+ writeRecordAtomic(filepath, updated);
598
+ }
599
+ /**
600
+ * Best-effort: append one row to corrections/_rejected.jsonl recording a
601
+ * gate-rejected correction candidate. INVARIANT: never throws — every fs op is
602
+ * wrapped so a rejection log can never escalate into the capture path. Reads
603
+ * nothing on the hot path except the (already-small) file it rotates.
604
+ *
605
+ * Rotation: when the file exceeds REJECTED_LOG_CAP rows, it is rewritten with
606
+ * only the most-recent rows (append-only semantics, bounded size). Rotation is
607
+ * itself best-effort — a rotation failure still leaves the append intact.
608
+ */
609
+ export function logRejectedCorrection(project, rule, reason) {
610
+ try {
611
+ const dir = correctionsDir(project);
612
+ ensureDir(dir);
613
+ const row = {
614
+ ts: new Date().toISOString(),
615
+ project,
616
+ rule,
617
+ reason,
618
+ gate_version: GATE_VERSION,
619
+ };
620
+ const p = rejectedPath(project);
621
+ fs.appendFileSync(p, JSON.stringify(row) + "\n", "utf-8");
622
+ // Bounded rotation — keep only the most-recent rows. Best-effort: if any
623
+ // step throws, the append above already succeeded and we simply skip trim.
624
+ try {
625
+ const raw = fs.readFileSync(p, "utf-8");
626
+ const lines = raw.split("\n").filter((l) => l.trim());
627
+ if (lines.length > REJECTED_LOG_CAP) {
628
+ const kept = lines.slice(-REJECTED_LOG_CAP).join("\n") + "\n";
629
+ const tmp = `${p}.tmp.${process.pid}.${Date.now()}`;
630
+ fs.writeFileSync(tmp, kept, { encoding: "utf-8", mode: 0o600 });
631
+ fs.renameSync(tmp, p);
632
+ }
633
+ }
634
+ catch {
635
+ /* rotation is best-effort — append already landed */
636
+ }
637
+ }
638
+ catch {
639
+ /* a rejection log can NEVER throw into the capture path */
640
+ }
641
+ }
642
+ /**
643
+ * Read all rejected correction candidates for a project, oldest-first (file
644
+ * order). Returns [] when no log exists — never throws. Skips malformed lines.
645
+ */
646
+ export function readRejectedCorrections(project) {
647
+ const p = rejectedPath(project);
648
+ if (!fs.existsSync(p))
649
+ return [];
650
+ let raw;
651
+ try {
652
+ raw = fs.readFileSync(p, "utf-8");
653
+ }
654
+ catch {
655
+ return [];
656
+ }
657
+ const out = [];
658
+ for (const line of raw.split("\n")) {
659
+ const trimmed = line.trim();
660
+ if (!trimmed)
661
+ continue;
662
+ try {
663
+ const rec = JSON.parse(trimmed);
664
+ if (rec && typeof rec.rule === "string" && typeof rec.reason === "string") {
665
+ out.push(rec);
666
+ }
667
+ }
668
+ catch {
669
+ /* skip malformed line */
670
+ }
671
+ }
672
+ return out;
673
+ }
674
+ /**
675
+ * Aggregate the rejected log into discard count + per-reason breakdown. When
676
+ * `acceptedCount` is supplied the discard RATE is computed too. Read-only.
677
+ */
678
+ export function getRejectedStats(project, acceptedCount) {
679
+ const rows = readRejectedCorrections(project);
680
+ const byReason = new Map();
681
+ for (const r of rows) {
682
+ byReason.set(r.reason, (byReason.get(r.reason) ?? 0) + 1);
683
+ }
684
+ const top_reasons = [...byReason.entries()]
685
+ .map(([reason, count]) => ({ reason, count }))
686
+ .sort((a, b) => b.count - a.count);
687
+ const discarded = rows.length;
688
+ const denom = acceptedCount !== undefined ? discarded + acceptedCount : undefined;
689
+ return {
690
+ project,
691
+ discarded,
692
+ accepted: acceptedCount,
693
+ rate: denom && denom > 0 ? Number((discarded / denom).toFixed(4)) : undefined,
694
+ top_reasons,
695
+ };
696
+ }
697
+ /**
698
+ * Internal: bucket _outcomes.jsonl events per correction id, keeping only the
699
+ * lines for which `keep(localDay)` returns true. `localDay` is the event's
700
+ * local-TZ date (`sv` locale → YYYY-MM-DD), matching the 1/day guards elsewhere.
701
+ * Returns an empty Map when no log exists — never throws.
702
+ *
703
+ * This is the single parsing core shared by readOutcomesForToday /
704
+ * readOutcomesBefore / readOutcomesOnDate so all three agree on date handling.
705
+ */
706
+ function bucketOutcomesBy(project, keep) {
707
+ const map = new Map();
708
+ const p = outcomesPath(project);
709
+ if (!fs.existsSync(p))
710
+ return map;
711
+ let raw;
712
+ try {
713
+ raw = fs.readFileSync(p, "utf-8");
714
+ }
715
+ catch {
716
+ return map;
717
+ }
718
+ for (const line of raw.split("\n")) {
719
+ const trimmed = line.trim();
720
+ if (!trimmed)
721
+ continue;
722
+ let evt;
723
+ try {
724
+ evt = JSON.parse(trimmed);
725
+ }
726
+ catch {
727
+ continue; // skip malformed lines
728
+ }
729
+ if (!evt || !evt.correction_id || !evt.at)
730
+ continue;
731
+ let day;
732
+ try {
733
+ day = new Date(evt.at).toLocaleDateString("sv");
734
+ }
735
+ catch {
736
+ continue;
737
+ }
738
+ if (!keep(day))
739
+ continue;
740
+ let set = map.get(evt.correction_id);
741
+ if (!set) {
742
+ set = new Set();
743
+ map.set(evt.correction_id, set);
744
+ }
745
+ set.add(evt.kind);
746
+ }
747
+ return map;
748
+ }
749
+ /**
750
+ * Wave 5 — single source for "what outcomes already fired today" across the
751
+ * predict / check-action / session-start / session-end call sites. Reads the
752
+ * _outcomes.jsonl audit trail and buckets today's events (local-TZ) per
753
+ * correction id. Returns an empty Map when no log exists — never throws.
754
+ *
755
+ * Local-TZ date (`sv` locale → YYYY-MM-DD) matches the 1/day guards elsewhere
756
+ * (session-start/session-end) so "today" agrees across all four readers.
757
+ */
758
+ export function readOutcomesForToday(project) {
759
+ const todayStr = new Date().toLocaleDateString("sv");
760
+ return bucketOutcomesBy(project, (day) => day === todayStr);
761
+ }
762
+ /**
763
+ * Loop 3 — bucket outcome events recorded STRICTLY BEFORE a given ISO/date
764
+ * cutoff (local-TZ day comparison). Mirrors readOutcomesForToday but with an
765
+ * explicit date arg, so the cross-day predict_hit path can ask "was this risk
766
+ * already PREDICTED on an earlier day?" without depending on today's bucket.
767
+ *
768
+ * `isoCutoff` may be a full ISO timestamp or a YYYY-MM-DD date; only its
769
+ * local-TZ day is used. An event on the SAME day as the cutoff is EXCLUDED
770
+ * (strictly-before) — this is what keeps a same-session/same-day prediction
771
+ * from ever counting as a cross-day hit.
772
+ */
773
+ export function readOutcomesBefore(project, isoCutoff) {
774
+ let cutoffDay;
775
+ try {
776
+ cutoffDay = new Date(isoCutoff).toLocaleDateString("sv");
777
+ }
778
+ catch {
779
+ return new Map();
780
+ }
781
+ return bucketOutcomesBy(project, (day) => day < cutoffDay);
782
+ }
783
+ /**
784
+ * Loop 3 — bucket outcome events recorded ON a specific local-TZ day. Mirrors
785
+ * readOutcomesForToday but with an explicit date arg (for replaying a past day
786
+ * in tests / offline analysis). `isoDate` may be a full ISO timestamp or a
787
+ * YYYY-MM-DD date; only its local-TZ day is used.
788
+ */
789
+ export function readOutcomesOnDate(project, isoDate) {
790
+ let onDay;
791
+ try {
792
+ onDay = new Date(isoDate).toLocaleDateString("sv");
793
+ }
794
+ catch {
795
+ return new Map();
796
+ }
797
+ return bucketOutcomesBy(project, (day) => day === onDay);
798
+ }
799
+ /**
800
+ * Read all outcome events for a project from _outcomes.jsonl, bucketed by correction_id.
801
+ * Returns a Map: correction_id → Set of all outcome kinds ever recorded for that id.
802
+ * Never throws — returns an empty Map on any fs/parse error.
803
+ *
804
+ * Used by getCorrectionKPIs to compute C3 verdict-coverage metrics without
805
+ * duplicating the outcomes log parsing logic.
806
+ */
807
+ export function readAllOutcomeKinds(project) {
808
+ return bucketOutcomesBy(project, () => true);
809
+ }
810
+ export function listUnknownVerdicts(project, date) {
811
+ // Default to yesterday
812
+ const targetDay = (() => {
813
+ if (date) {
814
+ try {
815
+ return new Date(date).toLocaleDateString("sv");
816
+ }
817
+ catch {
818
+ return new Date(Date.now() - 86400000).toLocaleDateString("sv");
819
+ }
820
+ }
821
+ return new Date(Date.now() - 86400000).toLocaleDateString("sv");
822
+ })();
823
+ // Parse ALL outcomes for this project (not just today's) to bucket by day
824
+ const outcomesFile = outcomesPath(project);
825
+ if (!fs.existsSync(outcomesFile))
826
+ return [];
827
+ let raw;
828
+ try {
829
+ raw = fs.readFileSync(outcomesFile, "utf-8");
830
+ }
831
+ catch {
832
+ return [];
833
+ }
834
+ // Bucket by correction_id → Set of kinds on targetDay
835
+ const retrievedOnDate = new Set();
836
+ const coveredOnDate = new Set(); // heeded | recurred | not_triggered
837
+ const COVERED_KINDS = new Set(["heeded", "recurred", "not_triggered"]);
838
+ for (const line of raw.split("\n")) {
839
+ const trimmed = line.trim();
840
+ if (!trimmed)
841
+ continue;
842
+ let evt;
843
+ try {
844
+ evt = JSON.parse(trimmed);
845
+ }
846
+ catch {
847
+ continue;
848
+ }
849
+ if (!evt || !evt.correction_id || !evt.at || !evt.kind)
850
+ continue;
851
+ let day;
852
+ try {
853
+ day = new Date(evt.at).toLocaleDateString("sv");
854
+ }
855
+ catch {
856
+ continue;
857
+ }
858
+ if (day !== targetDay)
859
+ continue;
860
+ if (evt.kind === "retrieved")
861
+ retrievedOnDate.add(evt.correction_id);
862
+ if (COVERED_KINDS.has(evt.kind))
863
+ coveredOnDate.add(evt.correction_id);
864
+ }
865
+ // Unknown = retrieved on targetDay but NOT covered on targetDay
866
+ const unknownIds = [...retrievedOnDate].filter((id) => !coveredOnDate.has(id));
867
+ if (unknownIds.length === 0)
868
+ return [];
869
+ // Resolve correction records
870
+ const allCorrections = readCorrections(project);
871
+ const recordById = new Map(allCorrections.map((r) => [r.id, r]));
872
+ // Resolve journal file paths for targetDay
873
+ const root = getRoot();
874
+ const safe = (project || "unnamed")
875
+ .replace(/[^a-zA-Z0-9_\-]/g, "-")
876
+ .replace(/^-+|-+$/g, "")
877
+ .slice(0, 100) || "unnamed";
878
+ const jDir = path.join(root, "projects", safe, "journal");
879
+ const journalPaths = [];
880
+ if (fs.existsSync(jDir)) {
881
+ try {
882
+ const files = fs.readdirSync(jDir);
883
+ for (const f of files) {
884
+ if (f.endsWith(".md") &&
885
+ f !== "index.md" &&
886
+ !f.includes("-log.md") &&
887
+ !f.includes("--capture--") &&
888
+ f.startsWith(targetDay)) {
889
+ journalPaths.push(path.join(jDir, f));
890
+ }
891
+ }
892
+ }
893
+ catch {
894
+ // non-fatal
895
+ }
896
+ }
897
+ const results = [];
898
+ for (const id of unknownIds) {
899
+ const rec = recordById.get(id);
900
+ if (!rec)
901
+ continue; // orphan — no current record; skip
902
+ results.push({
903
+ id: rec.id,
904
+ rule: rec.rule,
905
+ severity: rec.severity,
906
+ tags: rec.tags ?? [],
907
+ retrieved_date: targetDay,
908
+ journal_file_paths: journalPaths,
909
+ });
910
+ }
911
+ return results;
272
912
  }
273
913
  /**
274
914
  * Aggregate KPIs over all corrections for a project — the "is this learning loop working?" view.
915
+ * C3 (2026-07-03): adds verdict_coverage, triggered_count, unknown_count, not_triggered_count.
275
916
  */
276
917
  export function getCorrectionKPIs(project) {
277
918
  const all = readCorrections(project);
@@ -294,6 +935,45 @@ export function getCorrectionKPIs(project) {
294
935
  hot.push({ id: r.id, rule: r.rule, precision: p, retrieved: ret });
295
936
  }
296
937
  }
938
+ const nowMs = Date.now();
939
+ const stale = [];
940
+ for (const r of active) {
941
+ if (isStaleCorrection(r, nowMs)) {
942
+ stale.push({ id: r.id, rule: r.rule, last_seen: r.last_retrieved ?? r.last_outcome ?? r.date });
943
+ }
944
+ }
945
+ // C3: verdict_coverage — CANONICAL DEFINITION, mirrored verbatim by
946
+ // buildVerdictLedger in scripts/eval/rmr-report.mjs. Change one → change both
947
+ // (cross-consistency test: c3-heed-instrumentation.test.mjs asserts they agree).
948
+ // injected = CURRENT correction records with retrieved_count > 0
949
+ // covered = injected ids whose outcome kinds include heeded | recurred | not_triggered
950
+ // verdict_coverage = covered / injected (bounded [0,1] by construction —
951
+ // per-id membership, not per-verdict counting; orphan outcome ids whose
952
+ // record no longer exists are dropped, they can never inflate the numerator)
953
+ const allOutcomeKinds = readAllOutcomeKinds(project);
954
+ const injectedIds = new Set(all.filter((r) => (r.retrieved_count ?? 0) > 0).map((r) => r.id));
955
+ let coveredIds = 0;
956
+ for (const id of injectedIds) {
957
+ const kinds = allOutcomeKinds.get(id);
958
+ if (kinds && (kinds.has("heeded") || kinds.has("recurred") || kinds.has("not_triggered"))) {
959
+ coveredIds++;
960
+ }
961
+ }
962
+ // Informational counters stay GLOBAL (all outcome ids, orphans included) —
963
+ // they are observability tallies, not coverage-numerator components.
964
+ let triggeredCount = 0;
965
+ let unknownCount = 0;
966
+ let notTriggeredCount = 0;
967
+ for (const kinds of allOutcomeKinds.values()) {
968
+ if (kinds.has("triggered"))
969
+ triggeredCount++;
970
+ if (kinds.has("unknown"))
971
+ unknownCount++;
972
+ if (kinds.has("not_triggered"))
973
+ notTriggeredCount++;
974
+ }
975
+ const injectedCount = injectedIds.size;
976
+ const verdictCoverage = injectedCount > 0 ? Number((coveredIds / injectedCount).toFixed(4)) : null;
297
977
  return {
298
978
  project,
299
979
  total: all.length,
@@ -304,6 +984,56 @@ export function getCorrectionKPIs(project) {
304
984
  precision: retrieved > 0 ? Math.min(1, Number((heeded / retrieved).toFixed(3))) : NaN,
305
985
  noise_candidates: noise,
306
986
  high_signal: hot,
987
+ stale_candidates: stale,
988
+ verdict_coverage: verdictCoverage,
989
+ triggered_count: triggeredCount,
990
+ unknown_count: unknownCount,
991
+ not_triggered_count: notTriggeredCount,
992
+ };
993
+ }
994
+ /**
995
+ * P4: review low-signal corrections for archiving. SUGGEST-ONLY by default —
996
+ * returns candidates and mutates NOTHING. Set AR_CONSOLIDATE_AUTO=1 (or pass
997
+ * { auto: true }) to actually retract them. This mirrors AR's conservative
998
+ * posture: deleting belief is a deliberate act, so the default never mutates;
999
+ * an explicit human (or opt-in flag) triggers the retraction.
1000
+ */
1001
+ export function reviewNoiseCorrections(project, opts) {
1002
+ const auto = opts?.auto ?? (process.env.AR_CONSOLIDATE_AUTO === "1");
1003
+ const suggestions = getCorrectionKPIs(project).noise_candidates;
1004
+ const pruned = [];
1005
+ if (auto) {
1006
+ for (const c of suggestions) {
1007
+ const res = retractCorrection(project, c.id, "auto-pruned: low signal (precision<0.3, retrieved≥3)");
1008
+ if (res.success)
1009
+ pruned.push(c.id);
1010
+ }
1011
+ }
1012
+ return { suggestions, pruned, auto };
1013
+ }
1014
+ /**
1015
+ * P5: order corrections for surfacing when a cap applies. Today P0s are surfaced
1016
+ * `slice(0, 10)` in newest-first FILENAME order — so when a project has >10 P0s
1017
+ * the ones that survive are arbitrary (just the most-recently-dated). This ranks
1018
+ * by a composite LOCAL score (NO key, NO network) so the most authoritative +
1019
+ * evidence-backed + recently-relevant rules win the cap:
1020
+ * severity (p0 always above p1) ≫ proof_confidence ≫ recency ≫ proof_count.
1021
+ * Deterministic and stable; pure (Date.now only for recency decay).
1022
+ */
1023
+ export function rankCorrections(records, limit) {
1024
+ const nowMs = Date.now();
1025
+ const scoreOf = (r) => {
1026
+ const sev = r.severity === "p0" ? 1 : 0;
1027
+ const conf = r.proof_confidence ?? r.weight ?? 0;
1028
+ const touch = r.last_retrieved ?? r.last_outcome ?? r.date;
1029
+ const t = new Date(touch).getTime();
1030
+ const days = Number.isNaN(t) ? 9999 : Math.max(0, (nowMs - t) / (24 * 60 * 60 * 1000));
1031
+ const recency = Math.exp(-days / 180); // slow decay, matches knowledge half-life
1032
+ const proof = Math.min(1, (r.proof_count ?? 1) / 5);
1033
+ // severity dominates the ordering; the rest breaks ties within a severity tier.
1034
+ return sev * 100 + conf * 10 + recency * 3 + proof;
307
1035
  };
1036
+ const sorted = [...records].sort((a, b) => scoreOf(b) - scoreOf(a));
1037
+ return limit !== undefined ? sorted.slice(0, limit) : sorted;
308
1038
  }
309
1039
  //# sourceMappingURL=corrections.js.map