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.
Files changed (32) hide show
  1. package/dist/registry/scripts/learning/completion-state.js +123 -4
  2. package/dist/registry/scripts/learning/corpus-audit.js +205 -0
  3. package/dist/registry/scripts/learning/entry-authoring.js +181 -0
  4. package/dist/registry/scripts/learning/entry-format.js +10 -1
  5. package/dist/registry/scripts/learning/outcome-assessment.js +162 -0
  6. package/dist/registry/scripts/learning/outcome-types.js +92 -0
  7. package/dist/registry/scripts/learning/owner-identity.js +148 -0
  8. package/dist/registry/scripts/learning/proposal-queue.js +81 -0
  9. package/dist/registry/scripts/learning/run-application.js +1003 -0
  10. package/dist/registry/scripts/learning/run-publication.js +364 -0
  11. package/dist/registry/scripts/learning/run-record.js +895 -0
  12. package/dist/registry/scripts/learning-usage/learning-source-record.js +131 -0
  13. package/dist/src/cli/commands/learning.js +64 -0
  14. package/dist/src/cli/doctor/checks/scripts-checks.js +53 -1
  15. package/dist/src/cli/fraim.js +2 -0
  16. package/dist/src/cli/learning/inventory.js +285 -0
  17. package/dist/src/cli/learning/queue-operations.js +199 -0
  18. package/dist/src/cli/learning/runtime.js +82 -0
  19. package/dist/src/cli/learning/usage.js +277 -0
  20. package/dist/src/core/fraim-config-schema.generated.js +0 -4
  21. package/dist/src/local-mcp-server/learning-context-builder.js +141 -477
  22. package/dist/src/local-mcp-server/learning-entry-format.js +25 -0
  23. package/dist/src/local-mcp-server/learning-usage-analysis.js +12 -410
  24. package/dist/src/local-mcp-server/learning-usage-eligibility.js +8 -14
  25. package/dist/src/local-mcp-server/learning-usage-projection.js +5 -22
  26. package/dist/src/local-mcp-server/learning-usage-store.js +93 -393
  27. package/dist/src/local-mcp-server/learning-usage-verify.js +9 -40
  28. package/dist/src/local-mcp-server/stdio-server.js +0 -48
  29. package/package.json +11 -1
  30. package/dist/src/local-mcp-server/learning-firing-parser.js +0 -247
  31. package/dist/src/local-mcp-server/learning-usage-attestation.js +0 -238
  32. package/dist/src/local-mcp-server/learning-usage-command.js +0 -518
@@ -1,17 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
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;
4
- exports.learningEntryHeadingRegex = learningEntryHeadingRegex;
5
- exports.learningEntryIdLineRegex = learningEntryIdLineRegex;
6
- exports.generateLearningEntryId = generateLearningEntryId;
3
+ exports.CATEGORY_TO_FILETYPE = exports.BRAIN_LEARNING_FILETYPE_TO_REGION = exports.TEAM_CONTEXT_RELATIVE_PATHS = exports.generateLearningEntryId = exports.learningEntryIdLineRegex = exports.learningEntryHeadingRegex = exports.LEARNING_ENTRY_ID_REGEX = exports.LEARNING_PRIORITIES = exports.DEFAULT_THRESHOLD = void 0;
7
4
  exports.getScoreThreshold = getScoreThreshold;
8
5
  exports.computeEffectiveScore = computeEffectiveScore;
9
6
  exports.getLearningInputHomes = getLearningInputHomes;
10
7
  exports.readLearningSourceFile = readLearningSourceFile;
11
8
  exports.mergeLearningEntriesAcrossTiers = mergeLearningEntriesAcrossTiers;
12
9
  exports.buildLearningContextSection = buildLearningContextSection;
13
- exports.collectOfferedLearningEntries = collectOfferedLearningEntries;
14
- exports.collectOfferedRuleFiles = collectOfferedRuleFiles;
15
10
  exports.buildTeamContextSection = buildTeamContextSection;
16
11
  exports.resolveTeamContextFiles = resolveTeamContextFiles;
17
12
  exports.isTeamContextKey = isTeamContextKey;
@@ -33,10 +28,11 @@ const artifact_paths_1 = require("../../registry/scripts/artifact-paths");
33
28
  * workspace root on the user's machine.
34
29
  */
35
30
  const fs_1 = require("fs");
36
- const crypto_1 = require("crypto");
37
31
  const path_1 = require("path");
38
32
  const learning_source_record_1 = require("./learning-source-record");
39
33
  const completion_state_1 = require("../../registry/scripts/learning/completion-state");
34
+ const outcome_types_1 = require("../../registry/scripts/learning/outcome-types");
35
+ const owner_identity_1 = require("../../registry/scripts/learning/owner-identity");
40
36
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
41
37
  const brand_store_1 = require("../core/brand-store");
42
38
  // Issue #1043 round 2: org and manager content lives at one home named by
@@ -44,10 +40,6 @@ const brand_store_1 = require("../core/brand-store");
44
40
  // cache, which silently returned nothing once that cache stopped existing.
45
41
  const pack_home_1 = require("../cli/utils/pack-home");
46
42
  const learning_domains_1 = require("../config/learning-domains");
47
- // Key construction only. The store's read/write path is still required lazily
48
- // below, because that side pulls in the retention config and a static cycle
49
- // through this module would be fragile.
50
- const learning_usage_store_1 = require("./learning-usage-store");
51
43
  const REPO_LEARNINGS_REL = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)('personalized-employee/learnings').replace(/\/$/, '');
