fraim 2.0.270 → 2.0.271

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.
@@ -6,12 +6,16 @@
6
6
  * workspace root on the user's machine.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.CATEGORY_TO_FILETYPE = exports.BRAIN_LEARNING_FILETYPE_TO_REGION = exports.LEARNING_PRIORITIES = void 0;
9
+ 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
10
  exports.learningEntryHeadingRegex = learningEntryHeadingRegex;
11
+ exports.learningEntryIdLineRegex = learningEntryIdLineRegex;
12
+ exports.generateLearningEntryId = generateLearningEntryId;
11
13
  exports.getScoreThreshold = getScoreThreshold;
12
14
  exports.computeEffectiveScore = computeEffectiveScore;
13
15
  exports.mergeLearningEntriesAcrossTiers = mergeLearningEntriesAcrossTiers;
14
16
  exports.buildLearningContextSection = buildLearningContextSection;
17
+ exports.collectOfferedLearningEntries = collectOfferedLearningEntries;
18
+ exports.collectOfferedRuleFiles = collectOfferedRuleFiles;
15
19
  exports.buildTeamContextSection = buildTeamContextSection;
16
20
  exports.resolveTeamContextFiles = resolveTeamContextFiles;
17
21
  exports.isTeamContextKey = isTeamContextKey;
@@ -24,6 +28,7 @@ exports.readPreservedLearnings = readPreservedLearnings;
24
28
  exports.applyLearningEntryChange = applyLearningEntryChange;
25
29
  exports.isTruthyFlag = isTruthyFlag;
26
30
  const fs_1 = require("fs");
31
+ const crypto_1 = require("crypto");
27
32
  const path_1 = require("path");
28
33
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
29
34
  const brand_store_1 = require("../core/brand-store");
@@ -32,8 +37,17 @@ const brand_store_1 = require("../core/brand-store");
32
37
  // cache, which silently returned nothing once that cache stopped existing.
33
38
  const pack_home_1 = require("../cli/utils/pack-home");
34
39
  const learning_domains_1 = require("../config/learning-domains");
40
+ // Key construction only. The store's read/write path is still required lazily
41
+ // below, because that side pulls in the retention config and a static cycle
42
+ // through this module would be fragile.
43
+ const learning_usage_store_1 = require("./learning-usage-store");
35
44
  const REPO_LEARNINGS_REL = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)('personalized-employee/learnings').replace(/\/$/, '');
36
- const DEFAULT_THRESHOLD = 3.0;
45
+ /**
46
+ * The score at or above which an entry is injected. Exported so a test asserts
47
+ * the boundary the runtime actually uses rather than a copy of the number, which
48
+ * would keep passing if this default ever changed.
49
+ */
50
+ exports.DEFAULT_THRESHOLD = 3.0;
37
51
  const AGING_HORIZON_DAYS = 7;
38
52
  const MAX_ENTRIES_SCANNED = 200;
39
53
  const L0_SLEEP_ON_LEARNINGS_PROMPT_MIN = 5;
@@ -46,13 +60,44 @@ const L0_SLEEP_ON_LEARNINGS_PROMPT_MIN = 5;
46
60
  // 3. READ — the counters and the Hub (readPreservedLearnings) display them.
47
61
  // These previously drifted (each accepted a different priority set / heading
48
62
  // level), which silently dropped P-CRITICAL learnings. Everything now derives
49
- // from LEARNING_PRIORITIES, and a build-time contract test
50
- // (tests/isolated/test-learning-format-contract.ts) asserts all three sides agree.
63
+ // from LEARNING_PRIORITIES, and a build-time contract check
64
+ // (scripts/validate-learning-format-contract.ts, run by `npm run build`) asserts
65
+ // all three sides agree. Issue #1103 added the `**Id**` line to the same contract.
51
66
  exports.LEARNING_PRIORITIES = ['P-CRITICAL', 'P-HIGH', 'P-MED', 'P-LOW'];
52
67
  /** Canonical entry-heading matcher: `##`..`######` + `[P-X]` + optional title. */
53
68
  function learningEntryHeadingRegex(flags = '') {
54
69
  return new RegExp(`^#{2,}\\s+\\[(${exports.LEARNING_PRIORITIES.join('|')})\\]\\s*(.*)$`, flags);
55
70
  }
71
+ /**
72
+ * Issue #1103 R5: the entry's stable identifier, written as a `**Id**: L-…` line.
73
+ *
74
+ * A firing record has to survive a later title edit, which a title-derived key
75
+ * cannot do — so the identity is persisted on the entry rather than computed at
76
+ * read time. Generation is deterministic from (family, original title) only so a
77
+ * re-run of the migration never renumbers the corpus; once written, the id never
78
+ * changes, including when the title does.
79
+ */
80
+ /** What `generateLearningEntryId` emits: `L-` plus exactly ten hex characters. */
81
+ exports.LEARNING_ENTRY_ID_REGEX = /^L-[0-9a-f]{10}$/;
82
+ /**
83
+ * What the reader accepts. Deliberately wider than what the generator emits: a
84
+ * hand-written or hand-edited id must be honoured rather than silently ignored,
85
+ * because ignoring it would leave the entry looking un-stamped and route its
86
+ * firings to a title key without saying so.
87
+ */
88
+ const LEARNING_ENTRY_ID_LINE = /^\*\*Id\*\*:\s*(L-[0-9A-Za-z]{6,24})\s*$/;
89
+ function learningEntryIdLineRegex() {
90
+ return new RegExp(LEARNING_ENTRY_ID_LINE.source);
91
+ }
92
+ function generateLearningEntryId(fileType, title) {
93
+ // Ten hex characters: short enough for an agent to type into a retrospective
94
+ // by hand, wide enough that a collision across one manager's corpus is not a
95
+ // practical concern.
96
+ const digest = (0, crypto_1.createHash)('sha1')
97
+ .update(`${fileType}|${title.trim().replace(/\s+/g, ' ').toLowerCase()}`)
98
+ .digest('hex');
99
+ return `L-${digest.slice(0, 10)}`;
100
+ }
56
101
  /**
57
102
  * The manager location that is NOT the portable authoring base.
58
103
  *
@@ -243,7 +288,7 @@ function getScoreThreshold(workspaceRoot) {
243
288
  const t = config?.learning?.scoreThreshold;
244
289
  if (typeof t === 'number' && t > 0)
245
290
  return t;
246
- return DEFAULT_THRESHOLD;
291
+ return exports.DEFAULT_THRESHOLD;
247
292
  }
248
293
  /**
249
294
  * Effective score for an L1 learning entry. The aging-risk count below uses
@@ -252,7 +297,7 @@ function getScoreThreshold(workspaceRoot) {
252
297
  * @param now Optional override for "now" (for forward-looking aging-risk
253
298
  * calculations). Defaults to the current wall clock.
254
299
  */
