fraim 2.0.270 → 2.0.272
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 +37 -132
- package/dist/src/cli/commands/add-provider.js +32 -268
- package/dist/src/cli/commands/learning-usage.js +412 -0
- package/dist/src/cli/commands/login.js +5 -5
- package/dist/src/cli/commands/setup.js +15 -52
- package/dist/src/cli/commands/sync.js +111 -80
- package/dist/src/cli/fraim.js +1 -42
- package/dist/src/cli/mcp/ide-formats.js +1 -1
- package/dist/src/cli/mcp/mcp-server-registry.js +3 -3
- package/dist/src/cli/providers/local-provider-registry.js +4 -4
- package/dist/src/cli/setup/auto-mcp-setup.js +4 -13
- package/dist/src/cli/utils/remote-sync.js +41 -25
- 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/first-run/types.js +1 -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-command.js +408 -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/dist/src/services/provider-service.js +4 -4
- package/package.json +1 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.extractLegacyTitle = exports.parseFiringSection = void 0;
|
|
4
|
+
exports.recordAttestationsFromRetrospective = recordAttestationsFromRetrospective;
|
|
5
|
+
exports.runBackfill = runBackfill;
|
|
6
|
+
/**
|
|
7
|
+
* Issue #1103 — turning an attestation into a usage record.
|
|
8
|
+
*
|
|
9
|
+
* The retrospective is the durable record of a firing; the usage store is derived
|
|
10
|
+
* from it (R17). So there is exactly one parser (`learning-firing-parser.ts`) and
|
|
11
|
+
* one recorder here, used by two entry points: the live attestation at the end of
|
|
12
|
+
* a job, and the backfill over the retrospectives already on disk (R15).
|
|
13
|
+
*
|
|
14
|
+
* Matching is an id or an exact title, and nothing else. R5 forbids resolving a
|
|
15
|
+
* prose reference to the closest title, and an unmatched item is reported rather
|
|
16
|
+
* than guessed at: a wrong match writes a firing onto the wrong entry, which is
|
|
17
|
+
* worse than no firing at all.
|
|
18
|
+
*/
|
|
19
|
+
const fs_1 = require("fs");
|
|
20
|
+
const path_1 = require("path");
|
|
21
|
+
const learning_usage_store_1 = require("./learning-usage-store");
|
|
22
|
+
const learning_firing_parser_1 = require("./learning-firing-parser");
|
|
23
|
+
const learning_context_builder_1 = require("./learning-context-builder");
|
|
24
|
+
// Re-exported so callers that only need one of the two halves keep one import.
|
|
25
|
+
var learning_firing_parser_2 = require("./learning-firing-parser");
|
|
26
|
+
Object.defineProperty(exports, "parseFiringSection", { enumerable: true, get: function () { return learning_firing_parser_2.parseFiringSection; } });
|
|
27
|
+
Object.defineProperty(exports, "extractLegacyTitle", { enumerable: true, get: function () { return learning_firing_parser_2.extractLegacyTitle; } });
|
|
28
|
+
function buildEntryIndex(workspaceRoot, userId) {
|
|
29
|
+
const byId = new Map();
|
|
30
|
+
const byTitle = new Map();
|
|
31
|
+
// Every scope a firing could name: the manager's own entries, the org entries
|
|
32
|
+
// the agent also reads, and the manager-coaching family.
|
|
33
|
+
for (const scope of ['manager', 'org', 'reverse']) {
|
|
34
|
+
let entries = [];
|
|
35
|
+
try {
|
|
36
|
+
entries = (0, learning_context_builder_1.readPreservedLearnings)(workspaceRoot, userId, scope);
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
for (const entry of entries) {
|
|
42
|
+
if (entry.id && !byId.has(entry.id))
|
|
43
|
+
byId.set(entry.id, entry);
|
|
44
|
+
const key = (0, learning_usage_store_1.normalizeEntryTitle)(entry.title);
|
|
45
|
+
if (!byTitle.has(key))
|
|
46
|
+
byTitle.set(key, entry);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return { byId, byTitle };
|
|
50
|
+
}
|
|
51
|
+
function fileTypeOf(entry) {
|
|
52
|
+
return learning_context_builder_1.CATEGORY_TO_FILETYPE[entry.category] ?? 'mistake-patterns';
|
|
53
|
+
}
|
|
54
|
+
function retrospectiveDate(content, fallback) {
|
|
55
|
+
const match = content.match(/^date:\s*(\d{4}-\d{2}-\d{2})\s*$/m);
|
|
56
|
+
return match ? match[1] : fallback;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Record the firings a retrospective attests. Idempotent per (entry, source), so a
|
|
60
|
+
* re-run, or a backfill that overlaps a live attestation, cannot inflate a count.
|
|
61
|
+
*/
|
|
62
|
+
function recordAttestationsFromRetrospective(workspaceRoot, userId, options) {
|
|
63
|
+
const limits = options.limits ?? (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
|
|
64
|
+
const parsed = (0, learning_firing_parser_1.parseFiringSection)(options.content, { source: options.source, legacy: options.legacy });
|
|
65
|
+
const date = options.date ?? retrospectiveDate(options.content, new Date().toISOString().slice(0, 10));
|
|
66
|
+
const index = buildEntryIndex(workspaceRoot, userId);
|
|
67
|
+
const unmatched = [];
|
|
68
|
+
const inputs = [];
|
|
69
|
+
for (const item of parsed.items) {
|
|
70
|
+
let entry;
|
|
71
|
+
if (item.id) {
|
|
72
|
+
entry = index.byId.get(item.id);
|
|
73
|
+
if (!entry) {
|
|
74
|
+
unmatched.push({
|
|
75
|
+
id: item.id,
|
|
76
|
+
title: item.title,
|
|
77
|
+
reason: `No entry carries the id ${item.id}. Reported unmatched rather than assigned to the closest title.`,
|
|
78
|
+
raw: item.raw,
|
|
79
|
+
});
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
entry = index.byTitle.get((0, learning_usage_store_1.normalizeEntryTitle)(item.title));
|
|
85
|
+
if (!entry) {
|
|
86
|
+
unmatched.push({
|
|
87
|
+
id: null,
|
|
88
|
+
title: item.title,
|
|
89
|
+
reason: 'No entry has exactly this title. Exact titles match; a paraphrase is reported rather than resolved by similarity.',
|
|
90
|
+
raw: item.raw,
|
|
91
|
+
});
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
const fileType = fileTypeOf(entry);
|
|
96
|
+
inputs.push({
|
|
97
|
+
key: entry.id ?? (0, learning_usage_store_1.titleUsageKey)(fileType, entry.title),
|
|
98
|
+
family: 'learning',
|
|
99
|
+
title: entry.title,
|
|
100
|
+
fileType,
|
|
101
|
+
level: entry.level,
|
|
102
|
+
outcome: item.outcome,
|
|
103
|
+
note: item.note,
|
|
104
|
+
job: options.job,
|
|
105
|
+
date,
|
|
106
|
+
agent: options.agent,
|
|
107
|
+
model: options.model,
|
|
108
|
+
source: item.format === 'legacy' ? 'backfill-legacy' : 'attested',
|
|
109
|
+
sourceRef: options.source,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
let applied = 0;
|
|
113
|
+
let alreadyRecorded = 0;
|
|
114
|
+
if (inputs.length > 0) {
|
|
115
|
+
const result = (0, learning_usage_store_1.recordFirings)(inputs, { limits, now: options.now, storePath: options.storePath });
|
|
116
|
+
applied = result.recorded;
|
|
117
|
+
alreadyRecorded = result.alreadyRecorded;
|
|
118
|
+
}
|
|
119
|
+
return {
|
|
120
|
+
source: options.source,
|
|
121
|
+
sectionState: parsed.sectionState,
|
|
122
|
+
itemsRead: parsed.items.length + parsed.rejected.length,
|
|
123
|
+
applied,
|
|
124
|
+
alreadyRecorded,
|
|
125
|
+
unmatched,
|
|
126
|
+
rejected: parsed.rejected,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
function jobFromFrontmatter(content) {
|
|
130
|
+
const match = content.match(/^job:\s*(.+)$/m);
|
|
131
|
+
return match ? match[1].trim() : 'unknown';
|
|
132
|
+
}
|
|
133
|
+
/**
|
|
134
|
+
* Read every retrospective and apply the firings it attests.
|
|
135
|
+
*
|
|
136
|
+
* Best effort by design (R15): only an id or an exact title resolves, and the
|
|
137
|
+
* unmatched count is reported rather than resolved by fuzzy matching. A low match
|
|
138
|
+
* rate is an expected outcome to state, not a failure to hide — only a small
|
|
139
|
+
* fraction of the existing attestations name a machine-matchable identifier.
|
|
140
|
+
*
|
|
141
|
+
* A retrospective already stamped `synthesized` is still read: being synthesized
|
|
142
|
+
* for learning content is not the same as having contributed usage data.
|
|
143
|
+
*/
|
|
144
|
+
function runBackfill(workspaceRoot, userId, options) {
|
|
145
|
+
const limits = options.limits ?? (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
|
|
146
|
+
const dir = options.retrospectivesDir ?? (0, path_1.join)(workspaceRoot, 'docs', 'retrospectives');
|
|
147
|
+
const report = {
|
|
148
|
+
filesScanned: 0, filesWithSection: 0, itemsRead: 0, applied: 0,
|
|
149
|
+
alreadyRecorded: 0, unmatched: [], rejected: [], synthesizedFilesRead: 0,
|
|
150
|
+
};
|
|
151
|
+
if (!(0, fs_1.existsSync)(dir))
|
|
152
|
+
return report;
|
|
153
|
+
let files;
|
|
154
|
+
try {
|
|
155
|
+
files = (0, fs_1.readdirSync)(dir).filter((f) => f.endsWith('.md'));
|
|
156
|
+
}
|
|
157
|
+
catch {
|
|
158
|
+
return report;
|
|
159
|
+
}
|
|
160
|
+
for (const file of files) {
|
|
161
|
+
let content;
|
|
162
|
+
try {
|
|
163
|
+
content = (0, fs_1.readFileSync)((0, path_1.join)(dir, file), 'utf8');
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
report.filesScanned += 1;
|
|
169
|
+
if (/^synthesized:\s*\S/m.test(content))
|
|
170
|
+
report.synthesizedFilesRead += 1;
|
|
171
|
+
const result = recordAttestationsFromRetrospective(workspaceRoot, userId, {
|
|
172
|
+
content,
|
|
173
|
+
source: `docs/retrospectives/${file}`,
|
|
174
|
+
job: jobFromFrontmatter(content),
|
|
175
|
+
agent: options.agent,
|
|
176
|
+
model: options.model,
|
|
177
|
+
legacy: true,
|
|
178
|
+
limits,
|
|
179
|
+
now: options.now,
|
|
180
|
+
storePath: options.storePath,
|
|
181
|
+
});
|
|
182
|
+
if (result.sectionState !== 'absent')
|
|
183
|
+
report.filesWithSection += 1;
|
|
184
|
+
report.itemsRead += result.itemsRead;
|
|
185
|
+
report.applied += result.applied;
|
|
186
|
+
report.alreadyRecorded += result.alreadyRecorded;
|
|
187
|
+
report.unmatched.push(...result.unmatched);
|
|
188
|
+
report.rejected.push(...result.rejected);
|
|
189
|
+
}
|
|
190
|
+
return report;
|
|
191
|
+
}
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.learningUsageCommand = void 0;
|
|
7
|
+
/**
|
|
8
|
+
* Issue #1103 - command surface used by the learning-usage script.
|
|
9
|
+
*
|
|
10
|
+
* The read and analysis side of the usage record. The write side of an offer is the
|
|
11
|
+
* proxy (no agent involvement, R2); the write side of a firing is an attestation in
|
|
12
|
+
* a retrospective, which `record-firings` reads back.
|
|
13
|
+
*/
|
|
14
|
+
const commander_1 = require("commander");
|
|
15
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
16
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
17
|
+
const learning_context_builder_1 = require("./learning-context-builder");
|
|
18
|
+
const learning_usage_analysis_1 = require("./learning-usage-analysis");
|
|
19
|
+
const learning_usage_store_1 = require("./learning-usage-store");
|
|
20
|
+
const learning_usage_attestation_1 = require("./learning-usage-attestation");
|
|
21
|
+
function resolveWorkspaceRoot(options) {
|
|
22
|
+
return node_path_1.default.resolve(options.workspaceRoot || options.root || process.cwd());
|
|
23
|
+
}
|
|
24
|
+
function resolveUser(options) {
|
|
25
|
+
const email = options.user || process.env.FRAIM_ACTIVE_USER_EMAIL || '';
|
|
26
|
+
if (!email.trim()) {
|
|
27
|
+
throw new Error('Missing active user email. Pass --user <email> or set FRAIM_ACTIVE_USER_EMAIL.');
|
|
28
|
+
}
|
|
29
|
+
return email.trim();
|
|
30
|
+
}
|
|
31
|
+
function emit(payload, json, text) {
|
|
32
|
+
process.stdout.write(json ? `${JSON.stringify(payload, null, 2)}\n` : `${text()}\n`);
|
|
33
|
+
}
|
|
34
|
+
function addCommonOptions(command) {
|
|
35
|
+
return command
|
|
36
|
+
.option('--workspace-root <path>', 'Workspace root containing fraim/config.json')
|
|
37
|
+
.option('--root <path>', 'Alias for --workspace-root')
|
|
38
|
+
.option('--user <email>', 'Active user email')
|
|
39
|
+
.option('--json', 'Print JSON');
|
|
40
|
+
}
|
|
41
|
+
// ── offers ───────────────────────────────────────────────────────────────────
|
|
42
|
+
const offersCommand = addCommonOptions(new commander_1.Command('offers'))
|
|
43
|
+
.description('List the learning entries and rule files the runtime delivered, so an attestation is written against the record rather than memory')
|
|
44
|
+
.option('--job <name>', 'The job whose context was loaded', 'unknown')
|
|
45
|
+
.option('--domain <domain>', 'Learning domain the job resolved to')
|
|
46
|
+
.option('--session', 'Use the session frame rather than the job frame')
|
|
47
|
+
.action((options) => {
|
|
48
|
+
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
49
|
+
const user = resolveUser(options);
|
|
50
|
+
const forJob = !options.session;
|
|
51
|
+
// Prefer what the runtime recorded for this job, because that is what was
|
|
52
|
+
// actually delivered. Re-deriving from the files on disk answers a subtly
|
|
53
|
+
// different question — what would be delivered now — and the two diverge if a
|
|
54
|
+
// learning file changed during the job.
|
|
55
|
+
const store = (0, learning_usage_store_1.readUsageStore)();
|
|
56
|
+
const recorded = Object.values(store.entries)
|
|
57
|
+
.filter((entry) => entry.offered.log.some((offer) => offer.job === options.job))
|
|
58
|
+
.map((entry) => {
|
|
59
|
+
const last = [...entry.offered.log].reverse().find((offer) => offer.job === options.job);
|
|
60
|
+
return {
|
|
61
|
+
key: entry.key,
|
|
62
|
+
family: entry.family,
|
|
63
|
+
id: entry.key.startsWith('L-') ? entry.key : null,
|
|
64
|
+
title: entry.titles[entry.titles.length - 1] ?? entry.key,
|
|
65
|
+
fileType: entry.fileType ?? 'unknown',
|
|
66
|
+
level: entry.level ?? 'unknown',
|
|
67
|
+
displayPath: '',
|
|
68
|
+
severity: null,
|
|
69
|
+
aboveThreshold: last.aboveThreshold,
|
|
70
|
+
};
|
|
71
|
+
});
|
|
72
|
+
const source = recorded.length > 0 ? 'offer-record' : 'derived';
|
|
73
|
+
const offered = recorded.length > 0
|
|
74
|
+
? recorded
|
|
75
|
+
: [
|
|
76
|
+
...(0, learning_context_builder_1.collectOfferedLearningEntries)(workspaceRoot, user, forJob, options.domain ?? null),
|
|
77
|
+
...(0, learning_context_builder_1.collectOfferedRuleFiles)(workspaceRoot, forJob),
|
|
78
|
+
];
|
|
79
|
+
const payload = { job: options.job, forJob, source, offered };
|
|
80
|
+
emit(payload, options.json, () => {
|
|
81
|
+
if (payload.offered.length === 0) {
|
|
82
|
+
return `No offer record and nothing resolves for job ${options.job}. Do not attest any firing for this job.`;
|
|
83
|
+
}
|
|
84
|
+
const rows = payload.offered.map((o) => `| ${o.id ?? '(no id)'} | ${o.title} | ${o.fileType} | ${o.aboveThreshold ? 'yes' : 'no'} |`);
|
|
85
|
+
const header = source === 'offer-record'
|
|
86
|
+
? `Entries offered for job ${options.job}, from the offer record:`
|
|
87
|
+
: `No offer record exists for job ${options.job} yet, so this is what would be delivered now. Say so when you attest against it.`;
|
|
88
|
+
return [
|
|
89
|
+
header,
|
|
90
|
+
'',
|
|
91
|
+
'| Id | Title | Family | Above threshold |',
|
|
92
|
+
'|---|---|---|---|',
|
|
93
|
+
...rows,
|
|
94
|
+
].join('\n');
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
// ── record-firings ───────────────────────────────────────────────────────────
|
|
98
|
+
const recordFiringsCommand = addCommonOptions(new commander_1.Command('record-firings'))
|
|
99
|
+
.description('Read the firing section of a retrospective and write the attested firings into the usage record')
|
|
100
|
+
.requiredOption('--retrospective <path>', 'Retrospective file to read')
|
|
101
|
+
.option('--job <name>', 'The job that produced the retrospective')
|
|
102
|
+
.option('--agent <name>', 'Agent that attested')
|
|
103
|
+
.option('--model <model>', 'Model that attested')
|
|
104
|
+
.option('--legacy', 'Also accept the pre-#1103 prose form')
|
|
105
|
+
.action((options) => {
|
|
106
|
+
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
107
|
+
const user = resolveUser(options);
|
|
108
|
+
const filePath = node_path_1.default.resolve(options.retrospective);
|
|
109
|
+
if (!node_fs_1.default.existsSync(filePath)) {
|
|
110
|
+
throw new Error(`Retrospective not found: ${filePath}`);
|
|
111
|
+
}
|
|
112
|
+
const content = node_fs_1.default.readFileSync(filePath, 'utf8');
|
|
113
|
+
const source = node_path_1.default.relative(workspaceRoot, filePath).replace(/\\/g, '/');
|
|
114
|
+
const result = (0, learning_usage_attestation_1.recordAttestationsFromRetrospective)(workspaceRoot, user, {
|
|
115
|
+
content,
|
|
116
|
+
source,
|
|
117
|
+
job: options.job ?? 'unknown',
|
|
118
|
+
agent: options.agent ?? null,
|
|
119
|
+
model: options.model ?? null,
|
|
120
|
+
legacy: Boolean(options.legacy),
|
|
121
|
+
});
|
|
122
|
+
emit(result, options.json, () => {
|
|
123
|
+
const lines = [
|
|
124
|
+
`Section state: ${result.sectionState}`,
|
|
125
|
+
`Items read: ${result.itemsRead}`,
|
|
126
|
+
`Recorded: ${result.applied}`,
|
|
127
|
+
`Already recorded: ${result.alreadyRecorded}`,
|
|
128
|
+
`Unmatched: ${result.unmatched.length}`,
|
|
129
|
+
`Rejected: ${result.rejected.length}`,
|
|
130
|
+
];
|
|
131
|
+
for (const item of result.unmatched)
|
|
132
|
+
lines.push(` unmatched: ${item.title} — ${item.reason}`);
|
|
133
|
+
for (const item of result.rejected)
|
|
134
|
+
lines.push(` rejected: ${item.reason}`);
|
|
135
|
+
return lines.join('\n');
|
|
136
|
+
});
|
|
137
|
+
});
|
|
138
|
+
// ── classify ─────────────────────────────────────────────────────────────────
|
|
139
|
+
const classifyCommand = addCommonOptions(new commander_1.Command('classify'))
|
|
140
|
+
.description('Say whether a recurrence was ignored (the entry was in context) or not-offered (it never reached the agent), because the two need different fixes')
|
|
141
|
+
.option('--entry <id>', 'The entry id, for an entry that carries one')
|
|
142
|
+
.option('--title <title>', 'The exact entry title, for an entry with no id yet. Use with --family')
|
|
143
|
+
.option('--family <family>', 'The entry family: mistake-patterns, preferences, validated-patterns, or manager-coaching')
|
|
144
|
+
.requiredOption('--job <name>', 'The job the recurrence happened in')
|
|
145
|
+
.requiredOption('--date <YYYY-MM-DD>', 'The date the mistake recurred')
|
|
146
|
+
.option('--window <days>', 'How far back an offer still counts as in-context', '30')
|
|
147
|
+
.action((options) => {
|
|
148
|
+
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
149
|
+
// `--title` plus `--family` exists so a caller never has to build the composite
|
|
150
|
+
// title key by hand. An entry title is corpus content and can contain any
|
|
151
|
+
// character, so an agent assembling `T:<family>:<title>` into a shell command
|
|
152
|
+
// would be interpolating untrusted text into a command line. Passing the title
|
|
153
|
+
// as its own argument keeps it a single argv entry.
|
|
154
|
+
let key;
|
|
155
|
+
if (options.entry) {
|
|
156
|
+
key = String(options.entry);
|
|
157
|
+
}
|
|
158
|
+
else if (options.title && options.family) {
|
|
159
|
+
key = (0, learning_usage_store_1.titleUsageKey)(String(options.family), String(options.title));
|
|
160
|
+
}
|
|
161
|
+
else {
|
|
162
|
+
throw new Error('Pass --entry <id>, or --title <title> together with --family <family>.');
|
|
163
|
+
}
|
|
164
|
+
const store = (0, learning_usage_store_1.readUsageStore)();
|
|
165
|
+
const diagnosis = (0, learning_usage_analysis_1.classifyRecurrence)(store, {
|
|
166
|
+
key,
|
|
167
|
+
job: options.job,
|
|
168
|
+
date: options.date,
|
|
169
|
+
windowDays: Number.parseInt(options.window, 10) || 30,
|
|
170
|
+
});
|
|
171
|
+
void workspaceRoot;
|
|
172
|
+
emit(diagnosis, options.json, () => [
|
|
173
|
+
`Classification: ${diagnosis.classification}`,
|
|
174
|
+
`Diagnosis: ${diagnosis.diagnosis}`,
|
|
175
|
+
`Recommendation: ${diagnosis.recommendation}`,
|
|
176
|
+
`Raise the recurrence count: ${diagnosis.recurrenceBump ? 'yes' : 'no'}`,
|
|
177
|
+
].join('\n'));
|
|
178
|
+
});
|
|
179
|
+
// ── candidates ───────────────────────────────────────────────────────────────
|
|
180
|
+
const candidatesCommand = addCommonOptions(new commander_1.Command('candidates'))
|
|
181
|
+
.description('List retirement candidates: offered often under the current model with no firing under it, paired with a structural signal')
|
|
182
|
+
.option('--model <model>', 'Override the current model (defaults to the most recently offered-to model)')
|
|
183
|
+
.option('--detector <path>', 'Path to the corpus hygiene detector that provides the structural signal')
|
|
184
|
+
.action((options) => {
|
|
185
|
+
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
186
|
+
const limits = (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
|
|
187
|
+
const result = (0, learning_usage_analysis_1.findRetirementCandidates)((0, learning_usage_store_1.readUsageStore)(), {
|
|
188
|
+
workspaceRoot,
|
|
189
|
+
limits,
|
|
190
|
+
currentModel: options.model ?? undefined,
|
|
191
|
+
structuralSignalPath: options.detector,
|
|
192
|
+
});
|
|
193
|
+
emit(result, options.json, () => {
|
|
194
|
+
if (result.candidates.length === 0) {
|
|
195
|
+
return `No retirement candidates. Current model: ${result.currentModel ?? 'unknown'}; threshold ${result.offerThreshold} offers.`;
|
|
196
|
+
}
|
|
197
|
+
const lines = [`Current model: ${result.currentModel ?? 'unknown'}. Threshold: ${result.offerThreshold} offers.`, ''];
|
|
198
|
+
for (const c of result.candidates) {
|
|
199
|
+
const earlier = c.firedUnderEarlierModels.map((m) => `${m.count} under ${m.model}`).join(', ') || 'none under any earlier model';
|
|
200
|
+
lines.push(`- ${c.title}`, ` Offered ${c.offeredUnderCurrentModel} times under the current model, fired ${c.firedUnderCurrentModel}. Earlier: ${earlier}.`, ` Structural signal: ${c.structural}. ${c.structuralDetail}`, ` Recommendation: ${c.recommendation}`);
|
|
201
|
+
}
|
|
202
|
+
return lines.join('\n');
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
// ── standing ─────────────────────────────────────────────────────────────────
|
|
206
|
+
const standingCommand = addCommonOptions(new commander_1.Command('standing'))
|
|
207
|
+
.description('Assess each entry as promoted, standard or demoted from its usage record, and list the ones whose standing should change')
|
|
208
|
+
.option('--changes-only', 'Print only the entries whose standing should change')
|
|
209
|
+
.action((options) => {
|
|
210
|
+
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
211
|
+
const limits = (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
|
|
212
|
+
const report = (0, learning_usage_analysis_1.assessStanding)((0, learning_usage_store_1.readUsageStore)(), { limits });
|
|
213
|
+
const payload = options.changesOnly ? { ...report, assessments: report.changes } : report;
|
|
214
|
+
emit(payload, options.json, () => {
|
|
215
|
+
const rows = (options.changesOnly ? report.changes : report.assessments);
|
|
216
|
+
if (rows.length === 0) {
|
|
217
|
+
return options.changesOnly
|
|
218
|
+
? 'No standing changes. Every measured entry is already where the record says it belongs.'
|
|
219
|
+
: 'No entry has a usage record yet, so there is nothing to assess.';
|
|
220
|
+
}
|
|
221
|
+
const lines = [];
|
|
222
|
+
if (report.changes.length > 0) {
|
|
223
|
+
lines.push(`${report.changes.length} entr${report.changes.length === 1 ? 'y' : 'ies'} should change standing.`, '');
|
|
224
|
+
}
|
|
225
|
+
for (const a of rows) {
|
|
226
|
+
lines.push(`- ${a.title}`);
|
|
227
|
+
lines.push(` ${a.standing}${a.recommendation ? ` (recommend: ${a.recommendation})` : ''}. ${a.reason}`);
|
|
228
|
+
}
|
|
229
|
+
lines.push('', 'Nothing is applied here. Run sleep-on-learnings to decide.');
|
|
230
|
+
return lines.join('\n');
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
// ── report ───────────────────────────────────────────────────────────────────
|
|
234
|
+
const reportCommand = addCommonOptions(new commander_1.Command('report'))
|
|
235
|
+
.description('Answer which learnings are still earning their place, from the record rather than from memory')
|
|
236
|
+
.option('--window <days>', 'Reporting window', '90')
|
|
237
|
+
.action((options) => {
|
|
238
|
+
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
239
|
+
const limits = (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
|
|
240
|
+
const report = (0, learning_usage_analysis_1.buildUsageReport)((0, learning_usage_store_1.readUsageStore)(), {
|
|
241
|
+
workspaceRoot,
|
|
242
|
+
limits,
|
|
243
|
+
windowDays: Number.parseInt(options.window, 10) || 90,
|
|
244
|
+
});
|
|
245
|
+
emit(report, options.json, () => {
|
|
246
|
+
const lines = [
|
|
247
|
+
`${report.totals.entriesOffered} entries were offered. ${report.totals.entriesWithFirings} fired. ${report.totals.entriesNeverFired} have never fired.`,
|
|
248
|
+
];
|
|
249
|
+
if (report.workingHardest.length) {
|
|
250
|
+
lines.push('', 'Working hardest');
|
|
251
|
+
report.workingHardest.forEach((e, i) => lines.push(`${i + 1}. ${e.title}. Offered ${e.offered} times, fired ${e.fired}. Last fired ${e.lastFired ?? 'never'}.`));
|
|
252
|
+
}
|
|
253
|
+
if (report.neverFired.length) {
|
|
254
|
+
lines.push('', 'Never fired');
|
|
255
|
+
report.neverFired.forEach((e, i) => lines.push(`${i + 1}. ${e.title}. Offered ${e.offered} times, never fired.`));
|
|
256
|
+
}
|
|
257
|
+
if (report.ignoredWhileInContext.length) {
|
|
258
|
+
lines.push('', 'Ignored while in context');
|
|
259
|
+
report.ignoredWhileInContext.forEach((e, i) => lines.push(`${i + 1}. ${e.title}. In context and the work went against it, so the entry needs work rather than another recurrence.`));
|
|
260
|
+
}
|
|
261
|
+
lines.push('', 'Nothing is retired from this view. Run sleep-on-learnings to decide.');
|
|
262
|
+
return lines.join('\n');
|
|
263
|
+
});
|
|
264
|
+
});
|
|
265
|
+
// ── backfill ─────────────────────────────────────────────────────────────────
|
|
266
|
+
const backfillCommand = addCommonOptions(new commander_1.Command('backfill'))
|
|
267
|
+
.description('Read the firings already attested in existing retrospectives, matching only on an id or an exact title, and report what could not be matched')
|
|
268
|
+
.option('--agent <name>', 'Agent to attribute the backfilled records to', 'backfill')
|
|
269
|
+
.option('--model <model>', 'Model to attribute the backfilled records to')
|
|
270
|
+
.action((options) => {
|
|
271
|
+
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
272
|
+
const user = resolveUser(options);
|
|
273
|
+
const report = (0, learning_usage_attestation_1.runBackfill)(workspaceRoot, user, {
|
|
274
|
+
agent: options.agent ?? 'backfill',
|
|
275
|
+
model: options.model ?? null,
|
|
276
|
+
});
|
|
277
|
+
emit(report, options.json, () => [
|
|
278
|
+
`Files scanned: ${report.filesScanned} (${report.filesWithSection} carry the section, ${report.synthesizedFilesRead} already synthesized)`,
|
|
279
|
+
`Items read: ${report.itemsRead}`,
|
|
280
|
+
`Applied: ${report.applied}`,
|
|
281
|
+
`Already recorded: ${report.alreadyRecorded}`,
|
|
282
|
+
`Unmatched: ${report.unmatched.length}`,
|
|
283
|
+
`Rejected: ${report.rejected.length}`,
|
|
284
|
+
'',
|
|
285
|
+
'Only an id or an exact title resolves. A low match rate is expected on retrospectives written before entries carried ids, and is reported rather than resolved by similarity matching.',
|
|
286
|
+
].join('\n'));
|
|
287
|
+
});
|
|
288
|
+
/**
|
|
289
|
+
* Add the `**Id**` line to entries that do not have one.
|
|
290
|
+
*
|
|
291
|
+
* This is a format migration, not a change to any lesson: it inserts one
|
|
292
|
+
* identifier line per entry and touches nothing else. R19 forbids a new write path
|
|
293
|
+
* into the corpus for usage data, and this is not one — it writes no usage data,
|
|
294
|
+
* and it is run explicitly by the manager rather than by any automatic path.
|
|
295
|
+
*/
|
|
296
|
+
function stampFile(filePath, fileType, apply) {
|
|
297
|
+
const content = node_fs_1.default.readFileSync(filePath, 'utf8');
|
|
298
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
299
|
+
const lines = content.split(/\r?\n/);
|
|
300
|
+
const headingRe = (0, learning_context_builder_1.learningEntryHeadingRegex)();
|
|
301
|
+
const idRe = (0, learning_context_builder_1.learningEntryIdLineRegex)();
|
|
302
|
+
const out = [];
|
|
303
|
+
let stamped = 0;
|
|
304
|
+
let alreadyStamped = 0;
|
|
305
|
+
for (let i = 0; i < lines.length; i++) {
|
|
306
|
+
const line = lines[i];
|
|
307
|
+
out.push(line);
|
|
308
|
+
const header = line.match(headingRe);
|
|
309
|
+
if (!header)
|
|
310
|
+
continue;
|
|
311
|
+
// Look ahead to the next entry heading for an existing id line.
|
|
312
|
+
let hasId = false;
|
|
313
|
+
for (let j = i + 1; j < lines.length; j++) {
|
|
314
|
+
if (headingRe.test(lines[j]))
|
|
315
|
+
break;
|
|
316
|
+
if (idRe.test(lines[j].trim())) {
|
|
317
|
+
hasId = true;
|
|
318
|
+
break;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (hasId) {
|
|
322
|
+
alreadyStamped++;
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
const title = header[2].trim();
|
|
326
|
+
if (!title)
|
|
327
|
+
continue;
|
|
328
|
+
// Insert immediately after the heading, keeping the blank line that usually
|
|
329
|
+
// follows it so the file's shape is unchanged.
|
|
330
|
+
if (lines[i + 1] !== undefined && lines[i + 1].trim() === '') {
|
|
331
|
+
out.push('');
|
|
332
|
+
out.push(`**Id**: ${(0, learning_context_builder_1.generateLearningEntryId)(fileType, title)}`);
|
|
333
|
+
i++;
|
|
334
|
+
}
|
|
335
|
+
else {
|
|
336
|
+
out.push(`**Id**: ${(0, learning_context_builder_1.generateLearningEntryId)(fileType, title)}`);
|
|
337
|
+
}
|
|
338
|
+
stamped++;
|
|
339
|
+
}
|
|
340
|
+
if (apply && stamped > 0) {
|
|
341
|
+
node_fs_1.default.writeFileSync(filePath, out.join(eol), 'utf8');
|
|
342
|
+
}
|
|
343
|
+
return { file: filePath, entriesStamped: stamped, entriesAlreadyStamped: alreadyStamped };
|
|
344
|
+
}
|
|
345
|
+
const FILE_TYPES = ['mistake-patterns', 'preferences', 'validated-patterns', 'manager-coaching'];
|
|
346
|
+
function fileTypeFromName(fileName) {
|
|
347
|
+
for (const type of FILE_TYPES) {
|
|
348
|
+
if (fileName.includes(type))
|
|
349
|
+
return type;
|
|
350
|
+
}
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
const stampIdsCommand = addCommonOptions(new commander_1.Command('stamp-ids'))
|
|
354
|
+
.description('Add the stable Id line to learning entries that do not have one. A format migration: it inserts one identifier line per entry and changes no lesson text')
|
|
355
|
+
.option('--dir <path>', 'Learning directory to stamp (repeatable)', (value, previous) => [...previous, value], [])
|
|
356
|
+
.option('--apply', 'Write the changes. Without this flag the command reports what it would do')
|
|
357
|
+
.action((options) => {
|
|
358
|
+
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
359
|
+
const dirs = options.dir.length
|
|
360
|
+
? options.dir.map((d) => node_path_1.default.resolve(d))
|
|
361
|
+
: [node_path_1.default.join(workspaceRoot, 'fraim', 'personalized-employee', 'learnings')];
|
|
362
|
+
const results = [];
|
|
363
|
+
for (const dir of dirs) {
|
|
364
|
+
if (!node_fs_1.default.existsSync(dir))
|
|
365
|
+
continue;
|
|
366
|
+
for (const fileName of node_fs_1.default.readdirSync(dir)) {
|
|
367
|
+
if (!fileName.endsWith('.md'))
|
|
368
|
+
continue;
|
|
369
|
+
const fileType = fileTypeFromName(fileName);
|
|
370
|
+
if (!fileType)
|
|
371
|
+
continue;
|
|
372
|
+
results.push(stampFile(node_path_1.default.join(dir, fileName), fileType, Boolean(options.apply)));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
const payload = {
|
|
376
|
+
apply: Boolean(options.apply),
|
|
377
|
+
filesScanned: results.length,
|
|
378
|
+
entriesStamped: results.reduce((sum, r) => sum + r.entriesStamped, 0),
|
|
379
|
+
entriesAlreadyStamped: results.reduce((sum, r) => sum + r.entriesAlreadyStamped, 0),
|
|
380
|
+
files: results,
|
|
381
|
+
};
|
|
382
|
+
emit(payload, options.json, () => [
|
|
383
|
+
`${payload.apply ? 'Stamped' : 'Would stamp'} ${payload.entriesStamped} entries across ${payload.filesScanned} files.`,
|
|
384
|
+
`${payload.entriesAlreadyStamped} entries already carry an id.`,
|
|
385
|
+
payload.apply ? '' : 'Re-run with --apply to write the changes.',
|
|
386
|
+
].filter(Boolean).join('\n'));
|
|
387
|
+
});
|
|
388
|
+
// ── prune ────────────────────────────────────────────────────────────────────
|
|
389
|
+
const pruneCommand = addCommonOptions(new commander_1.Command('prune'))
|
|
390
|
+
.description('Apply the configured retention window and log bounds to the usage record. Aggregate counters are never pruned')
|
|
391
|
+
.action((options) => {
|
|
392
|
+
const workspaceRoot = resolveWorkspaceRoot(options);
|
|
393
|
+
const limits = (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
|
|
394
|
+
const result = (0, learning_usage_store_1.pruneUsageStore)(limits);
|
|
395
|
+
emit({ ...result, storePath: (0, learning_usage_store_1.resolveUsageStorePath)(), limits }, options.json, () => `Pruned ${result.prunedOfferRecords} offer records and ${result.prunedFiringRecords} firing records across ${result.entries} entries. Aggregates unchanged.`);
|
|
396
|
+
});
|
|
397
|
+
// ── root ─────────────────────────────────────────────────────────────────────
|
|
398
|
+
exports.learningUsageCommand = new commander_1.Command('learning-usage')
|
|
399
|
+
.description('Read and analyse the learning and rule usage record (issue #1103)')
|
|
400
|
+
.addCommand(offersCommand)
|
|
401
|
+
.addCommand(recordFiringsCommand)
|
|
402
|
+
.addCommand(classifyCommand)
|
|
403
|
+
.addCommand(candidatesCommand)
|
|
404
|
+
.addCommand(standingCommand)
|
|
405
|
+
.addCommand(reportCommand)
|
|
406
|
+
.addCommand(backfillCommand)
|
|
407
|
+
.addCommand(stampIdsCommand)
|
|
408
|
+
.addCommand(pruneCommand);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildUsageLookup = buildUsageLookup;
|
|
4
|
+
exports.lookupUsage = lookupUsage;
|
|
5
|
+
exports.resolveCurrentModel = resolveCurrentModel;
|
|
6
|
+
/**
|
|
7
|
+
* Issue #1103 — projecting the usage record into the shapes its readers need.
|
|
8
|
+
*
|
|
9
|
+
* The store owns writing and persistence; this owns turning what is stored into
|
|
10
|
+
* what a scorer, a surface, or a report asks for. Split out because those are two
|
|
11
|
+
* jobs with different reasons to change: the record's schema changes when the
|
|
12
|
+
* privacy or retention contract changes, and these projections change when a
|
|
13
|
+
* consumer needs a different view.
|
|
14
|
+
*
|
|
15
|
+
* Nothing here writes.
|
|
16
|
+
*/
|
|
17
|
+
const learning_usage_store_1 = require("./learning-usage-store");
|
|
18
|
+
function toLookupValue(record) {
|
|
19
|
+
return {
|
|
20
|
+
key: record.key,
|
|
21
|
+
offered: record.offered.total,
|
|
22
|
+
offeredAboveThreshold: record.offered.aboveThreshold,
|
|
23
|
+
fired: record.fired.byOutcome.prevented + record.fired.byOutcome.applied,
|
|
24
|
+
firedTotalAttestations: record.fired.total,
|
|
25
|
+
lastFired: record.fired.lastFiredAt,
|
|
26
|
+
lastOffered: record.offered.lastOfferedAt,
|
|
27
|
+
firedUnder: Object.keys(record.fired.byModel),
|
|
28
|
+
byOutcome: record.fired.byOutcome,
|
|
29
|
+
log: record.fired.log,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* A lookup keyed by every identifier that resolves to a record: the entry id, and
|
|
34
|
+
* every title the entry has been known by. That is what lets a scorer look up an
|
|
35
|
+
* entry by whichever handle it has.
|
|
36
|
+
*/
|
|
37
|
+
function buildUsageLookup(store) {
|
|
38
|
+
const lookup = new Map();
|
|
39
|
+
for (const record of Object.values(store.entries)) {
|
|
40
|
+
const value = toLookupValue(record);
|
|
41
|
+
lookup.set(record.key, value);
|
|
42
|
+
if (record.fileType) {
|
|
43
|
+
for (const title of record.titles) {
|
|
44
|
+
lookup.set((0, learning_usage_store_1.titleUsageKey)(record.fileType, title), value);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return lookup;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Resolve the usage for one entry from whichever handles it has. Prefers the id,
|
|
52
|
+
* because a title is only a fallback for entries the id migration has not reached.
|
|
53
|
+
*/
|
|
54
|
+
function lookupUsage(lookup, id, fileType, title) {
|
|
55
|
+
if (id) {
|
|
56
|
+
const byId = lookup.get(id);
|
|
57
|
+
if (byId)
|
|
58
|
+
return byId;
|
|
59
|
+
}
|
|
60
|
+
return lookup.get((0, learning_usage_store_1.titleUsageKey)(fileType, title));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* The model with the most recent offer. Used as "the current model" for the
|
|
64
|
+
* per-model retirement question, so it follows the agent in use rather than
|
|
65
|
+
* needing to be configured.
|
|
66
|
+
*/
|
|
67
|
+
function resolveCurrentModel(store) {
|
|
68
|
+
let bestDate = '';
|
|
69
|
+
let bestModel = null;
|
|
70
|
+
for (const record of Object.values(store.entries)) {
|
|
71
|
+
for (const entry of record.offered.log) {
|
|
72
|
+
if (entry.model && entry.date >= bestDate) {
|
|
73
|
+
bestDate = entry.date;
|
|
74
|
+
bestModel = entry.model;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return bestModel;
|
|
79
|
+
}
|