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.
Files changed (30) hide show
  1. package/dist/registry/scripts/artifact-paths.js +108 -0
  2. package/dist/registry/scripts/learning/completion-state.js +205 -5
  3. package/dist/registry/scripts/learning-usage/local-runtime.js +217 -0
  4. package/dist/src/cli/commands/init-project.js +2 -12
  5. package/dist/src/cli/commands/sync.js +36 -162
  6. package/dist/src/cli/setup/ide-invocation-surfaces.js +3 -3
  7. package/dist/src/cli/setup/user-level-sync.js +3 -3
  8. package/dist/src/cli/utils/agent-adapters.js +12 -12
  9. package/dist/src/cli/utils/fraim-gitignore.js +28 -3
  10. package/dist/src/cli/utils/pack-home.js +10 -0
  11. package/dist/src/cli/utils/project-catalog-cleanup.js +62 -0
  12. package/dist/src/cli/utils/remote-sync.js +26 -2
  13. package/dist/src/config/persona-hiring.js +7 -1
  14. package/dist/src/core/ai-mentor.js +42 -11
  15. package/dist/src/core/config-loader.js +4 -0
  16. package/dist/src/core/fraim-config-schema.generated.js +22 -0
  17. package/dist/src/core/handoff-contracts.js +8 -3
  18. package/dist/src/core/utils/local-registry-resolver.js +33 -57
  19. package/dist/src/fraim/db-service.js +127 -118
  20. package/dist/src/local-mcp-server/artifact-retention-cleanup.js +79 -6
  21. package/dist/src/local-mcp-server/learning-context-builder.js +101 -105
  22. package/dist/src/local-mcp-server/learning-source-record.js +122 -0
  23. package/dist/src/local-mcp-server/learning-usage-attestation.js +66 -56
  24. package/dist/src/local-mcp-server/learning-usage-command.js +5 -4
  25. package/dist/src/local-mcp-server/stdio-server.js +10 -7
  26. package/dist/src/local-mcp-server/usage-collector.js +2 -0
  27. package/dist/src/mcp/tool-schemas.js +5 -1
  28. package/dist/src/services/mcp-service.js +6 -4
  29. package/dist/src/services/usage-analytics-service.js +1 -0
  30. 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 archiveDir = node_path_1.default.join(content, 'learnings', 'archive');
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') || !userScoped(filePath, activeUserEmail))
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
- add('learning_archive', filePath, true, 'Archived learning source for active user.', fileMtime(filePath));
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 = node_path_1.default.join(workspaceRoot, 'docs', 'evidence');
231
- const evidenceGitDates = loadGitLastChangeDates(workspaceRoot, 'docs/evidence');
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 retentionValue = retention.values[candidate.category];
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
- countMatchingFilesByPrefix((0, path_1.join)(roots.repoLearningsBase, 'raw'), (fileName) => fileName.startsWith(`${candidate}-`)) +
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
- function collectPendingL0SourceFiles(workspaceRoot, resolvedUserId, roots) {
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 rawPath = (0, path_1.join)(roots.repoLearningsBase, 'raw');
614
- if ((0, fs_1.existsSync)(rawPath)) {
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
- for (const fileName of (0, fs_1.readdirSync)(rawPath)) {
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
- // Ignore read failures.
634
+ continue;
628
635
  }
629
- }
630
- const retrospectivesPath = (0, path_1.join)(workspaceRoot, 'docs', 'retrospectives');
631
- if ((0, fs_1.existsSync)(retrospectivesPath)) {
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
- sources.push({
639
- kind: 'retrospective',
640
- displayPath: `docs/retrospectives/${fileName}`,
641
- sortKey: pendingL0SortKey(fileName)
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, resolvedUserId, roots);
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' : ''} in \`${REPO_LEARNINGS_REL}/raw/${resolvedUserId}-*\`\n`;
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' : ''} in \`docs/retrospectives/${resolvedUserId}-*\` with unsynthesized or missing \`synthesized\` frontmatter\n`;
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
- // L0 raw signals still awaiting synthesis (not dismissed).
1524
- let rawSignals = 0;
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, resolvedUserId, roots).map((s) => ({
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
+ }