255
- function computeEffectiveScore(severity, lastSeenDate, recurrences, fileType, now = new Date()) {
300
+ function computeEffectiveScore(severity, lastSeenDate, recurrences, fileType, now = new Date(), lastFiredDate) {
256
301
  const baseScore = severity === 'P-CRITICAL' ? 10 : severity === 'P-HIGH' ? 8 : severity === 'P-MED' ? 5 : 3;
257
302
  // Mistake patterns decay faster (90d) — they're tied to environments that change.
258
303
  // Preferences, manager-coaching, and validated-patterns express durable judgment (180d half-life).
@@ -265,11 +310,55 @@ function computeEffectiveScore(severity, lastSeenDate, recurrences, fileType, no
265
310
  catch {
266
311
  // Treat as fresh.
267
312
  }
268
- const decay = Math.pow(0.5, daysSinceLastSeen / halfLife);
313
+ // Issue #1103 R8: the clock is max(last_seen, last_fired). `Last seen` only
314
+ // advances when the same signal recurs, which for a mistake pattern means the
315
+ // mistake happened again — so an entry that fires and prevents its mistake
316
+ // generates no signal and decays *because* it worked. Reading the newer of the
317
+ // two dates fixes that inversion with one changed input: no new term, the same
318
+ // half-lives, the same threshold. The newer date is the smaller elapsed time,
319
+ // hence min() here. An absent or unparseable firing date contributes nothing,
320
+ // so it can never silently resurrect a dormant entry, and an entry with no
321
+ // usage record at all scores exactly as it did before.
322
+ let daysSinceClock = daysSinceLastSeen;
323
+ const lastFiredMs = parseClockDate(lastFiredDate);
324
+ if (lastFiredMs > 0 && !isImplausiblyFuture(lastFiredMs, now)) {
325
+ const daysSinceFired = Math.max(0, (now.getTime() - lastFiredMs) / (1000 * 60 * 60 * 24));
326
+ daysSinceClock = Number.isNaN(daysSinceClock) ? daysSinceFired : Math.min(daysSinceClock, daysSinceFired);
327
+ }
328
+ const decay = Math.pow(0.5, daysSinceClock / halfLife);
269
329
  const recurrenceBoost = Math.log2(Math.max(1, recurrences) + 1);
270
330
  return baseScore * decay * recurrenceBoost;
271
331
  }
272
- function scanMistakePatternFile(filePath, threshold, fileType = 'mistake-patterns') {
332
+ /** Clock skew tolerance for a firing date, in milliseconds. */
333
+ const FUTURE_FIRING_TOLERANCE_MS = 24 * 60 * 60 * 1000;
334
+ /**
335
+ * Is this firing date far enough ahead of now to be untrustworthy?
336
+ *
337
+ * `lastFiredAt` is stamped by whichever machine ran the job, and the usage store
338
+ * lives in the manager home, which is commonly a folder synced across machines.
339
+ * A machine running fast therefore writes a date another machine reads as the
340
+ * future, and elapsed time clamps to zero, so decay stops and the entry stays at
341
+ * full score indefinitely. That is the opposite of a decay model.
342
+ *
343
+ * A day of tolerance absorbs ordinary skew, where treating the firing as "just
344
+ * now" is right anyway. Beyond that the date is not believable, so it is ignored
345
+ * exactly as an unparseable one is, leaving the recurrence date in charge.
346
+ */
347
+ function isImplausiblyFuture(timestampMs, now) {
348
+ return timestampMs - now.getTime() > FUTURE_FIRING_TOLERANCE_MS;
349
+ }
350
+ /**
351
+ * Parse one side of the decay clock. An absent or unparseable date contributes
352
+ * nothing, so a malformed `last fired` can never silently resurrect a dormant
353
+ * entry: it just leaves the recurrence date in charge.
354
+ */
355
+ function parseClockDate(value) {
356
+ if (!value)
357
+ return 0;
358
+ const parsed = new Date(value).getTime();
359
+ return Number.isNaN(parsed) ? 0 : parsed;
360
+ }
361
+ function scanMistakePatternFile(filePath, threshold, fileType = 'mistake-patterns', usageClock) {
273
362
  const empty = { active: 0, dormant: 0, agingRisk: 0 };
274
363
  if (!(0, fs_1.existsSync)(filePath))
275
364
  return empty;
@@ -289,17 +378,20 @@ function scanMistakePatternFile(filePath, threshold, fileType = 'mistake-pattern
289
378
  let scanned = 0;
290
379
  let inEntry = false;
291
380
  let severity = null;
381
+ let title = '';
382
+ let entryId = null;
292
383
  let lastSeen = '';
293
384
  let recurrences = 1;
294
385
  const flush = () => {
295
386
  if (!severity)
296
387
  return;
297
388
  scanned++;
298
- const today = computeEffectiveScore(severity, lastSeen, recurrences, fileType, now);
389
+ const lastFired = usageClock ? usageClock(entryId, fileType, title) : null;
390
+ const today = computeEffectiveScore(severity, lastSeen, recurrences, fileType, now, lastFired);
299
391
  if (today >= threshold) {
300
392
  active++;
301
393
  if (lastSeen) {
302
- const future = computeEffectiveScore(severity, lastSeen, recurrences, fileType, horizon);
394
+ const future = computeEffectiveScore(severity, lastSeen, recurrences, fileType, horizon, lastFired);
303
395
  if (future < threshold)
304
396
  agingRisk++;
305
397
  }
@@ -316,12 +408,19 @@ function scanMistakePatternFile(filePath, threshold, fileType = 'mistake-pattern
316
408
  flush();
317
409
  inEntry = true;
318
410
  severity = headerMatch[1];
411
+ title = headerMatch[2].trim();
412
+ entryId = null;
319
413
  lastSeen = '';
320
414
  recurrences = 1;
321
415
  continue;
322
416
  }
323
417
  if (!inEntry)
324
418
  continue;
419
+ const idMatch = line.trim().match(LEARNING_ENTRY_ID_LINE);
420
+ if (idMatch) {
421
+ entryId = idMatch[1];
422
+ continue;
423
+ }
325
424
  const lastSeenMatch = line.match(/^\*\*Last seen\*\*:\s*(.+)/);
326
425
  if (lastSeenMatch) {
327
426
  lastSeen = lastSeenMatch[1].trim();
@@ -335,6 +434,30 @@ function scanMistakePatternFile(filePath, threshold, fileType = 'mistake-pattern
335
434
  flush();
336
435
  return { active, dormant, agingRisk };
337
436
  }
437
+ /**
438
+ * Build the usage clock from the store, or return undefined when there is no
439
+ * usage data at all. Returning undefined rather than an always-null function is
440
+ * deliberate: it keeps the "no usage record" path identical to pre-#1103
441
+ * behaviour instead of merely equivalent.
442
+ */
443
+ function resolveUsageClock(workspaceRoot) {
444
+ try {
445
+ // Required lazily: the store imports the retention config, which imports the
446
+ // workspace path helpers, and a cycle through this module would be fragile.
447
+ const store = require('./learning-usage-store');
448
+ const projection = require('./learning-usage-projection');
449
+ const loaded = store.readUsageStore();
450
+ if (Object.keys(loaded.entries).length === 0)
451
+ return undefined;
452
+ const lookup = projection.buildUsageLookup(loaded);
453
+ return (id, fileType, title) => projection.lookupUsage(lookup, id, fileType, title)?.lastFired ?? null;
454
+ }
455
+ catch {
456
+ // Usage data is derived and optional. A failure here must never change what
457
+ // the agent is given.
458
+ return undefined;
459
+ }
460
+ }
338
461
  function readFrontmatter(content) {
339
462
  const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
340
463
  if (!match)
@@ -488,16 +611,22 @@ function mergeLearningEntriesAcrossTiers(tiers) {
488
611
  }
489
612
  return [...byTitle.values()];
490
613
  }
491
- function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
492
- const roots = getLearningRoots(workspaceRoot);
493
- const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
494
- const threshold = getScoreThreshold(workspaceRoot);
495
- const CAT = {
496
- mistake: { ft: 'mistake-patterns', gated: true, label: '(entries above score threshold)' },
497
- pref: { ft: 'preferences', gated: false, label: '(all entries)' },
498
- coach: { ft: 'manager-coaching', gated: false, label: '(manager-facing; all entries)' },
499
- validated: { ft: 'validated-patterns', gated: true, label: '(entries above score threshold)' },
500
- };
614
+ const CONTEXT_CATEGORIES = {
615
+ mistake: { ft: 'mistake-patterns', gated: true, label: '(entries above score threshold)' },
616
+ pref: { ft: 'preferences', gated: false, label: '(all entries)' },
617
+ coach: { ft: 'manager-coaching', gated: false, label: '(manager-facing; all entries)' },
618
+ validated: { ft: 'validated-patterns', gated: true, label: '(entries above score threshold)' },
619
+ };
620
+ /**
621
+ * Resolve exactly the files the injected context block lists, in the order it lists
622
+ * them.
623
+ *
624
+ * Extracted from `buildLearningContextSection` for issue #1103: the block renders
625
+ * these as text, and the offer recorder enumerates their entries. Both have to see
626
+ * the identical set, so there is one resolution and two consumers rather than two
627
+ * resolutions that can drift.
628
+ */
629
+ function resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJob, domain) {
501
630
  // Domain axis (issue #806): session startup stays broad enough for stable
502
631
  // preferences plus L0, while job contexts load the full global set and may
503
632
  // append the current job's domain files below the global files.
@@ -507,69 +636,86 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
507
636
  const l2Cats = forJob ? jobL2Cats : sessionCats;
508
637
  const l1Cats = forJob ? jobL1Cats : sessionCats;
509
638
  const activeDomain = forJob && domain ? domain : null;
510
- const dormantOf = (meta, filePath) => meta.gated ? scanMistakePatternFile(filePath, threshold, meta.ft).dormant : 0;
511
- // Issue #1002 R4: same shape as resolveTier below, but a family may resolve to
512
- // MORE THAN ONE file (one per level that holds it), so each is listed.
513
- const resolveTierMulti = (cats, resolveFiles) => {
514
- const global = [];
639
+ let multiTier = false;
640
+ let coachPresent = false;
641
+ // Issue #1002 R4: resolve every tier holding each family, not just the winner,
642
+ // so a promoted entry never hides the rest of its family. `multiTier` is true
643
+ // when at least one family exists at more than one level, which is when the
644
+ // collision rule needs stating.
645
+ const collect = (cats, tiersFor, fallbackLevel) => {
646
+ const globalFiles = [];
515
647
  const domainFiles = [];
516
- let coachPresent = false;
517
- for (const key of cats) {
518
- const meta = CAT[key];
519
- for (const g of resolveFiles(meta.ft)) {
520
- global.push({ displayPath: g.displayPath, label: meta.label, dormant: dormantOf(meta, g.path), isCoach: key === 'coach' });
648
+ const push = (into, key, tiers) => {
649
+ if (tiers.length > 1)
650
+ multiTier = true;
651
+ const meta = CONTEXT_CATEGORIES[key];
652
+ for (const tier of tiers) {
653
+ into.push({
654
+ path: tier.path,
655
+ displayPath: tier.displayPath,
656
+ fileType: meta.ft,
657
+ gated: meta.gated,
658
+ label: meta.label,
659
+ isCoach: key === 'coach',
660
+ level: tier.level ?? fallbackLevel,
661
+ });
521
662
  if (key === 'coach')
522
663
  coachPresent = true;
523
664
  }
524
- if (activeDomain) {
525
- for (const d of resolveFiles(meta.ft, activeDomain)) {
526
- domainFiles.push({ displayPath: d.displayPath, label: meta.label, dormant: dormantOf(meta, d.path), isCoach: key === 'coach' });
527
- if (key === 'coach')
528
- coachPresent = true;
529
- }
530
- }
531
- }
532
- return { global, domain: domainFiles, coachPresent };
533
- };
534
- // Resolve one tier (L2 org or L1 personal) into ordered global + domain files.
535
- const resolveTier = (cats, resolveFile) => {
536
- const global = [];
537
- const domainFiles = [];
538
- let coachPresent = false;
665
+ };
539
666
  for (const key of cats) {
540
- const meta = CAT[key];
541
- const g = resolveFile(meta.ft);
542
- if (g.present) {
543
- global.push({ displayPath: g.displayPath, label: meta.label, dormant: dormantOf(meta, g.path), isCoach: key === 'coach' });
544
- if (key === 'coach')
545
- coachPresent = true;
546
- }
547
- if (activeDomain) {
548
- const d = resolveFile(meta.ft, activeDomain);
549
- if (d.present) {
550
- domainFiles.push({ displayPath: d.displayPath, label: meta.label, dormant: dormantOf(meta, d.path), isCoach: key === 'coach' });
551
- if (key === 'coach')
552
- coachPresent = true;
553
- }
554
- }
667
+ const meta = CONTEXT_CATEGORIES[key];
668
+ push(globalFiles, key, tiersFor(meta.ft));
669
+ if (activeDomain)
670
+ push(domainFiles, key, tiersFor(meta.ft, activeDomain));
555
671
  }
556
- return { global, domain: domainFiles, coachPresent };
672
+ return [...globalFiles, ...domainFiles];
557
673
  };
558
- // Issue #1002 R4: resolve every tier holding each family, not just the winner,
559
- // so a promoted entry never hides the rest of its family. `multiTier` is true
560
- // when at least one family exists at more than one level, which is when the
561
- // collision rule needs stating.
562
- let multiTier = false;
563
- const resolveTiersFor = (tiersFor) => (ft, dom) => {
564
- const tiers = tiersFor(ft, dom);
565
- if (tiers.length > 1)
566
- multiTier = true;
567
- return tiers.map((t) => ({ present: true, path: t.path, displayPath: t.displayPath }));
568
- };
569
- const l2 = resolveTierMulti(l2Cats, resolveTiersFor((ft, dom) => resolveOrgLearningFileTiers(roots.repoLearningsBase, dom ? `org-${dom}-${ft}.md` : `org-${ft}.md`)));
570
- const l1 = resolveTierMulti(l1Cats, resolveTiersFor((ft, dom) => resolvePersonalLearningFileTiers(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, dom ? `${resolvedUserId}-${dom}-${ft}.md` : `${resolvedUserId}-${ft}.md`)));
571
- const l2Files = [...l2.global, ...l2.domain];
572
- const l1Files = [...l1.global, ...l1.domain];
674
+ const l2 = collect(l2Cats, (ft, dom) => resolveOrgLearningFileTiers(roots.repoLearningsBase, dom ? `org-${dom}-${ft}.md` : `org-${ft}.md`), 'org');
675
+ const l1 = collect(l1Cats, (ft, dom) => resolvePersonalLearningFileTiers(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, dom ? `${resolvedUserId}-${dom}-${ft}.md` : `${resolvedUserId}-${ft}.md`), 'manager');
676
+ return { l2, l1, multiTier, coachPresent };
677
+ }
678
+ /**
679
+ * Report how many entries have been demoted, without naming the file they live in.
680
+ *
681
+ * Issue #861 keeps demoted entries out of the injected context, and #1103 keeps it
682
+ * that way after trying the alternative. Naming the `.cold.md` path here was
683
+ * considered and reverted: the path states a family (`…-mistake-patterns.cold.md`)
684
+ * and not a subject, so an instruction to "read it if this job touches its
685
+ * subject" is one the agent has no basis to follow. It degrades to reading the
686
+ * file every job, which is the token cost #861 removed, or never, which makes the
687
+ * line noise.
688
+ *
689
+ * Recovery for a demoted entry is a synthesis-time concern, not a runtime one.
690
+ * `sleep-on-learnings` reads both tiers when deciding whether a signal is a
691
+ * recurrence, so an entry that becomes relevant again is found, updated and
692
+ * promoted there. See that job's gather-signals and analyze phases.
693
+ */
694
+ function renderDemotedLine(listed, label) {
695
+ const total = listed.reduce((sum, f) => sum + f.dormant, 0);
696
+ if (total === 0)
697
+ return '';
698
+ return total === 1
699
+ ? `Demoted: 1 ${label} pattern has gone quiet and is not listed above.\n`
700
+ : `Demoted: ${total} ${label} patterns have gone quiet and are not listed above.\n`;
701
+ }
702
+ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
703
+ const roots = getLearningRoots(workspaceRoot);
704
+ const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
705
+ const threshold = getScoreThreshold(workspaceRoot);
706
+ const usageClock = resolveUsageClock(workspaceRoot);
707
+ const resolved = resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJob, domain);
708
+ const multiTier = resolved.multiTier;
709
+ const toListed = (f) => ({
710
+ displayPath: f.displayPath,
711
+ label: f.label,
712
+ dormant: f.gated
713
+ ? scanMistakePatternFile(f.path, threshold, f.fileType, usageClock).dormant
714
+ : 0,
715
+ isCoach: f.isCoach,
716
+ });
717
+ const l2Files = resolved.l2.map(toListed);
718
+ const l1Files = resolved.l1.map(toListed);
573
719
  const pendingL0Sources = collectPendingL0SourceFiles(workspaceRoot, resolvedUserId, roots);
574
720
  const l0CoachingCount = pendingL0Sources.filter(source => source.kind === 'coaching-moment').length;
575
721
  const l0RetroCount = pendingL0Sources.filter(source => source.kind === 'retrospective').length;
@@ -585,20 +731,14 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
585
731
  section += '### L2 - Org patterns\n';
586
732
  for (const f of l2Files)
587
733
  section += `\`${f.displayPath}\` ${f.label}\n`;
588
- const l2DormantTotal = l2Files.reduce((sum, f) => sum + f.dormant, 0);
589
- if (l2DormantTotal > 0) {
590
- section += `Dormant: ${l2DormantTotal} org pattern${l2DormantTotal !== 1 ? 's' : ''} below threshold\n`;
591
- }
734
+ section += renderDemotedLine(l2Files, 'org');
592
735
  section += '\n';
593
736
  }
594
737
  if (hasL1) {
595
738
  section += '### L1 - Your patterns\n';
596
739
  for (const f of l1Files)
597
740
  section += `\`${f.displayPath}\` ${f.label}\n`;
598
- const l1DormantTotal = l1Files.reduce((sum, f) => sum + f.dormant, 0);
599
- if (l1DormantTotal > 0) {
600
- section += `Dormant: ${l1DormantTotal} personal pattern${l1DormantTotal !== 1 ? 's' : ''} below threshold\n`;
601
- }
741
+ section += renderDemotedLine(l1Files, 'personal');
602
742
  section += '\n';
603
743
  }
604
744
  if (l0CoachingCount > 0 || l0RetroCount > 0) {
@@ -620,7 +760,7 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
620
760
  }
621
761
  section += '\n';
622
762
  }
623
- const coachPresent = l1.coachPresent || l2.coachPresent;
763
+ const coachPresent = resolved.coachPresent;
624
764
  if (forJob) {
625
765
  if (hasL2 || hasL1) {
626
766
  section += 'Read the listed synthesized learning files before continuing, then apply the relevant patterns and preferences in this job.\n';
@@ -643,6 +783,146 @@ function buildLearningContextSection(workspaceRoot, userId, forJob, domain) {
643
783
  }
644
784
  return section;
645
785
  }
786
+ /** Enumerate one learning file's entries with their above-threshold state. */
787
+ function enumerateFileEntries(file, threshold, usageClock, now) {
788
+ if (!(0, fs_1.existsSync)(file.path))
789
+ return [];
790
+ let content;
791
+ try {
792
+ content = (0, fs_1.readFileSync)(file.path, 'utf8');
793
+ }
794
+ catch {
795
+ return [];
796
+ }
797
+ const out = [];
798
+ const headingRe = learningEntryHeadingRegex();
799
+ let current = null;
800
+ const flush = () => {
801
+ if (!current)
802
+ return;
803
+ // A non-gated family (preferences, manager-coaching) is delivered whole, so
804
+ // every entry in it is active guidance. A gated family delivers only entries
805
+ // at or above threshold; the rest are the dormant rollup.
806
+ let aboveThreshold = true;
807
+ if (file.gated) {
808
+ if (current.lastSeen) {
809
+ const lastFired = usageClock ? usageClock(current.id, file.fileType, current.title) : null;
810
+ aboveThreshold = computeEffectiveScore(current.severity, current.lastSeen, current.recurrences, file.fileType, now, lastFired) >= threshold;
811
+ }
812
+ // No date metadata: parseLearningEntries treats that as fresh and injects it,
813
+ // so the offer record has to agree.
814
+ }
815
+ out.push({
816
+ // The store owns key construction. Building the composite key by hand here
817
+ // would be a second definition of what an entry's identity is, and the two
818
+ // would drift the first time normalization changed.
819
+ key: current.id ?? (0, learning_usage_store_1.titleUsageKey)(file.fileType, current.title),
820
+ family: 'learning',
821
+ id: current.id,
822
+ title: current.title,
823
+ fileType: file.fileType,
824
+ level: file.level,
825
+ displayPath: file.displayPath,
826
+ severity: current.severity,
827
+ aboveThreshold,
828
+ });
829
+ current = null;
830
+ };
831
+ for (const line of content.split(/\r?\n/)) {
832
+ const header = line.match(headingRe);
833
+ if (header) {
834
+ flush();
835
+ current = { severity: header[1], title: header[2].trim(), id: null, lastSeen: '', recurrences: 1 };
836
+ continue;
837
+ }
838
+ if (!current)
839
+ continue;
840
+ const trimmed = line.trim();
841
+ const idMatch = trimmed.match(LEARNING_ENTRY_ID_LINE);
842
+ if (idMatch) {
843
+ current.id = idMatch[1];
844
+ continue;
845
+ }
846
+ const lastSeenMatch = trimmed.match(/^\*\*Last seen\*\*:\s*(.+)/i);
847
+ if (lastSeenMatch) {
848
+ current.lastSeen = lastSeenMatch[1].trim();
849
+ continue;
850
+ }
851
+ const recurrenceMatch = trimmed.match(/^\*\*Recurrences\*\*:\s*(\d+)/i);
852
+ if (recurrenceMatch) {
853
+ current.recurrences = parseInt(recurrenceMatch[1], 10) || 1;
854
+ }
855
+ }
856
+ flush();
857
+ return out;
858
+ }
859
+ /**
860
+ * Issue #1103 R1 and R2: every learning entry the runtime just delivered.
861
+ *
862
+ * "Delivered" is file-level, because the injected block lists file paths rather
863
+ * than entry titles: an entry is offered when it sat inside a file the block
864
+ * listed. `aboveThreshold` is what separates active guidance from the dormant
865
+ * rollup. Nothing here depends on the agent reading anything.
866
+ *
867
+ * Deduplicated by key with the later tier winning, matching how
868
+ * `mergeLearningEntriesAcrossTiers` resolves a title collision across levels, so
869
+ * one lesson is one offer even when two levels hold it.
870
+ */
871
+ function collectOfferedLearningEntries(workspaceRoot, userId, forJob, domain) {
872
+ const roots = getLearningRoots(workspaceRoot);
873
+ const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
874
+ const threshold = getScoreThreshold(workspaceRoot);
875
+ const usageClock = resolveUsageClock(workspaceRoot);
876
+ const now = new Date();
877
+ const resolved = resolveContextLearningFiles(workspaceRoot, resolvedUserId, roots, forJob, domain);
878
+ const byKey = new Map();
879
+ for (const file of [...resolved.l2, ...resolved.l1]) {
880
+ for (const entry of enumerateFileEntries(file, threshold, usageClock, now)) {
881
+ byKey.set(entry.key, entry);
882
+ }
883
+ }
884
+ return [...byKey.values()];
885
+ }
886
+ /**
887
+ * Issue #1103 R3: the auto-loaded Team Context rule files.
888
+ *
889
+ * These are the most influential instructions in the system and produce no usage
890
+ * signal today, because the agent reads them with its own file tool and the proxy
891
+ * never sees a `get_fraim_file` call for them. Recording the offer at composition
892
+ * time closes that gap. A rule is unconditional, so it is always above threshold.
893
+ */
894
+ function collectOfferedRuleFiles(workspaceRoot, forJob) {
895
+ const resolved = resolveTeamContextFiles(workspaceRoot);
896
+ // The three rule files by name, not by path sniffing: these are the ones R3
897
+ // names, and the other Team Context files are context rather than instruction,
898
+ // so recording them as offered guidance would misreport what they are.
899
+ const ruleFiles = [
900
+ { presence: resolved.orgRules, level: 'org' },
901
+ { presence: resolved.managerRules, level: 'manager' },
902
+ { presence: resolved.projectRules, level: 'project' },
903
+ ];
904
+ const out = [];
905
+ for (const { presence, level } of ruleFiles) {
906
+ if (!presence.present || !presence.displayPath)
907
+ continue;
908
+ const displayPath = presence.displayPath.replace(/\\/g, '/');
909
+ out.push({
910
+ key: (0, learning_usage_store_1.ruleUsageKey)(displayPath),
911
+ family: 'rule',
912
+ id: null,
913
+ title: displayPath.split('/').pop() ?? displayPath,
914
+ fileType: 'rules',
915
+ level,
916
+ displayPath,
917
+ severity: null,
918
+ aboveThreshold: true,
919
+ });
920
+ }
921
+ // `forJob` is accepted so both injection points call this identically; the Team
922
+ // Context block lists the same rule files in either frame.
923
+ void forJob;
924
+ return out;
925
+ }
646
926
  /**
647
927
  * Resolve an organization/manager-scope context file (issue #563 order, R3.1):
648
928
  * 1. repo-local override (`fraim/personalized-employee/…`) — wins when present
@@ -799,6 +1079,15 @@ const TEAM_CONTEXT_FILE_MAP = {
799
1079
  projectRules: { relativePath: 'rules/project_rules.md', scope: 'project' },
800
1080
  projectQa: { relativePath: 'context/project_qa.md', scope: 'project' }
801
1081
  };
1082
+ /**
1083
+ * The pack-relative paths of every Team Context file, which is the set an agent
1084
+ * receives with nothing referencing it. Exported because `registry/scripts/fraim/`
1085
+ * is standalone CommonJS and cannot import from here, so `instruction-hygiene.js`
1086
+ * carries a copy; `scripts/validate-team-context-contract.ts` compares the two and
1087
+ * fails the build if they diverge.
1088
+ */
1089
+ exports.TEAM_CONTEXT_RELATIVE_PATHS = Object.values(TEAM_CONTEXT_FILE_MAP)
1090
+ .map((entry) => entry.relativePath);
802
1091
  function isTeamContextKey(value) {
803
1092
  return typeof value === 'string' && Object.prototype.hasOwnProperty.call(TEAM_CONTEXT_FILE_MAP, value);
804
1093
  }
@@ -1095,10 +1384,12 @@ const PENDING_KIND_LABEL = {
1095
1384
  };
1096
1385
  /** Filename infix that separates hot (.md) from cold-tier (.cold.md) learning files (#861). */
1097
1386
  const COLD_FILE_SUFFIX = '.cold';
1387
+ /** R11's "fired recently" window. */
1388
+ const FIRED_RECENT_DAYS = 30;
1098
1389
  // Parse `## [P-…] Title` entries out of a learning file WITH their effective
1099
1390
  // score + last-seen (unlike parseLearningEntries, which drops that bookkeeping
1100
1391
  // for the Hub text display). Used only by the brain-dot projection.
1101
- function parseBrainLearningEntries(filePath, fileType) {
1392
+ function parseBrainLearningEntries(filePath, fileType, usage) {
1102
1393
  if (!(0, fs_1.existsSync)(filePath))
1103
1394
  return [];
1104
1395
  let content;
@@ -1115,14 +1406,17 @@ function parseBrainLearningEntries(filePath, fileType) {
1115
1406
  const flush = () => {
1116
1407
  if (!current)
1117
1408
  return;
1409
+ const projected = usage ? usage(current.id, fileType, current.title) : undefined;
1118
1410
  const rawScore = current.lastSeen
1119
- ? computeEffectiveScore(current.severity, current.lastSeen, current.recurrences, fileType, now)
1411
+ ? computeEffectiveScore(current.severity, current.lastSeen, current.recurrences, fileType, now, projected?.lastFired ?? null)
1120
1412
  : null;
1121
1413
  out.push({
1122
1414
  title: current.title,
1123
1415
  severity: current.severity,
1124
1416
  score: rawScore === null ? null : Math.round(rawScore * 10) / 10,
1125
1417
  lastSeen: current.lastSeen || null,
1418
+ id: current.id,
1419
+ ...(projected ?? {}),
1126
1420
  });
1127
1421
  current = null;
1128
1422
  };
@@ -1130,11 +1424,16 @@ function parseBrainLearningEntries(filePath, fileType) {
1130
1424
  const header = line.match(headingRe);
1131
1425
  if (header) {
1132
1426
  flush();
1133
- current = { severity: header[1], title: header[2].trim(), lastSeen: '', recurrences: 1 };
1427
+ current = { severity: header[1], title: header[2].trim(), id: null, lastSeen: '', recurrences: 1 };
1134
1428
  continue;
1135
1429
  }
1136
1430
  if (!current)
1137
1431
  continue;
1432
+ const idMatch = line.trim().match(LEARNING_ENTRY_ID_LINE);
1433
+ if (idMatch) {
1434
+ current.id = idMatch[1];
1435
+ continue;
1436
+ }
1138
1437
  const lastSeenMatch = line.match(/^\*\*Last seen\*\*:\s*(.+)/);
1139
1438
  if (lastSeenMatch) {
1140
1439
  current.lastSeen = lastSeenMatch[1].trim();
@@ -1148,6 +1447,46 @@ function parseBrainLearningEntries(filePath, fileType) {
1148
1447
  flush();
1149
1448
  return out;
1150
1449
  }
1450
+ /**
1451
+ * Project the store into the shape the Brain overlay reads (R11). Returns
1452
+ * undefined for an entry with no usage record, which is what keeps the detail
1453
+ * panel from showing counts that were never measured.
1454
+ */
1455
+ function resolveBrainUsageProjector() {
1456
+ try {
1457
+ const store = require('./learning-usage-store');
1458
+ const projection = require('./learning-usage-projection');
1459
+ const loaded = store.readUsageStore();
1460
+ if (Object.keys(loaded.entries).length === 0)
1461
+ return undefined;
1462
+ const lookup = projection.buildUsageLookup(loaded);
1463
+ const now = Date.now();
1464
+ return (id, fileType, title) => {
1465
+ const value = projection.lookupUsage(lookup, id, fileType, title);
1466
+ if (!value)
1467
+ return undefined;
1468
+ let usageState;
1469
+ if (value.lastFired) {
1470
+ const ageDays = (now - new Date(value.lastFired).getTime()) / 86_400_000;
1471
+ usageState = ageDays <= FIRED_RECENT_DAYS ? 'fired-recent' : 'fired-aging';
1472
+ }
1473
+ else {
1474
+ usageState = 'never-fired';
1475
+ }
1476
+ return {
1477
+ offered: value.offered,
1478
+ fired: value.fired,
1479
+ lastFired: value.lastFired,
1480
+ firedUnder: value.firedUnder,
1481
+ fireLog: value.log.map((l) => ({ date: l.date, outcome: l.outcome, job: l.job, note: l.note })),
1482
+ usageState,
1483
+ };
1484
+ };
1485
+ }
1486
+ catch {
1487
+ return undefined;
1488
+ }
1489
+ }
1151
1490
  /**
1152
1491
  * Build the brain learning-dot projection (issue #833) for one user: pending L0
1153
1492
  * signals + processed L1 (personal) and L2 (org) learnings grouped by file type
@@ -1163,6 +1502,8 @@ function collectBrainLearningDots(workspaceRoot, userId) {
1163
1502
  fileName: s.displayPath.split('/').pop() || s.displayPath,
1164
1503
  displayPath: s.displayPath,
1165
1504
  }));
1505
+ // Issue #1103 R11: one store read serves every dot in this projection.
1506
+ const usageProjector = resolveBrainUsageProjector();
1166
1507
  const resolvePersonal = (fileName) => resolvePersonalLearningFile(roots.repoLearningsBase, roots.managerCacheBase, roots.managerCacheDisplayBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, fileName);
1167
1508
  const processed = [];
1168
1509
  const fileTypes = ['mistake-patterns', 'preferences', 'validated-patterns', 'manager-coaching'];
@@ -1171,14 +1512,14 @@ function collectBrainLearningDots(workspaceRoot, userId) {
1171
1512
  const typeLabel = BRAIN_LEARNING_FILETYPE_LABEL[fileType];
1172
1513
  // L1 personal (hot file)
1173
1514
  const personal = resolvePersonal(`${resolvedUserId}-${fileType}.md`);
1174
- const personalEntries = parseBrainLearningEntries(personal.path, fileType);
1515
+ const personalEntries = parseBrainLearningEntries(personal.path, fileType, usageProjector);
1175
1516
  if (personalEntries.length) {
1176
1517
  processed.push({ type: fileType, typeLabel, region, scope: 'personal', displayPath: personal.displayPath, entries: personalEntries });
1177
1518
  }
1178
1519
  // L1 cold sibling — only emitted when the .cold.md file exists alongside the hot file
1179
1520
  const coldFileName = `${resolvedUserId}-${fileType}${COLD_FILE_SUFFIX}.md`;
1180
1521
  const coldResolved = resolvePersonal(coldFileName);
1181
- const coldEntries = parseBrainLearningEntries(coldResolved.path, fileType);
1522
+ const coldEntries = parseBrainLearningEntries(coldResolved.path, fileType, usageProjector);
1182
1523
  if (coldEntries.length) {
1183
1524
  processed.push({ type: fileType, typeLabel, region, scope: 'cold', displayPath: coldResolved.displayPath, entries: coldEntries });
1184
1525
  }
@@ -1187,7 +1528,7 @@ function collectBrainLearningDots(workspaceRoot, userId) {
1187
1528
  const orgTiers = resolveOrgLearningFileTiers(roots.repoLearningsBase, `org-${fileType}.md`);
1188
1529
  if (orgTiers.length > 0) {
1189
1530
  // Merge entries across tiers; deduplicate by title (last tier wins = repo-local wins).
1190
- const allOrgEntries = orgTiers.flatMap(t => parseBrainLearningEntries(t.path, fileType));
1531
+ const allOrgEntries = orgTiers.flatMap(t => parseBrainLearningEntries(t.path, fileType, usageProjector));
1191
1532
  const byTitle = new Map();
1192
1533
  for (const e of allOrgEntries)
1193
1534
  byTitle.set(e.title.trim().toLowerCase(), e);
@@ -1236,7 +1577,7 @@ function listDomainLearningFiles(dir, prefix, fileType) {
1236
1577
  // threshold: entries with score >= threshold (or in a non-gated category) are
1237
1578
  // marked injected:true. Callers that don't need scoring pass Infinity so
1238
1579
  // all entries are injected.
1239
- function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier, threshold = DEFAULT_THRESHOLD) {
1580
+ function parseLearningEntries(filePath, displayPath, category, level, domain = 'global', tier, threshold = exports.DEFAULT_THRESHOLD, usageClock) {
1240
1581
  if (!(0, fs_1.existsSync)(filePath))
1241
1582
  return [];
1242
1583
  let content;
@@ -1260,7 +1601,8 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1260
1601
  current.body = bodyLines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
1261
1602
  if (isScoreGated) {
1262
1603
  if (curLastSeen !== null) {
1263
- const s = computeEffectiveScore(current.severity, curLastSeen, curRecurrences, fileType);
1604
+ const lastFired = usageClock ? usageClock(current.id, fileType, current.title) : null;
1605
+ const s = computeEffectiveScore(current.severity, curLastSeen, curRecurrences, fileType, new Date(), lastFired);
1264
1606
  current.score = s;
1265
1607
  current.injected = s >= threshold;
1266
1608
  }
@@ -1289,6 +1631,7 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1289
1631
  const entry = {
1290
1632
  severity: header[1],
1291
1633
  title: header[2].trim(),
1634
+ id: null,
1292
1635
  body: '', source: displayPath, category, level, domain,
1293
1636
  score: null, injected: true,
1294
1637
  };
@@ -1305,6 +1648,11 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1305
1648
  }
1306
1649
  const t = line.trim();
1307
1650
  // Capture scoring metadata but exclude from body.
1651
+ const idMatch = t.match(LEARNING_ENTRY_ID_LINE);
1652
+ if (idMatch) {
1653
+ current.id = idMatch[1];
1654
+ continue;
1655
+ }
1308
1656
  const lastSeenMatch = t.match(/^\*\*Last seen\*\*:\s*(.+)/i);
1309
1657
  if (lastSeenMatch) {
1310
1658
  curLastSeen = lastSeenMatch[1].trim();
@@ -1315,7 +1663,9 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1315
1663
  curRecurrences = parseInt(recurrencesMatch[1], 10) || 1;
1316
1664
  continue;
1317
1665
  }
1318
- if (/^\*\*(Score|Technical trace|Users|First synthesized)\*\*:/i.test(t))
1666
+ // `Id` is listed here as well as matched above so a malformed id line is still
1667
+ // treated as bookkeeping rather than leaking into the displayed lesson.
1668
+ if (/^\*\*(Id|Score|Technical trace|Users|First synthesized)\*\*:/i.test(t))
1319
1669
  continue;
1320
1670
  if (/^(First|Last) synthesized:/i.test(t))
1321
1671
  continue;
@@ -1327,12 +1677,15 @@ function parseLearningEntries(filePath, displayPath, category, level, domain = '
1327
1677
  function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine') {
1328
1678
  const roots = getLearningRoots(workspaceRoot);
1329
1679
  const threshold = getScoreThreshold(workspaceRoot);
1680
+ // Issue #1103 R8: the Hub's injected flag must agree with the loader's, so both
1681
+ // read the same clock. One store read serves the whole call.
1682
+ const usageClock = resolveUsageClock(workspaceRoot);
1330
1683
  const out = [];
1331
1684
  // Helper: parse + merge a list of tier paths for one file family.
1332
1685
  // ResolvedLearningTier uses 'manager' to mean machine-level personal; map it to 'machine'.
1333
1686
  const tierLevel = (t) => t.level === 'manager' ? 'machine' : t.level;
1334
1687
  const parseTiers = (tiers, cat, tierVal, domainVal) => mergeLearningEntriesAcrossTiers(tiers.map((t) => ({
1335
- entries: parseLearningEntries(t.path, t.displayPath, cat, tierLevel(t), domainVal ?? 'global', tierVal, threshold),
1688
+ entries: parseLearningEntries(t.path, t.displayPath, cat, tierLevel(t), domainVal ?? 'global', tierVal, threshold, usageClock),
1336
1689
  })));
1337
1690
  if (scope === 'org') {
1338
1691
  for (const cat of ['avoid', 'preference', 'repeat', 'coaching']) {