agent-recall-core 3.4.30 → 3.4.35

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 (251) 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 +52 -11
  26. package/dist/index.d.ts.map +1 -1
  27. package/dist/index.js +48 -9
  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/archive-prune.d.ts +56 -0
  52. package/dist/storage/archive-prune.d.ts.map +1 -0
  53. package/dist/storage/archive-prune.js +118 -0
  54. package/dist/storage/archive-prune.js.map +1 -0
  55. package/dist/storage/archive-write.d.ts +38 -0
  56. package/dist/storage/archive-write.d.ts.map +1 -0
  57. package/dist/storage/archive-write.js +98 -0
  58. package/dist/storage/archive-write.js.map +1 -0
  59. package/dist/storage/blind-spots-store.d.ts +30 -0
  60. package/dist/storage/blind-spots-store.d.ts.map +1 -0
  61. package/dist/storage/blind-spots-store.js +91 -0
  62. package/dist/storage/blind-spots-store.js.map +1 -0
  63. package/dist/storage/capture-router.d.ts +60 -0
  64. package/dist/storage/capture-router.d.ts.map +1 -0
  65. package/dist/storage/capture-router.js +133 -0
  66. package/dist/storage/capture-router.js.map +1 -0
  67. package/dist/storage/classification.d.ts +32 -0
  68. package/dist/storage/classification.d.ts.map +1 -0
  69. package/dist/storage/classification.js +74 -0
  70. package/dist/storage/classification.js.map +1 -0
  71. package/dist/storage/consolidation-queue.d.ts +39 -0
  72. package/dist/storage/consolidation-queue.d.ts.map +1 -0
  73. package/dist/storage/consolidation-queue.js +120 -0
  74. package/dist/storage/consolidation-queue.js.map +1 -0
  75. package/dist/storage/content-guard.d.ts +74 -0
  76. package/dist/storage/content-guard.d.ts.map +1 -0
  77. package/dist/storage/content-guard.js +179 -0
  78. package/dist/storage/content-guard.js.map +1 -0
  79. package/dist/storage/corrections.d.ts +246 -21
  80. package/dist/storage/corrections.d.ts.map +1 -1
  81. package/dist/storage/corrections.js +605 -56
  82. package/dist/storage/corrections.js.map +1 -1
  83. package/dist/storage/durable-intent.d.ts +49 -0
  84. package/dist/storage/durable-intent.d.ts.map +1 -0
  85. package/dist/storage/durable-intent.js +124 -0
  86. package/dist/storage/durable-intent.js.map +1 -0
  87. package/dist/storage/filelock.d.ts +6 -0
  88. package/dist/storage/filelock.d.ts.map +1 -1
  89. package/dist/storage/filelock.js +6 -1
  90. package/dist/storage/filelock.js.map +1 -1
  91. package/dist/storage/memory-protocol.d.ts +19 -0
  92. package/dist/storage/memory-protocol.d.ts.map +1 -0
  93. package/dist/storage/memory-protocol.js +101 -0
  94. package/dist/storage/memory-protocol.js.map +1 -0
  95. package/dist/storage/paths.d.ts +23 -0
  96. package/dist/storage/paths.d.ts.map +1 -1
  97. package/dist/storage/paths.js +39 -0
  98. package/dist/storage/paths.js.map +1 -1
  99. package/dist/storage/retention.d.ts +23 -0
  100. package/dist/storage/retention.d.ts.map +1 -0
  101. package/dist/storage/retention.js +48 -0
  102. package/dist/storage/retention.js.map +1 -0
  103. package/dist/storage/session.d.ts +2 -2
  104. package/dist/storage/session.d.ts.map +1 -1
  105. package/dist/storage/session.js +11 -1
  106. package/dist/storage/session.js.map +1 -1
  107. package/dist/supabase/config.d.ts +8 -0
  108. package/dist/supabase/config.d.ts.map +1 -1
  109. package/dist/supabase/config.js +5 -0
  110. package/dist/supabase/config.js.map +1 -1
  111. package/dist/supabase/recall-backend.d.ts +1 -0
  112. package/dist/supabase/recall-backend.d.ts.map +1 -1
  113. package/dist/supabase/recall-backend.js +13 -13
  114. package/dist/supabase/recall-backend.js.map +1 -1
  115. package/dist/supabase/sync.d.ts.map +1 -1
  116. package/dist/supabase/sync.js +30 -3
  117. package/dist/supabase/sync.js.map +1 -1
  118. package/dist/tools-logic/bootstrap.d.ts +15 -0
  119. package/dist/tools-logic/bootstrap.d.ts.map +1 -1
  120. package/dist/tools-logic/bootstrap.js +238 -47
  121. package/dist/tools-logic/bootstrap.js.map +1 -1
  122. package/dist/tools-logic/brief.d.ts +84 -0
  123. package/dist/tools-logic/brief.d.ts.map +1 -0
  124. package/dist/tools-logic/brief.js +193 -0
  125. package/dist/tools-logic/brief.js.map +1 -0
  126. package/dist/tools-logic/check-action.d.ts +9 -0
  127. package/dist/tools-logic/check-action.d.ts.map +1 -1
  128. package/dist/tools-logic/check-action.js +31 -2
  129. package/dist/tools-logic/check-action.js.map +1 -1
  130. package/dist/tools-logic/check.d.ts +7 -0
  131. package/dist/tools-logic/check.d.ts.map +1 -1
  132. package/dist/tools-logic/check.js +24 -2
  133. package/dist/tools-logic/check.js.map +1 -1
  134. package/dist/tools-logic/confidence.d.ts +50 -0
  135. package/dist/tools-logic/confidence.d.ts.map +1 -0
  136. package/dist/tools-logic/confidence.js +70 -0
  137. package/dist/tools-logic/confidence.js.map +1 -0
  138. package/dist/tools-logic/drill-down.d.ts +37 -0
  139. package/dist/tools-logic/drill-down.d.ts.map +1 -0
  140. package/dist/tools-logic/drill-down.js +63 -0
  141. package/dist/tools-logic/drill-down.js.map +1 -0
  142. package/dist/tools-logic/export-corrections.d.ts +63 -0
  143. package/dist/tools-logic/export-corrections.d.ts.map +1 -0
  144. package/dist/tools-logic/export-corrections.js +124 -0
  145. package/dist/tools-logic/export-corrections.js.map +1 -0
  146. package/dist/tools-logic/journal-capture.d.ts.map +1 -1
  147. package/dist/tools-logic/journal-capture.js +4 -1
  148. package/dist/tools-logic/journal-capture.js.map +1 -1
  149. package/dist/tools-logic/journal-write.d.ts.map +1 -1
  150. package/dist/tools-logic/journal-write.js +3 -2
  151. package/dist/tools-logic/journal-write.js.map +1 -1
  152. package/dist/tools-logic/memory-query.d.ts +10 -3
  153. package/dist/tools-logic/memory-query.d.ts.map +1 -1
  154. package/dist/tools-logic/memory-query.js +31 -7
  155. package/dist/tools-logic/memory-query.js.map +1 -1
  156. package/dist/tools-logic/mirror-builder.d.ts +115 -0
  157. package/dist/tools-logic/mirror-builder.d.ts.map +1 -0
  158. package/dist/tools-logic/mirror-builder.js +348 -0
  159. package/dist/tools-logic/mirror-builder.js.map +1 -0
  160. package/dist/tools-logic/palace-write.d.ts.map +1 -1
  161. package/dist/tools-logic/palace-write.js +3 -2
  162. package/dist/tools-logic/palace-write.js.map +1 -1
  163. package/dist/tools-logic/pipeline-close.d.ts.map +1 -1
  164. package/dist/tools-logic/pipeline-close.js +2 -1
  165. package/dist/tools-logic/pipeline-close.js.map +1 -1
  166. package/dist/tools-logic/pipeline-open.d.ts.map +1 -1
  167. package/dist/tools-logic/pipeline-open.js +2 -1
  168. package/dist/tools-logic/pipeline-open.js.map +1 -1
  169. package/dist/tools-logic/predict-correction.d.ts +47 -0
  170. package/dist/tools-logic/predict-correction.d.ts.map +1 -0
  171. package/dist/tools-logic/predict-correction.js +137 -0
  172. package/dist/tools-logic/predict-correction.js.map +1 -0
  173. package/dist/tools-logic/prior-builder.d.ts +27 -0
  174. package/dist/tools-logic/prior-builder.d.ts.map +1 -0
  175. package/dist/tools-logic/prior-builder.js +57 -0
  176. package/dist/tools-logic/prior-builder.js.map +1 -0
  177. package/dist/tools-logic/recognition-builder.d.ts +116 -0
  178. package/dist/tools-logic/recognition-builder.d.ts.map +1 -0
  179. package/dist/tools-logic/recognition-builder.js +289 -0
  180. package/dist/tools-logic/recognition-builder.js.map +1 -0
  181. package/dist/tools-logic/safety-consolidation.d.ts +104 -0
  182. package/dist/tools-logic/safety-consolidation.d.ts.map +1 -0
  183. package/dist/tools-logic/safety-consolidation.js +256 -0
  184. package/dist/tools-logic/safety-consolidation.js.map +1 -0
  185. package/dist/tools-logic/session-end-reflect.d.ts +18 -0
  186. package/dist/tools-logic/session-end-reflect.d.ts.map +1 -1
  187. package/dist/tools-logic/session-end-reflect.js +68 -29
  188. package/dist/tools-logic/session-end-reflect.js.map +1 -1
  189. package/dist/tools-logic/session-end.d.ts +9 -0
  190. package/dist/tools-logic/session-end.d.ts.map +1 -1
  191. package/dist/tools-logic/session-end.js +111 -13
  192. package/dist/tools-logic/session-end.js.map +1 -1
  193. package/dist/tools-logic/session-start-lite.d.ts +2 -0
  194. package/dist/tools-logic/session-start-lite.d.ts.map +1 -1
  195. package/dist/tools-logic/session-start-lite.js +10 -0
  196. package/dist/tools-logic/session-start-lite.js.map +1 -1
  197. package/dist/tools-logic/session-start.d.ts +46 -0
  198. package/dist/tools-logic/session-start.d.ts.map +1 -1
  199. package/dist/tools-logic/session-start.js +96 -3
  200. package/dist/tools-logic/session-start.js.map +1 -1
  201. package/dist/tools-logic/skill-propose.d.ts +27 -0
  202. package/dist/tools-logic/skill-propose.d.ts.map +1 -0
  203. package/dist/tools-logic/skill-propose.js +66 -0
  204. package/dist/tools-logic/skill-propose.js.map +1 -0
  205. package/dist/tools-logic/skill-recall.d.ts +5 -0
  206. package/dist/tools-logic/skill-recall.d.ts.map +1 -1
  207. package/dist/tools-logic/skill-recall.js +14 -1
  208. package/dist/tools-logic/skill-recall.js.map +1 -1
  209. package/dist/tools-logic/smart-recall.d.ts +17 -0
  210. package/dist/tools-logic/smart-recall.d.ts.map +1 -1
  211. package/dist/tools-logic/smart-recall.js +51 -16
  212. package/dist/tools-logic/smart-recall.js.map +1 -1
  213. package/dist/tools-logic/store-doctor.d.ts +71 -0
  214. package/dist/tools-logic/store-doctor.d.ts.map +1 -0
  215. package/dist/tools-logic/store-doctor.js +387 -0
  216. package/dist/tools-logic/store-doctor.js.map +1 -0
  217. package/dist/tools-logic/store-repair.d.ts +64 -0
  218. package/dist/tools-logic/store-repair.d.ts.map +1 -0
  219. package/dist/tools-logic/store-repair.js +243 -0
  220. package/dist/tools-logic/store-repair.js.map +1 -0
  221. package/dist/tools-logic/supersession.d.ts +34 -0
  222. package/dist/tools-logic/supersession.d.ts.map +1 -0
  223. package/dist/tools-logic/supersession.js +103 -0
  224. package/dist/tools-logic/supersession.js.map +1 -0
  225. package/dist/types.d.ts +7 -1
  226. package/dist/types.d.ts.map +1 -1
  227. package/dist/types.js +1 -1
  228. package/dist/vector/local-vector-backend.d.ts.map +1 -1
  229. package/dist/vector/local-vector-backend.js +14 -17
  230. package/dist/vector/local-vector-backend.js.map +1 -1
  231. package/package.json +9 -1
  232. package/dist/relevance/injector.d.ts +0 -51
  233. package/dist/relevance/injector.d.ts.map +0 -1
  234. package/dist/relevance/injector.js +0 -121
  235. package/dist/relevance/injector.js.map +0 -1
  236. package/dist/relevance/precision.d.ts +0 -109
  237. package/dist/relevance/precision.d.ts.map +0 -1
  238. package/dist/relevance/precision.js +0 -279
  239. package/dist/relevance/precision.js.map +0 -1
  240. package/dist/relevance/promoter.d.ts +0 -60
  241. package/dist/relevance/promoter.d.ts.map +0 -1
  242. package/dist/relevance/promoter.js +0 -336
  243. package/dist/relevance/promoter.js.map +0 -1
  244. package/dist/relevance/stager.d.ts +0 -61
  245. package/dist/relevance/stager.d.ts.map +0 -1
  246. package/dist/relevance/stager.js +0 -251
  247. package/dist/relevance/stager.js.map +0 -1
  248. package/dist/relevance/surfacer.d.ts +0 -34
  249. package/dist/relevance/surfacer.d.ts.map +0 -1
  250. package/dist/relevance/surfacer.js +0 -333
  251. 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,13 +482,13 @@ 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
  /**
@@ -257,18 +524,247 @@ export function recordOutcome(outcome) {
257
524
  updated.recurrence_count = (updated.recurrence_count ?? 0) + 1;
258
525
  updated.last_outcome = outcome.at;
259
526
  }
527
+ else if (outcome.kind === "predicted") {
528
+ // Wave 5: prediction fired — instrument the predict-the-correction loop.
529
+ updated.predicted_count = (updated.predicted_count ?? 0) + 1;
530
+ updated.last_predicted = outcome.at;
531
+ }
532
+ else if (outcome.kind === "predict_hit") {
533
+ updated.predict_hits = (updated.predict_hits ?? 0) + 1;
534
+ }
260
535
  const r = updated.retrieved_count ?? 0;
261
536
  // Clamp to [0,1]: `retrieved` is guarded 1/day but `heeded` can fire on every
262
537
  // session_end, so raw heeded/retrieved can exceed 1.0 ("150% heeded" is
263
538
  // nonsense). min(1, …) keeps the metric honest. (Root-cause follow-up: apply
264
539
  // the same 1/day guard to heeded as retrieved has, for finer resolution.)
540
+ // NB (Wave 5): `precision` is heeded/retrieved ONLY — predict_* never touch it.
265
541
  updated.precision = r > 0 ? Math.min(1, Number(((updated.heeded_count ?? 0) / r).toFixed(3))) : undefined;
542
+ // Wave 5: predict_precision = predict_hits / predicted_count, kept SEPARATE
543
+ // from `precision`. Undefined until at least one prediction has fired.
544
+ // A predict_hit implies a prior prediction. If data is inconsistent (hits
545
+ // recorded without a matching predicted_count — e.g. migrated/corrupt records),
546
+ // floor the denominator at predict_hits so the metric stays VISIBLE and bounded
547
+ // rather than silently undefined while hits exist.
548
+ const pc = updated.predicted_count ?? 0;
549
+ const ph = updated.predict_hits ?? 0;
550
+ const predictDenom = Math.max(pc, ph);
551
+ updated.predict_precision = predictDenom > 0
552
+ ? Math.min(1, Number((ph / predictDenom).toFixed(3)))
553
+ : undefined;
554
+ // P3: evidence-grounded proof_confidence. With NO outcome evidence yet, keep the
555
+ // authority prior (weight); once heeded/recurrence accrue, move to the Beta
556
+ // posterior so a rule that keeps being honored strengthens and one whose bug
557
+ // keeps recurring weakens. Kept SEPARATE from `precision` (heeded/retrieved) and
558
+ // from `weight` (static authority) — this is the evidence axis.
559
+ const heededC = updated.heeded_count ?? 0;
560
+ const recurC = updated.recurrence_count ?? 0;
561
+ updated.proof_confidence = (heededC + recurC) > 0
562
+ ? Number(betaPosterior(heededC, recurC).toFixed(3))
563
+ : (updated.weight ?? defaultWeight(updated.severity));
266
564
  // Re-write the JSON file atomically (tmp + rename — prevents truncation on SIGTERM).
267
565
  const filename = `${updated.date}-${slugify(updated.rule || updated.id)}.json`;
268
566
  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);
567
+ writeRecordAtomic(filepath, updated);
568
+ }
569
+ /**
570
+ * Best-effort: append one row to corrections/_rejected.jsonl recording a
571
+ * gate-rejected correction candidate. INVARIANT: never throws — every fs op is
572
+ * wrapped so a rejection log can never escalate into the capture path. Reads
573
+ * nothing on the hot path except the (already-small) file it rotates.
574
+ *
575
+ * Rotation: when the file exceeds REJECTED_LOG_CAP rows, it is rewritten with
576
+ * only the most-recent rows (append-only semantics, bounded size). Rotation is
577
+ * itself best-effort — a rotation failure still leaves the append intact.
578
+ */
579
+ export function logRejectedCorrection(project, rule, reason) {
580
+ try {
581
+ const dir = correctionsDir(project);
582
+ ensureDir(dir);
583
+ const row = {
584
+ ts: new Date().toISOString(),
585
+ project,
586
+ rule,
587
+ reason,
588
+ gate_version: GATE_VERSION,
589
+ };
590
+ const p = rejectedPath(project);
591
+ fs.appendFileSync(p, JSON.stringify(row) + "\n", "utf-8");
592
+ // Bounded rotation — keep only the most-recent rows. Best-effort: if any
593
+ // step throws, the append above already succeeded and we simply skip trim.
594
+ try {
595
+ const raw = fs.readFileSync(p, "utf-8");
596
+ const lines = raw.split("\n").filter((l) => l.trim());
597
+ if (lines.length > REJECTED_LOG_CAP) {
598
+ const kept = lines.slice(-REJECTED_LOG_CAP).join("\n") + "\n";
599
+ const tmp = `${p}.tmp.${process.pid}.${Date.now()}`;
600
+ fs.writeFileSync(tmp, kept, { encoding: "utf-8", mode: 0o600 });
601
+ fs.renameSync(tmp, p);
602
+ }
603
+ }
604
+ catch {
605
+ /* rotation is best-effort — append already landed */
606
+ }
607
+ }
608
+ catch {
609
+ /* a rejection log can NEVER throw into the capture path */
610
+ }
611
+ }
612
+ /**
613
+ * Read all rejected correction candidates for a project, oldest-first (file
614
+ * order). Returns [] when no log exists — never throws. Skips malformed lines.
615
+ */
616
+ export function readRejectedCorrections(project) {
617
+ const p = rejectedPath(project);
618
+ if (!fs.existsSync(p))
619
+ return [];
620
+ let raw;
621
+ try {
622
+ raw = fs.readFileSync(p, "utf-8");
623
+ }
624
+ catch {
625
+ return [];
626
+ }
627
+ const out = [];
628
+ for (const line of raw.split("\n")) {
629
+ const trimmed = line.trim();
630
+ if (!trimmed)
631
+ continue;
632
+ try {
633
+ const rec = JSON.parse(trimmed);
634
+ if (rec && typeof rec.rule === "string" && typeof rec.reason === "string") {
635
+ out.push(rec);
636
+ }
637
+ }
638
+ catch {
639
+ /* skip malformed line */
640
+ }
641
+ }
642
+ return out;
643
+ }
644
+ /**
645
+ * Aggregate the rejected log into discard count + per-reason breakdown. When
646
+ * `acceptedCount` is supplied the discard RATE is computed too. Read-only.
647
+ */
648
+ export function getRejectedStats(project, acceptedCount) {
649
+ const rows = readRejectedCorrections(project);
650
+ const byReason = new Map();
651
+ for (const r of rows) {
652
+ byReason.set(r.reason, (byReason.get(r.reason) ?? 0) + 1);
653
+ }
654
+ const top_reasons = [...byReason.entries()]
655
+ .map(([reason, count]) => ({ reason, count }))
656
+ .sort((a, b) => b.count - a.count);
657
+ const discarded = rows.length;
658
+ const denom = acceptedCount !== undefined ? discarded + acceptedCount : undefined;
659
+ return {
660
+ project,
661
+ discarded,
662
+ accepted: acceptedCount,
663
+ rate: denom && denom > 0 ? Number((discarded / denom).toFixed(4)) : undefined,
664
+ top_reasons,
665
+ };
666
+ }
667
+ /**
668
+ * Internal: bucket _outcomes.jsonl events per correction id, keeping only the
669
+ * lines for which `keep(localDay)` returns true. `localDay` is the event's
670
+ * local-TZ date (`sv` locale → YYYY-MM-DD), matching the 1/day guards elsewhere.
671
+ * Returns an empty Map when no log exists — never throws.
672
+ *
673
+ * This is the single parsing core shared by readOutcomesForToday /
674
+ * readOutcomesBefore / readOutcomesOnDate so all three agree on date handling.
675
+ */
676
+ function bucketOutcomesBy(project, keep) {
677
+ const map = new Map();
678
+ const p = outcomesPath(project);
679
+ if (!fs.existsSync(p))
680
+ return map;
681
+ let raw;
682
+ try {
683
+ raw = fs.readFileSync(p, "utf-8");
684
+ }
685
+ catch {
686
+ return map;
687
+ }
688
+ for (const line of raw.split("\n")) {
689
+ const trimmed = line.trim();
690
+ if (!trimmed)
691
+ continue;
692
+ let evt;
693
+ try {
694
+ evt = JSON.parse(trimmed);
695
+ }
696
+ catch {
697
+ continue; // skip malformed lines
698
+ }
699
+ if (!evt || !evt.correction_id || !evt.at)
700
+ continue;
701
+ let day;
702
+ try {
703
+ day = new Date(evt.at).toLocaleDateString("sv");
704
+ }
705
+ catch {
706
+ continue;
707
+ }
708
+ if (!keep(day))
709
+ continue;
710
+ let set = map.get(evt.correction_id);
711
+ if (!set) {
712
+ set = new Set();
713
+ map.set(evt.correction_id, set);
714
+ }
715
+ set.add(evt.kind);
716
+ }
717
+ return map;
718
+ }
719
+ /**
720
+ * Wave 5 — single source for "what outcomes already fired today" across the
721
+ * predict / check-action / session-start / session-end call sites. Reads the
722
+ * _outcomes.jsonl audit trail and buckets today's events (local-TZ) per
723
+ * correction id. Returns an empty Map when no log exists — never throws.
724
+ *
725
+ * Local-TZ date (`sv` locale → YYYY-MM-DD) matches the 1/day guards elsewhere
726
+ * (session-start/session-end) so "today" agrees across all four readers.
727
+ */
728
+ export function readOutcomesForToday(project) {
729
+ const todayStr = new Date().toLocaleDateString("sv");
730
+ return bucketOutcomesBy(project, (day) => day === todayStr);
731
+ }
732
+ /**
733
+ * Loop 3 — bucket outcome events recorded STRICTLY BEFORE a given ISO/date
734
+ * cutoff (local-TZ day comparison). Mirrors readOutcomesForToday but with an
735
+ * explicit date arg, so the cross-day predict_hit path can ask "was this risk
736
+ * already PREDICTED on an earlier day?" without depending on today's bucket.
737
+ *
738
+ * `isoCutoff` may be a full ISO timestamp or a YYYY-MM-DD date; only its
739
+ * local-TZ day is used. An event on the SAME day as the cutoff is EXCLUDED
740
+ * (strictly-before) — this is what keeps a same-session/same-day prediction
741
+ * from ever counting as a cross-day hit.
742
+ */
743
+ export function readOutcomesBefore(project, isoCutoff) {
744
+ let cutoffDay;
745
+ try {
746
+ cutoffDay = new Date(isoCutoff).toLocaleDateString("sv");
747
+ }
748
+ catch {
749
+ return new Map();
750
+ }
751
+ return bucketOutcomesBy(project, (day) => day < cutoffDay);
752
+ }
753
+ /**
754
+ * Loop 3 — bucket outcome events recorded ON a specific local-TZ day. Mirrors
755
+ * readOutcomesForToday but with an explicit date arg (for replaying a past day
756
+ * in tests / offline analysis). `isoDate` may be a full ISO timestamp or a
757
+ * YYYY-MM-DD date; only its local-TZ day is used.
758
+ */
759
+ export function readOutcomesOnDate(project, isoDate) {
760
+ let onDay;
761
+ try {
762
+ onDay = new Date(isoDate).toLocaleDateString("sv");
763
+ }
764
+ catch {
765
+ return new Map();
766
+ }
767
+ return bucketOutcomesBy(project, (day) => day === onDay);
272
768
  }
