fraim 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-authoring.js +181 -0
- package/dist/registry/scripts/learning/entry-format.js +10 -1
- package/dist/registry/scripts/learning/outcome-assessment.js +162 -0
- 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/registry/scripts/learning/run-application.js +1003 -0
- package/dist/registry/scripts/learning/run-publication.js +364 -0
- package/dist/registry/scripts/learning/run-record.js +895 -0
- package/dist/registry/scripts/learning-usage/learning-source-record.js +131 -0
- package/dist/src/cli/commands/learning.js +64 -0
- package/dist/src/cli/doctor/checks/scripts-checks.js +53 -1
- package/dist/src/cli/fraim.js +2 -0
- package/dist/src/cli/learning/inventory.js +285 -0
- package/dist/src/cli/learning/queue-operations.js +199 -0
- package/dist/src/cli/learning/runtime.js +82 -0
- package/dist/src/cli/learning/usage.js +277 -0
- package/dist/src/core/fraim-config-schema.generated.js +0 -4
- 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-analysis.js +12 -410
- 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/dist/src/local-mcp-server/learning-usage-verify.js +9 -40
- package/dist/src/local-mcp-server/stdio-server.js +0 -48
- package/package.json +11 -1
- package/dist/src/local-mcp-server/learning-firing-parser.js +0 -247
- package/dist/src/local-mcp-server/learning-usage-attestation.js +0 -238
- package/dist/src/local-mcp-server/learning-usage-command.js +0 -518
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
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.deriveDomain = deriveDomain;
|
|
7
|
+
exports.readerWordCount = readerWordCount;
|
|
8
|
+
exports.validateEntry = validateEntry;
|
|
9
|
+
exports.renderEntry = renderEntry;
|
|
10
|
+
exports.isLearningScaffolding = isLearningScaffolding;
|
|
11
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
12
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
13
|
+
// Relative to the shipped helper, never to the job's working directory.
|
|
14
|
+
const map = JSON.parse(node_fs_1.default.readFileSync(node_path_1.default.join(__dirname, 'learning-domains.json'), 'utf8'));
|
|
15
|
+
function deriveDomain(origins) {
|
|
16
|
+
const unknown = (reason) => ({ status: 'needs-domain', value: null, reason, mapVersion: map.mapVersion });
|
|
17
|
+
if (!origins.length)
|
|
18
|
+
return unknown('No originating job recorded');
|
|
19
|
+
const decisions = [];
|
|
20
|
+
for (const origin of origins) {
|
|
21
|
+
let declaration;
|
|
22
|
+
const frontmatter = origin.content?.match(/^\uFEFF?---\r?\n([\s\S]*?)\r?\n---/);
|
|
23
|
+
if (frontmatter) {
|
|
24
|
+
try {
|
|
25
|
+
declaration = JSON.parse(frontmatter[1]).domain;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
declaration = frontmatter[1].match(/^domain:\s*["']?([\w-]+)/m)?.[1];
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
if (declaration !== undefined) {
|
|
32
|
+
if (declaration !== 'global' && !map.domains.includes(String(declaration)))
|
|
33
|
+
return unknown(`Invalid domain declaration for ${origin.name}`);
|
|
34
|
+
decisions.push({ value: String(declaration), basis: 'job-declaration' });
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
const clean = origin.registryPath?.replace(/\\/g, '/').replace(/^\/?registry\//, '');
|
|
38
|
+
const category = clean?.match(/^jobs\/(?:ai-employee\/|ai-manager\/|personalized-employee\/)?([^/]+)\/[^/]+\.md$/)?.[1];
|
|
39
|
+
if (!category)
|
|
40
|
+
return unknown(`No mapped job path for ${origin.name}`);
|
|
41
|
+
const value = map.jobDomainMap[category] || (map.globalJobCategories.includes(category) ? 'global' : null);
|
|
42
|
+
if (!value)
|
|
43
|
+
return unknown(`Unmapped category for ${origin.name}`);
|
|
44
|
+
decisions.push({ value, basis: 'category' });
|
|
45
|
+
}
|
|
46
|
+
if (new Set(decisions.map(d => d.value)).size !== 1)
|
|
47
|
+
return unknown('Originating jobs span multiple domains');
|
|
48
|
+
return { status: 'resolved', ...decisions[0], mapVersion: map.mapVersion };
|
|
49
|
+
}
|
|
50
|
+
function readerText(entry) {
|
|
51
|
+
return [entry.title, entry.takeaway, entry.why, entry.example, entry.next].join(' ');
|
|
52
|
+
}
|
|
53
|
+
function readerWordCount(entry) {
|
|
54
|
+
const plain = readerText(entry).replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1').replace(/[*_`~#]/g, '').trim();
|
|
55
|
+
return plain ? plain.split(/\s+/u).length : 0;
|
|
56
|
+
}
|
|
57
|
+
function validateEntry(entry) {
|
|
58
|
+
if (!entry || !['P-CRITICAL', 'P-HIGH', 'P-MED', 'P-LOW'].includes(entry.severity))
|
|
59
|
+
throw new Error('invalid-entry: severity');
|
|
60
|
+
for (const field of ['title', 'takeaway', 'why', 'example', 'next']) {
|
|
61
|
+
if (typeof entry[field] !== 'string' || !entry[field].trim() || /[\r\n\0]/.test(entry[field]))
|
|
62
|
+
throw new Error(`invalid-entry: ${field}`);
|
|
63
|
+
}
|
|
64
|
+
if (readerWordCount(entry) > 80)
|
|
65
|
+
throw new Error('invalid-entry: 80-word budget exceeded');
|
|
66
|
+
// Domain wording and whether an identifier is essential require semantic
|
|
67
|
+
// authoring review/evals; a vocabulary regex rejects legitimate lessons.
|
|
68
|
+
if (!entry.domain || !Number.isInteger(entry.domain.mapVersion))
|
|
69
|
+
throw new Error('invalid-entry: domain');
|
|
70
|
+
if (entry.domain.status === 'resolved') {
|
|
71
|
+
if (!['global', ...map.domains].includes(entry.domain.value) || !['category', 'job-declaration', 'manager'].includes(entry.domain.basis))
|
|
72
|
+
throw new Error('invalid-entry: domain');
|
|
73
|
+
}
|
|
74
|
+
else if (entry.domain.status !== 'needs-domain' || entry.domain.value !== null || !entry.domain.reason)
|
|
75
|
+
throw new Error('invalid-entry: domain');
|
|
76
|
+
const trace = entry.trace;
|
|
77
|
+
if (!trace || !Number.isFinite(trace.score) || !Number.isInteger(trace.recurrences) || trace.recurrences < 1 || !Array.isArray(trace.originatingJobs) || !Array.isArray(trace.sources))
|
|
78
|
+
throw new Error('invalid-entry: trace');
|
|
79
|
+
for (const date of [trace.lastSeen, trace.firstRecorded, ...(trace.firstSynthesized ? [trace.firstSynthesized] : [])]) {
|
|
80
|
+
if (typeof date !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(date) || new Date(date).toISOString().slice(0, 10) !== date)
|
|
81
|
+
throw new Error('invalid-entry: date');
|
|
82
|
+
}
|
|
83
|
+
if (trace.firstRecorded > trace.lastSeen)
|
|
84
|
+
throw new Error('invalid-entry: source dates');
|
|
85
|
+
if (!trace.originatingJobs.every(job => typeof job.name === 'string' && job.name.length > 0 && (job.registryPath === null || typeof job.registryPath === 'string')))
|
|
86
|
+
throw new Error('invalid-entry: origins');
|
|
87
|
+
if (!trace.sources.every(source => typeof source === 'string'))
|
|
88
|
+
throw new Error('invalid-entry: sources');
|
|
89
|
+
}
|
|
90
|
+
function renderEntry(entryId, entry) {
|
|
91
|
+
validateEntry(entry);
|
|
92
|
+
if (!/^L-[a-f0-9]{10}$/.test(entryId))
|
|
93
|
+
throw new Error('invalid-entry: Id');
|
|
94
|
+
if (entry.domain.status !== 'resolved')
|
|
95
|
+
throw new Error('needs-domain');
|
|
96
|
+
const trace = entry.trace;
|
|
97
|
+
const line = (value) => value.replace(/[\r\n\0]+/g, ' ');
|
|
98
|
+
// Metadata stays on standalone lines for existing learning readers.
|
|
99
|
+
return [
|
|
100
|
+
`### [${entry.severity}] ${entry.title}`, '',
|
|
101
|
+
`**Takeaway**: ${entry.takeaway}`, `**Why this matters**: ${entry.why}`,
|
|
102
|
+
`**Example**: ${entry.example}`, `**What to do next**: ${entry.next}`, '**Technical trace**:',
|
|
103
|
+
`**Id**: ${entryId}`, `**Domain**: ${entry.domain.value}`,
|
|
104
|
+
`**Originating job**: ${line(trace.originatingJobs.map(job => job.registryPath || job.name).join('; '))}`,
|
|
105
|
+
`**Domain basis**: ${entry.domain.basis}; map ${entry.domain.mapVersion}`,
|
|
106
|
+
`**Score**: ${trace.score}`, `**Last seen**: ${trace.lastSeen}`,
|
|
107
|
+
`**Recurrences**: ${trace.recurrences}`, `**First recorded**: ${trace.firstRecorded}`,
|
|
108
|
+
...(trace.firstSynthesized ? [`**First synthesized**: ${trace.firstSynthesized}`] : []),
|
|
109
|
+
`**Sources**: ${line(trace.sources.join('; '))}`,
|
|
110
|
+
// Investigation detail stays in evidence; active guidance retains only provenance metadata.
|
|
111
|
+
'',
|
|
112
|
+
].join('\n');
|
|
113
|
+
}
|
|
114
|
+
/** Recognize only known generated family preambles; unknown prose remains user content. */
|
|
115
|
+
function isLearningScaffolding(body, familyPath) {
|
|
116
|
+
const name = node_path_1.default.basename(familyPath);
|
|
117
|
+
const match = name.match(/-(preferences|mistake-patterns|validated-patterns|manager-coaching)(\.cold)?\.md$/);
|
|
118
|
+
if (!match)
|
|
119
|
+
return false;
|
|
120
|
+
const family = match[1];
|
|
121
|
+
const title = family.replace(/-/g, ' ');
|
|
122
|
+
const cold = !!match[2];
|
|
123
|
+
const descriptions = {
|
|
124
|
+
preferences: ['This file captures durable communication and interaction preferences observed across sessions.', 'This file captures working preferences common across multiple users. Run `organizational-learning-synthesis` to populate it.'],
|
|
125
|
+
'mistake-patterns': ['This file is an execution playbook for future agents. Keep it focused on durable behavior and decision quality.', 'This file captures recurring failure modes observed across multiple users in the org. Run `organizational-learning-synthesis` to populate it.'],
|
|
126
|
+
'validated-patterns': ['This file captures validated decisions, near-misses that were caught, and moments where past learnings successfully fired. These are behaviors to reinforce.'],
|
|
127
|
+
'manager-coaching': ['This file captures coaching and reflections about your management of AI agents - patterns you have developed (or been coached on) that make you a more effective practitioner. The audience is **you**, not the AI agent.', 'This file captures coaching patterns shared across managers in the org - guidance that managers have received or developed about how to work effectively with AI agents. The audience is the human manager, not the AI agent. Run `organizational-learning-synthesis` to populate it.'],
|
|
128
|
+
};
|
|
129
|
+
const heading = (value) => {
|
|
130
|
+
if (['# Learnings', '# Synthesized Learnings'].includes(value) || (family === 'mistake-patterns' && value === '# Prioritized Learnings'))
|
|
131
|
+
return true;
|
|
132
|
+
if (!value.startsWith('# '))
|
|
133
|
+
return false;
|
|
134
|
+
let text = value.slice(2);
|
|
135
|
+
const author = text.match(/ [\-\u2014] ([a-zA-Z0-9@._+-]+)$/);
|
|
136
|
+
if (author) {
|
|
137
|
+
if (!name.startsWith(author[1] + '-'))
|
|
138
|
+
return false;
|
|
139
|
+
text = text.slice(0, -author[0].length);
|
|
140
|
+
}
|
|
141
|
+
if (/^Cold-Tier /i.test(text)) {
|
|
142
|
+
if (!cold)
|
|
143
|
+
return false;
|
|
144
|
+
text = text.slice(10);
|
|
145
|
+
}
|
|
146
|
+
if (/^Org /i.test(text)) {
|
|
147
|
+
if (!name.startsWith('org-'))
|
|
148
|
+
return false;
|
|
149
|
+
text = text.slice(4);
|
|
150
|
+
}
|
|
151
|
+
const domain = text.match(/ \(([a-zA-Z][a-zA-Z -]*)\)$/);
|
|
152
|
+
if (domain) {
|
|
153
|
+
if (!name.toLowerCase().endsWith('-' + domain[1].toLowerCase().replace(/ /g, '-') + '-' + family + (cold ? '.cold' : '') + '.md'))
|
|
154
|
+
return false;
|
|
155
|
+
text = text.slice(0, -domain[0].length);
|
|
156
|
+
}
|
|
157
|
+
text = text.toLowerCase().replace(/-/g, ' ');
|
|
158
|
+
if (text === title)
|
|
159
|
+
return true;
|
|
160
|
+
const prefix = text.endsWith(' ' + title) ? text.slice(0, -title.length - 1).replace(/ /g, '-') : '';
|
|
161
|
+
return !!prefix && name.toLowerCase().endsWith('-' + prefix + '-' + family + (cold ? '.cold' : '') + '.md');
|
|
162
|
+
};
|
|
163
|
+
return body.split(/\r?\n/).every(line => {
|
|
164
|
+
const value = line.trim();
|
|
165
|
+
if (!value || value === '---' || heading(value) || descriptions[family].includes(value) || /^Last (?:synthesized|updated): \d{4}-\d{2}-\d{2}$/.test(value))
|
|
166
|
+
return true;
|
|
167
|
+
if (cold && (value === 'Entries whose effective score has fallen below the injection threshold. They stay readable and return to the active file the first time their problem recurs.'
|
|
168
|
+
|| /^Entries below the active-tier score threshold \(\d+(?:\.\d+)?\)\. Preserved for re-activation if recurrence rises\.$/.test(value)))
|
|
169
|
+
return true;
|
|
170
|
+
const sections = {
|
|
171
|
+
'mistake-patterns': ['## Severity Definitions', '## Critical Priorities', '- Critical: recurring across 3+ users, causes customer impact or significant rework.', '- Important: recurring across 2+ users, degrades quality or velocity.', '- Monitor: emerging pattern, not yet widespread but worth tracking.', '- Critical: recurring failure mode that causes rework, trust loss, or architectural mistakes.', '- Important: meaningful quality risk that frequently degrades output.', '- Monitor: lower-frequency risk to track and prevent from growing.'],
|
|
172
|
+
'validated-patterns': ['## Severity Definitions', '## Patterns to Reinforce', '- HIGH: validated decision that prevented major rework or unblocked a high-stakes path (reusable across many future contexts).', '- MED: validated context-specific judgment call (reusable when same context recurs).', '- LOW: validated micro-correctness (useful but narrow).'],
|
|
173
|
+
};
|
|
174
|
+
if (sections[family]?.includes(value))
|
|
175
|
+
return true;
|
|
176
|
+
const domain = value.match(/^([A-Za-z][A-Za-z-]*)-domain (manager coaching|validated patterns)\. (.+)$/);
|
|
177
|
+
const detail = family === 'manager-coaching' ? 'Feedback for how the manager should continue or improve managing AI on ' + domain?.[1].toLowerCase() + ' work.' : 'Durable judgment calls and successful unusual-but-correct decisions worth reproducing.';
|
|
178
|
+
return !!domain && domain[2] === title && name.toLowerCase().endsWith('-' + domain[1].toLowerCase() + '-' + family + '.md')
|
|
179
|
+
&& domain[3] === detail + ' These load only when an ' + domain[1].toLowerCase() + '-domain job runs.';
|
|
180
|
+
});
|
|
181
|
+
}
|
|
@@ -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 };
|