fraim 2.0.345 → 2.0.346

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.
@@ -16,6 +16,7 @@ const node_path_1 = __importDefault(require("node:path"));
16
16
  const node_fs_1 = __importDefault(require("node:fs"));
17
17
  const node_crypto_1 = require("node:crypto");
18
18
  const artifact_paths_1 = require("../artifact-paths");
19
+ const corpus_audit_1 = require("./corpus-audit");
19
20
  const sourcePathKey = (file) => {
20
21
  const value = node_path_1.default.resolve(file).replace(/\\/g, '/');
21
22
  return process.platform === 'win32' ? value.toLowerCase() : value;
@@ -233,7 +234,7 @@ function decodeCompletion(markdown, expected = {}) {
233
234
  catch {
234
235
  fail();
235
236
  }
236
- if (!keys(state, ['schemaVersion', 'runId', 'owner', 'finalizationState', 'decisions', 'sourceDispositions', 'cleanup', 'reconciliations', 'retrospective', 'processedSources', 'sourceParticipants']) || state.schemaVersion !== 1 || !text(state.runId) || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(state.runId) || !ownerValid(state.owner)
237
+ if (!keys(state, ['schemaVersion', 'runId', 'owner', 'finalizationState', 'decisions', 'sourceDispositions', 'cleanup', 'reconciliations', 'retrospective', 'processedSources', 'sourceParticipants', 'corpusAudit', 'reconciliationHistory']) || state.schemaVersion !== 1 || !text(state.runId) || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(state.runId) || !ownerValid(state.owner)
237
238
  || !['pending', 'complete'].includes(state.finalizationState) || !Array.isArray(state.decisions) || !Array.isArray(state.sourceDispositions) || !Array.isArray(state.cleanup))
238
239
  fail();
239
240
  if ((expected.runId && expected.runId !== state.runId) || (expected.owner && !sameOwner(expected.owner, state.owner)))
@@ -281,6 +282,48 @@ function decodeCompletion(markdown, expected = {}) {
281
282
  fail();
282
283
  }
283
284
  const decisions = new Map(state.decisions.map((item) => [item.actionId, item]));
285
+ if (state.corpusAudit !== undefined) {
286
+ try {
287
+ (0, corpus_audit_1.validateCorpusAudit)(state.corpusAudit);
288
+ }
289
+ catch {
290
+ fail();
291
+ }
292
+ for (const item of state.corpusAudit.dispositions)
293
+ for (const binding of item.actionBindings) {
294
+ const decision = decisions.get(binding.actionId);
295
+ if (!decision || decision.digest !== binding.digest || decision.revision !== binding.revision || decision.decision.reviewer !== state.corpusAudit.reviewer)
296
+ fail();
297
+ }
298
+ }
299
+ if (state.reconciliationHistory !== undefined) {
300
+ if (!Array.isArray(state.reconciliationHistory))
301
+ fail();
302
+ const seen = new Set();
303
+ const validRef = (ref) => keys(ref, ['owner', 'relativePath', 'entryId', 'learningSubject']) && ownerValid(ref.owner)
304
+ && typeof ref.relativePath === 'string' && ref.relativePath.startsWith('learnings/') && !ref.relativePath.includes('\\') && !ref.relativePath.includes(':')
305
+ && !ref.relativePath.split('/').some((part) => !part || part === '.' || part === '..') && /^L-[a-f0-9]{10}$/.test(ref.entryId)
306
+ && (ref.learningSubject === undefined || text(ref.learningSubject));
307
+ for (const history of state.reconciliationHistory) {
308
+ const decision = decisions.get(history.actionId);
309
+ if (!keys(history, ['actionId', 'revision', 'digest', 'target', 'resultHash', 'occurrences']) || !decision || seen.has(history.actionId)
310
+ || decision.decision.action !== 'approve' || history.revision !== decision.revision || history.digest !== decision.digest
311
+ || !validRef(history.target) || canonicalJournal(history.target) !== canonicalJournal(decision.target) || !hash(history.resultHash)
312
+ || !Array.isArray(history.occurrences) || !history.occurrences.length)
313
+ fail();
314
+ seen.add(history.actionId);
315
+ const locations = new Set();
316
+ for (const occurrence of history.occurrences) {
317
+ if (!keys(occurrence, ['ref', 'sha256', 'before']) || !validRef(occurrence.ref) || occurrence.ref.entryId !== history.target.entryId
318
+ || !text(occurrence.before) || !hash(occurrence.sha256) || (0, node_crypto_1.createHash)('sha256').update(occurrence.before).digest('hex') !== occurrence.sha256)
319
+ fail();
320
+ const location = canonicalJournal([occurrence.ref.owner, occurrence.ref.relativePath]);
321
+ if (locations.has(location))
322
+ fail();
323
+ locations.add(location);
324
+ }
325
+ }
326
+ }
284
327
  const outcome = (item) => item.decision.action === 'approve' ? 'applied' : 'rejected';
285
328
  const dispositions = new Set();
286
329
  for (const source of state.sourceDispositions) {
@@ -0,0 +1,205 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.corpusEntryKey = corpusEntryKey;
7
+ exports.collectLearningCorpus = collectLearningCorpus;
8
+ exports.validateCorpusAudit = validateCorpusAudit;
9
+ const node_fs_1 = __importDefault(require("node:fs"));
10
+ const node_path_1 = __importDefault(require("node:path"));
11
+ const node_crypto_1 = require("node:crypto");
12
+ const owner_identity_1 = require("./owner-identity");
13
+ const { learningEntryBlocks } = require('./entry-format');
14
+ const stable = (value) => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
15
+ ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
16
+ const hash = (value) => (0, node_crypto_1.createHash)('sha256').update(value).digest('hex');
17
+ const keys = (value, allowed) => !!value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).every(key => allowed.includes(key));
18
+ function corpusEntryKey(entry) {
19
+ return (0, owner_identity_1.ownerKey)(entry.ref.owner) + ':' + entry.ref.relativePath + ':' + (entry.ref.entryId ?? `unassigned:${entry.occurrence}`);
20
+ }
21
+ function corpusFingerprint(homes, entries) { return hash(stable({ homes, entries })); }
22
+ /** Only persisted identity and bytes are mechanical; audience and semantic conflicts require review. */
23
+ function collectLearningCorpus(homes, subjects, readerRoots = []) {
24
+ const entries = [], errors = [], seen = new Set();
25
+ const identities = [...new Map(homes.map(home => [(0, owner_identity_1.ownerKey)(home.owner), home.owner])).values()].sort((a, b) => (0, owner_identity_1.ownerKey)(a).localeCompare((0, owner_identity_1.ownerKey)(b)));
26
+ const escaped = [...new Set([...subjects, 'org'])].map(subject => subject.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'));
27
+ const family = new RegExp(`^(?:${escaped.join('|')})-(?:[a-z][a-z-]*-)?(?:preferences|mistake-patterns|validated-patterns|manager-coaching)(\\.cold)?\\.md$`);
28
+ const excluded = new Set(['raw', 'retrospectives', 'archive', 'review-queue', 'review-archive', 'review-evidence']);
29
+ for (const home of [...homes].sort((a, b) => (0, owner_identity_1.ownerKey)(a.owner).localeCompare((0, owner_identity_1.ownerKey)(b.owner)))) {
30
+ const walk = (directory) => {
31
+ try {
32
+ if (node_fs_1.default.lstatSync(directory).isSymbolicLink())
33
+ throw new Error('symlink-corpus-not-followed');
34
+ for (const item of node_fs_1.default.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
35
+ const file = node_path_1.default.join(directory, item.name);
36
+ if (item.isSymbolicLink()) {
37
+ if (!excluded.has(item.name))
38
+ errors.push({ path: file.replace(/\\/g, '/'), reason: 'symlink-corpus-not-followed' });
39
+ continue;
40
+ }
41
+ if (item.isDirectory()) {
42
+ if (!excluded.has(item.name))
43
+ walk(file);
44
+ continue;
45
+ }
46
+ if (!family.test(item.name))
47
+ continue;
48
+ try {
49
+ if (item.isSymbolicLink())
50
+ throw new Error('symlink-corpus-not-followed');
51
+ const real = node_fs_1.default.realpathSync(file);
52
+ const key = process.platform === 'win32' ? real.toLowerCase() : real;
53
+ if (seen.has(key))
54
+ continue;
55
+ seen.add(key);
56
+ const relativePath = node_path_1.default.relative(home.contentRoot, file).replace(/\\/g, '/');
57
+ (0, owner_identity_1.ownerArtifactPath)(home, relativePath);
58
+ const body = node_fs_1.default.readFileSync(file, 'utf8');
59
+ if (/^<{7}/m.test(body))
60
+ throw new Error('corpus-merge-conflict');
61
+ const blocks = learningEntryBlocks(body), ids = new Set();
62
+ let unassigned = 0;
63
+ for (const block of blocks) {
64
+ // Match the runtime reader's handwritten identity contract.
65
+ // Approved mutations still use the author's narrower generated-ID schema.
66
+ const declarations = block.text.split(/\r?\n/).map(line => line.trim()).filter(line => /^\*\*Id\*\*:/i.test(line));
67
+ const id = declarations[0]?.match(/^\*\*Id\*\*:\s*(L-[0-9A-Za-z]{6,24})\s*$/)?.[1] ?? null;
68
+ if ((declarations.length && (!id || declarations.length !== 1)) || (id && ids.has(id))
69
+ || !/^#{2,6}\s+\[P-(CRITICAL|HIGH|MED|LOW)\]\s+\S/.test(block.text)) {
70
+ errors.push({ path: file.replace(/\\/g, '/'), reason: 'ambiguous-corpus-entry' });
71
+ continue;
72
+ }
73
+ if (id)
74
+ ids.add(id);
75
+ entries.push({ ref: { owner: home.owner, relativePath, ...(id ? { entryId: id } : {}) }, sha256: hash(block.text),
76
+ ...(!id ? { occurrence: unassigned++ } : {}) });
77
+ }
78
+ }
79
+ catch (error) {
80
+ errors.push({ path: file.replace(/\\/g, '/'), reason: error.code || error.message });
81
+ }
82
+ }
83
+ }
84
+ catch (error) {
85
+ if (error.code !== 'ENOENT')
86
+ errors.push({ path: directory.replace(/\\/g, '/'), reason: error.code || error.message });
87
+ }
88
+ };
89
+ try {
90
+ walk((0, owner_identity_1.ownerArtifactPath)(home, 'learnings'));
91
+ }
92
+ catch (error) {
93
+ errors.push({ path: home.contentRoot, reason: error.message });
94
+ }
95
+ }
96
+ // Legacy reader locations are explicitly discoverable but cannot acquire a
97
+ // writable OwnerRef just by being present. Report every unowned family file.
98
+ const inspectReaderRoot = (directory) => {
99
+ try {
100
+ if (node_fs_1.default.lstatSync(directory).isSymbolicLink())
101
+ throw new Error('symlink-corpus-not-followed');
102
+ for (const item of node_fs_1.default.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
103
+ const file = node_path_1.default.join(directory, item.name);
104
+ if (excluded.has(item.name))
105
+ continue;
106
+ if (item.isSymbolicLink()) {
107
+ errors.push({ path: file.replace(/\\/g, '/'), reason: 'symlink-corpus-not-followed' });
108
+ continue;
109
+ }
110
+ if (item.isDirectory()) {
111
+ inspectReaderRoot(file);
112
+ continue;
113
+ }
114
+ if (!family.test(item.name))
115
+ continue;
116
+ try {
117
+ const real = node_fs_1.default.realpathSync(file), key = process.platform === 'win32' ? real.toLowerCase() : real;
118
+ if (seen.has(key))
119
+ continue;
120
+ seen.add(key);
121
+ errors.push({ path: file.replace(/\\/g, '/'), reason: 'reader-live-home-unowned' });
122
+ }
123
+ catch (error) {
124
+ errors.push({ path: file.replace(/\\/g, '/'), reason: error.code || error.message });
125
+ }
126
+ }
127
+ }
128
+ catch (error) {
129
+ if (error.code !== 'ENOENT')
130
+ errors.push({ path: directory.replace(/\\/g, '/'), reason: error.code || error.message });
131
+ }
132
+ };
133
+ for (const directory of [...new Set(readerRoots)].sort()) {
134
+ // An explicit configured alias of an owned learning root is already scanned.
135
+ try {
136
+ const key = (file) => { const real = node_fs_1.default.realpathSync(file); return process.platform === 'win32' ? real.toLowerCase() : real; };
137
+ const real = key(directory);
138
+ if (homes.some(home => { try {
139
+ return key(node_path_1.default.join(home.contentRoot, 'learnings')) === real;
140
+ }
141
+ catch {
142
+ return false;
143
+ } }))
144
+ continue;
145
+ }
146
+ catch { /* Missing/unreadable roots are handled by the scanner below. */ }
147
+ inspectReaderRoot(directory);
148
+ }
149
+ entries.sort((a, b) => corpusEntryKey(a).localeCompare(corpusEntryKey(b)));
150
+ const groups = new Map();
151
+ for (const entry of entries)
152
+ if (entry.ref.entryId)
153
+ groups.set(entry.ref.entryId, [...(groups.get(entry.ref.entryId) || []), entry]);
154
+ const duplicates = [...groups].filter(([, occurrences]) => occurrences.length > 1).map(([entryId, occurrences]) => ({ entryId, occurrences })).sort((a, b) => a.entryId.localeCompare(b.entryId));
155
+ return { schemaVersion: 1, homes: identities, entries, duplicates, fingerprint: corpusFingerprint(identities, entries), complete: !errors.length, errors };
156
+ }
157
+ /** Shared by journal replay and the strict compact receipt decoder. No filesystem access. */
158
+ function validateCorpusAudit(audit, reviewer) {
159
+ const fail = () => { throw new Error('invalid-corpus-audit'); };
160
+ if (!keys(audit, ['snapshot', 'reviewer', 'dispositions']) || typeof audit.reviewer !== 'string' || !audit.reviewer.trim() || (reviewer && audit.reviewer !== reviewer)
161
+ || !keys(audit.snapshot, ['schemaVersion', 'homes', 'entries', 'duplicates', 'fingerprint', 'complete', 'errors']) || audit.snapshot.schemaVersion !== 1 || audit.snapshot.complete !== true || !Array.isArray(audit.snapshot.errors) || audit.snapshot.errors.length
162
+ || !Array.isArray(audit.snapshot.homes) || !Array.isArray(audit.snapshot.entries) || !Array.isArray(audit.dispositions))
163
+ fail();
164
+ const snapshot = audit.snapshot;
165
+ for (const owner of snapshot.homes)
166
+ (0, owner_identity_1.validateOwner)(owner);
167
+ if (new Set(snapshot.homes.map(owner_identity_1.ownerKey)).size !== snapshot.homes.length || snapshot.fingerprint !== corpusFingerprint(snapshot.homes, snapshot.entries))
168
+ fail();
169
+ const entries = new Map();
170
+ for (const entry of snapshot.entries) {
171
+ if (!keys(entry, ['ref', 'sha256', 'occurrence']) || !keys(entry.ref, ['owner', 'relativePath', 'entryId']))
172
+ fail();
173
+ (0, owner_identity_1.validateOwner)(entry.ref?.owner);
174
+ if (!snapshot.homes.some(owner => (0, owner_identity_1.ownerKey)(owner) === (0, owner_identity_1.ownerKey)(entry.ref.owner))
175
+ || (entry.ref.entryId === undefined ? !Number.isSafeInteger(entry.occurrence) || entry.occurrence < 0 : !/^L-[0-9A-Za-z]{6,24}$/.test(entry.ref.entryId) || entry.occurrence !== undefined)
176
+ || !/^learnings\//.test(entry.ref.relativePath) || /[\\:]/.test(entry.ref.relativePath) || entry.ref.relativePath.split('/').some(part => !part || part === '.' || part === '..')
177
+ || !/^[a-f0-9]{64}$/.test(entry.sha256) || entries.has(corpusEntryKey(entry)))
178
+ fail();
179
+ entries.set(corpusEntryKey(entry), entry);
180
+ }
181
+ const groups = new Map();
182
+ for (const entry of snapshot.entries)
183
+ if (entry.ref.entryId)
184
+ groups.set(entry.ref.entryId, [...(groups.get(entry.ref.entryId) || []), entry]);
185
+ const duplicates = [...groups].filter(([, occurrences]) => occurrences.length > 1).map(([entryId, occurrences]) => ({ entryId, occurrences })).sort((a, b) => a.entryId.localeCompare(b.entryId));
186
+ if (stable(snapshot.duplicates) !== stable(duplicates))
187
+ fail();
188
+ if (audit.dispositions.length !== entries.size)
189
+ fail();
190
+ const seen = new Set();
191
+ for (const item of audit.dispositions) {
192
+ if (!keys(item, ['ref', 'sha256', 'occurrence', 'outcome', 'rationale', 'actionIds', 'actionBindings']) || !keys(item.ref, ['owner', 'relativePath', 'entryId']))
193
+ fail();
194
+ const key = corpusEntryKey(item);
195
+ if (seen.has(key) || entries.get(key)?.sha256 !== item.sha256 || entries.get(key)?.occurrence !== item.occurrence || !['retained', 'action'].includes(item.outcome)
196
+ || typeof item.rationale !== 'string' || !item.rationale.trim() || !Array.isArray(item.actionIds) || new Set(item.actionIds).size !== item.actionIds.length
197
+ || (item.outcome === 'retained' ? item.actionIds.length !== 0 : item.actionIds.length === 0)
198
+ || !Array.isArray(item.actionBindings) || item.actionBindings.length !== item.actionIds.length)
199
+ fail();
200
+ for (const [index, binding] of item.actionBindings.entries())
201
+ if (!keys(binding, ['actionId', 'revision', 'digest']) || binding.actionId !== item.actionIds[index] || !Number.isInteger(binding.revision) || binding.revision < 1 || !/^[a-f0-9]{64}$/.test(binding.digest))
202
+ fail();
203
+ seen.add(key);
204
+ }
205
+ }
@@ -0,0 +1,137 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ownerArtifactRoot = void 0;
7
+ exports.ownerPath = ownerPath;
8
+ exports.validateOwner = validateOwner;
9
+ exports.ownerKey = ownerKey;
10
+ exports.assertSameOwner = assertSameOwner;
11
+ exports.resolveOwnerHome = resolveOwnerHome;
12
+ exports.ownerArtifactPath = ownerArtifactPath;
13
+ exports.configuredArtifactPath = configuredArtifactPath;
14
+ const node_fs_1 = __importDefault(require("node:fs"));
15
+ const node_path_1 = __importDefault(require("node:path"));
16
+ /** A recorded home path is immutable; moving storage requires a newly reviewed run. */
17
+ function ownerPath(root) {
18
+ const absolute = node_path_1.default.resolve(root).replace(/\\/g, '/');
19
+ return process.platform === 'win32' ? absolute.toLowerCase() : absolute;
20
+ }
21
+ function validateOwner(owner) {
22
+ if (!owner || !['Project', 'Manager', 'Org'].includes(owner.level)
23
+ || Object.keys(owner).some(key => !['level', 'path'].includes(key))
24
+ || typeof owner.path !== 'string' || !node_path_1.default.isAbsolute(owner.path)
25
+ || /[\x00-\x1f]/.test(owner.path) || owner.path !== ownerPath(owner.path)) {
26
+ throw new Error('owner-unresolved: a canonical absolute home path is required');
27
+ }
28
+ }
29
+ function ownerKey(owner) {
30
+ validateOwner(owner);
31
+ return JSON.stringify([owner.level, owner.path]);
32
+ }
33
+ function assertSameOwner(expected, actual) {
34
+ if (ownerKey(expected) !== ownerKey(actual))
35
+ throw new Error('owner-path-conflict: the approved home differs from the configured path');
36
+ }
37
+ /** Pure read: resolve existing configured homes without migration or identity configuration. */
38
+ function resolveOwnerHome(projectPath, level, options) {
39
+ const load = (relative) => options.runtimeModule(projectPath, relative);
40
+ const paths = load('core/utils/project-fraim-paths');
41
+ let home;
42
+ if (level === 'Project') {
43
+ const root = paths.getWorkspaceFraimPath(projectPath);
44
+ home = { root, artifactRoot: node_path_1.default.resolve(projectPath), contentRoot: node_path_1.default.join(root, 'personalized-employee'), requiresPublish: false, backend: 'project' };
45
+ }
46
+ else {
47
+ if (!['Manager', 'Org'].includes(level))
48
+ throw new Error('owner-unresolved: invalid owner level');
49
+ const configFile = node_path_1.default.join(paths.getUserFraimDirPath(), 'config.json');
50
+ let config;
51
+ try {
52
+ config = node_fs_1.default.existsSync(configFile) ? JSON.parse(node_fs_1.default.readFileSync(configFile, 'utf8')) : {};
53
+ }
54
+ catch {
55
+ throw new Error('home-unavailable: user configuration is invalid');
56
+ }
57
+ if (!config || typeof config !== 'object' || Array.isArray(config))
58
+ throw new Error('home-unavailable: user configuration is invalid');
59
+ const raw = level === 'Manager' ? config.managerStorage : config.organization;
60
+ if (raw !== undefined && (!raw || typeof raw !== 'object' || Array.isArray(raw)
61
+ || !['git', 'local-folder', 'fraim-cloud', 'single-machine'].includes(raw.backend)
62
+ || (raw.backend === 'git' && (typeof raw.gitUrl !== 'string' || !raw.gitUrl.trim()))
63
+ || (raw.backend === 'local-folder' && (typeof raw.localPath !== 'string' || !raw.localPath.trim())))) {
64
+ throw new Error('home-unavailable: invalid configured storage home');
65
+ }
66
+ const resolved = load('cli/utils/pack-home').resolvePackHome(level === 'Manager' ? 'manager' : 'org', { migrateLegacy: false });
67
+ home = { root: resolved.root, contentRoot: resolved.contentRoot, requiresPublish: resolved.requiresPublish, backend: resolved.backend, ...(resolved.gitUrl ? { gitUrl: resolved.gitUrl } : {}) };
68
+ }
69
+ const owner = { level, path: ownerPath(home.root) };
70
+ if (options.expectedOwner)
71
+ assertSameOwner(options.expectedOwner, owner);
72
+ if (!node_fs_1.default.existsSync(home.root) || !node_fs_1.default.statSync(home.root).isDirectory()
73
+ || (home.backend === 'git' && !node_fs_1.default.existsSync(node_path_1.default.join(home.root, '.git')))) {
74
+ throw new Error('home-unavailable: configured home is not available locally');
75
+ }
76
+ return { ...home, level, owner };
77
+ }
78
+ /** Resolve a relative artifact without permitting lexical or realpath escapes, including junctions. */
79
+ function ownerArtifactPath(home, relativePath) {
80
+ assertSameOwner(home.owner, { level: home.level, path: ownerPath(home.root) });
81
+ if (!relativePath || node_path_1.default.isAbsolute(relativePath) || relativePath.includes('\\')
82
+ || relativePath.includes(':') || relativePath.split('/').some(part => !part || part === '.' || part === '..')) {
83
+ throw new Error('invalid-artifact-path');
84
+ }
85
+ // Shared project capabilities live in the repository registry, outside its personalized pack.
86
+ const shared = home.level === 'Project' && relativePath.startsWith('registry/');
87
+ const containmentRoot = shared ? node_path_1.default.dirname(home.root) : home.root;
88
+ // The workspace configuration has one explicit reference; arbitrary parent paths stay forbidden.
89
+ const workspaceConfig = home.level === 'Project' && relativePath === 'config.json';
90
+ const contentRoot = shared ? containmentRoot : workspaceConfig ? home.root : home.contentRoot;
91
+ const target = node_path_1.default.resolve(contentRoot, relativePath);
92
+ const existingAncestor = (file) => {
93
+ let current = file;
94
+ while (!node_fs_1.default.existsSync(current)) {
95
+ const parent = node_path_1.default.dirname(current);
96
+ if (parent === current)
97
+ throw new Error('home-unavailable');
98
+ current = parent;
99
+ }
100
+ return current;
101
+ };
102
+ const realRoot = node_fs_1.default.realpathSync(containmentRoot);
103
+ const ancestor = node_fs_1.default.realpathSync(existingAncestor(target));
104
+ const relative = node_path_1.default.relative(realRoot, ancestor);
105
+ if (relative === '..' || relative.startsWith('..' + node_path_1.default.sep) || node_path_1.default.isAbsolute(relative))
106
+ throw new Error('invalid-artifact-path: escaped owner home');
107
+ // Also reject escaping a content root via a symlink to a sibling in the pack.
108
+ if (node_fs_1.default.existsSync(contentRoot)) {
109
+ const contentRelative = node_path_1.default.relative(node_fs_1.default.realpathSync(contentRoot), ancestor);
110
+ if (contentRelative === '..' || contentRelative.startsWith('..' + node_path_1.default.sep) || node_path_1.default.isAbsolute(contentRelative))
111
+ throw new Error('invalid-artifact-path: escaped content root');
112
+ }
113
+ return target;
114
+ }
115
+ /** Explicit artifact base; never infer a project from its personalized directory. */
116
+ const ownerArtifactRoot = (home) => home.artifactRoot ?? home.root;
117
+ exports.ownerArtifactRoot = ownerArtifactRoot;
118
+ /** Configured artifacts may sit outside the personalized pack, but never outside their owner. */
119
+ function configuredArtifactPath(home, file) {
120
+ assertSameOwner(home.owner, { level: home.level, path: ownerPath(home.root) });
121
+ const root = node_path_1.default.resolve((0, exports.ownerArtifactRoot)(home));
122
+ const target = node_path_1.default.resolve(file);
123
+ const relative = node_path_1.default.relative(root, target);
124
+ if (!relative || relative === '..' || relative.startsWith('..' + node_path_1.default.sep) || node_path_1.default.isAbsolute(relative))
125
+ throw new Error('invalid-artifact-path: escaped owner home');
126
+ for (let current = target; ownerPath(current) !== ownerPath(root); current = node_path_1.default.dirname(current)) {
127
+ try {
128
+ if (node_fs_1.default.lstatSync(current).isSymbolicLink())
129
+ throw new Error('invalid-artifact-path: linked artifact');
130
+ }
131
+ catch (error) {
132
+ if (error.code !== 'ENOENT')
133
+ throw error;
134
+ }
135
+ }
136
+ return target;
137
+ }
@@ -186,7 +186,7 @@ function resolvePersonalLearningFile(repoBase, managerCacheBase, managerCacheDis
186
186
  * before the change, and the same shadowing applied to company families.
187
187
  *
188
188
  * Returning all tiers lets the context block list each present path and lets the
189
- * Hub merge entries with the project entry winning only on a title collision.
189
+ * Hub retain each current occurrence without treating title collisions as authority.
190
190
  */
191
191
  function resolvePersonalLearningFileTiers(repoBase, managerCacheBase, managerCacheDisplayBase, globalBase, globalDisplayBase, fileName) {
192
192
  const tiers = [];
@@ -723,19 +723,12 @@ function resolveOrgLearningFileTiers(repoLearningsBase, fileName) {
723
723
  return tiers;
724
724
  }
725
725
  /**
726
- * Issue #1002 R4: merge entries across tiers. Lowest precedence first in, so a
727
- * later tier overwrites on an identical title and nothing is duplicated. Entries
728
- * whose titles do not collide all survive, which is the whole point: promoting
729
- * one entry must not hide the rest of its family.
726
+ * Keep every current occurrence visible. A title is not identity and neither
727
+ * tier order nor equal IDs authorize discarding a conflicting revision.
728
+ * Reconciliation belongs to the reviewed learning application flow.
730
729
  */
731
730
  function mergeLearningEntriesAcrossTiers(tiers) {
732
- const byTitle = new Map();
733
- for (const tier of tiers) {
734
- for (const entry of tier.entries) {
735
- byTitle.set(entry.title.trim().toLowerCase(), entry);
736
- }
737
- }
738
- return [...byTitle.values()];
731
+ return tiers.flatMap(tier => tier.entries);
739
732
  }
740
733
  const CONTEXT_CATEGORIES = {
741
734
  mistake: { ft: 'mistake-patterns', gated: true, label: '(entries above score threshold)' },
@@ -919,7 +912,7 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
919
912
  if (hasL2 || hasL1) {
920
913
  section += 'Read the listed synthesized learning files before continuing, then apply the relevant patterns and preferences in this job.\n';
921
914
  if (multiTier) {
922
- section += 'A family listed at more than one level is combined, not replaced: apply every entry, and where the same entry title appears at two levels apply the more specific one (project over manager).\n';
915
+ section += 'A family listed at more than one level is combined, not replaced. Stable IDs identify lessons; equal titles do not establish identity. Conflicting copies require explicit reconciliation: do not choose a revision by scope, timestamp, count, or title. Preserve approved decisions and useful refinements; unresolved conflicting claims are not authority for action.\n';
923
916
  }
924
917
  if (coachPresent) {
925
918
  section += 'Treat manager-coaching as feedback for how the manager should continue or improve managing AI, not as agent instruction.\n';
@@ -929,7 +922,7 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
929
922
  else if (hasL2 || hasL1) {
930
923
  section += 'Read the listed synthesized learning files before continuing, then use this synthesized learning context throughout the session.\n';
931
924
  if (multiTier) {
932
- section += 'A family listed at more than one level is combined, not replaced: apply every entry, and where the same entry title appears at two levels apply the more specific one (project over manager).\n';
925
+ section += 'A family listed at more than one level is combined, not replaced. Stable IDs identify lessons; equal titles do not establish identity. Conflicting copies require explicit reconciliation: do not choose a revision by scope, timestamp, count, or title. Preserve approved decisions and useful refinements; unresolved conflicting claims are not authority for action.\n';
933
926
  }
934
927
  if (coachPresent) {
935
928
  section += 'Manager-coaching entries are manager-facing feedback, not instructions for the AI to follow.\n';
@@ -1019,27 +1012,10 @@ function enumerateFileEntries(file, threshold, usageClock, now) {
1019
1012
  * listed. `aboveThreshold` is what separates active guidance from the dormant
1020
1013
  * rollup. Nothing here depends on the agent reading anything.
1021
1014
  *
1022
- * ## Issue #1752 A2 — one lesson, one offer, stamped or not
1023
- *
1024
- * This deduped by usage key, which sounds like the same thing as the context
1025
- * reader's rule and is not. `resolveOrgLearningFileTiers` deliberately returns
1026
- * both the org pack copy and the repo-local copy of an org family, because #1002
1027
- * R4 requires that a promoted entry not hide the rest of its family. The context
1028
- * reader then merges those tiers with `mergeLearningEntriesAcrossTiers`, keyed on
1029
- * the normalized title, so the agent sees one lesson. Keying the offer record on
1030
- * `id ?? titleUsageKey(...)` instead meant that a lesson carrying an `**Id**` line
1031
- * in one tier and not the other produced two keys, and both were recorded.
1032
- *
1033
- * That is defect 4 in #1752, and it is why `standing --changes-only` could
1034
- * recommend promoting and demoting the same lesson in one response: the
1035
- * title-keyed twin collected the offers while the id-keyed one collected the
1036
- * firings (the firing path never split, because `buildEntryIndex` dedups by
1037
- * identity first-wins). Measured live: five titles emitting two keys each in a
1038
- * single derived offer batch.
1039
- *
1040
- * So group by the same normalized title the context reader groups by, resolve one
1041
- * identity per group preferring whichever tier carries an id, and let the later
1042
- * tier win on content exactly as before.
1015
+ * Offers use stable identity, independently of ownership reconciliation. A repeated
1016
+ * ID records one offer, but equal titles with different IDs remain distinct.
1017
+ * Unstamped legacy entries retain their existing title usage key; that fallback
1018
+ * never grants a stamped entry authority over a different identity.
1043
1019
  */
1044
1020
  function collectOfferedLearningEntries(workspaceRoot, userId, forJob, domain) {
1045
1021
  const roots = getLearningRoots(workspaceRoot);
@@ -1048,13 +1024,10 @@ function collectOfferedLearningEntries(workspaceRoot, userId, forJob, domain) {
1048
1024
  const usageClock = resolveUsageClock(workspaceRoot);
1049
1025
  const now = new Date();
1050
1026
  const resolved = resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJob, domain);
1051
- // Grouped by family plus normalized title, which is what `titleUsageKey`
1052
- // already computes. Two families holding the same title are two lessons, and
1053
- // that is the existing rule rather than a new one.
1054
1027
  const byLesson = new Map();
1055
1028
  for (const file of [...resolved.l2, ...resolved.l1]) {
1056
1029
  for (const entry of enumerateFileEntries(file, threshold, usageClock, now)) {
1057
- const lessonKey = (0, learning_usage_store_1.titleUsageKey)(entry.fileType, entry.title);
1030
+ const lessonKey = entry.id ?? (0, learning_usage_store_1.titleUsageKey)(entry.fileType, entry.title);
1058
1031
  const group = byLesson.get(lessonKey);
1059
1032
  if (group)
1060
1033
  group.push(entry);
@@ -1064,17 +1037,9 @@ function collectOfferedLearningEntries(workspaceRoot, userId, forJob, domain) {
1064
1037
  }
1065
1038
  const out = [];
1066
1039
  for (const group of byLesson.values()) {
1067
- // Later tier wins for content, unchanged precedence.
1068
- const winner = group[group.length - 1];
1069
- // The id comes from whichever tier carries one, because `stamp-ids` has not
1070
- // reached the org and manager pack homes for every corpus yet. Latest wins
1071
- // when more than one tier is stamped.
1072
- const id = group.reduce((found, entry) => entry.id ?? found, null);
1073
- out.push({
1074
- ...winner,
1075
- id,
1076
- key: (0, learning_usage_store_1.resolveEntryIdentity)({ id, fileType: winner.fileType, title: winner.title }).key,
1077
- });
1040
+ // This is an offer counter, not a choice of authoritative guidance.
1041
+ const entry = group[0];
1042
+ out.push({ ...entry, aboveThreshold: group.some(item => item.aboveThreshold) });
1078
1043
  }
1079
1044
  return out;
1080
1045
  }
@@ -1461,7 +1426,7 @@ function readLearningEntryTitles(filePath) {
1461
1426
  *
1462
1427
  * Issue #1002: two changes from the original.
1463
1428
  *
1464
- * R4: counts sum across every level that holds a family, deduped by entry title,
1429
+ * R4: counts sum across every level that holds a family,
1465
1430
  * rather than counting only the winning file. Under the old first-wins read a
1466
1431
  * family present at two levels was counted once, so the Brain under-reported
1467
1432
  * exactly the case this issue introduces.
@@ -1483,26 +1448,27 @@ function countPreservedLearnings(workspaceRoot, userId) {
1483
1448
  }
1484
1449
  return titles.size;
1485
1450
  };
1486
- // Same for a personal family, and reported per level so the Brain can show
1487
- // where each entry actually lives. A title present at both levels counts
1488
- // once, at the project level, matching which entry the agent applies.
1451
+ // Count current occurrences at their actual owner; reconciliation, not a
1452
+ // title match, determines whether a redundant copy may be removed.
1489
1453
  const countPersonalFamilyByLevel = (fileName) => {
1490
1454
  const tiers = resolvePersonalLearningFileTiers(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, fileName);
1491
- const projectTitles = new Set();
1492
- const managerTitles = new Set();
1455
+ const seenFiles = new Set();
1456
+ const counts = { manager: 0, project: 0 };
1493
1457
  for (const tier of tiers) {
1494
- for (const t of readLearningEntryTitles(tier.path)) {
1495
- if (tier.level === 'project')
1496
- projectTitles.add(t);
1497
- else
1498
- managerTitles.add(t);
1458
+ let file;
1459
+ try {
1460
+ file = (0, fs_1.realpathSync)(tier.path);
1461
+ }
1462
+ catch {
1463
+ continue;
1499
1464
  }
1465
+ if (seenFiles.has(file))
1466
+ continue;
1467
+ seenFiles.add(file);
1468
+ const owner = tier.level === 'project' ? 'project' : 'manager';
1469
+ counts[owner] += countLearningEntries(tier.path);
1500
1470
  }
1501
- // A promoted entry lives at the project level; do not also count it as the
1502
- // manager's, or the two tiles would double-report the same lesson.
1503
- for (const t of projectTitles)
1504
- managerTitles.delete(t);
1505
- return { manager: managerTitles.size, project: projectTitles.size };
1471
+ return counts;
1506
1472
  };
1507
1473
  const PERSONAL_WORK_TYPES = ['mistake-patterns', 'preferences', 'validated-patterns'];
1508
1474
  const ORG_TYPES = ['mistake-patterns', 'preferences', 'manager-coaching', 'validated-patterns'];
@@ -1803,6 +1769,8 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1803
1769
  current.score = null;
1804
1770
  current.injected = true;
1805
1771
  }
1772
+ if (tier === 'cold')
1773
+ current.injected = false;
1806
1774
  out.push(current);
1807
1775
  }
1808
1776
  current = null;
@@ -1926,7 +1894,7 @@ function readRejectedProposals(workspaceRoot, userId) {
1926
1894
  ? proposal.claimKey
1927
1895
  : parts[1] || heading[2];
1928
1896
  // The same claim can appear in more than one configured base. The first
1929
- // base that carries it wins, matching how the tier merge resolves.
1897
+ // base supplies display metadata for that same recorded decision.
1930
1898
  const key = `${claimKey}:${String(decision.at || '')}`;
1931
1899
  if (seen.has(key))
1932
1900
  continue;
@@ -1966,7 +1934,7 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
1966
1934
  for (const cat of ['avoid', 'preference', 'repeat', 'coaching']) {
1967
1935
  const fileType = exports.CATEGORY_TO_FILETYPE[cat];
1968
1936
  const f = `org-${fileType}.md`;
1969
- // org pack home first, repo-local override second; repo-local wins on title collision.
1937
+ // Keep occurrences from both homes; title matches do not grant authority.
1970
1938
  out.push(...parseTiers(resolveOrgLearningFileTiers(roots.repoLearningsBase, f), cat));
1971
1939
  // #806: domain-scoped org learnings — union from both locations.
1972
1940
  const allDomainFiles = [
@@ -1990,46 +1958,18 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
1990
1958
  }
1991
1959
  return out;
1992
1960
  }
1993
- // manager + reverse: use the same three-tier resolution as buildLearningContextSection.
1961
+ // The library is an ownership view, not the combined runtime context.
1994
1962
  const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
1995
1963
  const cats = scope === 'reverse' ? ['coaching'] : ['avoid', 'preference', 'repeat'];
1996
- if (level === 'project') {
1997
- // Project level: repo-local only (single tier, no merge needed).
1998
- for (const cat of cats) {
1999
- const fileType = exports.CATEGORY_TO_FILETYPE[cat];
2000
- const f = `${resolvedUserId}-${fileType}.md`;
2001
- out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, f), `${REPO_LEARNINGS_REL}/${f}`, cat, 'project', 'global', 'hot', threshold, usageClock, usageStats));
2002
- const coldF = `${resolvedUserId}-${fileType}${COLD_FILE_SUFFIX}.md`;
2003
- if ((0, fs_1.existsSync)((0, path_1.join)(roots.repoLearningsBase, coldF))) {
2004
- out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, coldF), `${REPO_LEARNINGS_REL}/${coldF}`, cat, 'project', 'global', 'cold', threshold, usageClock, usageStats));
2005
- }
2006
- for (const { fileName, domain } of listDomainLearningFiles(roots.repoLearningsBase, resolvedUserId, fileType)) {
2007
- out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'project', domain, 'hot', threshold, usageClock, usageStats));
2008
- }
2009
- }
2010
- return out;
2011
- }
2012
- // machine level: all three personal tiers (managerCacheBase, globalPersonalBase, repoLearningsBase).
2013
- // Cold siblings are read from each tier that has the hot file.
2014
1964
  for (const cat of cats) {
2015
1965
  const fileType = exports.CATEGORY_TO_FILETYPE[cat];
2016
- const f = `${resolvedUserId}-${fileType}.md`;
2017
- const tiers = resolvePersonalLearningFileTiers(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, f);
2018
- out.push(...parseTiers(tiers, cat, 'hot'));
2019
- // Cold sibling from each tier that has the hot file.
2020
- const coldF = `${resolvedUserId}-${fileType}${COLD_FILE_SUFFIX}.md`;
2021
- const coldTiers = resolvePersonalLearningFileTiers(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, coldF);
2022
- out.push(...parseTiers(coldTiers, cat, 'cold'));
2023
- // #806: domain files from managerHomeBase and repo base.
2024
- const domainDirs = [roots.globalPersonalBase, roots.managerCacheBase, roots.repoLearningsBase];
2025
- const seenDomain = new Set();
2026
- for (const dir of domainDirs) {
2027
- for (const { fileName, domain } of listDomainLearningFiles(dir, resolvedUserId, fileType)) {
2028
- if (seenDomain.has(fileName))
2029
- continue;
2030
- seenDomain.add(fileName);
2031
- const domTiers = resolvePersonalLearningFileTiers(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, fileName);
2032
- out.push(...parseTiers(domTiers, cat, 'hot', domain));
1966
+ for (const domain of ['global', ...learning_domains_1.LEARNING_DOMAINS]) {
1967
+ const domainPart = domain === 'global' ? '' : `-${domain}`;
1968
+ for (const storageTier of ['hot', 'cold']) {
1969
+ const suffix = storageTier === 'cold' ? COLD_FILE_SUFFIX : '';
1970
+ const fileName = `${resolvedUserId}${domainPart}-${fileType}${suffix}.md`;
1971
+ const tiers = resolvePersonalLearningFileTiers(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, fileName).filter(t => level === 'project' ? t.level === 'project' : t.level !== 'project');
1972
+ out.push(...parseTiers(tiers, cat, storageTier, domain));
2033
1973
  }
2034
1974
  }
2035
1975
  }
@@ -2097,12 +2037,22 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
2097
2037
  write(preamble + nl + nl + block);
2098
2038
  return { ok: true, path: filePath };
2099
2039
  }
2100
- const targetIdx = starts.findIndex((s) => {
2101
- const m = lines[s].match(headingRe);
2102
- return m && m[2].trim() === (payload.originalTitle || '').trim();
2040
+ if (payload.originalId !== undefined && (typeof payload.originalId !== 'string' || !/^L-[0-9A-Za-z]{6,24}$/.test(payload.originalId))) {
2041
+ throw new Error('Invalid learning entry identity');
2042
+ }
2043
+ const matches = starts.map((start, index) => ({ start, index })).filter(({ start, index }) => {
2044
+ if (payload.originalId !== undefined) {
2045
+ const end = index + 1 < starts.length ? starts[index + 1] : lines.length;
2046
+ const ids = lines.slice(start + 1, end).map(line => line.trim().match(LEARNING_ENTRY_ID_LINE)).filter(Boolean);
2047
+ return ids.length === 1 && ids[0][1] === payload.originalId;
2048
+ }
2049
+ return lines[start].match(headingRe)?.[2].trim() === (payload.originalTitle || '').trim();
2103
2050
  });
2104
- if (targetIdx === -1)
2105
- throw new Error(`Learning entry not found: "${payload.originalTitle}"`);
2051
+ if (!matches.length)
2052
+ throw new Error(`Learning entry not found: "${payload.originalId ?? payload.originalTitle}"`);
2053
+ if (matches.length !== 1)
2054
+ throw new Error('Learning entry is ambiguous; reconcile duplicate identities or select a unique entry');
2055
+ const targetIdx = matches[0].index;
2106
2056
  const start = starts[targetIdx];
2107
2057
  const end = targetIdx + 1 < starts.length ? starts[targetIdx + 1] : lines.length;
2108
2058
  if (action === 'delete') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fraim",
3
- "version": "2.0.345",
3
+ "version": "2.0.346",
4
4
  "description": "FRAIM core CLI and MCP package.",
5
5
  "main": "index.js",
6
6
  "bin": {
@@ -12,6 +12,8 @@
12
12
  "dist/registry/scripts/learning/learning-domains.json",
13
13
  "dist/registry/scripts/learning/entry-format.js",
14
14
  "dist/registry/scripts/learning/completion-state.js",
15
+ "dist/registry/scripts/learning/corpus-audit.js",
16
+ "dist/registry/scripts/learning/owner-identity.js",
15
17
  "dist/src/local-mcp-server/",
16
18
  "dist/src/cli/",
17
19
  "!dist/src/cli/fraim-hub.js",