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.
Files changed (32) hide show
  1. package/dist/registry/scripts/learning/completion-state.js +80 -4
  2. package/dist/registry/scripts/learning/corpus-audit.js +7 -7
  3. package/dist/registry/scripts/learning/entry-authoring.js +181 -0
  4. package/dist/registry/scripts/learning/entry-format.js +10 -1
  5. package/dist/registry/scripts/learning/outcome-assessment.js +162 -0
  6. package/dist/registry/scripts/learning/outcome-types.js +92 -0
  7. package/dist/registry/scripts/learning/owner-identity.js +11 -0
  8. package/dist/registry/scripts/learning/proposal-queue.js +81 -0
  9. package/dist/registry/scripts/learning/run-application.js +1003 -0
  10. package/dist/registry/scripts/learning/run-publication.js +364 -0
  11. package/dist/registry/scripts/learning/run-record.js +895 -0
  12. package/dist/registry/scripts/learning-usage/learning-source-record.js +131 -0
  13. package/dist/src/cli/commands/learning.js +64 -0
  14. package/dist/src/cli/doctor/checks/scripts-checks.js +53 -1
  15. package/dist/src/cli/fraim.js +2 -0
  16. package/dist/src/cli/learning/inventory.js +285 -0
  17. package/dist/src/cli/learning/queue-operations.js +199 -0
  18. package/dist/src/cli/learning/runtime.js +82 -0
  19. package/dist/src/cli/learning/usage.js +277 -0
  20. package/dist/src/core/fraim-config-schema.generated.js +0 -4
  21. package/dist/src/local-mcp-server/learning-context-builder.js +91 -377
  22. package/dist/src/local-mcp-server/learning-entry-format.js +25 -0
  23. package/dist/src/local-mcp-server/learning-usage-analysis.js +12 -410
  24. package/dist/src/local-mcp-server/learning-usage-eligibility.js +8 -14
  25. package/dist/src/local-mcp-server/learning-usage-projection.js +5 -22
  26. package/dist/src/local-mcp-server/learning-usage-store.js +93 -393
  27. package/dist/src/local-mcp-server/learning-usage-verify.js +9 -40
  28. package/dist/src/local-mcp-server/stdio-server.js +0 -48
  29. package/package.json +9 -1
  30. package/dist/src/local-mcp-server/learning-firing-parser.js +0 -247
  31. package/dist/src/local-mcp-server/learning-usage-attestation.js +0 -238
  32. 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
- * Issue #1752 E2 — the integrity picture for the usage record, in one output.
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, offersEraStart) {
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 violation = record.integrity ?? (0, learning_usage_store_1.integrityViolation)(record, offersEraStart);
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 clock-advancing firing that produced no bucket means the model went
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 clockAdvancing = record.fired.byOutcome.prevented + record.fired.byOutcome.applied;
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: clockAdvancing });
139
+ unknownModelAttribution.push({ key: record.key, title, fired: positiveOutcomes });
170
140
  }
171
141
  }
172
- return { invariantViolations, caseVariantModelBuckets, unknownModelAttribution };
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 { invariantViolations, caseVariantModelBuckets, unknownModelAttribution } = scanRecordsForIntegrity(records, store.offersEraStart);
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.346",
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
- }