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
|
|
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
|
-
*
|
|
727
|
-
*
|
|
728
|
-
*
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
1023
|
-
*
|
|
1024
|
-
*
|
|
1025
|
-
*
|
|
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
|
-
//
|
|
1068
|
-
const
|
|
1069
|
-
|
|
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,
|
|
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
|
-
//
|
|
1487
|
-
//
|
|
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
|
|
1492
|
-
const
|
|
1455
|
+
const seenFiles = new Set();
|
|
1456
|
+
const counts = { manager: 0, project: 0 };
|
|
1493
1457
|
for (const tier of tiers) {
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
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
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
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 (
|
|
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.
|
|
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",
|