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.
- package/dist/registry/scripts/learning/entry-format.js +27 -0
- package/dist/registry/scripts/learning/learning-domains.json +78 -0
- package/dist/src/cli/utils/pack-home.js +4 -2
- package/dist/src/config/learning-domains.js +10 -113
- package/dist/src/core/utils/local-registry-resolver.js +6 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +49 -8
- package/dist/src/local-mcp-server/learning-usage-analysis.js +79 -2
- package/dist/src/local-mcp-server/learning-usage-attestation.js +9 -2
- package/dist/src/local-mcp-server/learning-usage-command.js +132 -22
- package/dist/src/local-mcp-server/learning-usage-events.js +261 -0
- package/dist/src/local-mcp-server/learning-usage-projection.js +25 -21
- package/dist/src/local-mcp-server/learning-usage-store.js +589 -82
- package/dist/src/local-mcp-server/learning-usage-verify.js +222 -0
- package/dist/src/local-mcp-server/stdio-server.js +27 -26
- package/package.json +3 -1
|
@@ -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
|
-
*
|
|
16
|
-
* registry-category -> domain map, the per-domain authoring voice (R3)
|
|
17
|
-
* resolver used at the injection sites
|
|
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
|
-
|
|
28
|
-
exports.
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
943
|
-
*
|
|
944
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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)(
|
|
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
|
-
|
|
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, {
|
|
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;
|