fraim 2.0.337 → 2.0.339

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.
@@ -0,0 +1,27 @@
1
+ 'use strict';
2
+
3
+ // Read the shared author's current entry format for identity and update guards.
4
+ function learningEntryBlocks(body) {
5
+ const headings = [...body.matchAll(/^#{2,6}\s+\[P-[A-Z]+\].*$/gm)];
6
+ return headings.map((heading, index) => {
7
+ const start = heading.index;
8
+ const next = headings[index + 1]?.index ?? body.length;
9
+ const ownDepth = heading[0].match(/^#+/)[0].length;
10
+ const afterHeading = start + heading[0].length;
11
+ const shallower = [...body.slice(afterHeading, next).matchAll(/^(#{1,6})\s+.*$/gm)].find(m => m[1].length <= ownDepth);
12
+ const limit = shallower ? afterHeading + shallower.index : next;
13
+ const text = body.slice(start, limit).trimEnd();
14
+ return { start, end: start + text.length, text, id: text.match(/^\*\*Id\*\*:\s*(L-[a-f0-9]{10})\s*$/m)?.[1] ?? null };
15
+ });
16
+ }
17
+
18
+ function learningEntryMetadata(content) {
19
+ const metadata = {};
20
+ for (const match of content.matchAll(/^\*\*(Recurrences|First recorded|First synthesized)\*\*:\s*([^\r\n]+)$/gm)) {
21
+ if (metadata[match[1]] !== undefined && metadata[match[1]] !== match[2]) throw new Error('entry-metadata-conflict');
22
+ metadata[match[1]] = match[2];
23
+ }
24
+ return metadata;
25
+ }
26
+
27
+ module.exports = { learningEntryMetadata, learningEntryBlocks };
@@ -0,0 +1,78 @@
1
+ {
2
+ "schemaVersion": 1,
3
+ "mapVersion": 1,
4
+ "domains": [
5
+ "engineering",
6
+ "product",
7
+ "go-to-market",
8
+ "people-ops",
9
+ "finance",
10
+ "legal",
11
+ "compliance",
12
+ "customer",
13
+ "company-building",
14
+ "operations"
15
+ ],
16
+ "jobDomainMap": {
17
+ "product-building": "engineering",
18
+ "dev-ops": "engineering",
19
+ "quality-assurance": "engineering",
20
+ "migration": "engineering",
21
+ "replicate": "engineering",
22
+ "reviewer": "engineering",
23
+ "security": "engineering",
24
+ "delivery-ops": "engineering",
25
+ "salesforce": "engineering",
26
+ "ai-engineering": "engineering",
27
+ "product-management": "product",
28
+ "customer-development": "product",
29
+ "brainstorming": "product",
30
+ "marketing": "go-to-market",
31
+ "branding": "go-to-market",
32
+ "sales": "go-to-market",
33
+ "go-to-market": "go-to-market",
34
+ "business-development": "go-to-market",
35
+ "commercial-ops": "go-to-market",
36
+ "people-ops": "people-ops",
37
+ "inclusion-ops": "people-ops",
38
+ "career": "people-ops",
39
+ "finance": "finance",
40
+ "invoicing": "finance",
41
+ "banking": "finance",
42
+ "legal": "legal",
43
+ "compliance": "compliance",
44
+ "customer-success": "customer",
45
+ "customer-communication": "customer",
46
+ "company-creation": "company-building",
47
+ "bootstrap": "company-building",
48
+ "fundraising": "company-building",
49
+ "business-ops": "operations",
50
+ "procurement": "operations",
51
+ "project-management": "operations"
52
+ },
53
+ "globalJobCategories": [
54
+ "manager",
55
+ "fraim",
56
+ "1-1",
57
+ "coaching",
58
+ "delegation",
59
+ "hiring",
60
+ "productivity",
61
+ "project-setup",
62
+ "setup-guardrails",
63
+ "team-learning",
64
+ "verification"
65
+ ],
66
+ "domainVoice": {
67
+ "engineering": "Precise and technical; file paths, commands, and test names are appropriate.",
68
+ "product": "Product and user-outcome language; behavior and flow, not code symbols.",
69
+ "go-to-market": "Marketing and sales language; campaigns, funnels, messaging, audience.",
70
+ "people-ops": "Plain HR and recruiting language; no engineering jargon.",
71
+ "finance": "Finance and accounting language; figures, close, forecasts.",
72
+ "legal": "Contract and IP language; obligations, clauses, risk.",
73
+ "compliance": "Controls, evidence, and regulation language.",
74
+ "customer": "Customer-relationship and support language.",
75
+ "company-building": "Founder and operations language; setup, entity, funding.",
76
+ "operations": "Business-operations language; process, vendors, planning, coordination."
77
+ }
78
+ }
@@ -113,12 +113,14 @@ function defaultCloneDir(layer) {
113
113
  * Resolve where this layer's details live. Never throws and never touches the
114
114
  * network; call `ensurePackClone` separately when the git working copy has to
115
115
  * be present and current.
116
+ * Read-only inventory callers set `migrateLegacy: false` to suppress the
117
+ * otherwise backwards-compatible flat org layout migration.
116
118
  */
117
- function resolvePackHome(layer) {
119
+ function resolvePackHome(layer, options = {}) {
118
120
  const config = (0, user_config_1.getLayerBackend)(layer);
119
121
  const standard = standardHome(layer);
120
122
  const make = (root, extra) => {
121
- if (layer === 'org')
123
+ if (layer === 'org' && options.migrateLegacy !== false)
122
124
  migrateOrgFlatContent(root);
123
125
  return { layer, root, contentRoot: path_1.default.join(root, 'personalized-employee'), ...extra };
124
126
  };
@@ -12,130 +12,27 @@
12
12
  * the current job's-domain learnings only, so a marketing job no longer loads
13
13
  * engineering learnings.
14
14
  *
15
- * This module is the single source of truth for the domain list, the
16
- * registry-category -> domain map, the per-domain authoring voice (R3), and the
17
- * resolver used at the injection sites. It is pure (no I/O) and mirrors the
15
+ * The shared registry/scripts/learning/learning-domains.json owns the domain list, the
16
+ * registry-category -> domain map, and the per-domain authoring voice (R3).
17
+ * This module provides the pure resolver used at the injection sites, following the
18
18
  * config-module convention of `src/config/portfolio-slug-overrides.ts`.
19
19
  */
20
20
  Object.defineProperty(exports, "__esModule", { value: true });
21
- exports.DOMAIN_VOICE = exports.GLOBAL_JOB_CATEGORIES = exports.JOB_DOMAIN_MAP = exports.LEARNING_DOMAINS = void 0;
21
+ exports.DOMAIN_VOICE = exports.GLOBAL_JOB_CATEGORIES = exports.JOB_DOMAIN_MAP = exports.LEARNING_DOMAINS = exports.LEARNING_DOMAIN_MAP_VERSION = void 0;
22
22
  exports.isLearningDomain = isLearningDomain;
23
23
  exports.jobCategoryFromRegistryPath = jobCategoryFromRegistryPath;
24
24
  exports.resolveJobDomain = resolveJobDomain;
25
25
  exports.domainFromJobFrontmatter = domainFromJobFrontmatter;
26
26
  exports.resolveDomainForJob = resolveDomainForJob;
27
- /** The coarse learning domains. `global` is represented by the absence of a domain. */
28
- exports.LEARNING_DOMAINS = [
29
- 'engineering',
30
- 'product',
31
- 'go-to-market',
32
- 'people-ops',
33
- 'finance',
34
- 'legal',
35
- 'compliance',
36
- 'customer',
37
- 'company-building',
38
- 'operations',
39
- ];
27
+ const learningDomainData = require('../../registry/scripts/learning/learning-domains.json');
28
+ exports.LEARNING_DOMAIN_MAP_VERSION = learningDomainData.mapVersion;
29
+ exports.LEARNING_DOMAINS = Object.freeze(learningDomainData.domains);
30
+ exports.JOB_DOMAIN_MAP = Object.freeze(learningDomainData.jobDomainMap);
31
+ exports.GLOBAL_JOB_CATEGORIES = new Set(learningDomainData.globalJobCategories);
32
+ exports.DOMAIN_VOICE = Object.freeze(learningDomainData.domainVoice);
40
33
  function isLearningDomain(value) {
41
34
  return typeof value === 'string' && exports.LEARNING_DOMAINS.includes(value);
42
35
  }
43
- /**
44
- * Registry job category -> learning domain. Seeded from the registry taxonomy and
45
- * the `PERSONA_CAPABILITY_BUNDLES` groupings. Every domain-scoped category maps
46
- * here; cross-cutting categories are listed in `GLOBAL_JOB_CATEGORIES` instead.
47
- *
48
- * A build-time test (`tests/test-unit-learning-domains.ts`) asserts that every
49
- * registry job category (from `registry/jobs/**`) is covered by exactly one of
50
- * this map or `GLOBAL_JOB_CATEGORIES`, so the taxonomy cannot silently drift as
51
- * new job categories are added.
52
- */
53
- exports.JOB_DOMAIN_MAP = {
54
- // engineering
55
- 'product-building': 'engineering',
56
- 'dev-ops': 'engineering',
57
- 'quality-assurance': 'engineering',
58
- 'migration': 'engineering',
59
- 'replicate': 'engineering',
60
- 'reviewer': 'engineering',
61
- 'security': 'engineering',
62
- 'delivery-ops': 'engineering',
63
- 'salesforce': 'engineering',
64
- 'ai-engineering': 'engineering',
65
- // product
66
- 'product-management': 'product',
67
- 'customer-development': 'product',
68
- 'brainstorming': 'product',
69
- // go-to-market
70
- 'marketing': 'go-to-market',
71
- 'branding': 'go-to-market',
72
- 'sales': 'go-to-market',
73
- 'go-to-market': 'go-to-market',
74
- 'business-development': 'go-to-market',
75
- 'commercial-ops': 'go-to-market',
76
- // people-ops
77
- 'people-ops': 'people-ops',
78
- 'inclusion-ops': 'people-ops',
79
- 'career': 'people-ops',
80
- // finance
81
- 'finance': 'finance',
82
- 'invoicing': 'finance',
83
- 'banking': 'finance',
84
- // legal
85
- 'legal': 'legal',
86
- // compliance
87
- 'compliance': 'compliance',
88
- // customer
89
- 'customer-success': 'customer',
90
- 'customer-communication': 'customer',
91
- // company-building
92
- 'company-creation': 'company-building',
93
- 'bootstrap': 'company-building',
94
- 'fundraising': 'company-building',
95
- // operations
96
- 'business-ops': 'operations',
97
- 'procurement': 'operations',
98
- 'project-management': 'operations',
99
- };
100
- /**
101
- * Cross-cutting job categories that intentionally resolve to `global` (no domain).
102
- * Management and framework-meta learnings apply regardless of the work domain, so
103
- * they are never hidden from any job. Listed explicitly so the completeness test
104
- * can tell an intentional global category apart from a forgotten mapping.
105
- */
106
- exports.GLOBAL_JOB_CATEGORIES = new Set([
107
- // ai-employee cross-cutting
108
- 'manager',
109
- 'fraim',
110
- // ai-manager (all management categories)
111
- '1-1',
112
- 'coaching',
113
- 'delegation',
114
- 'hiring',
115
- 'productivity',
116
- 'project-setup',
117
- 'setup-guardrails',
118
- 'team-learning',
119
- 'verification',
120
- ]);
121
- /**
122
- * One-line authoring voice per domain (R3). Consumed as documentation and by the
123
- * synthesis jobs/skills (`sleep-on-learnings`, `organizational-learning-synthesis`,
124
- * `l1-signal-classification`) so a domain-specific learning is written in that
125
- * domain's language and stays human-auditable.
126
- */
127
- exports.DOMAIN_VOICE = {
128
- 'engineering': 'Precise and technical; file paths, commands, and test names are appropriate.',
129
- 'product': 'Product and user-outcome language; behavior and flow, not code symbols.',
130
- 'go-to-market': 'Marketing and sales language; campaigns, funnels, messaging, audience.',
131
- 'people-ops': 'Plain HR and recruiting language; no engineering jargon.',
132
- 'finance': 'Finance and accounting language; figures, close, forecasts.',
133
- 'legal': 'Contract and IP language; obligations, clauses, risk.',
134
- 'compliance': 'Controls, evidence, and regulation language.',
135
- 'customer': 'Customer-relationship and support language.',
136
- 'company-building': 'Founder and operations language; setup, entity, funding.',
137
- 'operations': 'Business-operations language; process, vendors, planning, coordination.',
138
- };
139
36
  /**
140
37
  * Extract the registry job category from a job's registry path.
141
38
  *
@@ -102,11 +102,17 @@ class LocalRegistryResolver {
102
102
  (0, path_1.join)(this.managerCacheRoot, dir),
103
103
  (0, path_1.join)(this.orgCacheRoot, dir),
104
104
  (0, path_1.join)(this.workspaceRoot, 'registry', dir),
105
+ // Installed projects have categorized job stubs in the synced roles,
106
+ // without a source registry checkout. Keep authored overrides first.
107
+ ...(type === 'jobs' ? [this.getFraimPath('ai-employee', dir), this.getFraimPath('ai-manager', dir)] : []),
105
108
  ];
106
109
  for (const root of searchRoots) {
107
110
  const found = this.searchFileRecursively(root, baseName);
108
111
  if (!found)
109
112
  continue;
113
+ const syncedRole = ['ai-employee', 'ai-manager'].find(role => root === this.getFraimPath(role, dir));
114
+ if (syncedRole)
115
+ return `${dir}/${syncedRole}/${found.slice(root.length + 1).replace(/\\/g, '/')}`;
110
116
  // Convert absolute back to a registry-relative path.
111
117
  const rel = found.replace(/\\/g, '/');
112
118
  const dirMarker = `/${dir}/`;
@@ -890,8 +890,9 @@ function enumerateFileEntries(file, threshold, usageClock, now) {
890
890
  out.push({
891
891
  // The store owns key construction. Building the composite key by hand here
892
892
  // would be a second definition of what an entry's identity is, and the two
893
- // would drift the first time normalization changed.
894
- key: current.id ?? (0, learning_usage_store_1.titleUsageKey)(file.fileType, current.title),
893
+ // would drift the first time normalization changed. Issue #1752 A1 made
894
+ // that one function rather than four copies of the same expression.
895
+ key: (0, learning_usage_store_1.resolveEntryIdentity)({ id: current.id, fileType: file.fileType, title: current.title }).key,
895
896
  family: 'learning',
896
897
  id: current.id,
897
898
  title: current.title,
@@ -939,9 +940,27 @@ function enumerateFileEntries(file, threshold, usageClock, now) {
939
940
  * listed. `aboveThreshold` is what separates active guidance from the dormant
940
941
  * rollup. Nothing here depends on the agent reading anything.
941
942
  *
942
- * Deduplicated by key with the later tier winning, matching how
943
- * `mergeLearningEntriesAcrossTiers` resolves a title collision across levels, so
944
- * one lesson is one offer even when two levels hold it.
943
+ * ## Issue #1752 A2 — one lesson, one offer, stamped or not
944
+ *
945
+ * This deduped by usage key, which sounds like the same thing as the context
946
+ * reader's rule and is not. `resolveOrgLearningFileTiers` deliberately returns
947
+ * both the org pack copy and the repo-local copy of an org family, because #1002
948
+ * R4 requires that a promoted entry not hide the rest of its family. The context
949
+ * reader then merges those tiers with `mergeLearningEntriesAcrossTiers`, keyed on
950
+ * the normalized title, so the agent sees one lesson. Keying the offer record on
951
+ * `id ?? titleUsageKey(...)` instead meant that a lesson carrying an `**Id**` line
952
+ * in one tier and not the other produced two keys, and both were recorded.
953
+ *
954
+ * That is defect 4 in #1752, and it is why `standing --changes-only` could
955
+ * recommend promoting and demoting the same lesson in one response: the
956
+ * title-keyed twin collected the offers while the id-keyed one collected the
957
+ * firings (the firing path never split, because `buildEntryIndex` dedups by
958
+ * identity first-wins). Measured live: five titles emitting two keys each in a
959
+ * single derived offer batch.
960
+ *
961
+ * So group by the same normalized title the context reader groups by, resolve one
962
+ * identity per group preferring whichever tier carries an id, and let the later
963
+ * tier win on content exactly as before.
945
964
  */
946
965
  function collectOfferedLearningEntries(workspaceRoot, userId, forJob, domain) {
947
966
  const roots = getLearningRoots(workspaceRoot);
@@ -950,13 +969,35 @@ function collectOfferedLearningEntries(workspaceRoot, userId, forJob, domain) {
950
969
  const usageClock = resolveUsageClock(workspaceRoot);
951
970
  const now = new Date();
952
971
  const resolved = resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJob, domain);
953
- const byKey = new Map();
972
+ // Grouped by family plus normalized title, which is what `titleUsageKey`
973
+ // already computes. Two families holding the same title are two lessons, and
974
+ // that is the existing rule rather than a new one.
975
+ const byLesson = new Map();
954
976
  for (const file of [...resolved.l2, ...resolved.l1]) {
955
977
  for (const entry of enumerateFileEntries(file, threshold, usageClock, now)) {
956
- byKey.set(entry.key, entry);
978
+ const lessonKey = (0, learning_usage_store_1.titleUsageKey)(entry.fileType, entry.title);
979
+ const group = byLesson.get(lessonKey);
980
+ if (group)
981
+ group.push(entry);
982
+ else
983
+ byLesson.set(lessonKey, [entry]);
957
984
  }
958
985
  }
959
- return [...byKey.values()];
986
+ const out = [];
987
+ for (const group of byLesson.values()) {
988
+ // Later tier wins for content, unchanged precedence.
989
+ const winner = group[group.length - 1];
990
+ // The id comes from whichever tier carries one, because `stamp-ids` has not
991
+ // reached the org and manager pack homes for every corpus yet. Latest wins
992
+ // when more than one tier is stamped.
993
+ const id = group.reduce((found, entry) => entry.id ?? found, null);
994
+ out.push({
995
+ ...winner,
996
+ id,
997
+ key: (0, learning_usage_store_1.resolveEntryIdentity)({ id, fileType: winner.fileType, title: winner.title }).key,
998
+ });
999
+ }
1000
+ return out;
960
1001
  }
961
1002
  /**
962
1003
  * Issue #1103 R3: the auto-loaded Team Context rule files.
@@ -81,6 +81,20 @@ function classifyRecurrence(store, options) {
81
81
  : null;
82
82
  const historyComplete = record.offered.log.length === record.offered.total
83
83
  || (oldestRetained !== null && new Date(oldestRetained).getTime() <= windowStartMs);
84
+ // #1752 D2, the issue's suggested fix 4. `report` selects an entry into
85
+ // `ignoredWhileInContext` on `byOutcome.ignored > 0`, which asserts the entry
86
+ // was in context; `classify` was answering `not-offered` for the same entry,
87
+ // which asserts the opposite, with no hedge. The #1740 postmortem states in
88
+ // first-hand prose that L-abc958f241 "was above threshold and in context".
89
+ // When the two halves of one record disagree, neither is evidence.
90
+ const ignoredInWindow = record.fired.log.filter((firing) => firing.outcome === 'ignored' && daysBetween(firing.date, options.date) <= options.windowDays);
91
+ // #1752 D1. Absence of an offer for this job is only evidence when the record
92
+ // can resolve this job at all. Measured: 2067 of 2354 retained offer records
93
+ // carry `session` or `__freeform__`, because `fraim_connect` writes one offer
94
+ // per entry per session and a plain newest-N bound evicted the job-labelled
95
+ // ones within about a day. Asking about a job the store has never retained an
96
+ // offer for is a gap in the record, not a delivery failure.
97
+ const jobCoverage = Object.values(store.entries).some((entry) => entry.offered.log.some((offer) => offer.job === options.job && daysBetween(offer.date, options.date) <= options.windowDays));
84
98
  if (matchingOffers.length > 0) {
85
99
  return {
86
100
  ...base,
@@ -91,6 +105,26 @@ function classifyRecurrence(store, options) {
91
105
  targetOfFix: 'entry',
92
106
  };
93
107
  }
108
+ if (ignoredInWindow.length > 0) {
109
+ return {
110
+ ...base,
111
+ classification: 'undetermined',
112
+ diagnosis: `The two halves of this entry's record disagree: it carries ${ignoredInWindow.length} attested \`ignored\` outcome${ignoredInWindow.length === 1 ? '' : 's'} inside the window, which says it was in context and the work went against it, while the offer log holds no matching offer for ${options.job}, which says it was never delivered. Both cannot be true.`,
113
+ recommendation: 'Do not act on either reading. An `ignored` attestation and an empty offer log are contradictory evidence about the same event, so the record cannot say whether the entry or the delivery is at fault. Run `learning-usage verify` to see whether this entry carries an integrity flag, and treat the recurrence on its own terms until the record agrees with itself.',
114
+ recurrenceBump: true,
115
+ targetOfFix: 'none',
116
+ };
117
+ }
118
+ if (!jobCoverage) {
119
+ return {
120
+ ...base,
121
+ classification: 'undetermined',
122
+ diagnosis: `No offer anywhere in the record carries the job label ${options.job} inside this window, so the per-job offer record has no resolution for that job and cannot say whether this entry was in context for it.`,
123
+ recommendation: 'The offer record cannot answer this question for this job, so absence of a match is not evidence of absence. Do not conclude a delivery problem. Re-ask once the job has run with offer recording in place, or widen the window to one the record covers.',
124
+ recurrenceBump: true,
125
+ targetOfFix: 'none',
126
+ };
127
+ }
94
128
  if (!historyComplete) {
95
129
  return {
96
130
  ...base,
@@ -179,6 +213,26 @@ function firingsUnderModel(record, model) {
179
213
  return 0;
180
214
  return record.fired.byModel[model] ?? 0;
181
215
  }
216
+ /**
217
+ * #1752 C4 - is this entry's per-model attribution unknown, as opposed to zero?
218
+ *
219
+ * All 781 retained firing records in the live store carry `agent: null,
220
+ * model: null`, because `attest-learning-firings.md` invoked `record-firings`
221
+ * with no `--agent` or `--model`. `firedUnderCurrentModel` was therefore
222
+ * structurally 0 for every entry, which is why `candidates` proposed 31
223
+ * retirements: every entry clearing the offer threshold became one, however hard
224
+ * it was working. Fixing the `currentModel` resolution alone would not have
225
+ * changed a single one of them.
226
+ *
227
+ * The predicate is deliberately narrower than "fired at all". An entry whose only
228
+ * outcomes are `ignored` or `not-applicable` has an empty `byModel` because those
229
+ * outcomes correctly do not bucket by model; that is information, not a gap. Only
230
+ * a clock-advancing firing that produced no bucket means the model went
231
+ * unrecorded.
232
+ */
233
+ function modelAttributionUnknown(record) {
234
+ return firedCount(record) > 0 && Object.keys(record.fired.byModel).length === 0;
235
+ }
182
236
  function earlierModelFirings(record, currentModel) {
183
237
  return Object.entries(record.fired.byModel)
184
238
  .filter(([model]) => model !== currentModel)
@@ -189,9 +243,22 @@ function latestTitle(record) {
189
243
  return record.titles[record.titles.length - 1] ?? record.key;
190
244
  }
191
245
  function findRetirementCandidates(store, options) {
192
- const currentModel = options.currentModel ?? (0, learning_usage_projection_1.resolveCurrentModel)(store);
246
+ const currentModel = (0, learning_usage_projection_1.resolveCurrentModel)(options.currentModel);
193
247
  const threshold = options.limits.retirementOfferThreshold;
194
248
  const probe = options.structuralProbe ?? resolveStructuralProbe(options.structuralSignalPath);
249
+ // #1752 C1: retirement candidacy is computed per model, so with no model there
250
+ // is no question to answer. An empty list with the reason stated is the honest
251
+ // answer; computing against a null bucket is how defect 3 produced 31
252
+ // proposals nobody could act on.
253
+ if (!currentModel) {
254
+ return {
255
+ currentModel: null,
256
+ offerThreshold: threshold,
257
+ candidates: [],
258
+ notCandidates: [],
259
+ reason: 'The current model did not resolve, so no per-model conclusion can be drawn. Pass --model <model>, or set FRAIM_ACTIVE_MODEL. The current model is never inferred from the usage record, because the record is shared by concurrent sessions from more than one agent and would report whichever wrote last.',
260
+ };
261
+ }
195
262
  const candidates = [];
196
263
  const notCandidates = [];
197
264
  for (const record of Object.values(store.entries)) {
@@ -204,6 +271,14 @@ function findRetirementCandidates(store, options) {
204
271
  notCandidates.push({ key: record.key, title, reason: `It fired ${fired} time${fired === 1 ? '' : 's'} under the current model, so it is still earning its place.` });
205
272
  continue;
206
273
  }
274
+ if (modelAttributionUnknown(record)) {
275
+ notCandidates.push({
276
+ key: record.key,
277
+ title,
278
+ reason: `Per-model attribution is unknown for this entry: it fired ${firedCount(record)} time${firedCount(record) === 1 ? '' : 's'} and no firing carries a model, so how hard it is working under ${currentModel} cannot be measured. Held back rather than counted as zero.`,
279
+ });
280
+ continue;
281
+ }
207
282
  if (offered < threshold) {
208
283
  notCandidates.push({
209
284
  key: record.key,
@@ -297,6 +372,7 @@ function toReportEntry(record) {
297
372
  title: latestTitle(record),
298
373
  offered: record.offered.total,
299
374
  fired: firedCount(record),
375
+ ignored: record.fired.byOutcome.ignored,
300
376
  lastFired: record.fired.lastFiredAt,
301
377
  firedUnder: Object.keys(record.fired.byModel),
302
378
  recentLog: record.fired.log,
@@ -320,7 +396,8 @@ function buildUsageReport(store, options) {
320
396
  return {
321
397
  generatedAt: now.toISOString(),
322
398
  windowDays: options.windowDays,
323
- currentModel: (0, learning_usage_projection_1.resolveCurrentModel)(store),
399
+ currentModel: (0, learning_usage_projection_1.resolveCurrentModel)(options.currentModel),
400
+ integrityFlagged: learningRecords.filter((r) => Boolean(r.integrity)).length,
324
401
  totals: {
325
402
  entriesOffered: offered.length,
326
403
  entriesWithFirings: fired.length,
@@ -48,7 +48,9 @@ function buildEntryIndex(workspaceRoot, userId) {
48
48
  for (const entry of entries) {
49
49
  const fileType = fileTypeOf(entry);
50
50
  const attestable = {
51
- key: entry.id ?? (0, learning_usage_store_1.titleUsageKey)(fileType, entry.title),
51
+ // #1752 A1: one definition of what an entry is, shared with the offer
52
+ // path. These two disagreeing is defect 4.
53
+ key: (0, learning_usage_store_1.resolveEntryIdentity)({ id: entry.id, fileType, title: entry.title }).key,
52
54
  family: 'learning',
53
55
  title: entry.title,
54
56
  fileType,
@@ -142,7 +144,9 @@ function recordAttestationsFromRetrospective(workspaceRoot, userId, options) {
142
144
  let applied = 0;
143
145
  let alreadyRecorded = 0;
144
146
  if (inputs.length > 0) {
145
- const result = (0, learning_usage_store_1.recordFirings)(inputs, { limits, now: options.now, storePath: options.storePath });
147
+ const result = (0, learning_usage_store_1.recordFirings)(inputs, {
148
+ limits, now: options.now, storePath: options.storePath, integrityMode: options.integrityMode,
149
+ });
146
150
  applied = result.recorded;
147
151
  alreadyRecorded = result.alreadyRecorded;
148
152
  }
@@ -208,6 +212,9 @@ function runBackfill(workspaceRoot, userId, options) {
208
212
  limits,
209
213
  now: options.now,
210
214
  storePath: options.storePath,
215
+ // A backfill reconciles hundreds of retrospectives at once, so a single
216
+ // stranded-offer entry must not refuse the whole run.
217
+ integrityMode: 'flag',
211
218
  });
212
219
  if (result.sectionState !== 'absent')
213
220
  report.filesWithSection += 1;