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.
- package/README.md +121 -441
- package/dist/digest/store.d.ts.map +1 -1
- package/dist/digest/store.js +4 -3
- package/dist/digest/store.js.map +1 -1
- package/dist/display/board-render.d.ts +61 -0
- package/dist/display/board-render.d.ts.map +1 -0
- package/dist/display/board-render.js +223 -0
- package/dist/display/board-render.js.map +1 -0
- package/dist/helpers/alignment-patterns.d.ts +10 -0
- package/dist/helpers/alignment-patterns.d.ts.map +1 -1
- package/dist/helpers/alignment-patterns.js +5 -1
- package/dist/helpers/alignment-patterns.js.map +1 -1
- package/dist/helpers/blind-spots.d.ts +86 -0
- package/dist/helpers/blind-spots.d.ts.map +1 -0
- package/dist/helpers/blind-spots.js +205 -0
- package/dist/helpers/blind-spots.js.map +1 -0
- package/dist/helpers/conflict-scan.d.ts +10 -0
- package/dist/helpers/conflict-scan.d.ts.map +1 -1
- package/dist/helpers/conflict-scan.js +3 -3
- package/dist/helpers/conflict-scan.js.map +1 -1
- package/dist/helpers/semantic-match.d.ts +82 -0
- package/dist/helpers/semantic-match.d.ts.map +1 -0
- package/dist/helpers/semantic-match.js +229 -0
- package/dist/helpers/semantic-match.js.map +1 -0
- package/dist/index.d.ts +57 -17
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +60 -14
- package/dist/index.js.map +1 -1
- package/dist/palace/awareness.d.ts +30 -0
- package/dist/palace/awareness.d.ts.map +1 -1
- package/dist/palace/awareness.js +65 -2
- package/dist/palace/awareness.js.map +1 -1
- package/dist/palace/consolidate.d.ts.map +1 -1
- package/dist/palace/consolidate.js +17 -0
- package/dist/palace/consolidate.js.map +1 -1
- package/dist/palace/decay-pass.d.ts +46 -0
- package/dist/palace/decay-pass.d.ts.map +1 -0
- package/dist/palace/decay-pass.js +87 -0
- package/dist/palace/decay-pass.js.map +1 -0
- package/dist/palace/insights-index.d.ts.map +1 -1
- package/dist/palace/insights-index.js +4 -2
- package/dist/palace/insights-index.js.map +1 -1
- package/dist/palace/skills.d.ts +41 -2
- package/dist/palace/skills.d.ts.map +1 -1
- package/dist/palace/skills.js +106 -4
- package/dist/palace/skills.js.map +1 -1
- package/dist/prompts/consolidation-prompt.d.ts +25 -0
- package/dist/prompts/consolidation-prompt.d.ts.map +1 -0
- package/dist/prompts/consolidation-prompt.js +91 -0
- package/dist/prompts/consolidation-prompt.js.map +1 -0
- package/dist/storage/ab-experiment.d.ts +184 -0
- package/dist/storage/ab-experiment.d.ts.map +1 -0
- package/dist/storage/ab-experiment.js +294 -0
- package/dist/storage/ab-experiment.js.map +1 -0
- package/dist/storage/archive-prune.d.ts +56 -0
- package/dist/storage/archive-prune.d.ts.map +1 -0
- package/dist/storage/archive-prune.js +118 -0
- package/dist/storage/archive-prune.js.map +1 -0
- package/dist/storage/archive-write.d.ts +38 -0
- package/dist/storage/archive-write.d.ts.map +1 -0
- package/dist/storage/archive-write.js +98 -0
- package/dist/storage/archive-write.js.map +1 -0
- package/dist/storage/blind-spots-store.d.ts +30 -0
- package/dist/storage/blind-spots-store.d.ts.map +1 -0
- package/dist/storage/blind-spots-store.js +91 -0
- package/dist/storage/blind-spots-store.js.map +1 -0
- package/dist/storage/capture-router.d.ts +60 -0
- package/dist/storage/capture-router.d.ts.map +1 -0
- package/dist/storage/capture-router.js +133 -0
- package/dist/storage/capture-router.js.map +1 -0
- package/dist/storage/classification.d.ts +36 -0
- package/dist/storage/classification.d.ts.map +1 -0
- package/dist/storage/classification.js +78 -0
- package/dist/storage/classification.js.map +1 -0
- package/dist/storage/consolidation-queue.d.ts +39 -0
- package/dist/storage/consolidation-queue.d.ts.map +1 -0
- package/dist/storage/consolidation-queue.js +120 -0
- package/dist/storage/consolidation-queue.js.map +1 -0
- package/dist/storage/content-guard.d.ts +74 -0
- package/dist/storage/content-guard.d.ts.map +1 -0
- package/dist/storage/content-guard.js +179 -0
- package/dist/storage/content-guard.js.map +1 -0
- package/dist/storage/corrections.d.ts +329 -22
- package/dist/storage/corrections.d.ts.map +1 -1
- package/dist/storage/corrections.js +788 -58
- package/dist/storage/corrections.js.map +1 -1
- package/dist/storage/durable-intent.d.ts +49 -0
- package/dist/storage/durable-intent.d.ts.map +1 -0
- package/dist/storage/durable-intent.js +124 -0
- package/dist/storage/durable-intent.js.map +1 -0
- package/dist/storage/filelock.d.ts +6 -0
- package/dist/storage/filelock.d.ts.map +1 -1
- package/dist/storage/filelock.js +6 -1
- package/dist/storage/filelock.js.map +1 -1
- package/dist/storage/memory-protocol.d.ts +19 -0
- package/dist/storage/memory-protocol.d.ts.map +1 -0
- package/dist/storage/memory-protocol.js +101 -0
- package/dist/storage/memory-protocol.js.map +1 -0
- package/dist/storage/paths.d.ts +23 -0
- package/dist/storage/paths.d.ts.map +1 -1
- package/dist/storage/paths.js +39 -0
- package/dist/storage/paths.js.map +1 -1
- package/dist/storage/retention.d.ts +23 -0
- package/dist/storage/retention.d.ts.map +1 -0
- package/dist/storage/retention.js +48 -0
- package/dist/storage/retention.js.map +1 -0
- package/dist/storage/session.d.ts +2 -2
- package/dist/storage/session.d.ts.map +1 -1
- package/dist/storage/session.js +11 -1
- package/dist/storage/session.js.map +1 -1
- package/dist/supabase/config.d.ts +26 -0
- package/dist/supabase/config.d.ts.map +1 -1
- package/dist/supabase/config.js +11 -0
- package/dist/supabase/config.js.map +1 -1
- package/dist/supabase/recall-backend.d.ts +1 -0
- package/dist/supabase/recall-backend.d.ts.map +1 -1
- package/dist/supabase/recall-backend.js +13 -13
- package/dist/supabase/recall-backend.js.map +1 -1
- package/dist/supabase/sync.d.ts +1 -1
- package/dist/supabase/sync.d.ts.map +1 -1
- package/dist/supabase/sync.js +85 -3
- package/dist/supabase/sync.js.map +1 -1
- package/dist/tools-logic/bootstrap.d.ts +15 -0
- package/dist/tools-logic/bootstrap.d.ts.map +1 -1
- package/dist/tools-logic/bootstrap.js +238 -47
- package/dist/tools-logic/bootstrap.js.map +1 -1
- package/dist/tools-logic/brief.d.ts +84 -0
- package/dist/tools-logic/brief.d.ts.map +1 -0
- package/dist/tools-logic/brief.js +193 -0
- package/dist/tools-logic/brief.js.map +1 -0
- package/dist/tools-logic/check-action.d.ts +9 -0
- package/dist/tools-logic/check-action.d.ts.map +1 -1
- package/dist/tools-logic/check-action.js +61 -3
- package/dist/tools-logic/check-action.js.map +1 -1
- package/dist/tools-logic/check.d.ts +7 -0
- package/dist/tools-logic/check.d.ts.map +1 -1
- package/dist/tools-logic/check.js +24 -2
- package/dist/tools-logic/check.js.map +1 -1
- package/dist/tools-logic/confidence.d.ts +50 -0
- package/dist/tools-logic/confidence.d.ts.map +1 -0
- package/dist/tools-logic/confidence.js +70 -0
- package/dist/tools-logic/confidence.js.map +1 -0
- package/dist/tools-logic/drill-down.d.ts +37 -0
- package/dist/tools-logic/drill-down.d.ts.map +1 -0
- package/dist/tools-logic/drill-down.js +63 -0
- package/dist/tools-logic/drill-down.js.map +1 -0
- package/dist/tools-logic/export-corrections.d.ts +63 -0
- package/dist/tools-logic/export-corrections.d.ts.map +1 -0
- package/dist/tools-logic/export-corrections.js +124 -0
- package/dist/tools-logic/export-corrections.js.map +1 -0
- package/dist/tools-logic/journal-capture.d.ts.map +1 -1
- package/dist/tools-logic/journal-capture.js +4 -1
- package/dist/tools-logic/journal-capture.js.map +1 -1
- package/dist/tools-logic/journal-write.d.ts.map +1 -1
- package/dist/tools-logic/journal-write.js +3 -2
- package/dist/tools-logic/journal-write.js.map +1 -1
- package/dist/tools-logic/local-archive-backend.d.ts +33 -0
- package/dist/tools-logic/local-archive-backend.d.ts.map +1 -0
- package/dist/tools-logic/local-archive-backend.js +124 -0
- package/dist/tools-logic/local-archive-backend.js.map +1 -0
- package/dist/tools-logic/memory-backend.d.ts +74 -0
- package/dist/tools-logic/memory-backend.d.ts.map +1 -0
- package/dist/tools-logic/memory-backend.js +172 -0
- package/dist/tools-logic/memory-backend.js.map +1 -0
- package/dist/tools-logic/memory-query.d.ts +10 -3
- package/dist/tools-logic/memory-query.d.ts.map +1 -1
- package/dist/tools-logic/memory-query.js +31 -7
- package/dist/tools-logic/memory-query.js.map +1 -1
- package/dist/tools-logic/mirror-builder.d.ts +115 -0
- package/dist/tools-logic/mirror-builder.d.ts.map +1 -0
- package/dist/tools-logic/mirror-builder.js +348 -0
- package/dist/tools-logic/mirror-builder.js.map +1 -0
- package/dist/tools-logic/palace-write.d.ts.map +1 -1
- package/dist/tools-logic/palace-write.js +3 -2
- package/dist/tools-logic/palace-write.js.map +1 -1
- package/dist/tools-logic/pipeline-close.d.ts.map +1 -1
- package/dist/tools-logic/pipeline-close.js +2 -1
- package/dist/tools-logic/pipeline-close.js.map +1 -1
- package/dist/tools-logic/pipeline-open.d.ts.map +1 -1
- package/dist/tools-logic/pipeline-open.js +2 -1
- package/dist/tools-logic/pipeline-open.js.map +1 -1
- package/dist/tools-logic/predict-correction.d.ts +47 -0
- package/dist/tools-logic/predict-correction.d.ts.map +1 -0
- package/dist/tools-logic/predict-correction.js +137 -0
- package/dist/tools-logic/predict-correction.js.map +1 -0
- package/dist/tools-logic/prior-builder.d.ts +27 -0
- package/dist/tools-logic/prior-builder.d.ts.map +1 -0
- package/dist/tools-logic/prior-builder.js +90 -0
- package/dist/tools-logic/prior-builder.js.map +1 -0
- package/dist/tools-logic/recognition-builder.d.ts +122 -0
- package/dist/tools-logic/recognition-builder.d.ts.map +1 -0
- package/dist/tools-logic/recognition-builder.js +289 -0
- package/dist/tools-logic/recognition-builder.js.map +1 -0
- package/dist/tools-logic/safety-consolidation.d.ts +104 -0
- package/dist/tools-logic/safety-consolidation.d.ts.map +1 -0
- package/dist/tools-logic/safety-consolidation.js +256 -0
- package/dist/tools-logic/safety-consolidation.js.map +1 -0
- package/dist/tools-logic/session-end-reflect.d.ts +18 -0
- package/dist/tools-logic/session-end-reflect.d.ts.map +1 -1
- package/dist/tools-logic/session-end-reflect.js +69 -30
- package/dist/tools-logic/session-end-reflect.js.map +1 -1
- package/dist/tools-logic/session-end.d.ts +18 -0
- package/dist/tools-logic/session-end.d.ts.map +1 -1
- package/dist/tools-logic/session-end.js +189 -34
- package/dist/tools-logic/session-end.js.map +1 -1
- package/dist/tools-logic/session-start-lite.d.ts +2 -0
- package/dist/tools-logic/session-start-lite.d.ts.map +1 -1
- package/dist/tools-logic/session-start-lite.js +11 -2
- package/dist/tools-logic/session-start-lite.js.map +1 -1
- package/dist/tools-logic/session-start.d.ts +81 -2
- package/dist/tools-logic/session-start.d.ts.map +1 -1
- package/dist/tools-logic/session-start.js +357 -40
- package/dist/tools-logic/session-start.js.map +1 -1
- package/dist/tools-logic/skill-propose.d.ts +27 -0
- package/dist/tools-logic/skill-propose.d.ts.map +1 -0
- package/dist/tools-logic/skill-propose.js +66 -0
- package/dist/tools-logic/skill-propose.js.map +1 -0
- package/dist/tools-logic/skill-recall.d.ts +5 -0
- package/dist/tools-logic/skill-recall.d.ts.map +1 -1
- package/dist/tools-logic/skill-recall.js +14 -1
- package/dist/tools-logic/skill-recall.js.map +1 -1
- package/dist/tools-logic/smart-recall.d.ts +17 -0
- package/dist/tools-logic/smart-recall.d.ts.map +1 -1
- package/dist/tools-logic/smart-recall.js +51 -16
- package/dist/tools-logic/smart-recall.js.map +1 -1
- package/dist/tools-logic/smart-remember.js +8 -9
- package/dist/tools-logic/smart-remember.js.map +1 -1
- package/dist/tools-logic/store-doctor.d.ts +71 -0
- package/dist/tools-logic/store-doctor.d.ts.map +1 -0
- package/dist/tools-logic/store-doctor.js +387 -0
- package/dist/tools-logic/store-doctor.js.map +1 -0
- package/dist/tools-logic/store-repair.d.ts +64 -0
- package/dist/tools-logic/store-repair.d.ts.map +1 -0
- package/dist/tools-logic/store-repair.js +243 -0
- package/dist/tools-logic/store-repair.js.map +1 -0
- package/dist/tools-logic/supersession.d.ts +34 -0
- package/dist/tools-logic/supersession.d.ts.map +1 -0
- package/dist/tools-logic/supersession.js +103 -0
- package/dist/tools-logic/supersession.js.map +1 -0
- package/dist/types.d.ts +7 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +1 -1
- package/dist/vector/embedding.d.ts +8 -0
- package/dist/vector/embedding.d.ts.map +1 -1
- package/dist/vector/embedding.js +9 -1
- package/dist/vector/embedding.js.map +1 -1
- package/dist/vector/local-vector-backend.d.ts.map +1 -1
- package/dist/vector/local-vector-backend.js +14 -17
- package/dist/vector/local-vector-backend.js.map +1 -1
- package/package.json +2 -2
- package/dist/relevance/injector.d.ts +0 -51
- package/dist/relevance/injector.d.ts.map +0 -1
- package/dist/relevance/injector.js +0 -121
- package/dist/relevance/injector.js.map +0 -1
- package/dist/relevance/precision.d.ts +0 -109
- package/dist/relevance/precision.d.ts.map +0 -1
- package/dist/relevance/precision.js +0 -279
- package/dist/relevance/precision.js.map +0 -1
- package/dist/relevance/promoter.d.ts +0 -60
- package/dist/relevance/promoter.d.ts.map +0 -1
- package/dist/relevance/promoter.js +0 -336
- package/dist/relevance/promoter.js.map +0 -1
- package/dist/relevance/stager.d.ts +0 -61
- package/dist/relevance/stager.d.ts.map +0 -1
- package/dist/relevance/stager.js +0 -251
- package/dist/relevance/stager.js.map +0 -1
- package/dist/relevance/surfacer.d.ts +0 -34
- package/dist/relevance/surfacer.d.ts.map +0 -1
- package/dist/relevance/surfacer.js +0 -333
- 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
|
|
55
|
-
weight
|
|
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
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
203
|
+
* v3 (2026-06-21, Loop 8): the ACTIONABLE-signal scan now runs over the FULL
|
|
204
|
+
* text AND each decimal-safe sentence fragment — accepting 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
|
-
*
|
|
71
|
-
*
|
|
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
|
-
*
|
|
83
|
-
*
|
|
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 —
|
|
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
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
293
|
+
// Gate 3 — doc / 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
|
-
// (
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
// (
|
|
125
|
-
if (
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
//
|
|
146
|
-
|
|
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
|
-
|
|
159
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|