52
44
  /**
53
45
  * The score at or above which an entry is injected. Exported so a test asserts
@@ -70,41 +62,13 @@ const L0_SLEEP_ON_LEARNINGS_PROMPT_MIN = 5;
70
62
  // from LEARNING_PRIORITIES, and a build-time contract check
71
63
  // (scripts/validate-learning-format-contract.ts, run by `npm run build`) asserts
72
64
  // all three sides agree. Issue #1103 added the `**Id**` line to the same contract.
73
- exports.LEARNING_PRIORITIES = ['P-CRITICAL', 'P-HIGH', 'P-MED', 'P-LOW'];
74
- /** Canonical entry-heading matcher: `##`..`######` + `[P-X]` + optional title. */
75
- function learningEntryHeadingRegex(flags = '') {
76
- return new RegExp(`^#{2,}\\s+\\[(${exports.LEARNING_PRIORITIES.join('|')})\\]\\s*(.*)$`, flags);
77
- }
78
- /**
79
- * Issue #1103 R5: the entry's stable identifier, written as a `**Id**: L-…` line.
80
- *
81
- * A firing record has to survive a later title edit, which a title-derived key
82
- * cannot do — so the identity is persisted on the entry rather than computed at
83
- * read time. Generation is deterministic from (family, original title) only so a
84
- * re-run of the migration never renumbers the corpus; once written, the id never
85
- * changes, including when the title does.
86
- */
87
- /** What `generateLearningEntryId` emits: `L-` plus exactly ten hex characters. */
88
- exports.LEARNING_ENTRY_ID_REGEX = /^L-[0-9a-f]{10}$/;
89
- /**
90
- * What the reader accepts. Deliberately wider than what the generator emits: a
91
- * hand-written or hand-edited id must be honoured rather than silently ignored,
92
- * because ignoring it would leave the entry looking un-stamped and route its
93
- * firings to a title key without saying so.
94
- */
95
- const LEARNING_ENTRY_ID_LINE = /^\*\*Id\*\*:\s*(L-[0-9A-Za-z]{6,24})\s*$/;
96
- function learningEntryIdLineRegex() {
97
- return new RegExp(LEARNING_ENTRY_ID_LINE.source);
98
- }
99
- function generateLearningEntryId(fileType, title) {
100
- // Ten hex characters: short enough for an agent to type into a retrospective
101
- // by hand, wide enough that a collision across one manager's corpus is not a
102
- // practical concern.
103
- const digest = (0, crypto_1.createHash)('sha1')
104
- .update(`${fileType}|${title.trim().replace(/\s+/g, ' ').toLowerCase()}`)
105
- .digest('hex');
106
- return `L-${digest.slice(0, 10)}`;
107
- }
65
+ const learning_entry_format_1 = require("./learning-entry-format");
66
+ var learning_entry_format_2 = require("./learning-entry-format");
67
+ Object.defineProperty(exports, "LEARNING_PRIORITIES", { enumerable: true, get: function () { return learning_entry_format_2.LEARNING_PRIORITIES; } });
68
+ Object.defineProperty(exports, "LEARNING_ENTRY_ID_REGEX", { enumerable: true, get: function () { return learning_entry_format_2.LEARNING_ENTRY_ID_REGEX; } });
69
+ Object.defineProperty(exports, "learningEntryHeadingRegex", { enumerable: true, get: function () { return learning_entry_format_2.learningEntryHeadingRegex; } });
70
+ Object.defineProperty(exports, "learningEntryIdLineRegex", { enumerable: true, get: function () { return learning_entry_format_2.learningEntryIdLineRegex; } });
71
+ Object.defineProperty(exports, "generateLearningEntryId", { enumerable: true, get: function () { return learning_entry_format_2.generateLearningEntryId; } });
108
72
  /**
109
73
  * The manager location that is NOT the portable authoring base.
110
74
  *
@@ -186,7 +150,7 @@ function resolvePersonalLearningFile(repoBase, managerCacheBase, managerCacheDis
186
150
  * before the change, and the same shadowing applied to company families.
187
151
  *
188
152
  * Returning all tiers lets the context block list each present path and lets the
189
- * Hub merge entries with the project entry winning only on a title collision.
153
+ * Hub retain each current occurrence without treating title collisions as authority.
190
154
  */
