fraim-hub 2.0.345 → 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.
- package/dist/registry/scripts/learning/completion-state.js +123 -4
- package/dist/registry/scripts/learning/corpus-audit.js +205 -0
- package/dist/registry/scripts/learning/entry-format.js +10 -1
- package/dist/registry/scripts/learning/outcome-types.js +92 -0
- package/dist/registry/scripts/learning/owner-identity.js +148 -0
- package/dist/registry/scripts/learning/proposal-queue.js +81 -0
- package/dist/src/ai-hub/hosts.js +35 -1
- package/dist/src/ai-hub/hub-app-materializer.js +20 -0
- package/dist/src/ai-hub/server.js +10 -3
- package/dist/src/local-mcp-server/learning-context-builder.js +141 -477
- package/dist/src/local-mcp-server/learning-entry-format.js +25 -0
- package/dist/src/local-mcp-server/learning-usage-eligibility.js +8 -14
- package/dist/src/local-mcp-server/learning-usage-projection.js +5 -22
- package/dist/src/local-mcp-server/learning-usage-store.js +93 -393
- package/package.json +11 -2
- package/public/ai-hub/review.css +2 -1
- package/public/ai-hub/script.js +161 -188
- package/public/ai-hub/styles.css +2 -1
|
@@ -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;
|
|
@@ -16,6 +19,34 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
16
19
|
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");
|
|
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
|
+
}
|
|
19
50
|
const sourcePathKey = (file) => {
|
|
20
51
|
const value = node_path_1.default.resolve(file).replace(/\\/g, '/');
|
|
21
52
|
return process.platform === 'win32' ? value.toLowerCase() : value;
|
|
@@ -144,9 +175,10 @@ function readRunJournal(file) {
|
|
|
144
175
|
}
|
|
145
176
|
return { events, lastHash, format: 'current' };
|
|
146
177
|
}
|
|
147
|
-
/**
|
|
148
|
-
function
|
|
178
|
+
/** Current archive plans and prepared assessments share one verified reservation inventory. */
|
|
179
|
+
function readRunReservations(runDirectories, excludingRunFile) {
|
|
149
180
|
const reserved = new Set();
|
|
181
|
+
const assessmentKeys = new Set();
|
|
150
182
|
for (const directory of new Set(runDirectories.map(dir => node_path_1.default.resolve(dir)))) {
|
|
151
183
|
let names;
|
|
152
184
|
try {
|
|
@@ -167,6 +199,23 @@ function readSourceReservations(runDirectories, excludingRunFile) {
|
|
|
167
199
|
const journal = readRunJournal(file);
|
|
168
200
|
if (journal.format === 'historical')
|
|
169
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
|
+
}
|
|
170
219
|
for (const event of journal.events)
|
|
171
220
|
if (event.payload.type === 'source-completion') {
|
|
172
221
|
for (const source of event.payload.data) {
|
|
@@ -182,7 +231,10 @@ function readSourceReservations(runDirectories, excludingRunFile) {
|
|
|
182
231
|
}
|
|
183
232
|
}
|
|
184
233
|
}
|
|
185
|
-
return [...reserved].sort();
|
|
234
|
+
return { sources: [...reserved].sort(), assessmentKeys: [...assessmentKeys].sort() };
|
|
235
|
+
}
|
|
236
|
+
function readSourceReservations(runDirectories, excludingRunFile) {
|
|
237
|
+
return readRunReservations(runDirectories, excludingRunFile).sources;
|
|
186
238
|
}
|
|
187
239
|
exports.COMPLETION_START = '<!-- fraim-sleep-decisions:v1 -->';
|
|
188
240
|
exports.COMPLETION_END = '<!-- /fraim-sleep-decisions -->';
|
|
@@ -233,7 +285,7 @@ function decodeCompletion(markdown, expected = {}) {
|
|
|
233
285
|
catch {
|
|
234
286
|
fail();
|
|
235
287
|
}
|
|
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)
|
|
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)
|
|
237
289
|
|| !['pending', 'complete'].includes(state.finalizationState) || !Array.isArray(state.decisions) || !Array.isArray(state.sourceDispositions) || !Array.isArray(state.cleanup))
|
|
238
290
|
fail();
|
|
239
291
|
if ((expected.runId && expected.runId !== state.runId) || (expected.owner && !sameOwner(expected.owner, state.owner)))
|
|
@@ -248,8 +300,31 @@ function decodeCompletion(markdown, expected = {}) {
|
|
|
248
300
|
fail();
|
|
249
301
|
if (state.processedSources !== undefined)
|
|
250
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
|
+
}
|
|
251
314
|
if (state.sourceParticipants !== undefined)
|
|
252
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
|
+
}
|
|
253
328
|
const ids = new Set();
|
|
254
329
|
for (const item of state.decisions) {
|
|
255
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)
|
|
@@ -281,6 +356,50 @@ function decodeCompletion(markdown, expected = {}) {
|
|
|
281
356
|
fail();
|
|
282
357
|
}
|
|
283
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();
|
|
361
|
+
if (state.corpusAudit !== undefined) {
|
|
362
|
+
try {
|
|
363
|
+
(0, corpus_audit_1.validateCorpusAudit)(state.corpusAudit);
|
|
364
|
+
}
|
|
365
|
+
catch {
|
|
366
|
+
fail();
|
|
367
|
+
}
|
|
368
|
+
for (const item of state.corpusAudit.dispositions)
|
|
369
|
+
for (const binding of item.actionBindings) {
|
|
370
|
+
const decision = decisions.get(binding.actionId);
|
|
371
|
+
if (!decision || decision.digest !== binding.digest || decision.revision !== binding.revision || decision.decision.reviewer !== state.corpusAudit.reviewer)
|
|
372
|
+
fail();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
if (state.reconciliationHistory !== undefined) {
|
|
376
|
+
if (!Array.isArray(state.reconciliationHistory))
|
|
377
|
+
fail();
|
|
378
|
+
const seen = new Set();
|
|
379
|
+
const validRef = (ref) => keys(ref, ['owner', 'relativePath', 'entryId', 'learningSubject']) && ownerValid(ref.owner)
|
|
380
|
+
&& typeof ref.relativePath === 'string' && ref.relativePath.startsWith('learnings/') && !ref.relativePath.includes('\\') && !ref.relativePath.includes(':')
|
|
381
|
+
&& !ref.relativePath.split('/').some((part) => !part || part === '.' || part === '..') && /^L-[a-f0-9]{10}$/.test(ref.entryId)
|
|
382
|
+
&& (ref.learningSubject === undefined || text(ref.learningSubject));
|
|
383
|
+
for (const history of state.reconciliationHistory) {
|
|
384
|
+
const decision = decisions.get(history.actionId);
|
|
385
|
+
if (!keys(history, ['actionId', 'revision', 'digest', 'target', 'resultHash', 'occurrences']) || !decision || seen.has(history.actionId)
|
|
386
|
+
|| decision.decision.action !== 'approve' || history.revision !== decision.revision || history.digest !== decision.digest
|
|
387
|
+
|| !validRef(history.target) || canonicalJournal(history.target) !== canonicalJournal(decision.target) || !hash(history.resultHash)
|
|
388
|
+
|| !Array.isArray(history.occurrences) || !history.occurrences.length)
|
|
389
|
+
fail();
|
|
390
|
+
seen.add(history.actionId);
|
|
391
|
+
const locations = new Set();
|
|
392
|
+
for (const occurrence of history.occurrences) {
|
|
393
|
+
if (!keys(occurrence, ['ref', 'sha256', 'before']) || !validRef(occurrence.ref) || occurrence.ref.entryId !== history.target.entryId
|
|
394
|
+
|| !text(occurrence.before) || !hash(occurrence.sha256) || (0, node_crypto_1.createHash)('sha256').update(occurrence.before).digest('hex') !== occurrence.sha256)
|
|
395
|
+
fail();
|
|
396
|
+
const location = canonicalJournal([occurrence.ref.owner, occurrence.ref.relativePath]);
|
|
397
|
+
if (locations.has(location))
|
|
398
|
+
fail();
|
|
399
|
+
locations.add(location);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
284
403
|
const outcome = (item) => item.decision.action === 'approve' ? 'applied' : 'rejected';
|
|
285
404
|
const dispositions = new Set();
|
|
286
405
|
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, learningEntryIdentity, isLearningEntryId } = 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 { id, ambiguous } = learningEntryIdentity(block.text);
|
|
67
|
+
if (ambiguous || (id && ids.has(id))
|
|
68
|
+
|| !/^#{2,6}\s+\[P-(CRITICAL|HIGH|MED|LOW)\]\s+\S/.test(block.text)) {
|
|
69
|
+
errors.push({ path: file.replace(/\\/g, '/'), reason: 'ambiguous-corpus-entry' });
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
if (id)
|
|
73
|
+
ids.add(id);
|
|
74
|
+
entries.push({ ref: { owner: home.owner, relativePath, ...(id ? { entryId: id } : {}) }, sha256: hash(block.text),
|
|
75
|
+
...(!id ? { occurrence: unassigned++ } : {}) });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
errors.push({ path: file.replace(/\\/g, '/'), reason: error.code || error.message });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
if (error.code !== 'ENOENT')
|
|
85
|
+
errors.push({ path: directory.replace(/\\/g, '/'), reason: error.code || error.message });
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
walk((0, owner_identity_1.ownerArtifactPath)(home, 'learnings'));
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
errors.push({ path: home.contentRoot, reason: error.message });
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
// Legacy reader locations are explicitly discoverable but cannot acquire a
|
|
96
|
+
// writable OwnerRef just by being present. Report every unowned family file.
|
|
97
|
+
const inspectReaderRoot = (directory) => {
|
|
98
|
+
try {
|
|
99
|
+
if (node_fs_1.default.lstatSync(directory).isSymbolicLink())
|
|
100
|
+
throw new Error('symlink-corpus-not-followed');
|
|
101
|
+
for (const item of node_fs_1.default.readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
102
|
+
const file = node_path_1.default.join(directory, item.name);
|
|
103
|
+
if (excluded.has(item.name))
|
|
104
|
+
continue;
|
|
105
|
+
if (item.isSymbolicLink()) {
|
|
106
|
+
errors.push({ path: file.replace(/\\/g, '/'), reason: 'symlink-corpus-not-followed' });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (item.isDirectory()) {
|
|
110
|
+
inspectReaderRoot(file);
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (!family.test(item.name))
|
|
114
|
+
continue;
|
|
115
|
+
try {
|
|
116
|
+
const real = node_fs_1.default.realpathSync(file), key = process.platform === 'win32' ? real.toLowerCase() : real;
|
|
117
|
+
if (seen.has(key))
|
|
118
|
+
continue;
|
|
119
|
+
seen.add(key);
|
|
120
|
+
errors.push({ path: file.replace(/\\/g, '/'), reason: 'reader-live-home-unowned' });
|
|
121
|
+
}
|
|
122
|
+
catch (error) {
|
|
123
|
+
errors.push({ path: file.replace(/\\/g, '/'), reason: error.code || error.message });
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
catch (error) {
|
|
128
|
+
if (error.code !== 'ENOENT')
|
|
129
|
+
errors.push({ path: directory.replace(/\\/g, '/'), reason: error.code || error.message });
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
for (const directory of [...new Set(readerRoots)].sort()) {
|
|
133
|
+
// An explicit configured alias of an owned learning root is already scanned.
|
|
134
|
+
try {
|
|
135
|
+
const key = (file) => { const real = node_fs_1.default.realpathSync(file); return process.platform === 'win32' ? real.toLowerCase() : real; };
|
|
136
|
+
const real = key(directory);
|
|
137
|
+
if (homes.some(home => { try {
|
|
138
|
+
return key(node_path_1.default.join(home.contentRoot, 'learnings')) === real;
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return false;
|
|
142
|
+
} }))
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
catch { /* Missing/unreadable roots are handled by the scanner below. */ }
|
|
146
|
+
inspectReaderRoot(directory);
|
|
147
|
+
}
|
|
148
|
+
entries.sort((a, b) => corpusEntryKey(a).localeCompare(corpusEntryKey(b)));
|
|
149
|
+
const groups = new Map();
|
|
150
|
+
for (const entry of entries)
|
|
151
|
+
if (entry.ref.entryId)
|
|
152
|
+
groups.set(entry.ref.entryId, [...(groups.get(entry.ref.entryId) || []), entry]);
|
|
153
|
+
const duplicates = [...groups].filter(([, occurrences]) => occurrences.length > 1).map(([entryId, occurrences]) => ({ entryId, occurrences })).sort((a, b) => a.entryId.localeCompare(b.entryId));
|
|
154
|
+
return { schemaVersion: 1, homes: identities, entries, duplicates, fingerprint: corpusFingerprint(identities, entries), complete: !errors.length, errors };
|
|
155
|
+
}
|
|
156
|
+
/** Shared by journal replay and the strict compact receipt decoder. No filesystem access. */
|
|
157
|
+
function validateCorpusAudit(audit, reviewer) {
|
|
158
|
+
const fail = () => { throw new Error('invalid-corpus-audit'); };
|
|
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
|
+
|| !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 : !isLearningEntryId(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
|
+
}
|
|
@@ -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
|
+
}
|