fraim-hub 2.0.344 → 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
+ }
@@ -11,6 +11,7 @@ const fs_1 = __importDefault(require("fs"));
11
11
  const path_1 = __importDefault(require("path"));
12
12
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
13
13
  const conversation_store_lock_1 = require("./conversation-store-lock");
14
+ const proposal_set_contract_1 = require("../core/proposal-set-contract");
14
15
  const atomic_json_file_1 = require("./atomic-json-file");
15
16
  const conversation_search_projection_1 = require("./conversation-search-projection");
16
17
  // Issue #708: reserved, project-independent bucket keys for non-project scopes.
@@ -169,6 +170,45 @@ function conversationContinuityCompatible(left, right) {
169
170
  return false;
170
171
  return true;
171
172
  }
173
+ // Acknowledged review snapshots are append-only presentation history. Merge under
174
+ // the bucket lock so delayed browser patches and richer run projections cannot erase them.
175
+ function mergeLearningReviewTurns(...sources) {
176
+ const turns = [];
177
+ const seen = new Set();
178
+ for (const source of sources) {
179
+ if (!Array.isArray(source))
180
+ continue;
181
+ for (const turn of source) {
182
+ if (!turn || !Number.isInteger(turn.messageCount) || turn.messageCount < 0
183
+ || !turn.proposalSet || (0, proposal_set_contract_1.validateProposalSet)(turn.proposalSet)
184
+ || !Array.isArray(turn.proposalSet.proposals) || !turn.proposalSet.proposals.length
185
+ || !Array.isArray(turn.decisions) || turn.decisions.length !== turn.proposalSet.proposals.length)
186
+ continue;
187
+ const proposals = turn.proposalSet.proposals;
188
+ if (!proposals.every((p, i) => {
189
+ const d = turn.decisions[i];
190
+ return d && d.proposalId === p.proposalId && d.revision === p.revision
191
+ && ['approve', 'reject'].includes(d.action) && ['project', 'manager', 'org'].includes(d.level)
192
+ && typeof d.reason === 'string';
193
+ }))
194
+ continue;
195
+ const indices = proposals.flatMap((p, i) => {
196
+ const key = JSON.stringify([p.proposalId, p.revision]);
197
+ if (seen.has(key))
198
+ return [];
199
+ seen.add(key);
200
+ return [i];
201
+ });
202
+ if (indices.length)
203
+ turns.push({
204
+ messageCount: turn.messageCount,
205
+ proposalSet: { ...turn.proposalSet, proposals: indices.map((i) => proposals[i]) },
206
+ decisions: indices.map((i) => turn.decisions[i]),
207
+ });
208
+ }
209
+ }
210
+ return turns;
211
+ }
172
212
  // Place a raw conversation into a bucket, deduping by id. When the id already exists, keep the
173
213
  // richer copy (more run history); tie-break on newer lastUpdatedAt. Order-independent.
174
214
  function placeConversationInBucket(bucket, conv) {
@@ -196,6 +236,9 @@ function placeConversationInBucket(bucket, conv) {
196
236
  && timestampValue(value?.lastUpdatedAt) > timestampValue(existing.lastUpdatedAt)) {
197
237
  bucket.conversations[idx] = withStableConversationCreatedAt(existing, conv);
198
238
  }
239
+ const history = mergeLearningReviewTurns(existing.learningReviewTurns, conv.learningReviewTurns);
240
+ if (history.length)
241
+ bucket.conversations[idx].learningReviewTurns = history;
199
242
  }
200
243
  function stableConversationCreatedAt(existing, incoming) {
201
244
  const existingValue = existing?.createdAt;
@@ -651,8 +694,18 @@ class AiHubConversationStore {
651
694
  }
652
695
  catch { /* ignore */ }
653
696
  }
654
- for (const conv of state.conversations)
697
+ for (const conv of state.conversations) {
698
+ try {
699
+ const prior = JSON.parse(fs_1.default.readFileSync(this.convFilePath(bucketDir, conv.id), 'utf8')).conversation;
700
+ if (conversationContinuityCompatible(prior, conv)) {
701
+ const history = mergeLearningReviewTurns(prior?.learningReviewTurns, conv.learningReviewTurns);
702
+ if (history.length)
703
+ conv.learningReviewTurns = history;
704
+ }
705
+ }
706
+ catch { /* no prior copy */ }
655
707
  this.writeConvFile(bucketDir, bucketKey, conv);
708
+ }
656
709
  this.writeIndex(bucketDir, bucketKey, state.activeId, state.conversations);
657
710
  // Issue #1065 (GDPR Art. 17 / C1): deletion must reach every copy. The whole-bucket
658
711
  // rewrite prunes conversation shards that are no longer wanted; prune the matching
@@ -903,12 +956,19 @@ class AiHubConversationStore {
903
956
  this.writeConvFile(bucketDir, key, candidate);
904
957
  return this.reindexAfterUpsert(bucketDir, key, candidate, undefined);
905
958
  }
959
+ const history = mergeLearningReviewTurns(existing.learningReviewTurns, patch.learningReviewTurns);
906
960
  if (patch.lastUpdatedAt !== undefined && timestampValue(patch.lastUpdatedAt) < timestampValue(existing.lastUpdatedAt)) {
961
+ if (JSON.stringify(history) !== JSON.stringify(mergeLearningReviewTurns(existing.learningReviewTurns))) {
962
+ existing.learningReviewTurns = history;
963
+ this.writeConvFile(bucketDir, key, existing);
964
+ return this.reindexAfterUpsert(bucketDir, key, existing, undefined);
965
+ }
907
966
  return this.stateFromIndex(bucketDir, key);
908
967
  }
909
968
  const merged = normalizeConversation(key, {
910
969
  ...existing,
911
970
  ...patch,
971
+ ...(history.length ? { learningReviewTurns: history } : {}),
912
972
  id: existing.id,
913
973
  projectPath: key,
914
974
  agentName: patch.agentName || existing.agentName,
@@ -5856,6 +5856,7 @@ class AiHubServer {
5856
5856
  }
5857
5857
  try {
5858
5858
  const result = (0, learning_context_builder_1.applyLearningEntryChange)(resolveProjectPath(req), userEmail, ref, b.action, {
5859
+ originalId: b.originalId,
5859
5860
  originalTitle: b.originalTitle,
5860
5861
  severity: b.severity,
5861
5862
  title: b.title,