fraim 2.0.270 → 2.0.271
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/dist/src/cli/commands/add-ide.js +28 -2
- package/dist/src/cli/commands/learning-usage.js +412 -0
- package/dist/src/cli/fraim.js +2 -0
- package/dist/src/core/ai-mentor.js +27 -14
- package/dist/src/core/config-loader.js +48 -3
- package/dist/src/core/fraim-config-schema.generated.js +18 -0
- package/dist/src/core/handoff-contracts.js +37 -1
- package/dist/src/core/job-phases.js +2 -14
- package/dist/src/core/resolve-phase-edge.js +75 -0
- package/dist/src/core/types.js +7 -1
- package/dist/src/core/utils/git-utils.js +24 -14
- package/dist/src/core/utils/project-fraim-paths.js +16 -1
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +8 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +448 -95
- package/dist/src/local-mcp-server/learning-firing-parser.js +247 -0
- package/dist/src/local-mcp-server/learning-usage-analysis.js +347 -0
- package/dist/src/local-mcp-server/learning-usage-attestation.js +191 -0
- package/dist/src/local-mcp-server/learning-usage-projection.js +79 -0
- package/dist/src/local-mcp-server/learning-usage-store.js +417 -0
- package/dist/src/local-mcp-server/stdio-server.js +43 -0
- package/package.json +1 -1
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.extractLegacyTitle = extractLegacyTitle;
|
|
4
|
+
exports.parseFiringSection = parseFiringSection;
|
|
5
|
+
/**
|
|
6
|
+
* Issue #1103 — reading the firing section a retrospective attests.
|
|
7
|
+
*
|
|
8
|
+
* Pure parsing, no I/O and no store access: it turns the markdown of
|
|
9
|
+
* `## Where Past Learnings Actually Fired` into typed items plus an explicit
|
|
10
|
+
* section state, so silence, an unfilled template, and a genuinely empty section
|
|
11
|
+
* are three different answers rather than one.
|
|
12
|
+
*
|
|
13
|
+
* Extracted from `learning-usage-attestation.ts`, which now owns resolving those
|
|
14
|
+
* items to entries and writing the record. The split is along the boundary the
|
|
15
|
+
* file already had: what a retrospective says, versus what that means for the
|
|
16
|
+
* corpus.
|
|
17
|
+
*
|
|
18
|
+
* Matching is deliberately strict elsewhere; extraction here is deliberately
|
|
19
|
+
* generous. R15 requires exact-title matching, and says nothing about how
|
|
20
|
+
* tolerantly the title is read off the line. A narrow extractor would report most
|
|
21
|
+
* of the corpus as unmatched for a formatting reason dressed up as an identity
|
|
22
|
+
* one.
|
|
23
|
+
*/
|
|
24
|
+
const learning_usage_store_1 = require("./learning-usage-store");
|
|
25
|
+
const FIRING_SECTION_HEADING = /^#{1,6}\s+Where Past Learnings Actually Fired\s*$/i;
|
|
26
|
+
const NEXT_SECTION_HEADING = /^#{1,6}\s+\S/;
|
|
27
|
+
/**
|
|
28
|
+
* The structured form R4 requires:
|
|
29
|
+
* `1. **[L-4f2a91c0de] Entry Title** - outcome: \`prevented\` - one sentence.`
|
|
30
|
+
* The separator may be a hyphen, an en dash, or an em dash, because a human editing
|
|
31
|
+
* a retrospective will not be careful about which.
|
|
32
|
+
*/
|
|
33
|
+
// The id pattern matches what the entry reader accepts, not only what the generator
|
|
34
|
+
// emits: a hand-written id in a learning file has to be referenceable from a
|
|
35
|
+
// retrospective, or the two sides disagree about what an id is.
|
|
36
|
+
const STRUCTURED_ITEM = /^\d+\.\s*\*\*\[(L-[0-9A-Za-z]{6,24})\]\s*(.+?)\*\*\s*[-–—:]+\s*outcome:\s*`?([A-Za-z-]+)`?\s*[-–—:]*\s*(.*)$/i;
|
|
37
|
+
/** The same, without an id, for an entry the id migration has not reached. */
|
|
38
|
+
const STRUCTURED_ITEM_NO_ID = /^\d+\.\s*\*\*(.+?)\*\*\s*[-–—:]+\s*outcome:\s*`?([A-Za-z-]+)`?\s*[-–—:]*\s*(.*)$/i;
|
|
39
|
+
/**
|
|
40
|
+
* The legacy form: a numbered item whose leading bold segment names the entry.
|
|
41
|
+
*
|
|
42
|
+
* Measured across the real corpus (346 retrospectives, 828 numbered items in this
|
|
43
|
+
* section, 819 of them with a leading bold segment), the shapes in use are:
|
|
44
|
+
*
|
|
45
|
+
* 1. **Pattern: "Entry Title" (graduated standing rule)** - prose
|
|
46
|
+
* 1. **`[P-HIGH] Entry Title` (score 19.0, PRs #507 and #518)** - prose
|
|
47
|
+
* 1. **Entry Title**: prose
|
|
48
|
+
* 1. **`someIdentifier` gate**: prose
|
|
49
|
+
*
|
|
50
|
+
* Extraction is deliberately generous and matching is deliberately strict. R15
|
|
51
|
+
* requires exact-title matching; it says nothing about how tolerantly the title is
|
|
52
|
+
* read off the line. A narrow extractor would report most of the corpus as
|
|
53
|
+
* unmatched for a formatting reason rather than an identity one, which is a
|
|
54
|
+
* measurement error dressed up as a finding.
|
|
55
|
+
*/
|
|
56
|
+
const LEGACY_ITEM = /^\d+\.\s*(.*)$/;
|
|
57
|
+
const LEGACY_BOLD_SEGMENT = /^\*\*(.+?)\*\*\s*(.*)$/;
|
|
58
|
+
/** An explicit statement that nothing fired (R4's second criterion). */
|
|
59
|
+
const DECLARED_NONE = /^[-*]?\s*none\b/i;
|
|
60
|
+
/** Placeholder text from the unfilled template, which must never be read as a firing. */
|
|
61
|
+
const TEMPLATE_PLACEHOLDER = /<[^>]*>|\{[^}]*\}/;
|
|
62
|
+
/** Split a bare `<title><separator><prose>` string on the first colon or spaced dash. */
|
|
63
|
+
function splitTitleAndNote(text) {
|
|
64
|
+
const split = text.match(/^(.+?)\s*(?::|\s[-–—]\s)\s*(.*)$/);
|
|
65
|
+
return {
|
|
66
|
+
title: extractLegacyTitle(split ? split[1] : text),
|
|
67
|
+
note: split ? split[2].trim() : '',
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Read a legacy numbered item into a title and a one-sentence note.
|
|
72
|
+
*
|
|
73
|
+
* The leading bold segment usually names the entry, but in the oldest form the
|
|
74
|
+
* bold segment is only the template's field label (`**Pattern**:`) and the title
|
|
75
|
+
* follows it. Both are handled, because both are in the corpus.
|
|
76
|
+
*/
|
|
77
|
+
/** The old template's field label. Only this justifies reading the title from what follows. */
|
|
78
|
+
const LEGACY_FIELD_LABEL = /^pattern\s*:?\s*$/i;
|
|
79
|
+
function parseLegacyItem(rest) {
|
|
80
|
+
const bold = rest.match(LEGACY_BOLD_SEGMENT);
|
|
81
|
+
if (bold) {
|
|
82
|
+
const remainder = bold[2].replace(/^\s*[-–—:]+\s*/, '').trim();
|
|
83
|
+
const boldTitle = extractLegacyTitle(bold[1]);
|
|
84
|
+
if (boldTitle)
|
|
85
|
+
return { title: boldTitle, note: remainder };
|
|
86
|
+
// The bold segment reduced to nothing. Read the title from what follows only
|
|
87
|
+
// when the segment was the template's field label; otherwise the bold text was
|
|
88
|
+
// something else that stripped away (a bare score parenthetical, say) and
|
|
89
|
+
// taking the prose after it as a title would attribute a firing to a sentence.
|
|
90
|
+
if (!LEGACY_FIELD_LABEL.test(bold[1].trim()))
|
|
91
|
+
return null;
|
|
92
|
+
const fromRemainder = splitTitleAndNote(remainder);
|
|
93
|
+
return fromRemainder.title ? { title: fromRemainder.title, note: fromRemainder.note } : null;
|
|
94
|
+
}
|
|
95
|
+
const bare = splitTitleAndNote(rest);
|
|
96
|
+
return bare.title ? { title: bare.title, note: bare.note } : null;
|
|
97
|
+
}
|
|
98
|
+
function extractSection(content) {
|
|
99
|
+
const lines = content.split(/\r?\n/);
|
|
100
|
+
const start = lines.findIndex((line) => FIRING_SECTION_HEADING.test(line.trim()));
|
|
101
|
+
if (start < 0)
|
|
102
|
+
return null;
|
|
103
|
+
const body = [];
|
|
104
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
105
|
+
const line = lines[i];
|
|
106
|
+
if (NEXT_SECTION_HEADING.test(line.trim()) && !FIRING_SECTION_HEADING.test(line.trim()))
|
|
107
|
+
break;
|
|
108
|
+
body.push(line);
|
|
109
|
+
}
|
|
110
|
+
return body;
|
|
111
|
+
}
|
|
112
|
+
function normalizeOutcome(raw) {
|
|
113
|
+
const candidate = raw.trim().toLowerCase();
|
|
114
|
+
return learning_usage_store_1.FIRING_OUTCOMES.includes(candidate) ? candidate : null;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Read the entry title a legacy item is naming, stripping the decoration the
|
|
118
|
+
* corpus actually carries: a `Pattern:` label, backticks, a severity or graduation
|
|
119
|
+
* tag, a trailing score parenthetical, and surrounding quotes.
|
|
120
|
+
*
|
|
121
|
+
* Returns null when nothing usable is left, which is a reportable outcome rather
|
|
122
|
+
* than a guess.
|
|
123
|
+
*/
|
|
124
|
+
function extractLegacyTitle(boldSegment) {
|
|
125
|
+
let title = boldSegment.trim();
|
|
126
|
+
// A `Pattern:` or `Pattern` label, which is the old template's field name.
|
|
127
|
+
title = title.replace(/^pattern\s*:?\s*/i, '');
|
|
128
|
+
title = title.replace(/`/g, '');
|
|
129
|
+
// Trailing parentheticals: score, recurrence counts, PR references, corpus notes.
|
|
130
|
+
// Repeated because some items carry more than one.
|
|
131
|
+
let previous;
|
|
132
|
+
do {
|
|
133
|
+
previous = title;
|
|
134
|
+
title = title.replace(/\s*\([^()]*\)\s*$/, '').trim();
|
|
135
|
+
} while (title !== previous);
|
|
136
|
+
// A severity or graduation tag prefix.
|
|
137
|
+
title = title.replace(/^\[(P-CRITICAL|P-HIGH|P-MED|P-LOW|GRADUATED)\]\s*/i, '');
|
|
138
|
+
// Surrounding quotes, straight or curly.
|
|
139
|
+
title = title.replace(/^["'“‘](.*)["'”’]$/, '$1');
|
|
140
|
+
// A trailing corpus qualifier the corpus writes after a comma or dash, such as
|
|
141
|
+
// "(engineering corpus)" already removed above, or "- L2 org".
|
|
142
|
+
title = title.replace(/\s*[-–—]\s*(L2 org|L1|org)\s*$/i, '').trim();
|
|
143
|
+
return title.length > 0 ? title : null;
|
|
144
|
+
}
|
|
145
|
+
function parseFiringSection(content, options) {
|
|
146
|
+
const body = extractSection(content);
|
|
147
|
+
if (body === null) {
|
|
148
|
+
return { sectionState: 'absent', declaredNone: false, items: [], rejected: [] };
|
|
149
|
+
}
|
|
150
|
+
const lines = body.map((l) => l.trim()).filter(Boolean);
|
|
151
|
+
if (lines.length === 0) {
|
|
152
|
+
return { sectionState: 'empty', declaredNone: false, items: [], rejected: [] };
|
|
153
|
+
}
|
|
154
|
+
const items = [];
|
|
155
|
+
const rejected = [];
|
|
156
|
+
let sawDeclaredNone = false;
|
|
157
|
+
let sawPlaceholderItem = false;
|
|
158
|
+
let inFence = false;
|
|
159
|
+
for (const line of lines) {
|
|
160
|
+
// A fenced block in this section is illustration, not attestation. The template
|
|
161
|
+
// carries a worked example in one, and copying the template must not create
|
|
162
|
+
// firings for the entries the example names.
|
|
163
|
+
if (/^```/.test(line)) {
|
|
164
|
+
inFence = !inFence;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
if (inFence)
|
|
168
|
+
continue;
|
|
169
|
+
if (DECLARED_NONE.test(line)) {
|
|
170
|
+
sawDeclaredNone = true;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
// A line still carrying template placeholders is the unfilled template, not an
|
|
174
|
+
// attestation. Recording it would create a firing for an entry named "<entry
|
|
175
|
+
// title>", which is exactly the silent-garbage case R4's second criterion
|
|
176
|
+
// exists to make visible.
|
|
177
|
+
const isPlaceholder = TEMPLATE_PLACEHOLDER.test(line);
|
|
178
|
+
const structured = line.match(STRUCTURED_ITEM);
|
|
179
|
+
if (structured) {
|
|
180
|
+
if (isPlaceholder) {
|
|
181
|
+
sawPlaceholderItem = true;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
const outcome = normalizeOutcome(structured[3]);
|
|
185
|
+
if (!outcome) {
|
|
186
|
+
rejected.push({ raw: line, reason: `Unknown outcome "${structured[3]}". Use one of: ${learning_usage_store_1.FIRING_OUTCOMES.join(', ')}.` });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
items.push({ id: structured[1], title: structured[2].trim(), outcome, note: structured[4].trim(), format: 'structured', raw: line });
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
const noId = line.match(STRUCTURED_ITEM_NO_ID);
|
|
193
|
+
if (noId) {
|
|
194
|
+
if (isPlaceholder) {
|
|
195
|
+
sawPlaceholderItem = true;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
// `**Pattern**: …` also matches this shape; keep it in the legacy lane.
|
|
199
|
+
if (/^pattern$/i.test(noId[1].trim())) {
|
|
200
|
+
rejected.push({ raw: line, reason: 'Legacy `**Pattern**:` item carrying an outcome is ambiguous. Write it in the structured form.' });
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
const outcome = normalizeOutcome(noId[2]);
|
|
204
|
+
if (!outcome) {
|
|
205
|
+
rejected.push({ raw: line, reason: `Unknown outcome "${noId[2]}". Use one of: ${learning_usage_store_1.FIRING_OUTCOMES.join(', ')}.` });
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
items.push({ id: null, title: noId[1].trim(), outcome, note: noId[3].trim(), format: 'structured-no-id', raw: line });
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const legacy = options.legacy ? line.match(LEGACY_ITEM) : null;
|
|
212
|
+
if (legacy) {
|
|
213
|
+
if (isPlaceholder) {
|
|
214
|
+
sawPlaceholderItem = true;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
const parsedLegacy = parseLegacyItem(legacy[1].trim());
|
|
218
|
+
if (!parsedLegacy) {
|
|
219
|
+
rejected.push({ raw: line, reason: 'No entry title could be read off this item.' });
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
const { title, note } = parsedLegacy;
|
|
223
|
+
// Membership in this section is the attestation that the entry fired and
|
|
224
|
+
// changed something, which is what the section's own template line says. The
|
|
225
|
+
// outcome is not inferred from the prose.
|
|
226
|
+
items.push({ id: null, title, outcome: 'applied', note, format: 'legacy', raw: line });
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
// A list item still carrying template placeholders is the unfilled template
|
|
230
|
+
// rather than an attestation, and is what distinguishes it from a genuinely
|
|
231
|
+
// empty section.
|
|
232
|
+
if (isPlaceholder && /^(\d+\.|[-*])\s/.test(line))
|
|
233
|
+
sawPlaceholderItem = true;
|
|
234
|
+
}
|
|
235
|
+
// A real item wins over a stray "None" line, because a template that has been
|
|
236
|
+
// filled in often still carries the guidance line underneath the items.
|
|
237
|
+
let sectionState;
|
|
238
|
+
if (items.length > 0 || rejected.length > 0)
|
|
239
|
+
sectionState = 'filled';
|
|
240
|
+
else if (sawDeclaredNone)
|
|
241
|
+
sectionState = 'declared-none';
|
|
242
|
+
else if (sawPlaceholderItem)
|
|
243
|
+
sectionState = 'unfilled-template';
|
|
244
|
+
else
|
|
245
|
+
sectionState = 'empty';
|
|
246
|
+
return { sectionState, declaredNone: sectionState === 'declared-none', items, rejected };
|
|
247
|
+
}
|
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.classifyRecurrence = classifyRecurrence;
|
|
4
|
+
exports.resolveStructuralProbe = resolveStructuralProbe;
|
|
5
|
+
exports.findRetirementCandidates = findRetirementCandidates;
|
|
6
|
+
exports.deriveStanding = deriveStanding;
|
|
7
|
+
exports.assessStanding = assessStanding;
|
|
8
|
+
exports.buildUsageReport = buildUsageReport;
|
|
9
|
+
/**
|
|
10
|
+
* Issue #1103 — what the usage record lets you conclude.
|
|
11
|
+
*
|
|
12
|
+
* Three questions, each with an answer the record can prove:
|
|
13
|
+
*
|
|
14
|
+
* R7 Why did this recur? `ignored` means the entry was in context and did not
|
|
15
|
+
* change the outcome, so the fix is the entry. `not-offered` means it never
|
|
16
|
+
* reached the agent, so the fix is the delivery. Today a recurrence cannot
|
|
17
|
+
* tell those apart, which is why the same lesson gets rewritten when the
|
|
18
|
+
* real bug is in the loader.
|
|
19
|
+
* R9 Which entries have stopped earning their place under the current model?
|
|
20
|
+
* R20 And is there a second, independent signal agreeing before anything is
|
|
21
|
+
* recommended for retirement?
|
|
22
|
+
*
|
|
23
|
+
* Everything here is read-only. R10 and R19: usage data produces recommendations,
|
|
24
|
+
* and every change to an entry still passes the existing review gate.
|
|
25
|
+
*/
|
|
26
|
+
const fs_1 = require("fs");
|
|
27
|
+
const path_1 = require("path");
|
|
28
|
+
const learning_usage_store_1 = require("./learning-usage-store");
|
|
29
|
+
const learning_usage_projection_1 = require("./learning-usage-projection");
|
|
30
|
+
function daysBetween(from, to) {
|
|
31
|
+
const a = new Date(from).getTime();
|
|
32
|
+
const b = new Date(to).getTime();
|
|
33
|
+
if (Number.isNaN(a) || Number.isNaN(b))
|
|
34
|
+
return Number.POSITIVE_INFINITY;
|
|
35
|
+
return Math.abs(b - a) / 86_400_000;
|
|
36
|
+
}
|
|
37
|
+
function firedCount(record) {
|
|
38
|
+
return learning_usage_store_1.CLOCK_ADVANCING_OUTCOMES.reduce((sum, outcome) => sum + record.fired.byOutcome[outcome], 0);
|
|
39
|
+
}
|
|
40
|
+
function classifyRecurrence(store, options) {
|
|
41
|
+
const record = store.entries[options.key];
|
|
42
|
+
const base = {
|
|
43
|
+
key: options.key,
|
|
44
|
+
title: record?.titles[record.titles.length - 1] ?? null,
|
|
45
|
+
evidence: {
|
|
46
|
+
recurrenceDate: options.date,
|
|
47
|
+
recurrenceJob: options.job,
|
|
48
|
+
windowDays: options.windowDays,
|
|
49
|
+
matchingOffers: [],
|
|
50
|
+
offeredTotal: record?.offered.total ?? 0,
|
|
51
|
+
firedTotal: record ? firedCount(record) : 0,
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
if (!record) {
|
|
55
|
+
return {
|
|
56
|
+
...base,
|
|
57
|
+
classification: 'undetermined',
|
|
58
|
+
diagnosis: 'There is no usage record for this entry, so whether it was in context for that job cannot be determined.',
|
|
59
|
+
recommendation: 'No usage record exists for this entry yet. Treat this recurrence the way you would have before usage attribution, and re-check after the entry has been offered a few times.',
|
|
60
|
+
recurrenceBump: true,
|
|
61
|
+
targetOfFix: 'none',
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
// "In context for that job" means an offer for the same job inside the window.
|
|
65
|
+
// A different job's offer is not evidence: domain filtering is exactly the
|
|
66
|
+
// mechanism that can deliver an entry to one job and withhold it from another.
|
|
67
|
+
const matchingOffers = record.offered.log.filter((offer) => offer.job === options.job
|
|
68
|
+
&& offer.aboveThreshold
|
|
69
|
+
&& daysBetween(offer.date, options.date) <= options.windowDays);
|
|
70
|
+
base.evidence.matchingOffers = matchingOffers;
|
|
71
|
+
// Absence of a matching offer record is only evidence of absence when the offer
|
|
72
|
+
// log actually reaches back over the window. The log is bounded (R16a), so an
|
|
73
|
+
// entry offered many times can have had every relevant record trimmed away.
|
|
74
|
+
// Reporting `not-offered` there would tell the manager to fix the loader for an
|
|
75
|
+
// entry that was in fact delivered, which is the wrong-fix failure R7 exists to
|
|
76
|
+
// prevent. The history is complete when nothing has been trimmed, or when the
|
|
77
|
+
// oldest retained record predates the window.
|
|
78
|
+
const windowStartMs = new Date(options.date).getTime() - options.windowDays * 86_400_000;
|
|
79
|
+
const oldestRetained = record.offered.log.length
|
|
80
|
+
? record.offered.log.reduce((min, o) => (o.date < min ? o.date : min), record.offered.log[0].date)
|
|
81
|
+
: null;
|
|
82
|
+
const historyComplete = record.offered.log.length === record.offered.total
|
|
83
|
+
|| (oldestRetained !== null && new Date(oldestRetained).getTime() <= windowStartMs);
|
|
84
|
+
if (matchingOffers.length > 0) {
|
|
85
|
+
return {
|
|
86
|
+
...base,
|
|
87
|
+
classification: 'ignored',
|
|
88
|
+
diagnosis: `This entry was in context for ${options.job} when the mistake recurred, and being in context did not change what happened.`,
|
|
89
|
+
recommendation: 'The entry reached the agent and did not change the outcome, so the entry itself is what needs work. Fix its wording so the required action is unambiguous, or fix its placement so it is read at the moment the decision is made. Do not record another recurrence as if the lesson were simply not learned yet.',
|
|
90
|
+
recurrenceBump: true,
|
|
91
|
+
targetOfFix: 'entry',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
if (!historyComplete) {
|
|
95
|
+
return {
|
|
96
|
+
...base,
|
|
97
|
+
classification: 'undetermined',
|
|
98
|
+
diagnosis: `Whether this entry was in context for ${options.job} cannot be determined: it has been offered ${record.offered.total} times, but the retained offer log no longer reaches back over the window being asked about.`,
|
|
99
|
+
recommendation: 'The offer history for this window has been pruned, so absence of a record is not evidence that the entry was not delivered. Do not conclude a delivery problem from this. Either widen the retained offer log for this corpus, or ask again about a more recent recurrence.',
|
|
100
|
+
recurrenceBump: true,
|
|
101
|
+
targetOfFix: 'none',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
...base,
|
|
106
|
+
classification: 'not-offered',
|
|
107
|
+
diagnosis: `This entry was never in context for ${options.job}, so it had no opportunity to change what happened.`,
|
|
108
|
+
recommendation: 'The entry never reached the agent, so the delivery is what is wrong and the entry text is fine. Check the entry\'s domain and its threshold in the loader: widen the domain if the lesson applies beyond the jobs it is currently scoped to. Do not raise the recurrence count, because the count measures how often the lesson failed to stick and this is not an instance of that.',
|
|
109
|
+
recurrenceBump: false,
|
|
110
|
+
targetOfFix: 'delivery',
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
const UNDETERMINED = {
|
|
114
|
+
signal: 'undetermined',
|
|
115
|
+
detail: 'The structural signal could not be determined: the corpus hygiene detector is not available in this workspace, so whether the thing this entry guards still exists is unknown.',
|
|
116
|
+
};
|
|
117
|
+
/**
|
|
118
|
+
* R20's structural half comes from issue #1100's inert and orphan detection, which
|
|
119
|
+
* answers a different question from usage: is the thing this entry guards still
|
|
120
|
+
* there? The two specs meet at exactly this point, and #1103 reads that detector
|
|
121
|
+
* rather than re-deriving it.
|
|
122
|
+
*
|
|
123
|
+
* When the detector is absent the probe says so plainly. That is not a fallback
|
|
124
|
+
* for convenience: R20's third acceptance criterion requires the undetermined case
|
|
125
|
+
* to be stated rather than presented as if both signals agreed.
|
|
126
|
+
*/
|
|
127
|
+
/**
|
|
128
|
+
* The only module this integration loads. Naming it exactly, rather than accepting
|
|
129
|
+
* any path, keeps `require` of a caller-supplied string from becoming a general
|
|
130
|
+
* arbitrary-module loader: a mistyped or hostile `--detector` value fails closed to
|
|
131
|
+
* the undetermined signal instead of executing.
|
|
132
|
+
*/
|
|
133
|
+
const STRUCTURAL_DETECTOR_FILENAME = 'instruction-hygiene.js';
|
|
134
|
+
function resolveStructuralProbe(detectorPath) {
|
|
135
|
+
if (!detectorPath || !(0, fs_1.existsSync)(detectorPath)) {
|
|
136
|
+
return () => UNDETERMINED;
|
|
137
|
+
}
|
|
138
|
+
if ((0, path_1.basename)(detectorPath) !== STRUCTURAL_DETECTOR_FILENAME) {
|
|
139
|
+
return () => ({
|
|
140
|
+
signal: 'undetermined',
|
|
141
|
+
detail: `The structural signal could not be determined: the detector path must end in ${STRUCTURAL_DETECTOR_FILENAME}, and this one does not.`,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
let detector;
|
|
145
|
+
try {
|
|
146
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
147
|
+
detector = require(detectorPath);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return () => UNDETERMINED;
|
|
151
|
+
}
|
|
152
|
+
if (!detector || typeof detector.analyzeInstructionHygiene !== 'function') {
|
|
153
|
+
return () => UNDETERMINED;
|
|
154
|
+
}
|
|
155
|
+
return (key, title) => {
|
|
156
|
+
try {
|
|
157
|
+
const report = detector.analyzeInstructionHygiene({ root: process.cwd() });
|
|
158
|
+
const findings = report?.findings ?? [];
|
|
159
|
+
const needle = (title ?? key).toLowerCase();
|
|
160
|
+
const inert = findings.find((f) => (f.type === 'inert' || f.type === 'orphan')
|
|
161
|
+
&& `${f.file ?? ''} ${f.message ?? ''}`.toLowerCase().includes(needle));
|
|
162
|
+
if (inert) {
|
|
163
|
+
return { signal: 'guard-absent', detail: `The corpus hygiene detector reports this as ${inert.type}: ${inert.message ?? 'no consumer found'}.` };
|
|
164
|
+
}
|
|
165
|
+
return { signal: 'still-referenced', detail: 'The corpus hygiene detector finds the surface this entry guards is still referenced.' };
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
return UNDETERMINED;
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function offersUnderModel(record, model) {
|
|
173
|
+
if (!model)
|
|
174
|
+
return 0;
|
|
175
|
+
return record.offered.byModel[model] ?? 0;
|
|
176
|
+
}
|
|
177
|
+
function firingsUnderModel(record, model) {
|
|
178
|
+
if (!model)
|
|
179
|
+
return 0;
|
|
180
|
+
return record.fired.byModel[model] ?? 0;
|
|
181
|
+
}
|
|
182
|
+
function earlierModelFirings(record, currentModel) {
|
|
183
|
+
return Object.entries(record.fired.byModel)
|
|
184
|
+
.filter(([model]) => model !== currentModel)
|
|
185
|
+
.map(([model, count]) => ({ model, count }))
|
|
186
|
+
.sort((a, b) => b.count - a.count);
|
|
187
|
+
}
|
|
188
|
+
function latestTitle(record) {
|
|
189
|
+
return record.titles[record.titles.length - 1] ?? record.key;
|
|
190
|
+
}
|
|
191
|
+
function findRetirementCandidates(store, options) {
|
|
192
|
+
const currentModel = options.currentModel ?? (0, learning_usage_projection_1.resolveCurrentModel)(store);
|
|
193
|
+
const threshold = options.limits.retirementOfferThreshold;
|
|
194
|
+
const probe = options.structuralProbe ?? resolveStructuralProbe(options.structuralSignalPath);
|
|
195
|
+
const candidates = [];
|
|
196
|
+
const notCandidates = [];
|
|
197
|
+
for (const record of Object.values(store.entries)) {
|
|
198
|
+
if (record.family !== 'learning')
|
|
199
|
+
continue;
|
|
200
|
+
const title = latestTitle(record);
|
|
201
|
+
const offered = offersUnderModel(record, currentModel);
|
|
202
|
+
const fired = firingsUnderModel(record, currentModel);
|
|
203
|
+
if (fired > 0) {
|
|
204
|
+
notCandidates.push({ key: record.key, title, reason: `It fired ${fired} time${fired === 1 ? '' : 's'} under the current model, so it is still earning its place.` });
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
if (offered < threshold) {
|
|
208
|
+
notCandidates.push({
|
|
209
|
+
key: record.key,
|
|
210
|
+
title,
|
|
211
|
+
reason: `Only ${offered} offer${offered === 1 ? '' : 's'} under the current model, which is below the threshold of ${threshold}. That is more likely to mean the work that triggers it has not come up than that it is no longer needed.`,
|
|
212
|
+
});
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const structural = probe(record.key, title);
|
|
216
|
+
// R20: two independent signals before recommending removal. Usage alone says
|
|
217
|
+
// "unused"; only usage plus an absent guard says "obsolete".
|
|
218
|
+
const recommendation = structural.signal === 'guard-absent' ? 'retire' : 'keep-and-reword';
|
|
219
|
+
candidates.push({
|
|
220
|
+
key: record.key,
|
|
221
|
+
title,
|
|
222
|
+
offeredUnderCurrentModel: offered,
|
|
223
|
+
firedUnderCurrentModel: fired,
|
|
224
|
+
firedUnderEarlierModels: earlierModelFirings(record, currentModel),
|
|
225
|
+
currentModel,
|
|
226
|
+
structural: structural.signal,
|
|
227
|
+
structuralDetail: structural.detail,
|
|
228
|
+
recommendation,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
candidates.sort((a, b) => b.offeredUnderCurrentModel - a.offeredUnderCurrentModel);
|
|
232
|
+
return { currentModel, offerThreshold: threshold, candidates, notCandidates };
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* The window in which a firing still counts as recent for standing purposes.
|
|
236
|
+
* Longer than the Brain's 30-day "fired recently" glance signal, because standing
|
|
237
|
+
* is a slower-moving decision than a dot colour.
|
|
238
|
+
*/
|
|
239
|
+
const STANDING_RECENT_DAYS = 90;
|
|
240
|
+
function daysSince(date, now) {
|
|
241
|
+
if (!date)
|
|
242
|
+
return Number.POSITIVE_INFINITY;
|
|
243
|
+
const parsed = new Date(date).getTime();
|
|
244
|
+
if (Number.isNaN(parsed))
|
|
245
|
+
return Number.POSITIVE_INFINITY;
|
|
246
|
+
return (now.getTime() - parsed) / 86_400_000;
|
|
247
|
+
}
|
|
248
|
+
function deriveStanding(record, options) {
|
|
249
|
+
const now = options.now ?? new Date();
|
|
250
|
+
const fired = firedCount(record);
|
|
251
|
+
const offered = record.offered.aboveThreshold;
|
|
252
|
+
const title = latestTitle(record);
|
|
253
|
+
const threshold = options.limits.retirementOfferThreshold;
|
|
254
|
+
let standing;
|
|
255
|
+
let reason;
|
|
256
|
+
if (fired > 0 && daysSince(record.fired.lastFiredAt, now) <= STANDING_RECENT_DAYS) {
|
|
257
|
+
standing = 'promoted';
|
|
258
|
+
reason = `Fired ${fired} time${fired === 1 ? '' : 's'}, most recently ${record.fired.lastFiredAt}. It is changing outcomes, so it stays fully in context.`;
|
|
259
|
+
}
|
|
260
|
+
else if (offered >= threshold && fired === 0) {
|
|
261
|
+
standing = 'demoted';
|
|
262
|
+
reason = `Offered ${offered} times as active guidance and never changed an outcome. Demoted rather than removed: it stays readable and can earn its way back the first time it fires.`;
|
|
263
|
+
}
|
|
264
|
+
else if (fired > 0) {
|
|
265
|
+
standing = 'standard';
|
|
266
|
+
reason = `Fired ${fired} time${fired === 1 ? '' : 's'}, but not within the last ${STANDING_RECENT_DAYS} days. Held at standard while it is neither proving itself nor failing to.`;
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
standing = 'standard';
|
|
270
|
+
reason = offered === 0
|
|
271
|
+
? 'No usage record yet. An entry FRAIM has not measured is held at standard rather than judged.'
|
|
272
|
+
: `Offered ${offered} times, which is below the ${threshold} needed to conclude anything from silence.`;
|
|
273
|
+
}
|
|
274
|
+
const current = options.currentStanding ? options.currentStanding(record.key) : 'standard';
|
|
275
|
+
const recommendation = standing === current ? null : standing === 'promoted' ? 'promote' : standing === 'demoted' ? 'demote' : null;
|
|
276
|
+
return { key: record.key, title, standing, reason, offered, fired, recommendation };
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Assess every entry with a usage record. Read-only: this recommends, and
|
|
280
|
+
* `sleep-on-learnings` applies under the existing approval gate (R10, R19).
|
|
281
|
+
*/
|
|
282
|
+
function assessStanding(store, options) {
|
|
283
|
+
const now = options.now ?? new Date();
|
|
284
|
+
const assessments = Object.values(store.entries)
|
|
285
|
+
.filter((r) => r.family === 'learning')
|
|
286
|
+
.map((r) => deriveStanding(r, { ...options, now }))
|
|
287
|
+
.sort((a, b) => b.fired - a.fired || b.offered - a.offered);
|
|
288
|
+
return {
|
|
289
|
+
generatedAt: now.toISOString(),
|
|
290
|
+
assessments,
|
|
291
|
+
changes: assessments.filter((a) => a.recommendation !== null),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
function toReportEntry(record) {
|
|
295
|
+
return {
|
|
296
|
+
key: record.key,
|
|
297
|
+
title: latestTitle(record),
|
|
298
|
+
offered: record.offered.total,
|
|
299
|
+
fired: firedCount(record),
|
|
300
|
+
lastFired: record.fired.lastFiredAt,
|
|
301
|
+
firedUnder: Object.keys(record.fired.byModel),
|
|
302
|
+
recentLog: record.fired.log,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* The answer to "which of my learnings are still earning their place?".
|
|
307
|
+
*
|
|
308
|
+
* Only entries with a usage record appear. An entry FRAIM has never measured is
|
|
309
|
+
* absent rather than listed with zeroes, because a fabricated zero reads as
|
|
310
|
+
* evidence of disuse when it is only evidence of no data.
|
|
311
|
+
*/
|
|
312
|
+
function buildUsageReport(store, options) {
|
|
313
|
+
const now = options.now ?? new Date();
|
|
314
|
+
const limit = options.limit ?? 10;
|
|
315
|
+
const learningRecords = Object.values(store.entries).filter((r) => r.family === 'learning');
|
|
316
|
+
const offered = learningRecords.filter((r) => r.offered.total > 0);
|
|
317
|
+
const fired = learningRecords.filter((r) => firedCount(r) > 0);
|
|
318
|
+
const neverFired = offered.filter((r) => firedCount(r) === 0);
|
|
319
|
+
const ignored = learningRecords.filter((r) => r.fired.byOutcome.ignored > 0);
|
|
320
|
+
return {
|
|
321
|
+
generatedAt: now.toISOString(),
|
|
322
|
+
windowDays: options.windowDays,
|
|
323
|
+
currentModel: (0, learning_usage_projection_1.resolveCurrentModel)(store),
|
|
324
|
+
totals: {
|
|
325
|
+
entriesOffered: offered.length,
|
|
326
|
+
entriesWithFirings: fired.length,
|
|
327
|
+
entriesNeverFired: neverFired.length,
|
|
328
|
+
},
|
|
329
|
+
workingHardest: fired
|
|
330
|
+
.sort((a, b) => {
|
|
331
|
+
const byCount = firedCount(b) - firedCount(a);
|
|
332
|
+
if (byCount !== 0)
|
|
333
|
+
return byCount;
|
|
334
|
+
return (b.fired.lastFiredAt ?? '').localeCompare(a.fired.lastFiredAt ?? '');
|
|
335
|
+
})
|
|
336
|
+
.slice(0, limit)
|
|
337
|
+
.map(toReportEntry),
|
|
338
|
+
neverFired: neverFired
|
|
339
|
+
.sort((a, b) => b.offered.total - a.offered.total)
|
|
340
|
+
.slice(0, limit)
|
|
341
|
+
.map(toReportEntry),
|
|
342
|
+
ignoredWhileInContext: ignored
|
|
343
|
+
.sort((a, b) => b.fired.byOutcome.ignored - a.fired.byOutcome.ignored)
|
|
344
|
+
.slice(0, limit)
|
|
345
|
+
.map(toReportEntry),
|
|
346
|
+
};
|
|
347
|
+
}
|