fraim 2.0.346 → 2.0.348
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/registry/scripts/learning/completion-state.js +80 -4
- package/dist/registry/scripts/learning/corpus-audit.js +7 -7
- package/dist/registry/scripts/learning/entry-authoring.js +181 -0
- package/dist/registry/scripts/learning/entry-format.js +10 -1
- package/dist/registry/scripts/learning/outcome-assessment.js +162 -0
- package/dist/registry/scripts/learning/outcome-types.js +92 -0
- package/dist/registry/scripts/learning/owner-identity.js +11 -0
- package/dist/registry/scripts/learning/proposal-queue.js +81 -0
- package/dist/registry/scripts/learning/run-application.js +1003 -0
- package/dist/registry/scripts/learning/run-publication.js +364 -0
- package/dist/registry/scripts/learning/run-record.js +895 -0
- package/dist/registry/scripts/learning-usage/learning-source-record.js +131 -0
- package/dist/src/cli/commands/learning.js +64 -0
- package/dist/src/cli/doctor/checks/scripts-checks.js +53 -1
- package/dist/src/cli/fraim.js +2 -0
- package/dist/src/cli/learning/inventory.js +285 -0
- package/dist/src/cli/learning/queue-operations.js +199 -0
- package/dist/src/cli/learning/runtime.js +82 -0
- package/dist/src/cli/learning/usage.js +277 -0
- package/dist/src/core/fraim-config-schema.generated.js +0 -4
- package/dist/src/local-mcp-server/learning-context-builder.js +91 -377
- package/dist/src/local-mcp-server/learning-entry-format.js +25 -0
- package/dist/src/local-mcp-server/learning-usage-analysis.js +12 -410
- package/dist/src/local-mcp-server/learning-usage-eligibility.js +8 -14
- package/dist/src/local-mcp-server/learning-usage-projection.js +5 -22
- package/dist/src/local-mcp-server/learning-usage-store.js +93 -393
- package/dist/src/local-mcp-server/learning-usage-verify.js +9 -40
- package/dist/src/local-mcp-server/stdio-server.js +0 -48
- package/package.json +9 -1
- package/dist/src/local-mcp-server/learning-firing-parser.js +0 -247
- package/dist/src/local-mcp-server/learning-usage-attestation.js +0 -238
- package/dist/src/local-mcp-server/learning-usage-command.js +0 -518
|
@@ -4,28 +4,8 @@ exports.COMPACTION_ADVISORY_BYTES = void 0;
|
|
|
4
4
|
exports.listUnmergedStoreCopies = listUnmergedStoreCopies;
|
|
5
5
|
exports.findTwinPairs = findTwinPairs;
|
|
6
6
|
exports.buildVerifyReport = buildVerifyReport;
|
|
7
|
-
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* Every defect in issue #1752 was found by a manager reading raw JSON during a
|
|
11
|
-
* `sleep-on-learnings` run, one at a time, over one session. This is the command
|
|
12
|
-
* that answers "can I trust what the record just told me" before acting on a
|
|
13
|
-
* recommendation, and it is the regression guard that makes every defect class
|
|
14
|
-
* visible in one place instead of one at a time.
|
|
15
|
-
*
|
|
16
|
-
* It reports and never blocks. Nothing here treats a finding as a gate: it states
|
|
17
|
-
* what it found, and the manager decides.
|
|
18
|
-
*
|
|
19
|
-
* ## Recovery boundary
|
|
20
|
-
*
|
|
21
|
-
* Rebuild only the snapshot, keeping the event directory intact. The retained
|
|
22
|
-
* events recover the offers and firings they cover; retrospective backfill can
|
|
23
|
-
* supplement older firings but cannot recreate offers. Normal compaction keeps
|
|
24
|
-
* lifetime aggregate totals in the snapshot, so a backup is required before
|
|
25
|
-
* discarding it if history already pruned from events must remain inspectable.
|
|
26
|
-
* Rebuilding cannot invent missing history or fix identities still present in
|
|
27
|
-
* the authoritative events. Verify reports those limitations rather than
|
|
28
|
-
* claiming that deleting the usage directory is a lossless repair.
|
|
7
|
+
/** Explicit historical-store diagnostics. Snapshot/checkpoint/journal provenance must
|
|
8
|
+
* establish recovery; no diagnostic can reconstruct already discarded observations.
|
|
29
9
|
*/
|
|
30
10
|
const fs_1 = require("fs");
|
|
31
11
|
const path_1 = require("path");
|
|
@@ -134,22 +114,12 @@ function findTwinPairs(entries) {
|
|
|
134
114
|
* Split out of `buildVerifyReport` so that function reads as what it is: an
|
|
135
115
|
* assembly of independently-computed parts.
|
|
136
116
|
*/
|
|
137
|
-
function scanRecordsForIntegrity(records
|
|
138
|
-
const invariantViolations = [];
|
|
117
|
+
function scanRecordsForIntegrity(records) {
|
|
139
118
|
const caseVariantModelBuckets = [];
|
|
140
119
|
const unknownModelAttribution = [];
|
|
141
120
|
for (const record of records) {
|
|
142
121
|
const title = record.titles[record.titles.length - 1] ?? record.key;
|
|
143
|
-
const
|
|
144
|
-
if (violation) {
|
|
145
|
-
invariantViolations.push({
|
|
146
|
-
key: record.key, title,
|
|
147
|
-
offered: record.offered.total,
|
|
148
|
-
firedInEra: record.offered.total + violation.count,
|
|
149
|
-
detail: violation.detail,
|
|
150
|
-
});
|
|
151
|
-
}
|
|
152
|
-
for (const buckets of [record.offered.byModel, record.fired.byModel]) {
|
|
122
|
+
for (const buckets of [record.fired.byModel]) {
|
|
153
123
|
const byNormalized = new Map();
|
|
154
124
|
for (const raw of Object.keys(buckets)) {
|
|
155
125
|
const normalized = (0, learning_usage_store_1.normalizeModelName)(raw) ?? raw;
|
|
@@ -161,15 +131,15 @@ function scanRecordsForIntegrity(records, offersEraStart) {
|
|
|
161
131
|
}
|
|
162
132
|
}
|
|
163
133
|
}
|
|
164
|
-
// Only a
|
|
134
|
+
// Only a positive outcome that produced no bucket means the model went
|
|
165
135
|
// unrecorded. An entry whose outcomes are all `ignored` has an empty bucket
|
|
166
136
|
// set correctly, and that is information rather than a gap.
|
|
167
|
-
const
|
|
137
|
+
const positiveOutcomes = record.fired.byOutcome.prevented + record.fired.byOutcome.applied;
|
|
168
138
|
if ((0, learning_usage_eligibility_1.modelAttributionIncomplete)(record)) {
|
|
169
|
-
unknownModelAttribution.push({ key: record.key, title, fired:
|
|
139
|
+
unknownModelAttribution.push({ key: record.key, title, fired: positiveOutcomes });
|
|
170
140
|
}
|
|
171
141
|
}
|
|
172
|
-
return {
|
|
142
|
+
return { caseVariantModelBuckets, unknownModelAttribution };
|
|
173
143
|
}
|
|
174
144
|
function buildVerifyReport(options) {
|
|
175
145
|
const now = options.now ?? new Date();
|
|
@@ -178,7 +148,7 @@ function buildVerifyReport(options) {
|
|
|
178
148
|
const snapshot = (0, learning_usage_store_1.readUsageSnapshot)(options.snapshotPath);
|
|
179
149
|
const logs = (0, learning_usage_events_1.listEventLogs)(eventDir);
|
|
180
150
|
const records = Object.values(store.entries);
|
|
181
|
-
const {
|
|
151
|
+
const { caseVariantModelBuckets, unknownModelAttribution } = scanRecordsForIntegrity(records);
|
|
182
152
|
const twins = findTwinPairs(store.entries);
|
|
183
153
|
const storeCopies = listUnmergedStoreCopies(options.snapshotPath);
|
|
184
154
|
const checkpoints = snapshot.foldedThroughBytes ?? {};
|
|
@@ -191,7 +161,6 @@ function buildVerifyReport(options) {
|
|
|
191
161
|
entries: records.length,
|
|
192
162
|
twinPairs: twins.pairs,
|
|
193
163
|
unmatchedTitleKeys: twins.unmatched,
|
|
194
|
-
invariantViolations,
|
|
195
164
|
caseVariantModelBuckets,
|
|
196
165
|
unknownModelAttribution,
|
|
197
166
|
storeCopies,
|
|
@@ -77,7 +77,6 @@ const usage_collector_js_1 = require("./usage-collector.js");
|
|
|
77
77
|
const otlp_metrics_receiver_js_1 = require("./otlp-metrics-receiver.js");
|
|
78
78
|
const token_adapter_registry_js_1 = require("./token-adapter-registry.js");
|
|
79
79
|
const learning_context_builder_js_1 = require("./learning-context-builder.js");
|
|
80
|
-
const learning_usage_store_js_1 = require("./learning-usage-store.js");
|
|
81
80
|
const learning_domains_js_1 = require("../config/learning-domains.js");
|
|
82
81
|
const skill_include_dedup_js_1 = require("./skill-include-dedup.js");
|
|
83
82
|
const git_utils_1 = require("../core/utils/git-utils");
|
|
@@ -1731,7 +1730,6 @@ class FraimLocalMCPServer {
|
|
|
1731
1730
|
responseText += teamContextSection;
|
|
1732
1731
|
this.log(`[req:${requestId}] Injected team context from ${workspaceRoot}`);
|
|
1733
1732
|
}
|
|
1734
|
-
this.recordUsageOffers(workspaceRoot, userEmail, false, null, 'session', requestId);
|
|
1735
1733
|
}
|
|
1736
1734
|
if (this.latestConnectSyncWarning) {
|
|
1737
1735
|
responseText += `\n\n## Local Catalog\n${this.latestConnectSyncWarning}`;
|
|
@@ -1774,54 +1772,8 @@ class FraimLocalMCPServer {
|
|
|
1774
1772
|
text += `\n\n---` + learningSection + teamContextSection;
|
|
1775
1773
|
this.log(`[req:${requestId}] Injected job-focus learning/team context for ${userEmail} (domain: ${jobDomain ?? 'global'})`);
|
|
1776
1774
|
}
|
|
1777
|
-
this.recordUsageOffers(workspaceRoot, userEmail, true, jobDomain, String(jobName ?? 'unknown'), requestId);
|
|
1778
1775
|
return text;
|
|
1779
1776
|
}
|
|
1780
|
-
/**
|
|
1781
|
-
* Issue #1103 R1, R2 and R3: record what this context injection delivered.
|
|
1782
|
-
*
|
|
1783
|
-
* Written here because this is the only place that knows all of it at once: the
|
|
1784
|
-
* files that were listed, the job, the agent, and the model. Nothing depends on
|
|
1785
|
-
* the agent reading anything, which is R2, and the three auto-loaded rule files
|
|
1786
|
-
* get a record for the first time, which is R3.
|
|
1787
|
-
*
|
|
1788
|
-
* Never throws and never blocks the response. Usage data is derived and optional:
|
|
1789
|
-
* a failure here must not change what the agent is given.
|
|
1790
|
-
*/
|
|
1791
|
-
recordUsageOffers(workspaceRoot, userEmail, forJob, domain, job, requestId) {
|
|
1792
|
-
try {
|
|
1793
|
-
const offered = [
|
|
1794
|
-
...(0, learning_context_builder_js_1.collectOfferedLearningEntries)(workspaceRoot, userEmail, forJob, domain),
|
|
1795
|
-
...(0, learning_context_builder_js_1.collectOfferedRuleFiles)(workspaceRoot, forJob),
|
|
1796
|
-
];
|
|
1797
|
-
if (offered.length === 0)
|
|
1798
|
-
return;
|
|
1799
|
-
const collector = this.usageCollector;
|
|
1800
|
-
const date = new Date().toISOString().slice(0, 10);
|
|
1801
|
-
const limits = (0, learning_usage_store_js_1.resolveUsageLimits)(workspaceRoot);
|
|
1802
|
-
const result = (0, learning_usage_store_js_1.recordOffers)(offered.map((entry) => ({
|
|
1803
|
-
key: entry.key,
|
|
1804
|
-
family: entry.family,
|
|
1805
|
-
title: entry.title,
|
|
1806
|
-
fileType: entry.fileType,
|
|
1807
|
-
level: entry.level,
|
|
1808
|
-
aboveThreshold: entry.aboveThreshold,
|
|
1809
|
-
job,
|
|
1810
|
-
date,
|
|
1811
|
-
agent: collector?.getAgentName() ?? null,
|
|
1812
|
-
// Issue #1752 C2: one model, one bucket. `GPT-5` and `gpt-5` were two,
|
|
1813
|
-
// splitting the denominator of every per-model conclusion about it.
|
|
1814
|
-
model: (0, learning_usage_store_js_1.normalizeModelName)(collector?.getAgentModel()),
|
|
1815
|
-
})), { limits });
|
|
1816
|
-
this.log(`[req:${requestId}] Recorded ${result.recorded} usage offers for job ${job} (write mode: append)`);
|
|
1817
|
-
}
|
|
1818
|
-
catch (error) {
|
|
1819
|
-
// Issue #1752: name the log and the write mode. During the migration window
|
|
1820
|
-
// "which path did this write take" is the first question on any report of
|
|
1821
|
-
// missing data, and the answer was not in the logs.
|
|
1822
|
-
this.log(`[req:${requestId}] Usage offer recording skipped (append, log ${(0, learning_usage_store_js_1.resolveEventDir)((0, learning_usage_store_js_1.resolveUsageStorePath)())}): ${error instanceof Error ? error.message : String(error)}`);
|
|
1823
|
-
}
|
|
1824
|
-
}
|
|
1825
1777
|
/**
|
|
1826
1778
|
* Resolve the learning domain for a `get_fraim_job` request (issue #806) from
|
|
1827
1779
|
* the job's registry path, so the loader injects global + that domain's
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "fraim",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.348",
|
|
4
4
|
"description": "FRAIM core CLI and MCP package.",
|
|
5
5
|
"main": "index.js",
|
|
6
6
|
"bin": {
|
|
@@ -13,7 +13,15 @@
|
|
|
13
13
|
"dist/registry/scripts/learning/entry-format.js",
|
|
14
14
|
"dist/registry/scripts/learning/completion-state.js",
|
|
15
15
|
"dist/registry/scripts/learning/corpus-audit.js",
|
|
16
|
+
"dist/registry/scripts/learning/entry-authoring.js",
|
|
16
17
|
"dist/registry/scripts/learning/owner-identity.js",
|
|
18
|
+
"dist/registry/scripts/learning/outcome-types.js",
|
|
19
|
+
"dist/registry/scripts/learning/outcome-assessment.js",
|
|
20
|
+
"dist/registry/scripts/learning/proposal-queue.js",
|
|
21
|
+
"dist/registry/scripts/learning/run-record.js",
|
|
22
|
+
"dist/registry/scripts/learning/run-application.js",
|
|
23
|
+
"dist/registry/scripts/learning/run-publication.js",
|
|
24
|
+
"dist/registry/scripts/learning-usage/learning-source-record.js",
|
|
17
25
|
"dist/src/local-mcp-server/",
|
|
18
26
|
"dist/src/cli/",
|
|
19
27
|
"!dist/src/cli/fraim-hub.js",
|
|
@@ -1,247 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,238 +0,0 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.extractLegacyTitle = exports.parseFiringSection = void 0;
|
|
4
|
-
exports.recordAttestationsFromRetrospective = recordAttestationsFromRetrospective;
|
|
5
|
-
exports.readRetrospectiveForAttestation = readRetrospectiveForAttestation;
|
|
6
|
-
exports.runBackfill = runBackfill;
|
|
7
|
-
const artifact_paths_1 = require("../../registry/scripts/artifact-paths");
|
|
8
|
-
/**
|
|
9
|
-
* Issue #1103 — turning an attestation into a usage record.
|
|
10
|
-
*
|
|
11
|
-
* The retrospective is the durable record of a firing; the usage store is derived
|
|
12
|
-
* from it (R17). So there is exactly one parser (`learning-firing-parser.ts`) and
|
|
13
|
-
* one recorder here, used by two entry points: the live attestation at the end of
|
|
14
|
-
* a job, and the backfill over the retrospectives already on disk (R15).
|
|
15
|
-
*
|
|
16
|
-
* Matching is an id or an exact title, and nothing else. R5 forbids resolving a
|
|
17
|
-
* prose reference to the closest title, and an unmatched item is reported rather
|
|
18
|
-
* than guessed at: a wrong match writes a firing onto the wrong entry, which is
|
|
19
|
-
* worse than no firing at all.
|
|
20
|
-
*/
|
|
21
|
-
const fs_1 = require("fs");
|
|
22
|
-
const path_1 = require("path");
|
|
23
|
-
const learning_source_record_1 = require("./learning-source-record");
|
|
24
|
-
const learning_usage_store_1 = require("./learning-usage-store");
|
|
25
|
-
const learning_firing_parser_1 = require("./learning-firing-parser");
|
|
26
|
-
const learning_context_builder_1 = require("./learning-context-builder");
|
|
27
|
-
// Re-exported so callers that only need one of the two halves keep one import.
|
|
28
|
-
var learning_firing_parser_2 = require("./learning-firing-parser");
|
|
29
|
-
Object.defineProperty(exports, "parseFiringSection", { enumerable: true, get: function () { return learning_firing_parser_2.parseFiringSection; } });
|
|
30
|
-
Object.defineProperty(exports, "extractLegacyTitle", { enumerable: true, get: function () { return learning_firing_parser_2.extractLegacyTitle; } });
|
|
31
|
-
const ENTRY_INDEX_READS = [
|
|
32
|
-
{ scope: 'manager', level: 'machine' },
|
|
33
|
-
{ scope: 'manager', level: 'project' },
|
|
34
|
-
{ scope: 'org', level: 'machine' },
|
|
35
|
-
{ scope: 'reverse', level: 'machine' },
|
|
36
|
-
{ scope: 'reverse', level: 'project' },
|
|
37
|
-
];
|
|
38
|
-
function buildEntryIndex(workspaceRoot, userId) {
|
|
39
|
-
const byId = new Map();
|
|
40
|
-
const byTitle = new Map();
|
|
41
|
-
// Keep this list in parity with the preserved-entry levels that the offer
|
|
42
|
-
// path can surface. Storage resolution stays inside readPreservedLearnings.
|
|
43
|
-
for (const { scope, level } of ENTRY_INDEX_READS) {
|
|
44
|
-
let entries = [];
|
|
45
|
-
try {
|
|
46
|
-
entries = (0, learning_context_builder_1.readPreservedLearnings)(workspaceRoot, userId, scope, level);
|
|
47
|
-
}
|
|
48
|
-
catch {
|
|
49
|
-
continue;
|
|
50
|
-
}
|
|
51
|
-
for (const entry of entries) {
|
|
52
|
-
const fileType = fileTypeOf(entry);
|
|
53
|
-
const attestable = {
|
|
54
|
-
// #1752 A1: one definition of what an entry is, shared with the offer
|
|
55
|
-
// path. These two disagreeing is defect 4.
|
|
56
|
-
key: (0, learning_usage_store_1.resolveEntryIdentity)({ id: entry.id, fileType, title: entry.title }).key,
|
|
57
|
-
family: 'learning',
|
|
58
|
-
title: entry.title,
|
|
59
|
-
fileType,
|
|
60
|
-
level: entry.level,
|
|
61
|
-
};
|
|
62
|
-
if (entry.id && !byId.has(entry.id))
|
|
63
|
-
byId.set(entry.id, attestable);
|
|
64
|
-
const titleKey = (0, learning_usage_store_1.normalizeEntryTitle)(entry.title);
|
|
65
|
-
if (!byTitle.has(titleKey))
|
|
66
|
-
byTitle.set(titleKey, attestable);
|
|
67
|
-
}
|
|
68
|
-
}
|
|
69
|
-
for (const rule of (0, learning_context_builder_1.collectOfferedRuleFiles)(workspaceRoot, true)) {
|
|
70
|
-
const attestable = {
|
|
71
|
-
key: rule.key,
|
|
72
|
-
family: 'rule',
|
|
73
|
-
title: rule.title,
|
|
74
|
-
fileType: rule.fileType,
|
|
75
|
-
level: rule.level,
|
|
76
|
-
};
|
|
77
|
-
if (!byId.has(rule.key))
|
|
78
|
-
byId.set(rule.key, attestable);
|
|
79
|
-
const titleKey = (0, learning_usage_store_1.normalizeEntryTitle)(rule.title);
|
|
80
|
-
if (!byTitle.has(titleKey))
|
|
81
|
-
byTitle.set(titleKey, attestable);
|
|
82
|
-
if (rule.id && !byId.has(rule.id))
|
|
83
|
-
byId.set(rule.id, attestable);
|
|
84
|
-
}
|
|
85
|
-
return { byId, byTitle };
|
|
86
|
-
}
|
|
87
|
-
function fileTypeOf(entry) {
|
|
88
|
-
return learning_context_builder_1.CATEGORY_TO_FILETYPE[entry.category] ?? 'mistake-patterns';
|
|
89
|
-
}
|
|
90
|
-
function retrospectiveDate(content, fallback) {
|
|
91
|
-
const match = content.match(/^date:\s*(\d{4}-\d{2}-\d{2})\s*$/m);
|
|
92
|
-
return match ? match[1] : fallback;
|
|
93
|
-
}
|
|
94
|
-
/**
|
|
95
|
-
* Record the firings a retrospective attests. Idempotent per (entry, source), so a
|
|
96
|
-
* re-run, or a backfill that overlaps a live attestation, cannot inflate a count.
|
|
97
|
-
*/
|
|
98
|
-
function recordAttestationsFromRetrospective(workspaceRoot, userId, options) {
|
|
99
|
-
const limits = options.limits ?? (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
|
|
100
|
-
const parsed = (0, learning_firing_parser_1.parseFiringSection)(options.content, { source: options.source, legacy: options.legacy });
|
|
101
|
-
const date = options.date ?? retrospectiveDate(options.content, new Date().toISOString().slice(0, 10));
|
|
102
|
-
const index = buildEntryIndex(workspaceRoot, userId);
|
|
103
|
-
const unmatched = [];
|
|
104
|
-
const inputs = [];
|
|
105
|
-
for (const item of parsed.items) {
|
|
106
|
-
let entry;
|
|
107
|
-
if (item.id) {
|
|
108
|
-
entry = index.byId.get(item.id);
|
|
109
|
-
if (!entry) {
|
|
110
|
-
unmatched.push({
|
|
111
|
-
id: item.id,
|
|
112
|
-
title: item.title,
|
|
113
|
-
reason: `No entry carries the id ${item.id}. Reported unmatched rather than assigned to the closest title.`,
|
|
114
|
-
raw: item.raw,
|
|
115
|
-
});
|
|
116
|
-
continue;
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
else {
|
|
120
|
-
entry = index.byTitle.get((0, learning_usage_store_1.normalizeEntryTitle)(item.title));
|
|
121
|
-
if (!entry) {
|
|
122
|
-
unmatched.push({
|
|
123
|
-
id: null,
|
|
124
|
-
title: item.title,
|
|
125
|
-
reason: 'No entry has exactly this title. Exact titles match; a paraphrase is reported rather than resolved by similarity.',
|
|
126
|
-
raw: item.raw,
|
|
127
|
-
});
|
|
128
|
-
continue;
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
inputs.push({
|
|
132
|
-
key: entry.key,
|
|
133
|
-
family: entry.family,
|
|
134
|
-
title: entry.title,
|
|
135
|
-
fileType: entry.fileType,
|
|
136
|
-
level: entry.level,
|
|
137
|
-
outcome: item.outcome,
|
|
138
|
-
note: item.note,
|
|
139
|
-
job: options.job,
|
|
140
|
-
date,
|
|
141
|
-
agent: options.agent,
|
|
142
|
-
model: options.model,
|
|
143
|
-
source: item.format === 'legacy' ? 'backfill-legacy' : 'attested',
|
|
144
|
-
sourceRef: options.source,
|
|
145
|
-
});
|
|
146
|
-
}
|
|
147
|
-
let applied = 0;
|
|
148
|
-
let alreadyRecorded = 0;
|
|
149
|
-
if (inputs.length > 0) {
|
|
150
|
-
const result = (0, learning_usage_store_1.recordFirings)(inputs, {
|
|
151
|
-
limits, now: options.now, storePath: options.storePath, integrityMode: options.integrityMode,
|
|
152
|
-
});
|
|
153
|
-
applied = result.recorded;
|
|
154
|
-
alreadyRecorded = result.alreadyRecorded;
|
|
155
|
-
}
|
|
156
|
-
return {
|
|
157
|
-
source: options.source,
|
|
158
|
-
sectionState: parsed.sectionState,
|
|
159
|
-
itemsRead: parsed.items.length + parsed.rejected.length,
|
|
160
|
-
applied,
|
|
161
|
-
alreadyRecorded,
|
|
162
|
-
unmatched,
|
|
163
|
-
rejected: parsed.rejected,
|
|
164
|
-
};
|
|
165
|
-
}
|
|
166
|
-
// ── R15: backfill ────────────────────────────────────────────────────────────
|
|
167
|
-
/** Resolve live attestation against its recorded home and immutable accounting identity. */
|
|
168
|
-
function readRetrospectiveForAttestation(workspaceRoot, userId, filePath) {
|
|
169
|
-
let reason = 'Retrospective is outside configured learning homes';
|
|
170
|
-
for (const home of (0, learning_context_builder_1.getLearningInputHomes)(workspaceRoot)) {
|
|
171
|
-
const result = (0, learning_context_builder_1.readLearningSourceFile)((0, path_1.resolve)(filePath), home.root, home.contentRoot);
|
|
172
|
-
if (result.status !== 'valid') {
|
|
173
|
-
reason = result.reason;
|
|
174
|
-
continue;
|
|
175
|
-
}
|
|
176
|
-
if (result.record.kind !== 'retrospective' || result.record.author !== userId) {
|
|
177
|
-
throw new Error('Retrospective kind or author does not match this attestation');
|
|
178
|
-
}
|
|
179
|
-
return { content: result.content, record: result.record };
|
|
180
|
-
}
|
|
181
|
-
throw new Error(`Invalid learning retrospective: ${reason}`);
|
|
182
|
-
}
|
|
183
|
-
/** Read only typed future reflections, including archives, with stable accounting identity. */
|
|
184
|
-
function runBackfill(workspaceRoot, userId, options) {
|
|
185
|
-
const limits = options.limits ?? (0, learning_usage_store_1.resolveUsageLimits)(workspaceRoot);
|
|
186
|
-
const report = {
|
|
187
|
-
filesScanned: 0, filesWithSection: 0, itemsRead: 0, applied: 0,
|
|
188
|
-
alreadyRecorded: 0, unmatched: [], rejected: [], synthesizedFilesRead: 0, invalidRecords: [],
|
|
189
|
-
};
|
|
190
|
-
const seen = new Set();
|
|
191
|
-
for (const home of (0, learning_context_builder_1.getLearningInputHomes)(workspaceRoot)) {
|
|
192
|
-
const paths = (0, artifact_paths_1.resolveLearningPaths)(home.root, home.contentRoot);
|
|
193
|
-
for (const dir of [paths.retrospectives, paths.archiveRetrospectives]) {
|
|
194
|
-
if (options.retrospectivesDir && (0, learning_source_record_1.normalizeLearningSourcePath)(dir) !== (0, learning_source_record_1.normalizeLearningSourcePath)(options.retrospectivesDir))
|
|
195
|
-
continue;
|
|
196
|
-
let files;
|
|
197
|
-
try {
|
|
198
|
-
if ((0, fs_1.lstatSync)(dir).isSymbolicLink())
|
|
199
|
-
continue;
|
|
200
|
-
files = (0, fs_1.readdirSync)(dir).filter(f => f.endsWith('.md'));
|
|
201
|
-
}
|
|
202
|
-
catch {
|
|
203
|
-
continue;
|
|
204
|
-
}
|
|
205
|
-
for (const file of files) {
|
|
206
|
-
const filePath = (0, path_1.join)(dir, file);
|
|
207
|
-
const parsed = (0, learning_context_builder_1.readLearningSourceFile)(filePath, home.root, home.contentRoot);
|
|
208
|
-
if (parsed.status !== 'valid') {
|
|
209
|
-
if (parsed.status === 'invalid')
|
|
210
|
-
report.invalidRecords.push({ path: filePath, reason: parsed.reason });
|
|
211
|
-
continue;
|
|
212
|
-
}
|
|
213
|
-
const { record } = parsed;
|
|
214
|
-
if (record.kind !== 'retrospective' || record.author !== userId)
|
|
215
|
-
continue;
|
|
216
|
-
if (seen.has(record.usageIdentity))
|
|
217
|
-
continue;
|
|
218
|
-
seen.add(record.usageIdentity);
|
|
219
|
-
report.filesScanned++;
|
|
220
|
-
if (record.synthesized)
|
|
221
|
-
report.synthesizedFilesRead++;
|
|
222
|
-
const result = recordAttestationsFromRetrospective(workspaceRoot, userId, {
|
|
223
|
-
content: parsed.content, source: record.usageIdentity, job: record.job, date: record.date,
|
|
224
|
-
agent: options.agent, model: options.model, legacy: false,
|
|
225
|
-
limits, now: options.now, storePath: options.storePath, integrityMode: 'flag',
|
|
226
|
-
});
|
|
227
|
-
if (result.sectionState !== 'absent')
|
|
228
|
-
report.filesWithSection++;
|
|
229
|
-
report.itemsRead += result.itemsRead;
|
|
230
|
-
report.applied += result.applied;
|
|
231
|
-
report.alreadyRecorded += result.alreadyRecorded;
|
|
232
|
-
report.unmatched.push(...result.unmatched);
|
|
233
|
-
report.rejected.push(...result.rejected);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
return report;
|
|
238
|
-
}
|