fraim-hub 2.0.346 → 2.0.348

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.
@@ -4,10 +4,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.COMPLETION_END = exports.COMPLETION_START = exports.UnsupportedRunHistoryError = exports.sourcePathKey = void 0;
7
+ exports.validateDeferredAttempt = validateDeferredAttempt;
8
+ exports.validateDeferredWork = validateDeferredWork;
7
9
  exports.cycleRetrospectivePath = cycleRetrospectivePath;
8
10
  exports.validateProcessedSources = validateProcessedSources;
9
11
  exports.canonicalJournal = canonicalJournal;
10
12
  exports.readRunJournal = readRunJournal;
13
+ exports.readRunReservations = readRunReservations;
11
14
  exports.readSourceReservations = readSourceReservations;
12
15
  exports.normalizeSourceParticipants = normalizeSourceParticipants;
13
16
  exports.sourceAuthorAllowed = sourceAuthorAllowed;
@@ -17,6 +20,33 @@ const node_fs_1 = __importDefault(require("node:fs"));
17
20
  const node_crypto_1 = require("node:crypto");
18
21
  const artifact_paths_1 = require("../artifact-paths");
19
22
  const corpus_audit_1 = require("./corpus-audit");
23
+ const outcome_types_1 = require("./outcome-types");
24
+ function validateDeferredAttempt(value) {
25
+ if (!keys(value, ['at', 'evidenceHash', 'deferred', 'reason']) || !text(value.at) || !Number.isFinite(Date.parse(value.at)) || !hash(value.evidenceHash))
26
+ throw new Error('invalid-deferred-attempt');
27
+ // Older journals embedded the checkpoint snapshot; keep accepting those exact records.
28
+ if (value.deferred !== undefined && value.reason === undefined)
29
+ validateDeferredWork(value.deferred);
30
+ else if (value.deferred !== undefined || !text(value.reason))
31
+ throw new Error('invalid-deferred-attempt');
32
+ }
33
+ function validateDeferredWork(value) {
34
+ if (!keys(value, ['actions', 'sources', 'corpusComplete', 'corpusErrors']) || value.corpusComplete !== false || !Array.isArray(value.actions) || !Array.isArray(value.sources))
35
+ throw new Error('invalid-deferred-work');
36
+ if (value.corpusErrors !== undefined && (!Array.isArray(value.corpusErrors) || value.corpusErrors.some(item => !keys(item, ['path', 'reason']) || !text(item.path) || !node_path_1.default.isAbsolute(item.path) || !text(item.reason))))
37
+ throw new Error('invalid-deferred-work');
38
+ const actions = new Set(), sources = new Set();
39
+ for (const item of value.actions) {
40
+ if (!keys(item, ['actionId', 'revision', 'digest', 'reason']) || !text(item.actionId) || !Number.isInteger(item.revision) || item.revision < 1 || !hash(item.digest) || !text(item.reason) || actions.has(item.actionId))
41
+ throw new Error('invalid-deferred-work');
42
+ actions.add(item.actionId);
43
+ }
44
+ for (const item of value.sources) {
45
+ if (!keys(item, ['path', 'owner', 'reason', 'sha256']) || !text(item.path) || !node_path_1.default.isAbsolute(item.path) || /[\x00-\x1f]/.test(item.path) || !ownerValid(item.owner) || !text(item.reason) || (item.sha256 !== undefined && !hash(item.sha256)) || sources.has((0, exports.sourcePathKey)(item.path)))
46
+ throw new Error('invalid-deferred-work');
47
+ sources.add((0, exports.sourcePathKey)(item.path));
48
+ }
49
+ }
20
50
  const sourcePathKey = (file) => {
21
51
  const value = node_path_1.default.resolve(file).replace(/\\/g, '/');
22
52
  return process.platform === 'win32' ? value.toLowerCase() : value;
@@ -145,9 +175,10 @@ function readRunJournal(file) {
145
175
  }
146
176
  return { events, lastHash, format: 'current' };
147
177
  }
