fraim 2.0.346 → 2.0.349

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 (34) 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/core/utils/local-registry-resolver.js +13 -3
  22. package/dist/src/local-mcp-server/learning-context-builder.js +109 -379
  23. package/dist/src/local-mcp-server/learning-entry-format.js +25 -0
  24. package/dist/src/local-mcp-server/learning-usage-analysis.js +12 -410
  25. package/dist/src/local-mcp-server/learning-usage-eligibility.js +8 -14
  26. package/dist/src/local-mcp-server/learning-usage-projection.js +5 -22
  27. package/dist/src/local-mcp-server/learning-usage-store.js +93 -393
  28. package/dist/src/local-mcp-server/learning-usage-verify.js +9 -40
  29. package/dist/src/local-mcp-server/stdio-server.js +50 -114
  30. package/dist/src/mcp/tool-schemas.js +2 -1
  31. package/package.json +9 -1
  32. package/dist/src/local-mcp-server/learning-firing-parser.js +0 -247
  33. package/dist/src/local-mcp-server/learning-usage-attestation.js +0 -238
  34. 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");
@@ -1658,8 +1657,7 @@ class FraimLocalMCPServer {
1658
1657
  if (request.method !== 'tools/call' || typeof toolName !== 'string') {
1659
1658
  return this.processResponseWithHydration(response, requestSessionId);
1660
1659
  }
1661
- const projectRoot = this.findProjectRoot();
1662
- const resolver = projectRoot ? this.getRegistryResolver(requestSessionId) : null;
1660
+ const resolver = this.getRegistryResolver(requestSessionId);
1663
1661
  let finalizedResponse = response;
1664
1662
  // 1. Resolve top-level override if this tool maps to a specific file.
1665
1663
  if (resolver) {
@@ -1671,14 +1669,14 @@ class FraimLocalMCPServer {
1671
1669
  else if (toolName === 'get_fraim_job')
1672
1670
  registryPath = await resolver.findRegistryPath('jobs', args.job);
1673
1671
  if (registryPath) {
1674
- const localOverride = resolver.hasLocalOverride(registryPath);
1672
+ const localOverride = resolver.hasPersonalizedOverride(registryPath);
1675
1673
  const remoteFailed = Boolean(response.error);
1676
1674
  if (response.error) {
1677
1675
  this.log(`[req:${requestId}] Remote error for ${registryPath}: code=${response.error.code}`);
1678
1676
  }
1679
1677
  if (localOverride || remoteFailed) {
1680
1678
  try {
1681
- // personalized-employee/ takes priority; when remote fails, resolver falls back to synced/cached content
1679
+ // Every configured personalized layer takes priority over the remote baseline.
1682
1680
  const resolved = await resolver.resolveFile(registryPath).catch(() => null);
1683
1681
  if (resolved?.content) {
1684
1682
  const newResponse = {
@@ -1731,7 +1729,6 @@ class FraimLocalMCPServer {
1731
1729
  responseText += teamContextSection;
1732
1730
  this.log(`[req:${requestId}] Injected team context from ${workspaceRoot}`);
1733
1731
  }
1734
- this.recordUsageOffers(workspaceRoot, userEmail, false, null, 'session', requestId);
1735
1732
  }
1736
1733
  if (this.latestConnectSyncWarning) {
1737
1734
  responseText += `\n\n## Local Catalog\n${this.latestConnectSyncWarning}`;
@@ -1774,54 +1771,8 @@ class FraimLocalMCPServer {
1774
1771
  text += `\n\n---` + learningSection + teamContextSection;
1775
1772
  this.log(`[req:${requestId}] Injected job-focus learning/team context for ${userEmail} (domain: ${jobDomain ?? 'global'})`);
1776
1773
  }
1777
- this.recordUsageOffers(workspaceRoot, userEmail, true, jobDomain, String(jobName ?? 'unknown'), requestId);
1778
1774
  return text;
1779
1775
  }
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
1776
  /**
1826
1777
  * Resolve the learning domain for a `get_fraim_job` request (issue #806) from
1827
1778
  * the job's registry path, so the loader injects global + that domain's
@@ -2038,75 +1989,60 @@ class FraimLocalMCPServer {
2038
1989
  const { getEffectiveFraimDir } = require('../core/utils/project-fraim-paths');
2039
1990
  const effectiveFraimDir = projectRoot
2040
1991
  ? getEffectiveFraimDir(projectRoot)
2041
- : getEffectiveFraimDir(process.cwd());
1992
+ : (0, project_fraim_paths_1.getUserFraimDirPath)();
2042
1993
  if (effectiveFraimDir) {
2043
1994
  this.log(`📂 Effective FRAIM dir: ${effectiveFraimDir}`);
2044
1995
  }
2045
- if (!projectRoot && !effectiveFraimDir) {
2046
- this.log('⚠️ No project root or user-level FRAIM found, override resolution disabled');
2047
- // Return a resolver that always falls back to remote
2048
- return new local_registry_resolver_1.LocalRegistryResolver({
2049
- workspaceRoot: process.cwd(),
2050
- // Issue #1043 round 2: overlay roots are the layers' homes. There is no
2051
- // separate cache to point at any more.
2052
- managerCacheRoot: (0, pack_home_1.resolvePackHome)('manager').contentRoot,
2053
- orgCacheRoot: (0, pack_home_1.resolvePackHome)('org').contentRoot,
2054
- remoteContentResolver: async (_path) => {
2055
- throw new Error('No project root available');
2056
- },
2057
- shouldFilter: (content) => this.isStub(content)
2058
- });
2059
- }
2060
- else {
2061
- // Determine if we need effectiveFraimDir override.
2062
- // If the effective dir is user-level (~/.fraim/), it's NOT under projectRoot/fraim/,
2063
- // so we pass it as effectiveFraimDir to bypass the getWorkspaceFraimPath logic.
2064
- const workspaceRoot = projectRoot || process.cwd();
2065
- const projectFraimDir = projectRoot ? (0, path_1.join)(projectRoot, 'fraim') : '';
2066
- const needsEffectiveDirOverride = effectiveFraimDir && effectiveFraimDir !== projectFraimDir;
2067
- return new local_registry_resolver_1.LocalRegistryResolver({
2068
- workspaceRoot,
2069
- ...(needsEffectiveDirOverride ? { effectiveFraimDir } : {}),
2070
- // Issue #1043 round 2: overlay roots are the layers' homes.
2071
- managerCacheRoot: (0, pack_home_1.resolvePackHome)('manager').contentRoot,
2072
- orgCacheRoot: (0, pack_home_1.resolvePackHome)('org').contentRoot,
2073
- shouldFilter: (content) => this.isStub(content),
2074
- remoteContentResolver: async (path) => {
2075
- // Fetch parent content from remote for inheritance
2076
- this.log(`🔄 Remote content resolver: fetching ${path}`);
2077
- this.log(`🔄 Fetching raw file content: ${path}`);
2078
- const request = {
2079
- jsonrpc: '2.0',
2080
- id: (0, crypto_1.randomUUID)(),
2081
- method: 'tools/call',
2082
- params: {
2083
- name: 'get_fraim_file',
2084
- arguments: {
2085
- path,
2086
- raw: true
2087
- }
1996
+ // Determine if we need effectiveFraimDir override.
1997
+ // If the effective dir is user-level (~/.fraim/), it's NOT under projectRoot/fraim/,
1998
+ // so we pass it as effectiveFraimDir to bypass the getWorkspaceFraimPath logic.
1999
+ const workspaceRoot = projectRoot || (0, project_fraim_paths_1.getUserFraimDirPath)();
2000
+ const projectFraimDir = projectRoot ? (0, path_1.join)(projectRoot, 'fraim') : '';
2001
+ const needsEffectiveDirOverride = effectiveFraimDir && effectiveFraimDir !== projectFraimDir;
2002
+ return new local_registry_resolver_1.LocalRegistryResolver({
2003
+ workspaceRoot,
2004
+ userLevelOnly: !projectRoot,
2005
+ ...(needsEffectiveDirOverride ? { effectiveFraimDir } : {}),
2006
+ // Issue #1043 round 2: overlay roots are the layers' homes.
2007
+ managerLocalRoot: (0, pack_home_1.resolvePackHome)('manager', { migrateLegacy: false }).contentRoot,
2008
+ managerCacheRoot: (0, pack_home_1.resolvePackHome)('manager', { migrateLegacy: false }).contentRoot,
2009
+ orgCacheRoot: (0, pack_home_1.resolvePackHome)('org', { migrateLegacy: false }).contentRoot,
2010
+ shouldFilter: (content) => this.isStub(content),
2011
+ remoteContentResolver: async (path) => {
2012
+ // Fetch parent content from remote for inheritance
2013
+ this.log(`🔄 Remote content resolver: fetching ${path}`);
2014
+ this.log(`🔄 Fetching raw file content: ${path}`);
2015
+ const request = {
2016
+ jsonrpc: '2.0',
2017
+ id: (0, crypto_1.randomUUID)(),
2018
+ method: 'tools/call',
2019
+ params: {
2020
+ name: 'get_fraim_file',
2021
+ arguments: {
2022
+ path,
2023
+ raw: true
2088
2024
  }
2089
- };
2090
- this.applyRequestSessionId(request, requestSessionId);
2091
- const response = await this._doProxyToRemote(request);
2092
- if (response.error) {
2093
- this.logError(`❌ Remote content resolver failed: ${response.error.message}`);
2094
- throw new Error(`Failed to fetch parent: ${response.error.message}`);
2095
2025
  }
2096
- // Extract content from MCP response format
2097
- if (response.result?.content && Array.isArray(response.result.content)) {
2098
- const textContent = response.result.content.find((c) => c.type === 'text');
2099
- if (textContent?.text) {
2100
- this.log(`✅ Remote content resolver: fetched ${textContent.text.length} chars`);
2101
- return textContent.text;
2102
- }
2026
+ };
2027
+ this.applyRequestSessionId(request, requestSessionId);
2028
+ const response = await this._doProxyToRemote(request);
2029
+ if (response.error) {
2030
+ this.logError(`❌ Remote content resolver failed: ${response.error.message}`);
2031
+ throw new Error(`Failed to fetch parent: ${response.error.message}`);
2032
+ }
2033
+ // Extract content from MCP response format
2034
+ if (response.result?.content && Array.isArray(response.result.content)) {
2035
+ const textContent = response.result.content.find((c) => c.type === 'text');
2036
+ if (textContent?.text) {
2037
+ this.log(`✅ Remote content resolver: fetched ${textContent.text.length} chars`);
2038
+ return textContent.text;
2103
2039
  }
2104
- this.logError(`❌ Remote content resolver: no content in response for ${path}`);
2105
- this.logError(`Response: ${JSON.stringify(response, null, 2)}`);
2106
- throw new Error(`No content in remote response for ${path}`);
2107
2040
  }
2108
- });
2109
- }
2041
+ this.logError(`❌ Remote content resolver: no content in response for ${path}`);
2042
+ this.logError(`Response: ${JSON.stringify(response, null, 2)}`);
2043
+ throw new Error(`No content in remote response for ${path}`);
2044
+ }
2045
+ });
2110
2046
  }
2111
2047
  /**
2112
2048
  * Check if content is a "stub" indicating local version should be ignored.
@@ -201,6 +201,7 @@ Example:
201
201
  description: `Get a specific skill, rule, or reference file from the FRAIM registry by path.
202
202
 
203
203
  For running FRAIM jobs, use get_fraim_job instead — do NOT call get_fraim_file for job execution.
204
+ For read-only diagnosis or delivery verification, use get_fraim_file({ path: "jobs/<category>/<job>.md", raw: true }). This does not start a job; the local proxy resolves effective overrides, inheritance, includes, and template values.
204
205
 
205
206
  Examples:
206
207
  - get_fraim_file({ path: "skills/communication/active-listening.md" })
@@ -226,7 +227,7 @@ Examples:
226
227
  name: 'get_fraim_job',
227
228
  description: `Execute a named FRAIM job after explicit FRAIM activation. Call this tool whenever the user asks to run, start, or execute a named FRAIM job. Returns phased instructions — follow each phase and call seekMentoring to advance phases.
228
229
 
229
- Do NOT use get_fraim_file to load job content. Always use get_fraim_job for job execution.
230
+ Use this tool to start job execution, not to inspect candidate jobs. Read-only inspection uses get_fraim_file with the catalog job path and raw: true.
230
231
 
231
232
  Examples:
232
233
  - get_fraim_job({ job: "feature-specification" })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.346",
3
+ "version": "2.0.349",
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
- }