273
769
  /**
274
770
  * Aggregate KPIs over all corrections for a project — the "is this learning loop working?" view.
@@ -294,6 +790,13 @@ export function getCorrectionKPIs(project) {
294
790
  hot.push({ id: r.id, rule: r.rule, precision: p, retrieved: ret });
295
791
  }
296
792
  }
793
+ const nowMs = Date.now();
794
+ const stale = [];
795
+ for (const r of active) {
796
+ if (isStaleCorrection(r, nowMs)) {
797
+ stale.push({ id: r.id, rule: r.rule, last_seen: r.last_retrieved ?? r.last_outcome ?? r.date });
798
+ }
799
+ }
297
800
  return {
298
801
  project,
299
802
  total: all.length,
@@ -304,6 +807,52 @@ export function getCorrectionKPIs(project) {
304
807
  precision: retrieved > 0 ? Math.min(1, Number((heeded / retrieved).toFixed(3))) : NaN,
305
808
  noise_candidates: noise,
306
809
  high_signal: hot,
810
+ stale_candidates: stale,
811
+ };
812
+ }
813
+ /**
814
+ * P4: review low-signal corrections for archiving. SUGGEST-ONLY by default —
815
+ * returns candidates and mutates NOTHING. Set AR_CONSOLIDATE_AUTO=1 (or pass
816
+ * { auto: true }) to actually retract them. This mirrors AR's conservative
817
+ * posture: deleting belief is a deliberate act, so the default never mutates;
818
+ * an explicit human (or opt-in flag) triggers the retraction.
819
+ */
820
+ export function reviewNoiseCorrections(project, opts) {
821
+ const auto = opts?.auto ?? (process.env.AR_CONSOLIDATE_AUTO === "1");
822
+ const suggestions = getCorrectionKPIs(project).noise_candidates;
823
+ const pruned = [];
824
+ if (auto) {
825
+ for (const c of suggestions) {
826
+ const res = retractCorrection(project, c.id, "auto-pruned: low signal (precision<0.3, retrieved≥3)");
827
+ if (res.success)
828
+ pruned.push(c.id);
829
+ }
830
+ }
831
+ return { suggestions, pruned, auto };
832
+ }
833
+ /**
834
+ * P5: order corrections for surfacing when a cap applies. Today P0s are surfaced
835
+ * `slice(0, 10)` in newest-first FILENAME order — so when a project has >10 P0s
836
+ * the ones that survive are arbitrary (just the most-recently-dated). This ranks
837
+ * by a composite LOCAL score (NO key, NO network) so the most authoritative +
838
+ * evidence-backed + recently-relevant rules win the cap:
839
+ * severity (p0 always above p1) ≫ proof_confidence ≫ recency ≫ proof_count.
840
+ * Deterministic and stable; pure (Date.now only for recency decay).
841
+ */
842
+ export function rankCorrections(records, limit) {
843
+ const nowMs = Date.now();
844
+ const scoreOf = (r) => {
845
+ const sev = r.severity === "p0" ? 1 : 0;
846
+ const conf = r.proof_confidence ?? r.weight ?? 0;
847
+ const touch = r.last_retrieved ?? r.last_outcome ?? r.date;
848
+ const t = new Date(touch).getTime();
849
+ const days = Number.isNaN(t) ? 9999 : Math.max(0, (nowMs - t) / (24 * 60 * 60 * 1000));
850
+ const recency = Math.exp(-days / 180); // slow decay, matches knowledge half-life
851
+ const proof = Math.min(1, (r.proof_count ?? 1) / 5);
852
+ // severity dominates the ordering; the rest breaks ties within a severity tier.
853
+ return sev * 100 + conf * 10 + recency * 3 + proof;
307
854
  };
855
+ const sorted = [...records].sort((a, b) => scoreOf(b) - scoreOf(a));
856
+ return limit !== undefined ? sorted.slice(0, limit) : sorted;
308
857
  }
309
858
  //# sourceMappingURL=corrections.js.map