148
- /** Only exact, hash-chain-verified current archive plans reserve files. Older plans are outside this lifecycle. */
149
- function readSourceReservations(runDirectories, excludingRunFile) {
178
+ /** Current archive plans and prepared assessments share one verified reservation inventory. */
179
+ function readRunReservations(runDirectories, excludingRunFile) {
150
180
  const reserved = new Set();
181
+ const assessmentKeys = new Set();
151
182
  for (const directory of new Set(runDirectories.map(dir => node_path_1.default.resolve(dir)))) {
152
183
  let names;
153
184
  try {
@@ -168,6 +199,23 @@ function readSourceReservations(runDirectories, excludingRunFile) {
168
199
  const journal = readRunJournal(file);
169
200
  if (journal.format === 'historical')
170
201
  continue;
202
+ const assessmentSources = new Map();
203
+ for (const event of journal.events) {
204
+ if (event.payload.type === 'assessment-prepared') {
205
+ const { record, sourcePaths } = event.payload.data;
206
+ (0, outcome_types_1.validateOutcomeAssessmentRecord)(record);
207
+ if (!Array.isArray(sourcePaths) || sourcePaths.length !== record.sourceRefs.length || sourcePaths.some(file => typeof file !== 'string' || !node_path_1.default.isAbsolute(file)))
208
+ throw new Error('invalid-assessment-sources');
209
+ assessmentSources.set(record.digest, { paths: sourcePaths, key: (0, outcome_types_1.assessmentKey)(record) });
210
+ }
211
+ else if (event.payload.type === 'assessment-committed')
212
+ assessmentSources.delete(event.payload.data.digest);
213
+ }
214
+ for (const reservation of assessmentSources.values()) {
215
+ assessmentKeys.add(reservation.key);
216
+ for (const source of reservation.paths)
217
+ reserved.add((0, exports.sourcePathKey)(source));
218
+ }
171
219
  for (const event of journal.events)
172
220
  if (event.payload.type === 'source-completion') {
173
221
  for (const source of event.payload.data) {
@@ -183,7 +231,10 @@ function readSourceReservations(runDirectories, excludingRunFile) {
183
231
  }
184
232
  }
185
233
  }
186
- return [...reserved].sort();
234
+ return { sources: [...reserved].sort(), assessmentKeys: [...assessmentKeys].sort() };
235
+ }
236
+ function readSourceReservations(runDirectories, excludingRunFile) {
237
+ return readRunReservations(runDirectories, excludingRunFile).sources;
187
238
  }
188
239
  exports.COMPLETION_START = '<!-- fraim-sleep-decisions:v1 -->';
189
240
  exports.COMPLETION_END = '<!-- /fraim-sleep-decisions -->';
@@ -234,7 +285,7 @@ function decodeCompletion(markdown, expected = {}) {
234
285
  catch {
235
286
  fail();
236
287
  }
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)
288
+ if (!keys(state, ['schemaVersion', 'runId', 'owner', 'finalizationState', 'decisions', 'sourceDispositions', 'cleanup', 'reconciliations', 'retrospective', 'processedSources', 'sourceParticipants', 'corpusAudit', 'reconciliationHistory', 'deferred', 'deferredHistory', 'deferredAttempts', 'outcomeAssessments']) || state.schemaVersion !== 1 || !text(state.runId) || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(state.runId) || !ownerValid(state.owner)
238
289
  || !['pending', 'complete'].includes(state.finalizationState) || !Array.isArray(state.decisions) || !Array.isArray(state.sourceDispositions) || !Array.isArray(state.cleanup))
239
290
  fail();
240
291
  if ((expected.runId && expected.runId !== state.runId) || (expected.owner && !sameOwner(expected.owner, state.owner)))
@@ -249,8 +300,31 @@ function decodeCompletion(markdown, expected = {}) {
249
300
  fail();
250
301
  if (state.processedSources !== undefined)
251
302
  validateProcessedSources(state.processedSources);
303
+ if (state.outcomeAssessments !== undefined) {
304
+ if (!Array.isArray(state.outcomeAssessments))
305
+ fail();
306
+ const digests = new Set();
307
+ for (const assessment of state.outcomeAssessments) {
308
+ (0, outcome_types_1.validateOutcomeAssessmentRecord)(assessment);
309
+ if (assessment.runId !== state.runId || digests.has(assessment.digest))
310
+ fail();
311
+ digests.add(assessment.digest);
312
+ }
313
+ }
252
314
  if (state.sourceParticipants !== undefined)
253
315
  normalizeSourceParticipants(state.sourceParticipants, state.owner);
316
+ if (state.deferred !== undefined)
317
+ validateDeferredWork(state.deferred);
318
+ if (state.deferredHistory !== undefined) {
319
+ if (!Array.isArray(state.deferredHistory))
320
+ fail();
321
+ state.deferredHistory.forEach(validateDeferredWork);
322
+ }
323
+ if (state.deferredAttempts !== undefined) {
324
+ if (!Array.isArray(state.deferredAttempts))
325
+ fail();
326
+ state.deferredAttempts.forEach(validateDeferredAttempt);
327
+ }
254
328
  const ids = new Set();
255
329
  for (const item of state.decisions) {
256
330
  if (!keys(item, ['actionId', 'revision', 'digest', 'owner', 'claimKey', 'evidenceFingerprint', 'sourceRefs', 'decision', 'publicationRef', 'target', 'dependencies']) || !text(item.actionId) || ids.has(item.actionId) || !Number.isInteger(item.revision) || item.revision < 1 || !hash(item.digest) || !ownerValid(item.owner)
@@ -282,6 +356,8 @@ function decodeCompletion(markdown, expected = {}) {
282
356
  fail();
283
357
  }
284
358
  const decisions = new Map(state.decisions.map((item) => [item.actionId, item]));
359
+ if (state.deferred?.actions.some((item) => decisions.has(item.actionId)))
360
+ fail();
285
361
  if (state.corpusAudit !== undefined) {
286
362
  try {
287
363
  (0, corpus_audit_1.validateCorpusAudit)(state.corpusAudit);
@@ -10,7 +10,7 @@ const node_fs_1 = __importDefault(require("node:fs"));
10
10
  const node_path_1 = __importDefault(require("node:path"));
11
11
  const node_crypto_1 = require("node:crypto");
12
12
  const owner_identity_1 = require("./owner-identity");
13
- const { learningEntryBlocks } = require('./entry-format');
13
+ const { learningEntryBlocks, learningEntryIdentity, isLearningEntryId } = require('./entry-format');
14
14
  const stable = (value) => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
15
15
  ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
16
16
  const hash = (value) => (0, node_crypto_1.createHash)('sha256').update(value).digest('hex');
@@ -63,9 +63,8 @@ function collectLearningCorpus(homes, subjects, readerRoots = []) {
63
63
  for (const block of blocks) {
64
64
  // Match the runtime reader's handwritten identity contract.
65
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))
66
+ const { id, ambiguous } = learningEntryIdentity(block.text);
67
+ if (ambiguous || (id && ids.has(id))
69
68
  || !/^#{2,6}\s+\[P-(CRITICAL|HIGH|MED|LOW)\]\s+\S/.test(block.text)) {
70
69
  errors.push({ path: file.replace(/\\/g, '/'), reason: 'ambiguous-corpus-entry' });
71
70
  continue;
@@ -157,8 +156,9 @@ function collectLearningCorpus(homes, subjects, readerRoots = []) {
157
156
  /** Shared by journal replay and the strict compact receipt decoder. No filesystem access. */
158
157
  function validateCorpusAudit(audit, reviewer) {
159
158
  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
159
+ if (!keys(audit, ['snapshot', 'reviewer', 'dispositions', 'partial']) || typeof audit.reviewer !== 'string' || !audit.reviewer.trim() || (reviewer && audit.reviewer !== reviewer) || (audit.partial !== undefined && audit.partial !== true)
160
+ || !keys(audit.snapshot, ['schemaVersion', 'homes', 'entries', 'duplicates', 'fingerprint', 'complete', 'errors']) || audit.snapshot.schemaVersion !== 1 || typeof audit.snapshot.complete !== 'boolean' || (!audit.partial && !audit.snapshot.complete) || !Array.isArray(audit.snapshot.errors) || (!audit.partial && audit.snapshot.errors.length)
161
+ || audit.snapshot.errors.some(item => !keys(item, ['path', 'reason']) || typeof item.path !== 'string' || !node_path_1.default.isAbsolute(item.path) || typeof item.reason !== 'string' || !item.reason.trim())
162
162
  || !Array.isArray(audit.snapshot.homes) || !Array.isArray(audit.snapshot.entries) || !Array.isArray(audit.dispositions))
163
163
  fail();
164
164
  const snapshot = audit.snapshot;
@@ -172,7 +172,7 @@ function validateCorpusAudit(audit, reviewer) {
172
172
  fail();
173
173
  (0, owner_identity_1.validateOwner)(entry.ref?.owner);
174
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)
175
+ || (entry.ref.entryId === undefined ? !Number.isSafeInteger(entry.occurrence) || entry.occurrence < 0 : !isLearningEntryId(entry.ref.entryId) || entry.occurrence !== undefined)
176
176
  || !/^learnings\//.test(entry.ref.relativePath) || /[\\:]/.test(entry.ref.relativePath) || entry.ref.relativePath.split('/').some(part => !part || part === '.' || part === '..')
177
177
  || !/^[a-f0-9]{64}$/.test(entry.sha256) || entries.has(corpusEntryKey(entry)))
178
178
  fail();
@@ -1,5 +1,14 @@
1
1
  'use strict';
2
2
 
3
+ const LEARNING_ENTRY_ID_LINE = /^\*\*Id\*\*:\s*(L-[0-9A-Za-z]{6,24})\s*$/;
4
+ function isLearningEntryId(value) { return typeof value === 'string' && /^L-[0-9A-Za-z]{6,24}$/.test(value); }
5
+ /** Reader/corpus identity accepts stable handwritten IDs; approved mutation IDs remain narrower below. */
6
+ function learningEntryIdentity(content) {
7
+ const declarations = content.split(/\r?\n/).map(line => line.trim()).filter(line => /^\*\*Id\*\*:/i.test(line));
8
+ const id = declarations[0]?.match(LEARNING_ENTRY_ID_LINE)?.[1] ?? null;
9
+ return { id: declarations.length === 1 ? id : null, ambiguous: declarations.length > 0 && (!id || declarations.length !== 1) };
10
+ }
11
+
3
12
  // Read the shared author's current entry format for identity and update guards.
4
13
  function learningEntryBlocks(body) {
5
14
  const headings = [...body.matchAll(/^#{2,6}\s+\[P-[A-Z]+\].*$/gm)];
@@ -24,4 +33,4 @@ function learningEntryMetadata(content) {
24
33
  return metadata;
25
34
  }
26
35
 
27
- module.exports = { learningEntryMetadata, learningEntryBlocks };
36
+ module.exports = { learningEntryMetadata, learningEntryBlocks, learningEntryIdentity, isLearningEntryId, LEARNING_ENTRY_ID_LINE };
@@ -0,0 +1,92 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.matchesOutcomeScope = matchesOutcomeScope;
4
+ exports.assessmentKey = assessmentKey;
5
+ exports.assessmentContent = assessmentContent;
6
+ exports.outcomeAssessmentDigest = outcomeAssessmentDigest;
7
+ exports.validateOutcomeAssessmentInput = validateOutcomeAssessmentInput;
8
+ exports.validateOutcomeAssessmentRecord = validateOutcomeAssessmentRecord;
9
+ exports.summarizeLearningOutcomes = summarizeLearningOutcomes;
10
+ const node_crypto_1 = require("node:crypto");
11
+ const owner_identity_1 = require("./owner-identity");
12
+ const { isLearningEntryId } = require('./entry-format');
13
+ function matchesOutcomeScope(record, scope) {
14
+ return (scope.key === undefined || assessmentKey(record) === scope.key)
15
+ && (scope.principal === undefined || record.learning.learningSubject === scope.principal)
16
+ && (scope.owners === undefined || scope.owners.some(owner => (0, owner_identity_1.ownerKey)(owner) === (0, owner_identity_1.ownerKey)(record.learning.owner)));
17
+ }
18
+ const canonical = (value) => JSON.stringify(value, (_key, item) => item && typeof item === 'object' && !Array.isArray(item)
19
+ ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
20
+ const text = (value) => typeof value === 'string' && !!value.trim() && !/[\x00-\x08\x0b\x0c\x0e-\x1f]/.test(value);
21
+ const hash = (value) => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value);
22
+ const relative = (value) => text(value) && !/[\\:\x00-\x1f]/.test(value) && value.split('/').every(part => !!part && part !== '.' && part !== '..');
23
+ const date = (value) => typeof value === 'string' && /^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z)?$/.test(value)
24
+ && Number.isFinite(Date.parse(value)) && new Date(value).toISOString().slice(0, 10) === value.slice(0, 10);
25
+ /** Semantic identity comes from Sleep; paths are provenance, never a title or move-sensitive join. */
26
+ function assessmentKey(input) {
27
+ return canonical([(0, owner_identity_1.ownerKey)(input.learning.owner), input.learning.learningSubject, input.learning.entryId, input.incidentId]);
28
+ }
29
+ function assessmentContent(input) {
30
+ return canonical({ learning: input.learning, incidentId: input.incidentId, observedAt: input.observedAt, outcome: input.outcome,
31
+ reportedHelp: input.reportedHelp, explicitlySkipped: input.explicitlySkipped,
32
+ sourceRefs: [...input.sourceRefs].sort((a, b) => canonical(a).localeCompare(canonical(b))), rationale: input.rationale,
33
+ guidanceExistedAt: input.guidanceExistedAt });
34
+ }
35
+ function outcomeAssessmentDigest(record) {
36
+ return (0, node_crypto_1.createHash)('sha256').update(canonical({ content: assessmentContent(record), revision: record.revision,
37
+ expectedPriorDigest: record.expectedPriorDigest, reviewer: record.reviewer })).digest('hex');
38
+ }
39
+ function validateOutcomeAssessmentInput(input) {
40
+ if (!input || !input.learning)
41
+ throw new Error('invalid-outcome-assessment');
42
+ (0, owner_identity_1.validateOwner)(input.learning.owner);
43
+ if (!relative(input.learning.relativePath) || !isLearningEntryId(input.learning.entryId)
44
+ || !text(input.learning.learningSubject) || !text(input.incidentId) || input.incidentId.length > 512
45
+ || !date(input.observedAt) || !date(input.guidanceExistedAt)
46
+ || !['success', 'miss', 'unknown'].includes(input.outcome) || typeof input.reportedHelp !== 'boolean'
47
+ || typeof input.explicitlySkipped !== 'boolean' || !text(input.rationale)
48
+ || (input.expectedPriorDigest !== null && !hash(input.expectedPriorDigest))
49
+ || !Array.isArray(input.sourceRefs) || !input.sourceRefs.length)
50
+ throw new Error('invalid-outcome-assessment');
51
+ if (input.reportedHelp && input.outcome !== 'success' || input.explicitlySkipped && input.outcome !== 'miss')
52
+ throw new Error('invalid-outcome-qualification');
53
+ if (Date.parse(input.guidanceExistedAt) > Date.parse(input.observedAt))
54
+ throw new Error('guidance-after-incident');
55
+ for (const source of input.sourceRefs) {
56
+ if (!source)
57
+ throw new Error('invalid-assessment-source');
58
+ (0, owner_identity_1.validateOwner)(source.owner);
59
+ if (!relative(source.relativePath) || !/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(source.sourceId)
60
+ || !hash(source.sha256))
61
+ throw new Error('invalid-assessment-source');
62
+ }
63
+ if (new Set(input.sourceRefs.map(source => canonical([(0, owner_identity_1.ownerKey)(source.owner), source.sourceId]))).size !== input.sourceRefs.length)
64
+ throw new Error('duplicate-assessment-source');
65
+ }
66
+ function validateOutcomeAssessmentRecord(record) {
67
+ validateOutcomeAssessmentInput(record);
68
+ if (!Number.isInteger(record.revision) || record.revision < 1 || !date(record.assessedAt)
69
+ || !text(record.reviewer) || !text(record.runId) || record.digest !== outcomeAssessmentDigest(record))
70
+ throw new Error('invalid-outcome-record');
71
+ }
72
+ function summarizeLearningOutcomes(records, learning) {
73
+ const selected = new Map();
74
+ for (const record of records) {
75
+ if ((0, owner_identity_1.ownerKey)(record.learning.owner) !== (0, owner_identity_1.ownerKey)(learning.owner) || record.learning.entryId !== learning.entryId
76
+ || record.learning.learningSubject !== learning.learningSubject)
77
+ continue;
78
+ validateOutcomeAssessmentRecord(record);
79
+ const key = assessmentKey(record), prior = selected.get(key);
80
+ if (!prior || record.revision > prior.revision)
81
+ selected.set(key, record);
82
+ else if (record.revision === prior.revision && record.digest !== prior.digest)
83
+ throw new Error('assessment-history-conflict');
84
+ }
85
+ if (!selected.size)
86
+ return undefined;
87
+ const current = [...selected.values()], observed = current.map(record => record.observedAt).sort();
88
+ return { success: current.filter(record => record.outcome === 'success').length,
89
+ reportedHelp: current.filter(record => record.outcome === 'success' && record.reportedHelp).length,
90
+ miss: current.filter(record => record.outcome === 'miss').length, unknown: current.filter(record => record.outcome === 'unknown').length,
91
+ firstObservedAt: observed[0], lastObservedAt: observed[observed.length - 1], assessedAt: current.map(record => record.assessedAt).sort().at(-1) };
92
+ }
@@ -8,6 +8,7 @@ exports.ownerPath = ownerPath;
8
8
  exports.validateOwner = validateOwner;
9
9
  exports.ownerKey = ownerKey;
10
10
  exports.assertSameOwner = assertSameOwner;
11
+ exports.assertLearningTargetPath = assertLearningTargetPath;
11
12
  exports.resolveOwnerHome = resolveOwnerHome;
12
13
  exports.ownerArtifactPath = ownerArtifactPath;
13
14
  exports.configuredArtifactPath = configuredArtifactPath;
@@ -34,6 +35,16 @@ function assertSameOwner(expected, actual) {
34
35
  if (ownerKey(expected) !== ownerKey(actual))
35
36
  throw new Error('owner-path-conflict: the approved home differs from the configured path');
36
37
  }
38
+ /** Shared authored-family identity check. Handwritten entries can omit Domain metadata. */
39
+ function assertLearningTargetPath(ref, domain) {
40
+ const subject = ref.owner.level === 'Org' ? 'org' : ref.learningSubject;
41
+ if (!subject || !/^[a-zA-Z0-9@._+-]+$/.test(subject))
42
+ throw new Error('learning-subject-required');
43
+ const escaped = subject.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
44
+ const match = ref.relativePath.match(new RegExp(`^learnings/${escaped}-((?:[a-z][a-z-]*-)?)(preferences|mistake-patterns|validated-patterns|manager-coaching)(\\.cold)?\\.md$`));
45
+ if (!match || (domain !== undefined && match[1] !== (domain === 'global' ? '' : domain + '-')))
46
+ throw new Error('domain-target-mismatch: learning subject, domain and family must match its file');
47
+ }
37
48
  /** Pure read: resolve existing configured homes without migration or identity configuration. */
38
49
  function resolveOwnerHome(projectPath, level, options) {
39
50
  const load = (relative) => options.runtimeModule(projectPath, relative);
@@ -0,0 +1,81 @@
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.hashContent = void 0;
7
+ exports.atomicWrite = atomicWrite;
8
+ exports.withFileLock = withFileLock;
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 node_os_1 = require("node:os");
13
+ const hashContent = (text) => (0, node_crypto_1.createHash)('sha256').update(text).digest('hex');
14
+ exports.hashContent = hashContent;
15
+ function atomicWrite(file, text) {
16
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(file), { recursive: true });
17
+ const temporary = file + '.' + (0, node_crypto_1.randomUUID)() + '.tmp';
18
+ try {
19
+ node_fs_1.default.writeFileSync(temporary, text, { encoding: 'utf8', flag: 'wx' });
20
+ const fd = node_fs_1.default.openSync(temporary, 'r+');
21
+ try {
22
+ node_fs_1.default.fsyncSync(fd);
23
+ }
24
+ finally {
25
+ node_fs_1.default.closeSync(fd);
26
+ }
27
+ node_fs_1.default.renameSync(temporary, file);
28
+ }
29
+ finally {
30
+ if (node_fs_1.default.existsSync(temporary))
31
+ node_fs_1.default.unlinkSync(temporary);
32
+ }
33
+ }
34
+ function withFileLock(file, fn) {
35
+ node_fs_1.default.mkdirSync(node_path_1.default.dirname(file), { recursive: true });
36
+ let fd;
37
+ try {
38
+ fd = node_fs_1.default.openSync(file + '.lock', 'wx');
39
+ }
40
+ catch {
41
+ // Serialize recovery so two contenders cannot unlink each other's new lock.
42
+ let recovery;
43
+ try {
44
+ recovery = node_fs_1.default.openSync(file + '.lock.recovery', 'wx');
45
+ }
46
+ catch {
47
+ throw new Error('busy: lock recovery is already in progress');
48
+ }
49
+ try {
50
+ const owner = JSON.parse(node_fs_1.default.readFileSync(file + '.lock', 'utf8'));
51
+ let stale = false;
52
+ if (owner.host === (0, node_os_1.hostname)() && Number.isInteger(owner.pid) && owner.pid > 0) {
53
+ try {
54
+ process.kill(owner.pid, 0);
55
+ }
56
+ catch (error) {
57
+ stale = error.code === 'ESRCH';
58
+ }
59
+ }
60
+ if (!stale)
61
+ throw new Error('busy');
62
+ node_fs_1.default.unlinkSync(file + '.lock');
63
+ fd = node_fs_1.default.openSync(file + '.lock', 'wx');
64
+ }
65
+ catch {
66
+ throw new Error('busy: reconcile outstanding lock before retry');
67
+ }
68
+ finally {
69
+ node_fs_1.default.closeSync(recovery);
70
+ node_fs_1.default.unlinkSync(file + '.lock.recovery');
71
+ }
72
+ }
73
+ node_fs_1.default.writeFileSync(fd, JSON.stringify({ pid: process.pid, host: (0, node_os_1.hostname)(), at: new Date().toISOString() }));
74
+ try {
75
+ return fn();
76
+ }
77
+ finally {
78
+ node_fs_1.default.closeSync(fd);
79
+ node_fs_1.default.unlinkSync(file + '.lock');
80
+ }
81
+ }
@@ -31,6 +31,7 @@ exports.buildDirectStartPlan = buildDirectStartPlan;
31
31
  exports.buildDirectContinuePlan = buildDirectContinuePlan;
32
32
  exports.parseHostLine = parseHostLine;
33
33
  exports.detectCodexCompactTaskFailure = detectCodexCompactTaskFailure;
34
+ exports.wireHostProcess = wireHostProcess;
34
35
  exports.describeSpawnError = describeSpawnError;
35
36
  exports.hostDefaultUnsetEnv = hostDefaultUnsetEnv;
36
37
  exports.buildHostSpawnEnv = buildHostSpawnEnv;
@@ -2398,6 +2399,32 @@ function detectCodexCompactTaskFailure(line) {
2398
2399
  return null;
2399
2400
  }
2400
2401
  function wireHostProcess(hostId, child, handlers, spawnCwd) {
2402
+ // Exec's JSON error has no auth code. Require independent provider stderr
2403
+ // provenance AND the same terminal protocol error; never scan tool/prose text.
2404
+ const codexRefreshFailure = 'Your access token could not be refreshed because your refresh token was already used. Please log out and sign in again.';
2405
+ let codexAuthDiagnostic = false;
2406
+ let codexTerminalAuthFailure = false;
2407
+ const observeCodexAuth = (line, channel) => {
2408
+ if (hostId !== 'codex')
2409
+ return;
2410
+ if (channel === 'stderr') {
2411
+ const clean = line.replace(/\x1b\[[0-9;]*m/g, '').trim();
2412
+ const diagnostic = clean.match(/^(?:\d{4}-\d{2}-\d{2}T[\d:.]+Z\s+)?ERROR\s+codex_login::auth::manager:\s+Failed to refresh token: (.+)$/);
2413
+ if (diagnostic?.[1] === codexRefreshFailure)
2414
+ codexAuthDiagnostic = true;
2415
+ return;
2416
+ }
2417
+ try {
2418
+ const event = JSON.parse(line);
2419
+ if (event.type === 'turn.failed' || event.type === 'error') {
2420
+ codexTerminalAuthFailure = (event.type === 'turn.failed' ? event.error?.message : event.message) === codexRefreshFailure;
2421
+ }
2422
+ else if (event.type === 'turn.started' || event.type === 'turn.completed') {
2423
+ codexTerminalAuthFailure = false;
2424
+ }
2425
+ }
2426
+ catch { /* Plain stdout cannot establish authentication failure. */ }
2427
+ };
2401
2428
  const wire = (buffer, channel) => {
2402
2429
  let pending = '';
2403
2430
  let suppressCodexModelRefreshStderr = false;
@@ -2406,6 +2433,7 @@ function wireHostProcess(hostId, child, handlers, spawnCwd) {
2406
2433
  const lines = pending.split(/\r?\n/);
2407
2434
  pending = lines.pop() || '';
2408
2435
  for (const line of lines) {
2436
+ observeCodexAuth(line, channel);
2409
2437
  if (hostId === 'codex' && channel === 'stderr') {
2410
2438
  if (line.includes('codex_models_manager::manager: failed to refresh available models:')) {
2411
2439
  suppressCodexModelRefreshStderr = true;
@@ -2431,6 +2459,7 @@ function wireHostProcess(hostId, child, handlers, spawnCwd) {
2431
2459
  });
2432
2460
  buffer.on('end', () => {
2433
2461
  if (pending.trim().length > 0) {
2462
+ observeCodexAuth(pending, channel);
2434
2463
  const parsed = parseHostLine(hostId, pending);
2435
2464
  if (parsed.message || parsed.sessionId || parsed.raw) {
2436
2465
  handlers.onEvent(parsed, channel);
@@ -2444,7 +2473,12 @@ function wireHostProcess(hostId, child, handlers, spawnCwd) {
2444
2473
  // signal='SIGSEGV') are NOT treated as clean exits by classifyExit's
2445
2474
  // `exitCode ?? 0` guard. Pass -1 for any signal-terminated child so the
2446
2475
  // non-zero branch fires and recovery is attempted.
2447
- child.on('close', (code, signal) => handlers.onExit(signal ? -1 : code));
2476
+ child.on('close', (code, signal) => {
2477
+ if (!signal && code !== null && code !== 0 && codexAuthDiagnostic && codexTerminalAuthFailure) {
2478
+ handlers.onEvent({ hostAuthenticationRequired: true, hostError: codexRefreshFailure, raw: codexRefreshFailure }, 'system');
2479
+ }
2480
+ handlers.onExit(signal ? -1 : code);
2481
+ });
2448
2482
  // Issue #1327: without this listener, an OS-level spawn failure (EAGAIN,
2449
2483
  // ENOENT, EACCES) fires an 'error' event on the ChildProcess. An unhandled
2450
2484
  // EventEmitter 'error' throws synchronously and crashes the Hub process.
@@ -10,6 +10,7 @@ exports.resolveHubAppCacheDir = resolveHubAppCacheDir;
10
10
  exports.isHubAppCacheComplete = isHubAppCacheComplete;
11
11
  exports.listCachedHubAppVersions = listCachedHubAppVersions;
12
12
  exports.runNpmInstall = runNpmInstall;
13
+ exports.pruneHubAppCache = pruneHubAppCache;
13
14
  exports.ensureMaterializedHubApp = ensureMaterializedHubApp;
14
15
  exports.resolveHubAppEntry = resolveHubAppEntry;
15
16
  const fs_1 = __importDefault(require("fs"));
@@ -209,6 +210,24 @@ const defaultInstallPackage = async (version, destDir) => {
209
210
  }
210
211
  fs_1.default.rmSync(installedRoot, { recursive: true, force: true });
211
212
  };
213
+ const HUB_APP_CACHE_MAX_VERSIONS = 3;
214
+ /** Prune the hub-app cache to at most HUB_APP_CACHE_MAX_VERSIONS entries, always preserving keepVersion. Non-fatal. */
215
+ function pruneHubAppCache(fraimDir, keepVersion) {
216
+ try {
217
+ const all = listCachedHubAppVersions(fraimDir);
218
+ const sorted = all.map((e) => e.version).sort((a, b) => (semver_1.default.gt(a, b) ? -1 : 1));
219
+ const toKeep = new Set(sorted.slice(0, HUB_APP_CACHE_MAX_VERSIONS));
220
+ toKeep.add(keepVersion);
221
+ for (const v of sorted) {
222
+ if (!toKeep.has(v)) {
223
+ fs_1.default.rmSync(resolveHubAppCacheDir(v, fraimDir), { recursive: true, force: true });
224
+ }
225
+ }
226
+ }
227
+ catch {
228
+ // Prune failure must never break a launch.
229
+ }
230
+ }
212
231
  /**
213
232
  * Return the entry path for `version`, materializing it first if it is not already a complete
214
233
  * cache entry. Returns immediately (no npm invocation) when a complete entry already exists.
@@ -248,6 +267,7 @@ async function ensureMaterializedHubApp(version, options = {}) {
248
267
  finally {
249
268
  fs_1.default.rmSync(stagingDir, { recursive: true, force: true });
250
269
  }
270
+ pruneHubAppCache(fraimDir, version);
251
271
  return entryPath;
252
272
  }
253
273
  /**
@@ -2596,7 +2596,9 @@ function summarizeHostErrorForThread(error) {
2596
2596
  // noise that the system handled on its own.
2597
2597
  function pushTerminalFailureToThread(run, exitCode) {
2598
2598
  if (exitCode !== 0 && run.lastHostError) {
2599
- run.messages.push((0, hosts_1.createHubMessage)('system', `This run stopped: ${summarizeHostErrorForThread(run.lastHostError)}`));
2599
+ const detail = detectLoginFailureSignature(run, exitCode)
2600
+ ? run.lastHostError : summarizeHostErrorForThread(run.lastHostError);
2601
+ run.messages.push((0, hosts_1.createHubMessage)('system', `This run stopped: ${detail}`));
2600
2602
  }
2601
2603
  }
2602
2604
  function createHubRecoveryEvent(run, exitCode, attempt) {
@@ -3017,6 +3019,7 @@ class AiHubServer {
3017
3019
  this.latestVersionPollTimer = null;
3018
3020
  this.catalogSyncChild = null;
3019
3021
  this.attachmentPolicy = (0, attachment_store_1.attachmentPolicy)();
3022
+ this.configureAgentSurface = options.configureAgentSurface ?? configureFraimForHubAgent;
3020
3023
  this.openTerminal = options.openTerminal ?? hubOpenTerminal;
3021
3024
  this.preferencesStore = options.preferencesStore || new preferences_1.AiHubPreferencesStore();
3022
3025
  const launchResolution = options.projectPath
@@ -4254,7 +4257,10 @@ class AiHubServer {
4254
4257
  const option = hubAgentOption(run.hostId);
4255
4258
  run.status = 'failed';
4256
4259
  run.pauseReason = 'awaiting_user';
4257
- run.loginRequired = option ? { agentLabel: option.label } : undefined;
4260
+ run.loginRequired = option ? {
4261
+ agentLabel: option.label,
4262
+ ...(run.hostId === 'codex' && run.lastHostError ? { remediation: run.lastHostError } : {}),
4263
+ } : undefined;
4258
4264
  }
4259
4265
  if (run.status !== 'running') {
4260
4266
  run.eventLogRefs = (run.eventLogRefs || []).map((ref) => {
@@ -7013,7 +7019,7 @@ class AiHubServer {
7013
7019
  return res.json({ ok: true, configured: [], message: 'No ready CLI agents to configure.' });
7014
7020
  }
7015
7021
  const results = await Promise.all(readyIds.map(async (hubId) => {
7016
- const result = await configureFraimForHubAgent(hubId);
7022
+ const result = await this.configureAgentSurface(hubId);
7017
7023
  return { hubId, ...result };
7018
7024
  }));
7019
7025
  const failed = results.filter((r) => !r.configured);