191
155
  function resolvePersonalLearningFileTiers(repoBase, managerCacheBase, managerCacheDisplayBase, globalBase, globalDisplayBase, fileName) {
192
156
  const tiers = [];
@@ -348,7 +312,7 @@ function getScoreThreshold(workspaceRoot) {
348
312
  * @param now Optional override for "now" (for forward-looking aging-risk
349
313
  * calculations). Defaults to the current wall clock.
350
314
  */
351
- function computeEffectiveScore(severity, lastSeenDate, recurrences, fileType, now = new Date(), lastFiredDate) {
315
+ function computeEffectiveScore(severity, lastSeenDate, recurrences, fileType, now = new Date()) {
352
316
  const baseScore = severity === 'P-CRITICAL' ? 10 : severity === 'P-HIGH' ? 8 : severity === 'P-MED' ? 5 : 3;
353
317
  // Mistake patterns decay faster (90d) — they're tied to environments that change.
354
318
  // Preferences, manager-coaching, and validated-patterns express durable judgment (180d half-life).
@@ -361,55 +325,12 @@ function computeEffectiveScore(severity, lastSeenDate, recurrences, fileType, no
361
325
  catch {
362
326
  // Treat as fresh.
363
327
  }
364
- // Issue #1103 R8: the clock is max(last_seen, last_fired). `Last seen` only
365
- // advances when the same signal recurs, which for a mistake pattern means the
366
- // mistake happened again — so an entry that fires and prevents its mistake
367
- // generates no signal and decays *because* it worked. Reading the newer of the
368
- // two dates fixes that inversion with one changed input: no new term, the same
369
- // half-lives, the same threshold. The newer date is the smaller elapsed time,
370
- // hence min() here. An absent or unparseable firing date contributes nothing,
371
- // so it can never silently resurrect a dormant entry, and an entry with no
372
- // usage record at all scores exactly as it did before.
373
- let daysSinceClock = daysSinceLastSeen;
374
- const lastFiredMs = parseClockDate(lastFiredDate);
375
- if (lastFiredMs > 0 && !isImplausiblyFuture(lastFiredMs, now)) {
376
- const daysSinceFired = Math.max(0, (now.getTime() - lastFiredMs) / (1000 * 60 * 60 * 24));
377
- daysSinceClock = Number.isNaN(daysSinceClock) ? daysSinceFired : Math.min(daysSinceClock, daysSinceFired);
378
- }
379
- const decay = Math.pow(0.5, daysSinceClock / halfLife);
328
+ // Selection uses recorded learning experience, independently of optional usage history.
329
+ const decay = Math.pow(0.5, daysSinceLastSeen / halfLife);
380
330
  const recurrenceBoost = Math.log2(Math.max(1, recurrences) + 1);
381
331
  return baseScore * decay * recurrenceBoost;
382
332
  }
383
- /** Clock skew tolerance for a firing date, in milliseconds. */
384
- const FUTURE_FIRING_TOLERANCE_MS = 24 * 60 * 60 * 1000;
385
- /**
386
- * Is this firing date far enough ahead of now to be untrustworthy?
387
- *
388
- * `lastFiredAt` is stamped by whichever machine ran the job, and the usage store
389
- * lives in the manager home, which is commonly a folder synced across machines.
390
- * A machine running fast therefore writes a date another machine reads as the
391
- * future, and elapsed time clamps to zero, so decay stops and the entry stays at
392
- * full score indefinitely. That is the opposite of a decay model.
393
- *
394
- * A day of tolerance absorbs ordinary skew, where treating the firing as "just
395
- * now" is right anyway. Beyond that the date is not believable, so it is ignored
396
- * exactly as an unparseable one is, leaving the recurrence date in charge.
397
- */
398
- function isImplausiblyFuture(timestampMs, now) {
399
- return timestampMs - now.getTime() > FUTURE_FIRING_TOLERANCE_MS;
400
- }
401
- /**
402
- * Parse one side of the decay clock. An absent or unparseable date contributes
403
- * nothing, so a malformed `last fired` can never silently resurrect a dormant
404
- * entry: it just leaves the recurrence date in charge.
405
- */
406
- function parseClockDate(value) {
407
- if (!value)
408
- return 0;
409
- const parsed = new Date(value).getTime();
410
- return Number.isNaN(parsed) ? 0 : parsed;
411
- }
412
- function scanMistakePatternFile(filePath, threshold, fileType = 'mistake-patterns', usageClock) {
333
+ function scanMistakePatternFile(filePath, threshold, fileType = 'mistake-patterns') {
413
334
  const empty = { active: 0, dormant: 0, agingRisk: 0 };
414
335
  if (!(0, fs_1.existsSync)(filePath))
415
336
  return empty;
@@ -429,20 +350,17 @@ function scanMistakePatternFile(filePath, threshold, fileType = 'mistake-pattern
429
350
  let scanned = 0;
430
351
  let inEntry = false;
431
352
  let severity = null;
432
- let title = '';
433
- let entryId = null;
434
353
  let lastSeen = '';
435
354
  let recurrences = 1;
436
355
  const flush = () => {
437
356
  if (!severity)
438
357
  return;
439
358
  scanned++;
440
- const lastFired = usageClock ? usageClock(entryId, fileType, title) : null;
441
- const today = computeEffectiveScore(severity, lastSeen, recurrences, fileType, now, lastFired);
359
+ const today = computeEffectiveScore(severity, lastSeen, recurrences, fileType, now);
442
360
  if (today >= threshold) {
443
361
  active++;
444
362
  if (lastSeen) {
445
- const future = computeEffectiveScore(severity, lastSeen, recurrences, fileType, horizon, lastFired);
363
+ const future = computeEffectiveScore(severity, lastSeen, recurrences, fileType, horizon);
446
364
  if (future < threshold)
447
365
  agingRisk++;
448
366
  }
@@ -454,24 +372,17 @@ function scanMistakePatternFile(filePath, threshold, fileType = 'mistake-pattern
454
372
  for (const line of lines) {
455
373
  if (scanned >= MAX_ENTRIES_SCANNED)
456
374
  break;
457
- const headerMatch = line.match(learningEntryHeadingRegex());
375
+ const headerMatch = line.match((0, learning_entry_format_1.learningEntryHeadingRegex)());
458
376
  if (headerMatch) {
459
377
  flush();
460
378
  inEntry = true;
461
379
  severity = headerMatch[1];
462
- title = headerMatch[2].trim();
463
- entryId = null;
464
380
  lastSeen = '';
465
381
  recurrences = 1;
466
382
  continue;
467
383
  }
468
384
  if (!inEntry)
469
385
  continue;
470
- const idMatch = line.trim().match(LEARNING_ENTRY_ID_LINE);
471
- if (idMatch) {
472
- entryId = idMatch[1];
473
- continue;
474
- }
475
386
  const lastSeenMatch = line.match(/^\*\*Last seen\*\*:\s*(.+)/);
476
387
  if (lastSeenMatch) {
477
388
  lastSeen = lastSeenMatch[1].trim();
@@ -485,22 +396,8 @@ function scanMistakePatternFile(filePath, threshold, fileType = 'mistake-pattern
485
396
  flush();
486
397
  return { active, dormant, agingRisk };
487
398
  }
488
- /**
489
- * Build the usage clock from the store, or return undefined when there is no
490
- * usage data at all. Returning undefined rather than an always-null function is
491
- * deliberate: it keeps the "no usage record" path identical to pre-#1103
492
- * behaviour instead of merely equivalent.
493
- */
494
- function resolveUsageClock(workspaceRoot) {
495
- return resolveUsageViews(workspaceRoot).clock;
496
- }
497
- /**
498
- * Issue #1755: one store read, two views of it: the clock the scorer needs and
499
- * the counters a surface renders. They are resolved together because reading the
500
- * store twice for the same pass would let the two disagree, and because the
501
- * identity resolution that finds a record is the projection's, not each caller's.
502
- */
503
- function resolveUsageViews(workspaceRoot) {
399
+ /** Historical counters are only read for the human library view. */
400
+ function resolveUsageStats() {
504
401
  try {
505
402
  // Required lazily: the store imports the retention config, which imports the
506
403
  // workspace path helpers, and a cycle through this module would be fragile.
@@ -508,31 +405,28 @@ function resolveUsageViews(workspaceRoot) {
508
405
  const projection = require('./learning-usage-projection');
509
406
  const loaded = store.readUsageStore();
510
407
  if (Object.keys(loaded.entries).length === 0)
511
- return {};
408
+ return undefined;
512
409
  const lookup = projection.buildUsageLookup(loaded);
513
- return {
514
- clock: (id, fileType, title) => projection.lookupUsage(lookup, id, fileType, title)?.lastFired ?? null,
515
- stats: (id, fileType, title) => {
516
- const found = projection.lookupUsage(lookup, id, fileType, title);
517
- if (!found)
518
- return null;
519
- return {
520
- offered: found.offered,
521
- offeredAboveThreshold: found.offeredAboveThreshold,
522
- applied: found.byOutcome.applied || 0,
523
- prevented: found.byOutcome.prevented || 0,
524
- ignored: found.byOutcome.ignored || 0,
525
- notApplicable: found.byOutcome['not-applicable'] || 0,
526
- lastOfferedAt: found.lastOffered,
527
- lastFiredAt: found.lastFired,
528
- };
529
- },
410
+ return (id, fileType, title) => {
411
+ const found = projection.lookupUsage(lookup, id, fileType, title);
412
+ if (!found)
413
+ return null;
414
+ return {
415
+ historical: true,
416
+ countingStatus: 'deferred',
417
+ lastObservedAt: found.lastObservedAt,
418
+ applied: found.byOutcome.applied || 0,
419
+ prevented: found.byOutcome.prevented || 0,
420
+ ignored: found.byOutcome.ignored || 0,
421
+ notApplicable: found.byOutcome['not-applicable'] || 0,
422
+ lastFiredAt: found.lastFired,
423
+ };
530
424
  };
531
425
  }
532
426
  catch {
533
427
  // Usage data is derived and optional. A failure here must never change what
534
428
  // the agent is given.
535
- return {};
429
+ return undefined;
536
430
  }
537
431
  }
538
432
  function readFrontmatter(content) {
@@ -552,34 +446,6 @@ function readFrontmatter(content) {
552
446
  }
553
447
  return frontmatter;
554
448
  }
555
- /**
556
- * Issue #1857 R11: how many proposals are waiting for the manager's review.
557
- *
558
- * This counts directory entries and nothing else. It does not open a proposal, and
559
- * deliberately has no access to one: R10 is an invariant about influence rather
560
- * than storage, so the employee learns that a backlog exists and which job clears
561
- * it, never what any pending claim says.
562
- *
563
- * The same shape as the pending-L0 line it sits beside, for the same reason: a
564
- * count plus the job that clears it is actionable; a list of contents is not.
565
- */
566
- function countPendingProposals(roots, resolvedUserId) {
567
- let total = 0;
568
- for (const base of [roots.repoLearningsBase, roots.globalPersonalBase]) {
569
- if (!base)
570
- continue;
571
- const queueDir = (0, path_1.join)(base, 'review-queue', resolvedUserId);
572
- if (!(0, fs_1.existsSync)(queueDir))
573
- continue;
574
- try {
575
- total += (0, fs_1.readdirSync)(queueDir).filter((name) => name.endsWith('.json')).length;
576
- }
577
- catch {
578
- // An unreadable queue is not worth failing a job's startup over.
579
- }
580
- }
581
- return total;
582
- }
583
449
  /** Configured owning homes only; discovery never provisions or infers a home. */
584
450
  function getLearningInputHomes(workspaceRoot) {
585
451
  const homes = [{ root: workspaceRoot, contentRoot: (0, path_1.dirname)((0, project_fraim_paths_1.getWorkspaceLearningsDir)(workspaceRoot)) },
@@ -723,19 +589,12 @@ function resolveOrgLearningFileTiers(repoLearningsBase, fileName) {
723
589
  return tiers;
724
590
  }
725
591
  /**
726
- * Issue #1002 R4: merge entries across tiers. Lowest precedence first in, so a
727
- * later tier overwrites on an identical title and nothing is duplicated. Entries
728
- * whose titles do not collide all survive, which is the whole point: promoting
729
- * one entry must not hide the rest of its family.
592
+ * Keep every current occurrence visible. A title is not identity and neither
593
+ * tier order nor equal IDs authorize discarding a conflicting revision.
594
+ * Reconciliation belongs to the reviewed learning application flow.
730
595
  */
731
596
  function mergeLearningEntriesAcrossTiers(tiers) {
732
- const byTitle = new Map();
733
- for (const tier of tiers) {
734
- for (const entry of tier.entries) {
735
- byTitle.set(entry.title.trim().toLowerCase(), entry);
736
- }
737
- }
738
- return [...byTitle.values()];
597
+ return tiers.flatMap(tier => tier.entries);
739
598
  }
740
599
  const CONTEXT_CATEGORIES = {
741
600
  mistake: { ft: 'mistake-patterns', gated: true, label: '(entries above score threshold)' },
@@ -743,15 +602,7 @@ const CONTEXT_CATEGORIES = {
743
602
  coach: { ft: 'manager-coaching', gated: false, label: '(manager-facing; all entries)' },
744
603
  validated: { ft: 'validated-patterns', gated: true, label: '(entries above score threshold)' },
745
604
  };
746
- /**
747
- * Resolve exactly the files the injected context block lists, in the order it lists
748
- * them.
749
- *
750
- * Extracted from `buildLearningContextSection` for issue #1103: the block renders
751
- * these as text, and the offer recorder enumerates their entries. Both have to see
752
- * the identical set, so there is one resolution and two consumers rather than two
753
- * resolutions that can drift.
754
- */
605
+ /** Resolve the files listed in injected context, in delivery order. */
755
606
  function resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJob, domain) {
756
607
  // Domain axis (issue #806) plus the connect/job single-injection contract (issue
757
608
  // #860). Session startup stays broad enough for stable preferences plus L0, while
@@ -846,14 +697,13 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
846
697
  const roots = getLearningRoots(workspaceRoot);
847
698
  const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
848
699
  const threshold = getScoreThreshold(workspaceRoot);
849
- const usageClock = resolveUsageClock(workspaceRoot);
850
700
  const resolved = resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJob, domain);
851
701
  const multiTier = resolved.multiTier;
852
702
  const toListed = (f) => ({
853
703
  displayPath: f.displayPath,
854
704
  label: f.label,
855
705
  dormant: f.gated
856
- ? scanMistakePatternFile(f.path, threshold, f.fileType, usageClock).dormant
706
+ ? scanMistakePatternFile(f.path, threshold, f.fileType).dormant
857
707
  : 0,
858
708
  isCoach: f.isCoach,
859
709
  });
@@ -864,11 +714,9 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
864
714
  const l0RetroCount = pendingL0Sources.filter(source => source.kind === 'retrospective').length;
865
715
  const hasL2 = l2Files.length > 0;
866
716
  const hasL1 = l1Files.length > 0;
867
- // Issue #1857 R11. Counted before the gate on purpose: a manager with a full
868
- // review queue and an empty corpus is exactly the case where the backlog most
869
- // needs saying, and computing it after the early return would silence it there.
870
- const pendingProposals = countPendingProposals(roots, resolvedUserId);
871
- const hasContent = hasL2 || hasL1 || l0CoachingCount > 0 || l0RetroCount > 0 || pendingProposals > 0;
717
+ // Saved proposal copies do not establish an outstanding human decision.
718
+ // The originating run's current review state owns reminders.
719
+ const hasContent = hasL2 || hasL1 || l0CoachingCount > 0 || l0RetroCount > 0;
872
720
  if (!hasContent)
873
721
  return '';
874
722
  let section = forJob
@@ -910,16 +758,12 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
910
758
  // Issue #1857 R11. The count, never the content: an unapproved proposal does
911
759
  // not influence this job, and the employee is told only that the manager has a
912
760
  // backlog and which job clears it.
913
- if (pendingProposals > 0) {
914
- const plural = pendingProposals !== 1;
915
- section += `${pendingProposals} proposal${plural ? 's are' : ' is'} waiting for your manager's review; run \`sleep-on-learnings\` to review ${plural ? 'them' : 'it'}.\n\n`;
916
- }
917
761
  const coachPresent = resolved.coachPresent;
918
762
  if (forJob) {
919
763
  if (hasL2 || hasL1) {
920
764
  section += 'Read the listed synthesized learning files before continuing, then apply the relevant patterns and preferences in this job.\n';
921
765
  if (multiTier) {
922
- section += 'A family listed at more than one level is combined, not replaced: apply every entry, and where the same entry title appears at two levels apply the more specific one (project over manager).\n';
766
+ section += 'A family listed at more than one level is combined, not replaced. Stable IDs identify lessons; equal titles do not establish identity. Conflicting copies require explicit reconciliation: do not choose a revision by scope, timestamp, count, or title. Preserve approved decisions and useful refinements; unresolved conflicting claims are not authority for action.\n';
923
767
  }
924
768
  if (coachPresent) {
925
769
  section += 'Treat manager-coaching as feedback for how the manager should continue or improve managing AI, not as agent instruction.\n';
@@ -929,7 +773,7 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
929
773
  else if (hasL2 || hasL1) {
930
774
  section += 'Read the listed synthesized learning files before continuing, then use this synthesized learning context throughout the session.\n';
931
775
  if (multiTier) {
932
- section += 'A family listed at more than one level is combined, not replaced: apply every entry, and where the same entry title appears at two levels apply the more specific one (project over manager).\n';
776
+ section += 'A family listed at more than one level is combined, not replaced. Stable IDs identify lessons; equal titles do not establish identity. Conflicting copies require explicit reconciliation: do not choose a revision by scope, timestamp, count, or title. Preserve approved decisions and useful refinements; unresolved conflicting claims are not authority for action.\n';
933
777
  }
934
778
  if (coachPresent) {
935
779
  section += 'Manager-coaching entries are manager-facing feedback, not instructions for the AI to follow.\n';
@@ -937,187 +781,6 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
937
781
  }
938
782
  return section;
939
783
  }
940
- /** Enumerate one learning file's entries with their above-threshold state. */
941
- function enumerateFileEntries(file, threshold, usageClock, now) {
942
- if (!(0, fs_1.existsSync)(file.path))
943
- return [];
944
- let content;
945
- try {
946
- content = (0, fs_1.readFileSync)(file.path, 'utf8');
947
- }
948
- catch {
949
- return [];
950
- }
951
- const out = [];
952
- const headingRe = learningEntryHeadingRegex();
953
- let current = null;
954
- const flush = () => {
955
- if (!current)
956
- return;
957
- // A non-gated family (preferences, manager-coaching) is delivered whole, so
958
- // every entry in it is active guidance. A gated family delivers only entries
959
- // at or above threshold; the rest are the dormant rollup.
960
- let aboveThreshold = true;
961
- if (file.gated) {
962
- if (current.lastSeen) {
963
- const lastFired = usageClock ? usageClock(current.id, file.fileType, current.title) : null;
964
- aboveThreshold = computeEffectiveScore(current.severity, current.lastSeen, current.recurrences, file.fileType, now, lastFired) >= threshold;
965
- }
966
- // No date metadata: parseLearningEntries treats that as fresh and injects it,
967
- // so the offer record has to agree.
968
- }
969
- out.push({
970
- // The store owns key construction. Building the composite key by hand here
971
- // would be a second definition of what an entry's identity is, and the two
972
- // would drift the first time normalization changed. Issue #1752 A1 made
973
- // that one function rather than four copies of the same expression.
974
- key: (0, learning_usage_store_1.resolveEntryIdentity)({ id: current.id, fileType: file.fileType, title: current.title }).key,
975
- family: 'learning',
976
- id: current.id,
977
- title: current.title,
978
- fileType: file.fileType,
979
- level: file.level,
980
- displayPath: file.displayPath,
981
- severity: current.severity,
982
- aboveThreshold,
983
- });
984
- current = null;
985
- };
986
- for (const line of content.split(/\r?\n/)) {
987
- const header = line.match(headingRe);
988
- if (header) {
989
- flush();
990
- current = { severity: header[1], title: header[2].trim(), id: null, lastSeen: '', recurrences: 1 };
991
- continue;
992
- }
993
- if (!current)
994
- continue;
995
- const trimmed = line.trim();
996
- const idMatch = trimmed.match(LEARNING_ENTRY_ID_LINE);
997
- if (idMatch) {
998
- current.id = idMatch[1];
999
- continue;
1000
- }
1001
- const lastSeenMatch = trimmed.match(/^\*\*Last seen\*\*:\s*(.+)/i);
1002
- if (lastSeenMatch) {
1003
- current.lastSeen = lastSeenMatch[1].trim();
1004
- continue;
1005
- }
1006
- const recurrenceMatch = trimmed.match(/^\*\*Recurrences\*\*:\s*(\d+)/i);
1007
- if (recurrenceMatch) {
1008
- current.recurrences = parseInt(recurrenceMatch[1], 10) || 1;
1009
- }
1010
- }
1011
- flush();
1012
- return out;
1013
- }
1014
- /**
1015
- * Issue #1103 R1 and R2: every learning entry the runtime just delivered.
1016
- *
1017
- * "Delivered" is file-level, because the injected block lists file paths rather
1018
- * than entry titles: an entry is offered when it sat inside a file the block
1019
- * listed. `aboveThreshold` is what separates active guidance from the dormant
1020
- * rollup. Nothing here depends on the agent reading anything.
1021
- *
1022
- * ## Issue #1752 A2 — one lesson, one offer, stamped or not
1023
- *
1024
- * This deduped by usage key, which sounds like the same thing as the context
1025
- * reader's rule and is not. `resolveOrgLearningFileTiers` deliberately returns
1026
- * both the org pack copy and the repo-local copy of an org family, because #1002
1027
- * R4 requires that a promoted entry not hide the rest of its family. The context
1028
- * reader then merges those tiers with `mergeLearningEntriesAcrossTiers`, keyed on
1029
- * the normalized title, so the agent sees one lesson. Keying the offer record on
1030
- * `id ?? titleUsageKey(...)` instead meant that a lesson carrying an `**Id**` line
1031
- * in one tier and not the other produced two keys, and both were recorded.
1032
- *
1033
- * That is defect 4 in #1752, and it is why `standing --changes-only` could
1034
- * recommend promoting and demoting the same lesson in one response: the
1035
- * title-keyed twin collected the offers while the id-keyed one collected the
1036
- * firings (the firing path never split, because `buildEntryIndex` dedups by
1037
- * identity first-wins). Measured live: five titles emitting two keys each in a
1038
- * single derived offer batch.
1039
- *
1040
- * So group by the same normalized title the context reader groups by, resolve one
1041
- * identity per group preferring whichever tier carries an id, and let the later
1042
- * tier win on content exactly as before.
1043
- */
1044
- function collectOfferedLearningEntries(workspaceRoot, userId, forJob, domain) {
1045
- const roots = getLearningRoots(workspaceRoot);
1046
- const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
1047
- const threshold = getScoreThreshold(workspaceRoot);
1048
- const usageClock = resolveUsageClock(workspaceRoot);
1049
- const now = new Date();
1050
- const resolved = resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJob, domain);
1051
- // Grouped by family plus normalized title, which is what `titleUsageKey`
1052
- // already computes. Two families holding the same title are two lessons, and
1053
- // that is the existing rule rather than a new one.
1054
- const byLesson = new Map();
1055
- for (const file of [...resolved.l2, ...resolved.l1]) {
1056
- for (const entry of enumerateFileEntries(file, threshold, usageClock, now)) {
1057
- const lessonKey = (0, learning_usage_store_1.titleUsageKey)(entry.fileType, entry.title);
1058
- const group = byLesson.get(lessonKey);
1059
- if (group)
1060
- group.push(entry);
1061
- else
1062
- byLesson.set(lessonKey, [entry]);
1063
- }
1064
- }
1065
- const out = [];
1066
- for (const group of byLesson.values()) {
1067
- // Later tier wins for content, unchanged precedence.
1068
- const winner = group[group.length - 1];
1069
- // The id comes from whichever tier carries one, because `stamp-ids` has not
1070
- // reached the org and manager pack homes for every corpus yet. Latest wins
1071
- // when more than one tier is stamped.
1072
- const id = group.reduce((found, entry) => entry.id ?? found, null);
1073
- out.push({
1074
- ...winner,
1075
- id,
1076
- key: (0, learning_usage_store_1.resolveEntryIdentity)({ id, fileType: winner.fileType, title: winner.title }).key,
1077
- });
1078
- }
1079
- return out;
1080
- }
1081
- /**
1082
- * Issue #1103 R3: the auto-loaded Team Context rule files.
1083
- *
1084
- * These are the most influential instructions in the system and produce no usage
1085
- * signal today, because the agent reads them with its own file tool and the proxy
1086
- * never sees a `get_fraim_file` call for them. Recording the offer at composition
1087
- * time closes that gap. A rule is unconditional, so it is always above threshold.
1088
- */
1089
- function collectOfferedRuleFiles(workspaceRoot, forJob) {
1090
- const resolved = resolveTeamContextFiles(workspaceRoot);
1091
- // The three rule files by name, not by path sniffing: these are the ones R3
1092
- // names, and the other Team Context files are context rather than instruction,
1093
- // so recording them as offered guidance would misreport what they are.
1094
- const ruleFiles = [
1095
- { presence: resolved.orgRules, level: 'org' },
1096
- { presence: resolved.managerRules, level: 'manager' },
1097
- { presence: resolved.projectRules, level: 'project' },
1098
- ];
1099
- const out = [];
1100
- for (const { presence, level } of ruleFiles) {
1101
- if (!presence.present || !presence.displayPath)
1102
- continue;
1103
- const displayPath = presence.displayPath.replace(/\\/g, '/');
1104
- out.push({
1105
- key: (0, learning_usage_store_1.ruleUsageKey)(displayPath),
1106
- family: 'rule',
1107
- id: null,
1108
- title: displayPath.split('/').pop() ?? displayPath,
1109
- fileType: 'rules',
1110
- level,
1111
- displayPath,
1112
- severity: null,
1113
- aboveThreshold: true,
1114
- });
1115
- }
1116
- // `forJob` is accepted so both injection points call this identically; the Team
1117
- // Context block lists the same rule files in either frame.
1118
- void forJob;
1119
- return out;
1120
- }
1121
784
  /**
1122
785
  * Resolve an organization/manager-scope context file (issue #563 order, R3.1):
1123
786
  * 1. repo-local override (`fraim/personalized-employee/…`) — wins when present
@@ -1425,7 +1088,7 @@ function countLearningEntries(filePath) {
1425
1088
  return 0;
1426
1089
  }
1427
1090
  let count = 0;
1428
- const headingRe = learningEntryHeadingRegex();
1091
+ const headingRe = (0, learning_entry_format_1.learningEntryHeadingRegex)();
1429
1092
  for (const line of content.split(/\r?\n/)) {
1430
1093
  if (headingRe.test(line))
1431
1094
  count++;
@@ -1448,7 +1111,7 @@ function readLearningEntryTitles(filePath) {
1448
1111
  return [];
1449
1112
  }
1450
1113
  const titles = [];
1451
- const headingRe = learningEntryHeadingRegex();
1114
+ const headingRe = (0, learning_entry_format_1.learningEntryHeadingRegex)();
1452
1115
  for (const line of content.split(/\r?\n/)) {
1453
1116
  const m = line.match(headingRe);
1454
1117
  if (m)
@@ -1461,7 +1124,7 @@ function readLearningEntryTitles(filePath) {
1461
1124
  *
1462
1125
  * Issue #1002: two changes from the original.
1463
1126
  *
1464
- * R4: counts sum across every level that holds a family, deduped by entry title,
1127
+ * R4: counts sum across every level that holds a family,
1465
1128
  * rather than counting only the winning file. Under the old first-wins read a
1466
1129
  * family present at two levels was counted once, so the Brain under-reported
1467
1130
  * exactly the case this issue introduces.
@@ -1483,26 +1146,27 @@ function countPreservedLearnings(workspaceRoot, userId) {
1483
1146
  }
1484
1147
  return titles.size;
1485
1148
  };
1486
- // Same for a personal family, and reported per level so the Brain can show
1487
- // where each entry actually lives. A title present at both levels counts
1488
- // once, at the project level, matching which entry the agent applies.
1149
+ // Count current occurrences at their actual owner; reconciliation, not a
1150
+ // title match, determines whether a redundant copy may be removed.
1489
1151
  const countPersonalFamilyByLevel = (fileName) => {
1490
1152
  const tiers = resolvePersonalLearningFileTiers(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, fileName);
1491
- const projectTitles = new Set();
1492
- const managerTitles = new Set();
1153
+ const seenFiles = new Set();
1154
+ const counts = { manager: 0, project: 0 };
1493
1155
  for (const tier of tiers) {
1494
- for (const t of readLearningEntryTitles(tier.path)) {
1495
- if (tier.level === 'project')
1496
- projectTitles.add(t);
1497
- else
1498
- managerTitles.add(t);
1156
+ let file;
1157
+ try {
1158
+ file = (0, fs_1.realpathSync)(tier.path);
1159
+ }
1160
+ catch {
1161
+ continue;
1499
1162
  }
1163
+ if (seenFiles.has(file))
1164
+ continue;
1165
+ seenFiles.add(file);
1166
+ const owner = tier.level === 'project' ? 'project' : 'manager';
1167
+ counts[owner] += countLearningEntries(tier.path);
1500
1168
  }
1501
- // A promoted entry lives at the project level; do not also count it as the
1502
- // manager's, or the two tiles would double-report the same lesson.
1503
- for (const t of projectTitles)
1504
- managerTitles.delete(t);
1505
- return { manager: managerTitles.size, project: projectTitles.size };
1169
+ return counts;
1506
1170
  };
1507
1171
  const PERSONAL_WORK_TYPES = ['mistake-patterns', 'preferences', 'validated-patterns'];
1508
1172
  const ORG_TYPES = ['mistake-patterns', 'preferences', 'manager-coaching', 'validated-patterns'];
@@ -1567,8 +1231,6 @@ const PENDING_KIND_LABEL = {
1567
1231
  };
1568
1232
  /** Filename infix that separates hot (.md) from cold-tier (.cold.md) learning files (#861). */
1569
1233
  const COLD_FILE_SUFFIX = '.cold';
1570
- /** R11's "fired recently" window. */
1571
- const FIRED_RECENT_DAYS = 30;
1572
1234
  // Parse `## [P-…] Title` entries out of a learning file WITH their effective
1573
1235
  // score + last-seen (unlike parseLearningEntries, which drops that bookkeeping
1574
1236
  // for the Hub text display). Used only by the brain-dot projection.
@@ -1583,7 +1245,7 @@ function parseBrainLearningEntries(filePath, fileType, usage) {
1583
1245
  return [];
1584
1246
  }
1585
1247
  const out = [];
1586
- const headingRe = learningEntryHeadingRegex();
1248
+ const headingRe = (0, learning_entry_format_1.learningEntryHeadingRegex)();
1587
1249
  const now = new Date();
1588
1250
  let current = null;
1589
1251
  const flush = () => {
@@ -1591,7 +1253,7 @@ function parseBrainLearningEntries(filePath, fileType, usage) {
1591
1253
  return;
1592
1254
  const projected = usage ? usage(current.id, fileType, current.title) : undefined;
1593
1255
  const rawScore = current.lastSeen
1594
- ? computeEffectiveScore(current.severity, current.lastSeen, current.recurrences, fileType, now, projected?.lastFired ?? null)
1256
+ ? computeEffectiveScore(current.severity, current.lastSeen, current.recurrences, fileType, now)
1595
1257
  : null;
1596
1258
  out.push({
1597
1259
  title: current.title,
@@ -1612,7 +1274,7 @@ function parseBrainLearningEntries(filePath, fileType, usage) {
1612
1274
  }
1613
1275
  if (!current)
1614
1276
  continue;
1615
- const idMatch = line.trim().match(LEARNING_ENTRY_ID_LINE);
1277
+ const idMatch = line.trim().match(learning_entry_format_1.LEARNING_ENTRY_ID_LINE);
1616
1278
  if (idMatch) {
1617
1279
  current.id = idMatch[1];
1618
1280
  continue;
@@ -1643,26 +1305,19 @@ function resolveBrainUsageProjector() {
1643
1305
  if (Object.keys(loaded.entries).length === 0)
1644
1306
  return undefined;
1645
1307
  const lookup = projection.buildUsageLookup(loaded);
1646
- const now = Date.now();
1647
1308
  return (id, fileType, title) => {
1648
1309
  const value = projection.lookupUsage(lookup, id, fileType, title);
1649
1310
  if (!value)
1650
1311
  return undefined;
1651
- let usageState;
1652
- if (value.lastFired) {
1653
- const ageDays = (now - new Date(value.lastFired).getTime()) / 86_400_000;
1654
- usageState = ageDays <= FIRED_RECENT_DAYS ? 'fired-recent' : 'fired-aging';
1655
- }
1656
- else {
1657
- usageState = 'never-fired';
1658
- }
1659
1312
  return {
1660
- offered: value.offered,
1313
+ historical: true,
1314
+ countingStatus: 'deferred',
1315
+ lastObservedAt: value.lastObservedAt,
1661
1316
  fired: value.fired,
1662
1317
  lastFired: value.lastFired,
1663
1318
  firedUnder: value.firedUnder,
1664
1319
  fireLog: value.log.map((l) => ({ date: l.date, outcome: l.outcome, job: l.job, note: l.note })),
1665
- usageState,
1320
+ usageState: 'historical',
1666
1321
  };
1667
1322
  };
1668
1323
  }
@@ -1760,7 +1415,7 @@ function listDomainLearningFiles(dir, prefix, fileType) {
1760
1415
  // threshold: entries with score >= threshold (or in a non-gated category) are
1761
1416
  // marked injected:true. Callers that don't need scoring pass Infinity so
1762
1417
  // all entries are injected.
1763
- function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier, threshold = exports.DEFAULT_THRESHOLD, usageClock, usageStats) {
1418
+ function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier, threshold = exports.DEFAULT_THRESHOLD, usageStats) {
1764
1419
  if (!(0, fs_1.existsSync)(filePath))
1765
1420
  return [];
1766
1421
  let content;
@@ -1782,14 +1437,11 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1782
1437
  const flush = () => {
1783
1438
  if (current) {
1784
1439
  current.body = bodyLines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
1785
- // Issue #1755: resolve usage once per entry, before scoring, so the
1786
- // counters a surface renders and the clock the score uses come from
1787
- // the same record rather than two independent lookups.
1440
+ // Historical observations decorate the library; they never select guidance.
1788
1441
  current.usage = usageStats ? usageStats(current.id, fileType, current.title) : null;
1789
1442
  if (isScoreGated) {
1790
1443
  if (curLastSeen !== null) {
1791
- const lastFired = usageClock ? usageClock(current.id, fileType, current.title) : null;
1792
- const s = computeEffectiveScore(current.severity, curLastSeen, curRecurrences, fileType, new Date(), lastFired);
1444
+ const s = computeEffectiveScore(current.severity, curLastSeen, curRecurrences, fileType, new Date());
1793
1445
  current.score = s;
1794
1446
  current.injected = s >= threshold;
1795
1447
  }
@@ -1803,6 +1455,8 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1803
1455
  current.score = null;
1804
1456
  current.injected = true;
1805
1457
  }
1458
+ if (tier === 'cold')
1459
+ current.injected = false;
1806
1460
  out.push(current);
1807
1461
  }
1808
1462
  current = null;
@@ -1810,7 +1464,7 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1810
1464
  curLastSeen = null;
1811
1465
  curRecurrences = 1;
1812
1466
  };
1813
- const headingRe = learningEntryHeadingRegex();
1467
+ const headingRe = (0, learning_entry_format_1.learningEntryHeadingRegex)();
1814
1468
  for (const line of content.split(/\r?\n/)) {
1815
1469
  const header = line.match(headingRe);
1816
1470
  if (header) {
@@ -1835,7 +1489,7 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1835
1489
  }
1836
1490
  const t = line.trim();
1837
1491
  // Capture scoring metadata but exclude from body.
1838
- const idMatch = t.match(LEARNING_ENTRY_ID_LINE);
1492
+ const idMatch = t.match(learning_entry_format_1.LEARNING_ENTRY_ID_LINE);
1839
1493
  if (idMatch) {
1840
1494
  current.id = idMatch[1];
1841
1495
  continue;
@@ -1926,7 +1580,7 @@ function readRejectedProposals(workspaceRoot, userId) {
1926
1580
  ? proposal.claimKey
1927
1581
  : parts[1] || heading[2];
1928
1582
  // The same claim can appear in more than one configured base. The first
1929
- // base that carries it wins, matching how the tier merge resolves.
1583
+ // base supplies display metadata for that same recorded decision.
1930
1584
  const key = `${claimKey}:${String(decision.at || '')}`;
1931
1585
  if (seen.has(key))
1932
1586
  continue;
@@ -1951,22 +1605,50 @@ function readRejectedProposals(workspaceRoot, userId) {
1951
1605
  function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine') {
1952
1606
  const roots = getLearningRoots(workspaceRoot);
1953
1607
  const threshold = getScoreThreshold(workspaceRoot);
1954
- // Issue #1103 R8: the Hub's injected flag must agree with the loader's, so both
1955
- // read the same clock. One store read serves the whole call.
1956
- // Issue #1755: the same read also yields the counters the Hub renders.
1957
- const { clock: usageClock, stats: usageStats } = resolveUsageViews(workspaceRoot);
1608
+ const usageStats = resolveUsageStats();
1609
+ // Only the human library reads these observations. An unavailable store must
1610
+ // fail this read, so a client can retain known history rather than invent zeroes.
1611
+ const resolvedUserId = scope === 'org' ? userId : resolveLearningUserId(workspaceRoot, userId, roots);
1612
+ const managerHome = (0, pack_home_1.resolvePackHome)('manager', { migrateLegacy: false });
1613
+ const orgHome = scope === 'org' ? (0, pack_home_1.resolvePackHome)('org', { migrateLegacy: false }) : null;
1614
+ const assessmentHomes = [
1615
+ { level: 'Project', root: (0, project_fraim_paths_1.getWorkspaceFraimDir)(workspaceRoot), contentRoot: (0, path_1.join)((0, project_fraim_paths_1.getWorkspaceFraimDir)(workspaceRoot), 'personalized-employee') },
1616
+ { level: 'Manager', root: managerHome.root, contentRoot: managerHome.contentRoot },
1617
+ ...(orgHome ? [{ level: 'Org', root: orgHome.root, contentRoot: orgHome.contentRoot }] : []),
1618
+ ];
1619
+ const requestedHomes = assessmentHomes.filter(home => scope === 'org'
1620
+ ? home.level !== 'Manager' : home.level === (level === 'project' ? 'Project' : 'Manager'));
1621
+ const assessed = require('./learning-usage-store').readAssessedOutcomes(undefined, {
1622
+ owners: requestedHomes.map(home => ({ level: home.level, path: (0, owner_identity_1.ownerPath)(home.root) })), principal: userId,
1623
+ });
1624
+ const decorateAssessment = (entry, file) => {
1625
+ const home = assessmentHomes.find(candidate => {
1626
+ if (scope !== 'org' && candidate.level !== (entry.level === 'project' ? 'Project' : 'Manager'))
1627
+ return false;
1628
+ const child = (0, path_1.relative)(candidate.contentRoot, file);
1629
+ return child !== '..' && !child.startsWith('..' + path_1.sep) && !(0, path_1.isAbsolute)(child);
1630
+ });
1631
+ entry.assessment = home && entry.id ? (0, outcome_types_1.summarizeLearningOutcomes)(assessed, {
1632
+ owner: { level: home.level, path: (0, owner_identity_1.ownerPath)(home.root) },
1633
+ relativePath: (0, path_1.relative)(home.contentRoot, file).replace(/\\/g, '/'),
1634
+ // A legacy filename alias is not the authenticated learning subject.
1635
+ entryId: entry.id, learningSubject: userId,
1636
+ }) ?? null : null;
1637
+ return entry;
1638
+ };
1958
1639
  const out = [];
1959
1640
  // Helper: parse + merge a list of tier paths for one file family.
1960
1641
  // ResolvedLearningTier uses 'manager' to mean machine-level personal; map it to 'machine'.
1961
1642
  const tierLevel = (t) => t.level === 'manager' ? 'machine' : t.level;
1962
1643
  const parseTiers = (tiers, cat, tierVal, domainVal) => mergeLearningEntriesAcrossTiers(tiers.map((t) => ({
1963
- entries: parseLearningEntries(t.path, t.displayPath, cat, tierLevel(t), domainVal ?? 'global', tierVal, threshold, usageClock, usageStats),
1644
+ entries: parseLearningEntries(t.path, t.displayPath, cat, tierLevel(t), domainVal ?? 'global', tierVal, threshold, usageStats)
1645
+ .map(entry => decorateAssessment(entry, t.path)),
1964
1646
  })));
1965
1647
  if (scope === 'org') {
1966
1648
  for (const cat of ['avoid', 'preference', 'repeat', 'coaching']) {
1967
1649
  const fileType = exports.CATEGORY_TO_FILETYPE[cat];
1968
1650
  const f = `org-${fileType}.md`;
1969
- // org pack home first, repo-local override second; repo-local wins on title collision.
1651
+ // Keep occurrences from both homes; title matches do not grant authority.
1970
1652
  out.push(...parseTiers(resolveOrgLearningFileTiers(roots.repoLearningsBase, f), cat));
1971
1653
  // #806: domain-scoped org learnings — union from both locations.
1972
1654
  const allDomainFiles = [
@@ -1981,7 +1663,8 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
1981
1663
  seenDomainFile.add(fileName);
1982
1664
  const domainTiers = resolveOrgLearningFileTiers(roots.repoLearningsBase, fileName);
1983
1665
  if (domainTiers.length === 0) {
1984
- out.push(...parseLearningEntries((0, path_1.join)(base, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'org', domain, undefined, threshold, usageClock, usageStats));
1666
+ out.push(...parseLearningEntries((0, path_1.join)(base, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'org', domain, undefined, threshold, usageStats)
1667
+ .map(entry => decorateAssessment(entry, (0, path_1.join)(base, fileName))));
1985
1668
  }
1986
1669
  else {
1987
1670
  out.push(...parseTiers(domainTiers, cat, undefined, domain));
@@ -1990,46 +1673,17 @@ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine')
1990
1673
  }
1991
1674
  return out;
1992
1675
  }
1993
- // manager + reverse: use the same three-tier resolution as buildLearningContextSection.
1994
- const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
1676
+ // The library is an ownership view, not the combined runtime context.
1995
1677
  const cats = scope === 'reverse' ? ['coaching'] : ['avoid', 'preference', 'repeat'];
1996
- if (level === 'project') {
1997
- // Project level: repo-local only (single tier, no merge needed).
1998
- for (const cat of cats) {
1999
- const fileType = exports.CATEGORY_TO_FILETYPE[cat];
2000
- const f = `${resolvedUserId}-${fileType}.md`;
2001
- out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, f), `${REPO_LEARNINGS_REL}/${f}`, cat, 'project', 'global', 'hot', threshold, usageClock, usageStats));
2002
- const coldF = `${resolvedUserId}-${fileType}${COLD_FILE_SUFFIX}.md`;
2003
- if ((0, fs_1.existsSync)((0, path_1.join)(roots.repoLearningsBase, coldF))) {
2004
- out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, coldF), `${REPO_LEARNINGS_REL}/${coldF}`, cat, 'project', 'global', 'cold', threshold, usageClock, usageStats));
2005
- }
2006
- for (const { fileName, domain } of listDomainLearningFiles(roots.repoLearningsBase, resolvedUserId, fileType)) {
2007
- out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, fileName), `${REPO_LEARNINGS_REL}/${fileName}`, cat, 'project', domain, 'hot', threshold, usageClock, usageStats));
2008
- }
2009
- }
2010
- return out;
2011
- }
2012
- // machine level: all three personal tiers (managerCacheBase, globalPersonalBase, repoLearningsBase).
2013
- // Cold siblings are read from each tier that has the hot file.
2014
1678
  for (const cat of cats) {
2015
1679
  const fileType = exports.CATEGORY_TO_FILETYPE[cat];
2016
- const f = `${resolvedUserId}-${fileType}.md`;
2017
- const tiers = resolvePersonalLearningFileTiers(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, f);
2018
- out.push(...parseTiers(tiers, cat, 'hot'));
2019
- // Cold sibling from each tier that has the hot file.
2020
- const coldF = `${resolvedUserId}-${fileType}${COLD_FILE_SUFFIX}.md`;
2021
- const coldTiers = resolvePersonalLearningFileTiers(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, coldF);
2022
- out.push(...parseTiers(coldTiers, cat, 'cold'));
2023
- // #806: domain files from managerHomeBase and repo base.
2024
- const domainDirs = [roots.globalPersonalBase, roots.managerCacheBase, roots.repoLearningsBase];
2025
- const seenDomain = new Set();
2026
- for (const dir of domainDirs) {
2027
- for (const { fileName, domain } of listDomainLearningFiles(dir, resolvedUserId, fileType)) {
2028
- if (seenDomain.has(fileName))
2029
- continue;
2030
- seenDomain.add(fileName);
2031
- const domTiers = resolvePersonalLearningFileTiers(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, fileName);
2032
- out.push(...parseTiers(domTiers, cat, 'hot', domain));
1680
+ for (const domain of ['global', ...learning_domains_1.LEARNING_DOMAINS]) {
1681
+ const domainPart = domain === 'global' ? '' : `-${domain}`;
1682
+ for (const storageTier of ['hot', 'cold']) {
1683
+ const suffix = storageTier === 'cold' ? COLD_FILE_SUFFIX : '';
1684
+ const fileName = `${resolvedUserId}${domainPart}-${fileType}${suffix}.md`;
1685
+ const tiers = resolvePersonalLearningFileTiers(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, fileName).filter(t => level === 'project' ? t.level === 'project' : t.level !== 'project');
1686
+ out.push(...parseTiers(tiers, cat, storageTier, domain));
2033
1687
  }
2034
1688
  }
2035
1689
  }
@@ -2076,7 +1730,7 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
2076
1730
  const existing = (0, fs_1.existsSync)(filePath) ? (0, fs_1.readFileSync)(filePath, 'utf8') : '';
2077
1731
  const nl = existing.includes('\r\n') ? '\r\n' : '\n';
2078
1732
  const lines = existing.length ? existing.split(/\r?\n/) : [];
2079
- const headingRe = learningEntryHeadingRegex();
1733
+ const headingRe = (0, learning_entry_format_1.learningEntryHeadingRegex)();
2080
1734
  const starts = [];
2081
1735
  lines.forEach((l, i) => { if (headingRe.test(l))
2082
1736
  starts.push(i); });
@@ -2097,12 +1751,22 @@ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
2097
1751
  write(preamble + nl + nl + block);
2098
1752
  return { ok: true, path: filePath };
2099
1753
  }
2100
- const targetIdx = starts.findIndex((s) => {
2101
- const m = lines[s].match(headingRe);
2102
- return m && m[2].trim() === (payload.originalTitle || '').trim();
1754
+ if (payload.originalId !== undefined && (typeof payload.originalId !== 'string' || !/^L-[0-9A-Za-z]{6,24}$/.test(payload.originalId))) {
1755
+ throw new Error('Invalid learning entry identity');
1756
+ }
1757
+ const matches = starts.map((start, index) => ({ start, index })).filter(({ start, index }) => {
1758
+ if (payload.originalId !== undefined) {
1759
+ const end = index + 1 < starts.length ? starts[index + 1] : lines.length;
1760
+ const ids = lines.slice(start + 1, end).map(line => line.trim().match(learning_entry_format_1.LEARNING_ENTRY_ID_LINE)).filter(Boolean);
1761
+ return ids.length === 1 && ids[0][1] === payload.originalId;
1762
+ }
1763
+ return lines[start].match(headingRe)?.[2].trim() === (payload.originalTitle || '').trim();
2103
1764
  });
2104
- if (targetIdx === -1)
2105
- throw new Error(`Learning entry not found: "${payload.originalTitle}"`);
1765
+ if (!matches.length)
1766
+ throw new Error(`Learning entry not found: "${payload.originalId ?? payload.originalTitle}"`);
1767
+ if (matches.length !== 1)
1768
+ throw new Error('Learning entry is ambiguous; reconcile duplicate identities or select a unique entry');
1769
+ const targetIdx = matches[0].index;
2106
1770
  const start = starts[targetIdx];
2107
1771
  const end = targetIdx + 1 < starts.length ? starts[targetIdx + 1] : lines.length;
2108
1772
  if (action === 'delete') {