fraim 2.0.342 → 2.0.344
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/artifact-paths.js +108 -0
- package/dist/registry/scripts/learning/completion-state.js +205 -5
- package/dist/registry/scripts/learning-usage/local-runtime.js +217 -0
- package/dist/src/cli/commands/init-project.js +2 -12
- package/dist/src/cli/commands/sync.js +36 -162
- package/dist/src/cli/setup/ide-invocation-surfaces.js +3 -3
- package/dist/src/cli/setup/user-level-sync.js +3 -3
- package/dist/src/cli/utils/agent-adapters.js +12 -12
- package/dist/src/cli/utils/fraim-gitignore.js +28 -3
- package/dist/src/cli/utils/pack-home.js +10 -0
- package/dist/src/cli/utils/project-catalog-cleanup.js +62 -0
- package/dist/src/cli/utils/remote-sync.js +26 -2
- package/dist/src/config/persona-hiring.js +7 -1
- package/dist/src/core/ai-mentor.js +42 -11
- package/dist/src/core/config-loader.js +4 -0
- package/dist/src/core/fraim-config-schema.generated.js +22 -0
- package/dist/src/core/handoff-contracts.js +8 -3
- package/dist/src/core/utils/local-registry-resolver.js +33 -57
- package/dist/src/fraim/db-service.js +127 -118
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +79 -6
- package/dist/src/local-mcp-server/learning-context-builder.js +101 -105
- package/dist/src/local-mcp-server/learning-source-record.js +122 -0
- package/dist/src/local-mcp-server/learning-usage-attestation.js +66 -56
- package/dist/src/local-mcp-server/learning-usage-command.js +5 -4
- package/dist/src/local-mcp-server/stdio-server.js +10 -7
- package/dist/src/local-mcp-server/usage-collector.js +2 -0
- package/dist/src/mcp/tool-schemas.js +5 -1
- package/dist/src/services/mcp-service.js +6 -4
- package/dist/src/services/usage-analytics-service.js +1 -0
- package/package.json +3 -1
|
@@ -12,6 +12,10 @@ const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
|
12
12
|
const node_child_process_1 = require("node:child_process");
|
|
13
13
|
const node_path_1 = __importDefault(require("node:path"));
|
|
14
14
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
15
|
+
const learning_source_record_1 = require("./learning-source-record");
|
|
16
|
+
const artifact_paths_1 = require("../../registry/scripts/artifact-paths");
|
|
17
|
+
const completion_state_1 = require("../../registry/scripts/learning/completion-state");
|
|
18
|
+
const pack_home_1 = require("../cli/utils/pack-home");
|
|
15
19
|
exports.ARTIFACT_RETENTION_CATEGORIES = [
|
|
16
20
|
'learning_archive',
|
|
17
21
|
'retrospectives',
|
|
@@ -191,6 +195,40 @@ function evidenceEligibilityDate(workspaceRoot, absolutePath, gitDates) {
|
|
|
191
195
|
const relativePath = normalizeRelative(workspaceRoot, absolutePath);
|
|
192
196
|
return gitDates.get(relativePath) ?? fileMtime(absolutePath);
|
|
193
197
|
}
|
|
198
|
+
/** Only typed future evidence opts into the combined retention contract. */
|
|
199
|
+
function evidenceMarker(body) {
|
|
200
|
+
const header = body.match(/^(?:\uFEFF)?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
201
|
+
const fields = header?.[1] ?? body;
|
|
202
|
+
if (!/^\s*["']?artifactKind["']?\s*:/m.test(fields))
|
|
203
|
+
return { typed: false, valid: false };
|
|
204
|
+
// Unsupported spellings still participate in ambiguity detection. Otherwise
|
|
205
|
+
// a quoted or indented second key could silently shorten feedback retention.
|
|
206
|
+
const kindKeys = [...fields.matchAll(/^[ \t]*["']?artifactKind["']?[ \t]*:/gm)];
|
|
207
|
+
const feedbackKeys = [...fields.matchAll(/^[ \t]*["']?containsFeedback["']?[ \t]*:/gm)];
|
|
208
|
+
const kinds = [...fields.matchAll(/^artifactKind:[ \t]*([^\r\n]*)/gm)];
|
|
209
|
+
const feedback = [...fields.matchAll(/^containsFeedback:[ \t]*([^\r\n]*)/gm)];
|
|
210
|
+
const valid = !!header && kindKeys.length === 1 && feedbackKeys.length === 1
|
|
211
|
+
&& kinds.length === 1 && kinds[0][1].trim() === 'work-evidence'
|
|
212
|
+
&& feedback.length === 1 && /^(true|false)$/.test(feedback[0][1].trim());
|
|
213
|
+
return { typed: true, valid, ...(valid ? { feedback: feedback[0][1].trim() === 'true' } : {}) };
|
|
214
|
+
}
|
|
215
|
+
const recordHash = (body) => node_crypto_1.default.createHash('sha256').update(body).digest('hex');
|
|
216
|
+
const pathKey = (file) => process.platform === 'win32' ? node_path_1.default.resolve(file).toLowerCase() : node_path_1.default.resolve(file);
|
|
217
|
+
function reservedSources(workspaceRoot) {
|
|
218
|
+
// A Manager run can reserve an input owned by this project. These reads only
|
|
219
|
+
// protect exact paths; they never apply this workspace's policy to other homes.
|
|
220
|
+
const content = contentRoot(workspaceRoot);
|
|
221
|
+
const packs = [(0, pack_home_1.resolvePackHome)('manager', { migrateLegacy: false }), (0, pack_home_1.resolvePackHome)('org', { migrateLegacy: false })];
|
|
222
|
+
const homes = [{ root: workspaceRoot, contentRoot: content }, { root: node_path_1.default.dirname(content), contentRoot: content }, ...packs];
|
|
223
|
+
return new Set((0, completion_state_1.readSourceReservations)(homes.map(home => (0, artifact_paths_1.resolveRunJournalDirectory)(home.root))).map(pathKey));
|
|
224
|
+
}
|
|
225
|
+
function matchesConfiguredLocation(workspaceRoot, candidate) {
|
|
226
|
+
const content = contentRoot(workspaceRoot);
|
|
227
|
+
const paths = (0, artifact_paths_1.resolveArtifactPaths)(workspaceRoot, content);
|
|
228
|
+
const directory = candidate.category === 'evidence' ? paths.evidence : node_path_1.default.dirname(paths.archiveRaw);
|
|
229
|
+
const relative = node_path_1.default.relative(directory, candidate.absolutePath);
|
|
230
|
+
return candidate.configuredDirectory === pathKey(directory) && Boolean(relative) && relative !== '..' && !relative.startsWith('..' + node_path_1.default.sep) && !node_path_1.default.isAbsolute(relative);
|
|
231
|
+
}
|
|
194
232
|
function collectCandidates(workspaceRoot, options) {
|
|
195
233
|
const { activeUserEmail } = options;
|
|
196
234
|
const candidates = [];
|
|
@@ -205,12 +243,28 @@ function collectCandidates(workspaceRoot, options) {
|
|
|
205
243
|
});
|
|
206
244
|
};
|
|
207
245
|
const content = contentRoot(workspaceRoot);
|
|
208
|
-
const
|
|
246
|
+
const reserved = reservedSources(workspaceRoot);
|
|
247
|
+
const artifactPaths = (0, artifact_paths_1.resolveArtifactPaths)(workspaceRoot, content);
|
|
248
|
+
const archiveDir = node_path_1.default.dirname(artifactPaths.archiveRaw);
|
|
209
249
|
for (const filePath of listFiles(archiveDir)) {
|
|
210
250
|
const fileName = node_path_1.default.basename(filePath);
|
|
211
|
-
if (!fileName.endsWith('.md')
|
|
251
|
+
if (!fileName.endsWith('.md'))
|
|
252
|
+
continue;
|
|
253
|
+
const body = node_fs_1.default.readFileSync(filePath, 'utf8');
|
|
254
|
+
const parsed = (0, learning_source_record_1.parseLearningSourceRecord)(body, filePath, workspaceRoot, content, artifactPaths);
|
|
255
|
+
if (parsed.status === 'valid' && parsed.record.author !== activeUserEmail)
|
|
212
256
|
continue;
|
|
213
|
-
|
|
257
|
+
if (parsed.status === 'unsupported' && !userScoped(filePath, activeUserEmail))
|
|
258
|
+
continue;
|
|
259
|
+
const newArchive = /^(raw|retrospectives)\//.test(node_path_1.default.relative(archiveDir, filePath).replace(/\\/g, '/'));
|
|
260
|
+
if (parsed.status !== 'unsupported' || newArchive) {
|
|
261
|
+
const valid = parsed.status === 'valid' && parsed.record.state === 'archived';
|
|
262
|
+
add('learning_archive', filePath, valid && !reserved.has(pathKey(filePath)), reserved.has(pathKey(filePath)) ? 'Learning source reserved by unfinished synthesis.'
|
|
263
|
+
: valid ? 'Learning archive dated by successful processing.' : 'Invalid new learning archive metadata; retained.', valid ? parseDate(parsed.record.synthesized || undefined) : undefined);
|
|
264
|
+
Object.assign(candidates[candidates.length - 1], { recordHash: recordHash(body), configuredDirectory: pathKey(archiveDir) });
|
|
265
|
+
}
|
|
266
|
+
else
|
|
267
|
+
add('learning_archive', filePath, !reserved.has(pathKey(filePath)), 'Archived learning source for active user.', fileMtime(filePath));
|
|
214
268
|
}
|
|
215
269
|
const retrospectives = new Set([
|
|
216
270
|
node_path_1.default.join(workspaceRoot, 'docs', 'retrospectives'),
|
|
@@ -227,8 +281,8 @@ function collectCandidates(workspaceRoot, options) {
|
|
|
227
281
|
else
|
|
228
282
|
add('retrospectives', filePath, false, 'Retrospective has no synthesized frontmatter.');
|
|
229
283
|
}
|
|
230
|
-
const evidenceDir =
|
|
231
|
-
const evidenceGitDates = loadGitLastChangeDates(workspaceRoot, '
|
|
284
|
+
const evidenceDir = artifactPaths.evidence;
|
|
285
|
+
const evidenceGitDates = loadGitLastChangeDates(workspaceRoot, node_path_1.default.relative(workspaceRoot, evidenceDir).replace(/\\/g, '/'));
|
|
232
286
|
for (const filePath of listFiles(evidenceDir)) {
|
|
233
287
|
const fileName = node_path_1.default.basename(filePath);
|
|
234
288
|
// A surviving run journal is unfinished recovery; completion removes it.
|
|
@@ -239,6 +293,17 @@ function collectCandidates(workspaceRoot, options) {
|
|
|
239
293
|
const isMarkdown = fileName.endsWith('.md');
|
|
240
294
|
const isJson = fileName.endsWith('.json');
|
|
241
295
|
const eligibilityDate = evidenceEligibilityDate(workspaceRoot, filePath, evidenceGitDates);
|
|
296
|
+
if (isMarkdown) {
|
|
297
|
+
const body = node_fs_1.default.readFileSync(filePath, 'utf8');
|
|
298
|
+
const marker = evidenceMarker(body);
|
|
299
|
+
if (marker.typed) {
|
|
300
|
+
const mtime = fileMtime(filePath);
|
|
301
|
+
const latest = eligibilityDate && mtime ? new Date(Math.max(eligibilityDate.getTime(), mtime.getTime())) : eligibilityDate ?? mtime;
|
|
302
|
+
add('evidence', filePath, marker.valid, marker.valid ? 'Typed work evidence.' : 'Ambiguous new evidence classification; retained.', latest);
|
|
303
|
+
Object.assign(candidates[candidates.length - 1], { containsFeedback: marker.feedback, recordHash: recordHash(body), configuredDirectory: pathKey(evidenceDir) });
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
242
307
|
if (fileName.includes('cleanup-manifest') || fileName.includes('cleanup_manifest')) {
|
|
243
308
|
if (isMarkdown || isJson) {
|
|
244
309
|
add('cleanup_manifests', filePath, true, 'Cleanup manifest file.', eligibilityDate);
|
|
@@ -368,7 +433,10 @@ function runArtifactCleanup(workspaceRoot, options) {
|
|
|
368
433
|
items: [],
|
|
369
434
|
};
|
|
370
435
|
for (const candidate of candidates) {
|
|
371
|
-
const
|
|
436
|
+
const categoryValue = retention.values[candidate.category];
|
|
437
|
+
const retentionValue = candidate.containsFeedback
|
|
438
|
+
? categoryValue === -1 || retention.values.feedback === -1 ? -1 : Math.max(categoryValue, retention.values.feedback)
|
|
439
|
+
: categoryValue;
|
|
372
440
|
let decision = shouldDelete(candidate, retentionValue, now);
|
|
373
441
|
let action;
|
|
374
442
|
let error;
|
|
@@ -383,6 +451,11 @@ function runArtifactCleanup(workspaceRoot, options) {
|
|
|
383
451
|
}
|
|
384
452
|
else {
|
|
385
453
|
try {
|
|
454
|
+
if (candidate.recordHash && (!matchesConfiguredLocation(workspaceRoot, candidate) || !isConfinedRegularFile(workspaceRoot, candidate.absolutePath)
|
|
455
|
+
|| recordHash(node_fs_1.default.readFileSync(candidate.absolutePath, 'utf8')) !== candidate.recordHash
|
|
456
|
+
|| reservedSources(workspaceRoot).has(pathKey(candidate.absolutePath)))) {
|
|
457
|
+
decision = { delete: false, reason: 'Record changed or reserved after enumeration; retained for a later cleanup.' };
|
|
458
|
+
}
|
|
386
459
|
if (candidate.category === 'hub_event_logs' && candidate.hubArchive) {
|
|
387
460
|
const prior = candidate.hubArchive;
|
|
388
461
|
const current = readHubArchiveState(prior.root, candidate.absolutePath);
|
|
@@ -1,10 +1,4 @@
|
|
|
1
1
|
"use strict";
|
|
2
|
-
/**
|
|
3
|
-
* LearningContextBuilder builds the learning context section for a user.
|
|
4
|
-
*
|
|
5
|
-
* Lives in the local proxy so learning files are always read from the correct
|
|
6
|
-
* workspace root on the user's machine.
|
|
7
|
-
*/
|
|
8
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
3
|
exports.CATEGORY_TO_FILETYPE = exports.BRAIN_LEARNING_FILETYPE_TO_REGION = exports.TEAM_CONTEXT_RELATIVE_PATHS = exports.LEARNING_ENTRY_ID_REGEX = exports.LEARNING_PRIORITIES = exports.DEFAULT_THRESHOLD = void 0;
|
|
10
4
|
exports.learningEntryHeadingRegex = learningEntryHeadingRegex;
|
|
@@ -12,6 +6,8 @@ exports.learningEntryIdLineRegex = learningEntryIdLineRegex;
|
|
|
12
6
|
exports.generateLearningEntryId = generateLearningEntryId;
|
|
13
7
|
exports.getScoreThreshold = getScoreThreshold;
|
|
14
8
|
exports.computeEffectiveScore = computeEffectiveScore;
|
|
9
|
+
exports.getLearningInputHomes = getLearningInputHomes;
|
|
10
|
+
exports.readLearningSourceFile = readLearningSourceFile;
|
|
15
11
|
exports.mergeLearningEntriesAcrossTiers = mergeLearningEntriesAcrossTiers;
|
|
16
12
|
exports.buildLearningContextSection = buildLearningContextSection;
|
|
17
13
|
exports.collectOfferedLearningEntries = collectOfferedLearningEntries;
|
|
@@ -29,9 +25,18 @@ exports.readPreservedLearnings = readPreservedLearnings;
|
|
|
29
25
|
exports.normalizeLearningDomain = normalizeLearningDomain;
|
|
30
26
|
exports.applyLearningEntryChange = applyLearningEntryChange;
|
|
31
27
|
exports.isTruthyFlag = isTruthyFlag;
|
|
28
|
+
const artifact_paths_1 = require("../../registry/scripts/artifact-paths");
|
|
29
|
+
/**
|
|
30
|
+
* LearningContextBuilder builds the learning context section for a user.
|
|
31
|
+
*
|
|
32
|
+
* Lives in the local proxy so learning files are always read from the correct
|
|
33
|
+
* workspace root on the user's machine.
|
|
34
|
+
*/
|
|
32
35
|
const fs_1 = require("fs");
|
|
33
36
|
const crypto_1 = require("crypto");
|
|
34
37
|
const path_1 = require("path");
|
|
38
|
+
const learning_source_record_1 = require("./learning-source-record");
|
|
39
|
+
const completion_state_1 = require("../../registry/scripts/learning/completion-state");
|
|
35
40
|
const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
|
|
36
41
|
const brand_store_1 = require("../core/brand-store");
|
|
37
42
|
// Issue #1043 round 2: org and manager content lives at one home named by
|
|
@@ -111,14 +116,14 @@ function generateLearningEntryId(fileType, title) {
|
|
|
111
116
|
* therefore contributes no tier, matching the behavior before round 2.
|
|
112
117
|
*/
|
|
113
118
|
function managerSecondaryLearningsBase() {
|
|
114
|
-
const home = (0, pack_home_1.resolvePackHome)('manager');
|
|
119
|
+
const home = (0, pack_home_1.resolvePackHome)('manager', { migrateLegacy: false });
|
|
115
120
|
if (home.synced)
|
|
116
121
|
return (0, path_1.join)(home.contentRoot, 'learnings');
|
|
117
122
|
// pack-home-exempt: deliberate historical secondary location, see above.
|
|
118
123
|
return (0, path_1.join)((0, project_fraim_paths_1.getUserFraimDirPath)(), 'manager', 'learnings');
|
|
119
124
|
}
|
|
120
125
|
function getLearningRoots(workspaceRoot) {
|
|
121
|
-
const managerHome = (0, pack_home_1.resolvePackHome)('manager');
|
|
126
|
+
const managerHome = (0, pack_home_1.resolvePackHome)('manager', { migrateLegacy: false });
|
|
122
127
|
const managerHomeBase = (0, path_1.join)(managerHome.contentRoot, 'learnings');
|
|
123
128
|
const managerCacheBase = managerSecondaryLearningsBase();
|
|
124
129
|
// Derive the display base from the actual cache path relative to ~/.fraim so
|
|
@@ -141,7 +146,7 @@ function sameResolvedPath(a, b) {
|
|
|
141
146
|
return (0, path_1.resolve)(a) === (0, path_1.resolve)(b);
|
|
142
147
|
}
|
|
143
148
|
function portablePersonalBaseIsLive(globalBase) {
|
|
144
|
-
return sameResolvedPath((0, path_1.join)((0, pack_home_1.resolvePackHome)('manager').contentRoot, 'learnings'), globalBase)
|
|
149
|
+
return sameResolvedPath((0, path_1.join)((0, pack_home_1.resolvePackHome)('manager', { migrateLegacy: false }).contentRoot, 'learnings'), globalBase)
|
|
145
150
|
|| !sameResolvedPath(globalBase, (0, project_fraim_paths_1.getUserFraimLearningsDir)());
|
|
146
151
|
}
|
|
147
152
|
function resolvePersonalLearningFile(repoBase, managerCacheBase, managerCacheDisplayBase, globalBase, globalDisplayBase, fileName) {
|
|
@@ -272,16 +277,6 @@ function buildUserIdCandidates(userId) {
|
|
|
272
277
|
candidates.add(trimmed.slice(0, atIndex));
|
|
273
278
|
return Array.from(candidates).filter(isPathSafeUserIdCandidate);
|
|
274
279
|
}
|
|
275
|
-
function countMatchingFilesByPrefix(dirPath, matcher) {
|
|
276
|
-
if (!(0, fs_1.existsSync)(dirPath))
|
|
277
|
-
return 0;
|
|
278
|
-
try {
|
|
279
|
-
return (0, fs_1.readdirSync)(dirPath).filter(matcher).length;
|
|
280
|
-
}
|
|
281
|
-
catch {
|
|
282
|
-
return 0;
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
280
|
function resolveLearningUserId(workspaceRoot, userId, roots) {
|
|
286
281
|
const candidates = buildUserIdCandidates(userId);
|
|
287
282
|
// Issue #754: falling back to the raw `userId` here would route every rejected
|
|
@@ -305,8 +300,7 @@ function resolveLearningUserId(workspaceRoot, userId, roots) {
|
|
|
305
300
|
((0, fs_1.existsSync)((0, path_1.join)(roots.repoLearningsBase, `${candidate}-manager-coaching.md`)) ? 1 : 0) +
|
|
306
301
|
((0, fs_1.existsSync)((0, path_1.join)(roots.repoLearningsBase, `${candidate}-mistake-patterns.md`)) ? 1 : 0) +
|
|
307
302
|
((0, fs_1.existsSync)((0, path_1.join)(roots.repoLearningsBase, `${candidate}-validated-patterns.md`)) ? 1 : 0) +
|
|
308
|
-
|
|
309
|
-
countMatchingFilesByPrefix((0, path_1.join)(workspaceRoot, 'docs', 'retrospectives'), (fileName) => fileName.startsWith(`${candidate}-`) && fileName.endsWith('.md'));
|
|
303
|
+
collectPendingL0SourceFiles(workspaceRoot, candidate, roots).length;
|
|
310
304
|
if (score > bestScore) {
|
|
311
305
|
bestCandidate = candidate;
|
|
312
306
|
bestScore = score;
|
|
@@ -558,28 +552,6 @@ function readFrontmatter(content) {
|
|
|
558
552
|
}
|
|
559
553
|
return frontmatter;
|
|
560
554
|
}
|
|
561
|
-
function isRetrospectiveSynthesized(value) {
|
|
562
|
-
if (value === undefined)
|
|
563
|
-
return false;
|
|
564
|
-
const normalized = value.trim().toLowerCase();
|
|
565
|
-
if (!normalized)
|
|
566
|
-
return false;
|
|
567
|
-
return normalized !== 'false' && normalized !== 'no' && normalized !== '0' && normalized !== 'null';
|
|
568
|
-
}
|
|
569
|
-
function isUnsynthesizedRetrospective(filePath) {
|
|
570
|
-
try {
|
|
571
|
-
const content = (0, fs_1.readFileSync)(filePath, 'utf8');
|
|
572
|
-
const frontmatter = readFrontmatter(content);
|
|
573
|
-
return !isRetrospectiveSynthesized(frontmatter.synthesized);
|
|
574
|
-
}
|
|
575
|
-
catch {
|
|
576
|
-
return false;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
function pendingL0SortKey(fileName) {
|
|
580
|
-
const timestamp = fileName.match(/\d{4}-\d{2}-\d{2}(?:T\d{2}-\d{2}-\d{2})?/);
|
|
581
|
-
return timestamp?.[0] || fileName;
|
|
582
|
-
}
|
|
583
555
|
/**
|
|
584
556
|
* Issue #1857 R11: how many proposals are waiting for the manager's review.
|
|
585
557
|
*
|
|
@@ -608,42 +580,82 @@ function countPendingProposals(roots, resolvedUserId) {
|
|
|
608
580
|
}
|
|
609
581
|
return total;
|
|
610
582
|
}
|
|
611
|
-
|
|
583
|
+
/** Configured owning homes only; discovery never provisions or infers a home. */
|
|
584
|
+
function getLearningInputHomes(workspaceRoot) {
|
|
585
|
+
const homes = [{ root: workspaceRoot, contentRoot: (0, path_1.dirname)((0, project_fraim_paths_1.getWorkspaceLearningsDir)(workspaceRoot)) },
|
|
586
|
+
(0, pack_home_1.resolvePackHome)('manager', { migrateLegacy: false }), (0, pack_home_1.resolvePackHome)('org', { migrateLegacy: false })];
|
|
587
|
+
const seen = new Set();
|
|
588
|
+
return homes.filter(home => {
|
|
589
|
+
const key = (0, learning_source_record_1.normalizeLearningSourcePath)(home.root) + ':' + (0, learning_source_record_1.normalizeLearningSourcePath)(home.contentRoot);
|
|
590
|
+
if (seen.has(key))
|
|
591
|
+
return false;
|
|
592
|
+
seen.add(key);
|
|
593
|
+
return true;
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
/** Guard the physical owner boundary before reading any input bytes. */
|
|
597
|
+
function readLearningSourceFile(filePath, homeRoot, contentRoot) {
|
|
598
|
+
try {
|
|
599
|
+
if (!(0, fs_1.lstatSync)(filePath).isFile() || (0, fs_1.lstatSync)(filePath).isSymbolicLink()) {
|
|
600
|
+
return { status: 'invalid', reason: 'Learning source must be a regular file' };
|
|
601
|
+
}
|
|
602
|
+
const rel = (0, path_1.relative)((0, fs_1.realpathSync)(homeRoot), (0, fs_1.realpathSync)(filePath));
|
|
603
|
+
if (rel === '..' || rel.startsWith('..' + path_1.sep) || (0, path_1.isAbsolute)(rel)) {
|
|
604
|
+
return { status: 'invalid', reason: 'Learning source escapes configured home' };
|
|
605
|
+
}
|
|
606
|
+
const content = (0, fs_1.readFileSync)(filePath, 'utf8');
|
|
607
|
+
return { ...(0, learning_source_record_1.parseLearningSourceRecord)(content, filePath, homeRoot, contentRoot), content };
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
return { status: 'invalid', reason: 'Learning source unavailable' };
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
function collectPendingL0SourceFiles(workspaceRoot, resolvedUserId, _roots) {
|
|
614
|
+
if (!resolvedUserId)
|
|
615
|
+
return [];
|
|
612
616
|
const sources = [];
|
|
613
|
-
const
|
|
614
|
-
|
|
617
|
+
const homes = getLearningInputHomes(workspaceRoot);
|
|
618
|
+
let reservations;
|
|
619
|
+
try {
|
|
620
|
+
reservations = new Set((0, completion_state_1.readSourceReservations)(homes.flatMap(home => {
|
|
621
|
+
const roots = home.root === workspaceRoot ? [home.root, (0, path_1.dirname)(home.contentRoot)] : [home.root];
|
|
622
|
+
return roots.map(root => (0, artifact_paths_1.resolveRunJournalDirectory)(root));
|
|
623
|
+
})));
|
|
624
|
+
}
|
|
625
|
+
catch {
|
|
626
|
+
return [];
|
|
627
|
+
}
|
|
628
|
+
for (const home of homes) {
|
|
629
|
+
let paths;
|
|
615
630
|
try {
|
|
616
|
-
|
|
617
|
-
if (!fileName.startsWith(`${resolvedUserId}-`))
|
|
618
|
-
continue;
|
|
619
|
-
sources.push({
|
|
620
|
-
kind: 'coaching-moment',
|
|
621
|
-
displayPath: `${REPO_LEARNINGS_REL}/raw/${fileName}`,
|
|
622
|
-
sortKey: pendingL0SortKey(fileName)
|
|
623
|
-
});
|
|
624
|
-
}
|
|
631
|
+
paths = (0, artifact_paths_1.resolveLearningPaths)(home.root, home.contentRoot);
|
|
625
632
|
}
|
|
626
633
|
catch {
|
|
627
|
-
|
|
634
|
+
continue;
|
|
628
635
|
}
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
try {
|
|
633
|
-
for (const fileName of (0, fs_1.readdirSync)(retrospectivesPath)) {
|
|
634
|
-
if (!fileName.startsWith(`${resolvedUserId}-`) || !fileName.endsWith('.md'))
|
|
635
|
-
continue;
|
|
636
|
-
if (!isUnsynthesizedRetrospective((0, path_1.join)(retrospectivesPath, fileName)))
|
|
636
|
+
for (const directory of [paths.raw, paths.retrospectives]) {
|
|
637
|
+
try {
|
|
638
|
+
if ((0, fs_1.lstatSync)(directory).isSymbolicLink())
|
|
637
639
|
continue;
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
640
|
+
for (const name of (0, fs_1.readdirSync)(directory)) {
|
|
641
|
+
if (!name.endsWith('.md'))
|
|
642
|
+
continue;
|
|
643
|
+
const file = (0, path_1.join)(directory, name);
|
|
644
|
+
if (reservations.has((0, learning_source_record_1.normalizeLearningSourcePath)(file)))
|
|
645
|
+
continue;
|
|
646
|
+
const result = readLearningSourceFile(file, home.root, home.contentRoot);
|
|
647
|
+
if (result.status !== 'valid' || result.record.author !== resolvedUserId
|
|
648
|
+
|| result.record.state !== 'pending' || result.record.synthesized
|
|
649
|
+
|| isTruthyFlag(readFrontmatter(result.content).dismissed))
|
|
650
|
+
continue;
|
|
651
|
+
sources.push({
|
|
652
|
+
kind: result.record.kind === 'raw' ? 'coaching-moment' : 'retrospective',
|
|
653
|
+
displayPath: (0, path_1.resolve)(file).replace(/\\/g, '/'),
|
|
654
|
+
sortKey: result.record.date,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
643
657
|
}
|
|
644
|
-
|
|
645
|
-
catch {
|
|
646
|
-
// Ignore read failures.
|
|
658
|
+
catch { /* Missing or unavailable homes do not block startup. */ }
|
|
647
659
|
}
|
|
648
660
|
}
|
|
649
661
|
return sources.sort((a, b) => b.sortKey.localeCompare(a.sortKey) || a.displayPath.localeCompare(b.displayPath));
|
|
@@ -656,7 +668,7 @@ function collectPendingL0SourceFiles(workspaceRoot, resolvedUserId, roots) {
|
|
|
656
668
|
* it cannot drift from the read path again.
|
|
657
669
|
*/
|
|
658
670
|
function orgCacheDisplayPath(fileName) {
|
|
659
|
-
const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings', fileName);
|
|
671
|
+
const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org', { migrateLegacy: false }).contentRoot, 'learnings', fileName);
|
|
660
672
|
return userFraimDisplayPathFor(cachePath);
|
|
661
673
|
}
|
|
662
674
|
function userFraimDisplayPathFor(absolutePath) {
|
|
@@ -679,7 +691,7 @@ function resolveOrgLearningFile(repoLearningsBase, fileName) {
|
|
|
679
691
|
if ((0, fs_1.existsSync)(repoPath)) {
|
|
680
692
|
return { present: true, path: repoPath, displayPath: `${REPO_LEARNINGS_REL}/${fileName}` };
|
|
681
693
|
}
|
|
682
|
-
const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings', fileName);
|
|
694
|
+
const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org', { migrateLegacy: false }).contentRoot, 'learnings', fileName);
|
|
683
695
|
if ((0, fs_1.existsSync)(cachePath)) {
|
|
684
696
|
return { present: true, path: cachePath, displayPath: orgCacheDisplayPath(fileName) };
|
|
685
697
|
}
|
|
@@ -692,7 +704,7 @@ function resolveOrgLearningFile(repoLearningsBase, fileName) {
|
|
|
692
704
|
*/
|
|
693
705
|
function resolveOrgLearningFileTiers(repoLearningsBase, fileName) {
|
|
694
706
|
const tiers = [];
|
|
695
|
-
const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings', fileName);
|
|
707
|
+
const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org', { migrateLegacy: false }).contentRoot, 'learnings', fileName);
|
|
696
708
|
if ((0, fs_1.existsSync)(cachePath)) {
|
|
697
709
|
tiers.push({
|
|
698
710
|
level: 'org',
|
|
@@ -847,7 +859,7 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
|
|
|
847
859
|
});
|
|
848
860
|
const l2Files = resolved.l2.map(toListed);
|
|
849
861
|
const l1Files = resolved.l1.map(toListed);
|
|
850
|
-
const pendingL0Sources = collectPendingL0SourceFiles(workspaceRoot,
|
|
862
|
+
const pendingL0Sources = collectPendingL0SourceFiles(workspaceRoot, userId.trim(), roots);
|
|
851
863
|
const l0CoachingCount = pendingL0Sources.filter(source => source.kind === 'coaching-moment').length;
|
|
852
864
|
const l0RetroCount = pendingL0Sources.filter(source => source.kind === 'retrospective').length;
|
|
853
865
|
const hasL2 = l2Files.length > 0;
|
|
@@ -880,10 +892,10 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
|
|
|
880
892
|
const totalL0 = pendingL0Sources.length;
|
|
881
893
|
section += '### L0 - Your unprocessed signals\n';
|
|
882
894
|
if (l0CoachingCount > 0) {
|
|
883
|
-
section += `${l0CoachingCount} coaching moment${l0CoachingCount !== 1 ? 's' : ''}
|
|
895
|
+
section += `${l0CoachingCount} coaching moment${l0CoachingCount !== 1 ? 's' : ''} across configured learning homes\n`;
|
|
884
896
|
}
|
|
885
897
|
if (l0RetroCount > 0) {
|
|
886
|
-
section += `${l0RetroCount} retrospective${l0RetroCount !== 1 ? 's' : ''}
|
|
898
|
+
section += `${l0RetroCount} retrospective${l0RetroCount !== 1 ? 's' : ''} across configured learning homes awaiting synthesis\n`;
|
|
887
899
|
}
|
|
888
900
|
section += 'Pending L0 Source Files:\n';
|
|
889
901
|
for (const source of pendingL0Sources) {
|
|
@@ -1123,7 +1135,7 @@ function resolveOrgContextFile(workspaceRoot, relativePath, orgCacheEligible = t
|
|
|
1123
1135
|
};
|
|
1124
1136
|
}
|
|
1125
1137
|
if (orgCacheEligible) {
|
|
1126
|
-
const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, relativePath);
|
|
1138
|
+
const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org', { migrateLegacy: false }).contentRoot, relativePath);
|
|
1127
1139
|
if ((0, fs_1.existsSync)(cachePath)) {
|
|
1128
1140
|
return {
|
|
1129
1141
|
present: true,
|
|
@@ -1132,7 +1144,7 @@ function resolveOrgContextFile(workspaceRoot, relativePath, orgCacheEligible = t
|
|
|
1132
1144
|
}
|
|
1133
1145
|
}
|
|
1134
1146
|
if (!orgCacheEligible) {
|
|
1135
|
-
const managerHome = (0, pack_home_1.resolvePackHome)('manager');
|
|
1147
|
+
const managerHome = (0, pack_home_1.resolvePackHome)('manager', { migrateLegacy: false });
|
|
1136
1148
|
const managerCachePath = (0, path_1.join)(managerHome.contentRoot, relativePath);
|
|
1137
1149
|
if ((0, fs_1.existsSync)(managerCachePath)) {
|
|
1138
1150
|
// Derive the display path relative to ~/.fraim. For the standard
|
|
@@ -1312,7 +1324,7 @@ function resolveTeamContextFile(workspaceRoot, key, options = {}) {
|
|
|
1312
1324
|
// destination (R5.1), so writePath stays empty and the caller must route
|
|
1313
1325
|
// edits through the org backend's propose-and-approve flow.
|
|
1314
1326
|
if (key === 'org' || key === 'orgRules') {
|
|
1315
|
-
const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org', options).contentRoot, relativePath);
|
|
1327
|
+
const cachePath = (0, path_1.join)((0, pack_home_1.resolvePackHome)('org', { migrateLegacy: false, ...options }).contentRoot, relativePath);
|
|
1316
1328
|
if ((0, fs_1.existsSync)(cachePath)) {
|
|
1317
1329
|
const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
|
|
1318
1330
|
const orgDisplayPath = cachePath.startsWith(fraimDir)
|
|
@@ -1329,7 +1341,7 @@ function resolveTeamContextFile(workspaceRoot, key, options = {}) {
|
|
|
1329
1341
|
}
|
|
1330
1342
|
}
|
|
1331
1343
|
if (key === 'manager' || key === 'managerRules') {
|
|
1332
|
-
const managerHome = (0, pack_home_1.resolvePackHome)('manager', options);
|
|
1344
|
+
const managerHome = (0, pack_home_1.resolvePackHome)('manager', { migrateLegacy: false, ...options });
|
|
1333
1345
|
const managerCachePath = (0, path_1.join)(managerHome.contentRoot, relativePath);
|
|
1334
1346
|
if ((0, fs_1.existsSync)(managerCachePath)) {
|
|
1335
1347
|
const fraimDir = (0, project_fraim_paths_1.getUserFraimDirPath)();
|
|
@@ -1371,7 +1383,7 @@ function resolveTeamContextFile(workspaceRoot, key, options = {}) {
|
|
|
1371
1383
|
function orgBrandCandidateDirs(workspaceRoot) {
|
|
1372
1384
|
return [
|
|
1373
1385
|
(0, path_1.join)((0, project_fraim_paths_1.getWorkspaceFraimDir)(workspaceRoot), 'personalized-employee', 'context'),
|
|
1374
|
-
(0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'context'),
|
|
1386
|
+
(0, path_1.join)((0, pack_home_1.resolvePackHome)('org', { migrateLegacy: false }).contentRoot, 'context'),
|
|
1375
1387
|
(0, path_1.join)((0, project_fraim_paths_1.getUserFraimDirPath)(), 'personalized-employee', 'context'),
|
|
1376
1388
|
];
|
|
1377
1389
|
}
|
|
@@ -1520,24 +1532,8 @@ function countPreservedLearnings(workspaceRoot, userId) {
|
|
|
1520
1532
|
const domainCoach = countPersonalFamilyByLevel(`${resolvedUserId}-${domain}-manager-coaching.md`);
|
|
1521
1533
|
reverseMentoring += domainCoach.manager + domainCoach.project;
|
|
1522
1534
|
}
|
|
1523
|
-
//
|
|
1524
|
-
|
|
1525
|
-
const rawDir = (0, path_1.join)(roots.repoLearningsBase, 'raw');
|
|
1526
|
-
if ((0, fs_1.existsSync)(rawDir)) {
|
|
1527
|
-
try {
|
|
1528
|
-
for (const fileName of (0, fs_1.readdirSync)(rawDir)) {
|
|
1529
|
-
if (!fileName.endsWith('.md'))
|
|
1530
|
-
continue;
|
|
1531
|
-
const fm = readFrontmatter((0, fs_1.readFileSync)((0, path_1.join)(rawDir, fileName), 'utf8'));
|
|
1532
|
-
if (isTruthyFlag(fm.dismissed))
|
|
1533
|
-
continue;
|
|
1534
|
-
rawSignals++;
|
|
1535
|
-
}
|
|
1536
|
-
}
|
|
1537
|
-
catch {
|
|
1538
|
-
// ignore unreadable raw dir
|
|
1539
|
-
}
|
|
1540
|
-
}
|
|
1535
|
+
// Pending counts and Brain/context use exactly the same typed input selection.
|
|
1536
|
+
const rawSignals = collectPendingL0SourceFiles(workspaceRoot, userId.trim(), roots).length;
|
|
1541
1537
|
return {
|
|
1542
1538
|
organization,
|
|
1543
1539
|
manager,
|
|
@@ -1683,7 +1679,7 @@ function resolveBrainUsageProjector() {
|
|
|
1683
1679
|
function collectBrainLearningDots(workspaceRoot, userId) {
|
|
1684
1680
|
const roots = getLearningRoots(workspaceRoot);
|
|
1685
1681
|
const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
|
|
1686
|
-
const pendingSignals = collectPendingL0SourceFiles(workspaceRoot,
|
|
1682
|
+
const pendingSignals = collectPendingL0SourceFiles(workspaceRoot, userId.trim(), roots).map((s) => ({
|
|
1687
1683
|
type: s.kind,
|
|
1688
1684
|
typeLabel: PENDING_KIND_LABEL[s.kind],
|
|
1689
1685
|
fileName: s.displayPath.split('/').pop() || s.displayPath,
|
|
@@ -1975,8 +1971,8 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
|
|
|
1975
1971
|
// #806: domain-scoped org learnings — union from both locations.
|
|
1976
1972
|
const allDomainFiles = [
|
|
1977
1973
|
...listDomainLearningFiles(roots.repoLearningsBase, 'org', fileType).map((d) => ({ ...d, base: roots.repoLearningsBase })),
|
|
1978
|
-
...listDomainLearningFiles((0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings'), 'org', fileType)
|
|
1979
|
-
.map((d) => ({ ...d, base: (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings') })),
|
|
1974
|
+
...listDomainLearningFiles((0, path_1.join)((0, pack_home_1.resolvePackHome)('org', { migrateLegacy: false }).contentRoot, 'learnings'), 'org', fileType)
|
|
1975
|
+
.map((d) => ({ ...d, base: (0, path_1.join)((0, pack_home_1.resolvePackHome)('org', { migrateLegacy: false }).contentRoot, 'learnings') })),
|
|
1980
1976
|
];
|
|
1981
1977
|
const seenDomainFile = new Set();
|
|
1982
1978
|
for (const { fileName, domain, base } of allDomainFiles) {
|
|
@@ -2065,7 +2061,7 @@ function resolveLearningFilePath(workspaceRoot, userId, ref) {
|
|
|
2065
2061
|
// Write org entries to the org pack home — same location share-with-others uses.
|
|
2066
2062
|
// The Hub read path already merges pack home + repo-local, so edits land where
|
|
2067
2063
|
// the agent reads them without needing a repo commit.
|
|
2068
|
-
return (0, path_1.join)((0, pack_home_1.resolvePackHome)('org').contentRoot, 'learnings', learningFileName('org', fileType, ref.domain));
|
|
2064
|
+
return (0, path_1.join)((0, pack_home_1.resolvePackHome)('org', { migrateLegacy: false }).contentRoot, 'learnings', learningFileName('org', fileType, ref.domain));
|
|
2069
2065
|
}
|
|
2070
2066
|
const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
|
|
2071
2067
|
// Issue #1070: for the 'machine' level use the configured manager home.
|
|
@@ -0,0 +1,122 @@
|
|
|
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.normalizeLearningSourcePath = normalizeLearningSourcePath;
|
|
7
|
+
exports.parseLearningSourceRecord = parseLearningSourceRecord;
|
|
8
|
+
/** Metadata and configured location contract for future learning inputs. */
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const artifact_paths_1 = require("../../registry/scripts/artifact-paths");
|
|
11
|
+
function normalizeLearningSourcePath(value) {
|
|
12
|
+
const normalized = node_path_1.default.resolve(value).replace(/\\/g, '/');
|
|
13
|
+
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
|
|
14
|
+
}
|
|
15
|
+
function validDate(value) {
|
|
16
|
+
if (!/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z)?$/.test(value))
|
|
17
|
+
return false;
|
|
18
|
+
const date = new Date(value);
|
|
19
|
+
return Number.isFinite(date.getTime()) && date.toISOString().slice(0, 10) === value.slice(0, 10);
|
|
20
|
+
}
|
|
21
|
+
function safePart(value) {
|
|
22
|
+
return Boolean(value) && value !== '.' && value !== '..' && !/[\\/:*?"<>|\x00-\x1f]/.test(value)
|
|
23
|
+
&& value.trim() === value && !/[. ]$/.test(value);
|
|
24
|
+
}
|
|
25
|
+
/** Explicit paths keep parsing pure; otherwise resolve the owning configuration. Callers guard physical source containment. */
|
|
26
|
+
function parseLearningSourceRecord(content, filePath, homeRoot, contentRoot, configuredPaths) {
|
|
27
|
+
const invalid = (reason) => ({ status: 'invalid', reason });
|
|
28
|
+
const normalized = content.replace(/^\uFEFF/, '');
|
|
29
|
+
const fm = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(normalized);
|
|
30
|
+
if (!fm) {
|
|
31
|
+
// Diagnose interrupted new records only in the leading metadata block.
|
|
32
|
+
// Never search document bodies for a marker that could be an example.
|
|
33
|
+
if (/^---\r?\n/.test(normalized)) {
|
|
34
|
+
for (const line of normalized.split(/\r?\n/).slice(1)) {
|
|
35
|
+
if (!line.trim())
|
|
36
|
+
continue;
|
|
37
|
+
if (!/^[ \t]*["']?[A-Za-z][A-Za-z0-9]*["']?[ \t]*:/.test(line))
|
|
38
|
+
break;
|
|
39
|
+
if (/^[ \t]*["']?artifactKind["']?[ \t]*:[ \t]*["']?learning-(?:raw|retrospective)["']?[ \t]*(?:#.*)?$/.test(line)) {
|
|
40
|
+
return invalid('Unclosed typed learning frontmatter');
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return { status: 'unsupported', reason: 'No typed learning frontmatter' };
|
|
45
|
+
}
|
|
46
|
+
const fields = {};
|
|
47
|
+
const duplicates = new Set();
|
|
48
|
+
let malformedField = false;
|
|
49
|
+
for (const line of fm[1].split(/\r?\n/)) {
|
|
50
|
+
const match = /^([A-Za-z][A-Za-z0-9]*):[ \t]*(.*)$/.exec(line);
|
|
51
|
+
if (!match) {
|
|
52
|
+
if (/^\s*["']?(artifactKind|author|date|job|runId|usageSourceRef|synthesized)["']?\s*:/.test(line))
|
|
53
|
+
malformedField = true;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (Object.prototype.hasOwnProperty.call(fields, match[1]))
|
|
57
|
+
duplicates.add(match[1]);
|
|
58
|
+
let value = match[2].trim();
|
|
59
|
+
if (!value.startsWith('"') && !value.startsWith("'"))
|
|
60
|
+
value = value.replace(/(?:^|\s+)#.*$/, '').trim();
|
|
61
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
62
|
+
value = value.slice(1, -1);
|
|
63
|
+
}
|
|
64
|
+
fields[match[1]] = value;
|
|
65
|
+
}
|
|
66
|
+
if (!fields.artifactKind)
|
|
67
|
+
return malformedField || Object.prototype.hasOwnProperty.call(fields, 'artifactKind')
|
|
68
|
+
? invalid('Unsupported metadata key spelling')
|
|
69
|
+
: { status: 'unsupported', reason: 'No typed learning artifactKind' };
|
|
70
|
+
if (fields.artifactKind !== 'learning-raw' && fields.artifactKind !== 'learning-retrospective') {
|
|
71
|
+
return fields.artifactKind.startsWith('learning')
|
|
72
|
+
? invalid('Invalid learning artifactKind')
|
|
73
|
+
: { status: 'unsupported', reason: 'Unsupported artifactKind' };
|
|
74
|
+
}
|
|
75
|
+
if (malformedField)
|
|
76
|
+
return invalid('Unsupported metadata key spelling');
|
|
77
|
+
if (duplicates.size)
|
|
78
|
+
return invalid(`Duplicate metadata: ${[...duplicates].join(', ')}`);
|
|
79
|
+
for (const field of ['author', 'date', 'job', 'runId', 'usageSourceRef', 'synthesized']) {
|
|
80
|
+
if (!Object.prototype.hasOwnProperty.call(fields, field))
|
|
81
|
+
return invalid(`Missing metadata: ${field}`);
|
|
82
|
+
}
|
|
83
|
+
if (![fields.author, fields.job, fields.runId].every(safePart))
|
|
84
|
+
return invalid('Unsafe or empty author, job or runId');
|
|
85
|
+
if (!validDate(fields.date) || (fields.synthesized && !validDate(fields.synthesized)))
|
|
86
|
+
return invalid('Invalid date or synthesized date');
|
|
87
|
+
if (!node_path_1.default.isAbsolute(homeRoot) || !node_path_1.default.isAbsolute(contentRoot) || !node_path_1.default.isAbsolute(filePath))
|
|
88
|
+
return invalid('Absolute configured root and source path required');
|
|
89
|
+
const kind = fields.artifactKind === 'learning-raw' ? 'raw' : 'retrospective';
|
|
90
|
+
const basename = node_path_1.default.basename(filePath);
|
|
91
|
+
const prefix = `${fields.author}-${fields.runId}-`;
|
|
92
|
+
const nameKey = (value) => process.platform === 'win32' ? value.toLowerCase() : value;
|
|
93
|
+
if (!nameKey(basename).startsWith(nameKey(prefix)) || !basename.endsWith('.md') || basename.length <= prefix.length + 3
|
|
94
|
+
|| (kind === 'retrospective' && nameKey(basename) !== nameKey(`${prefix}${fields.job}-retrospective.md`))) {
|
|
95
|
+
return invalid('Filename does not match recorded author, runId and kind');
|
|
96
|
+
}
|
|
97
|
+
let paths;
|
|
98
|
+
try {
|
|
99
|
+
paths = configuredPaths ?? (0, artifact_paths_1.resolveLearningPaths)(homeRoot, contentRoot);
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
return invalid(error instanceof Error ? error.message : 'Invalid artifact configuration');
|
|
103
|
+
}
|
|
104
|
+
const pendingPath = node_path_1.default.resolve(kind === 'raw' ? paths.raw : paths.retrospectives, basename).replace(/\\/g, '/');
|
|
105
|
+
const archivePath = node_path_1.default.resolve(kind === 'raw' ? paths.archiveRaw : paths.archiveRetrospectives, basename).replace(/\\/g, '/');
|
|
106
|
+
const current = normalizeLearningSourcePath(filePath);
|
|
107
|
+
const state = current === normalizeLearningSourcePath(pendingPath) ? 'pending'
|
|
108
|
+
: current === normalizeLearningSourcePath(archivePath) ? 'archived' : null;
|
|
109
|
+
if (!state)
|
|
110
|
+
return invalid('Source is outside its configured learning location');
|
|
111
|
+
if (state === 'archived' && !fields.synthesized)
|
|
112
|
+
return invalid('Archive has no successful processing date');
|
|
113
|
+
if (!node_path_1.default.isAbsolute(fields.usageSourceRef) || /[\x00-\x1f]/.test(fields.usageSourceRef)
|
|
114
|
+
|| fields.usageSourceRef.includes('\\')
|
|
115
|
+
|| fields.usageSourceRef !== node_path_1.default.resolve(fields.usageSourceRef).replace(/\\/g, '/')
|
|
116
|
+
|| normalizeLearningSourcePath(fields.usageSourceRef) !== normalizeLearningSourcePath(pendingPath)) {
|
|
117
|
+
return invalid('usageSourceRef must be the normalized absolute original pending path');
|
|
118
|
+
}
|
|
119
|
+
return { status: 'valid', record: { artifactKind: fields.artifactKind, kind, author: fields.author,
|
|
120
|
+
date: fields.date, job: fields.job, runId: fields.runId, usageSourceRef: fields.usageSourceRef,
|
|
121
|
+
synthesized: fields.synthesized, pendingPath, archivePath, state } };
|
|
122
|
+
}
|