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.
- package/dist/src/cli/commands/add-ide.js +28 -2
- package/dist/src/cli/commands/learning-usage.js +412 -0
- package/dist/src/cli/fraim.js +2 -0
- package/dist/src/core/ai-mentor.js +27 -14
- package/dist/src/core/config-loader.js +48 -3
- package/dist/src/core/fraim-config-schema.generated.js +18 -0
- package/dist/src/core/handoff-contracts.js +37 -1
- package/dist/src/core/job-phases.js +2 -14
- package/dist/src/core/resolve-phase-edge.js +75 -0
- package/dist/src/core/types.js +7 -1
- package/dist/src/core/utils/git-utils.js +24 -14
- package/dist/src/core/utils/project-fraim-paths.js +16 -1
- package/dist/src/local-mcp-server/artifact-retention-cleanup.js +8 -0
- package/dist/src/local-mcp-server/learning-context-builder.js +448 -95
- package/dist/src/local-mcp-server/learning-firing-parser.js +247 -0
- package/dist/src/local-mcp-server/learning-usage-analysis.js +347 -0
- package/dist/src/local-mcp-server/learning-usage-attestation.js +191 -0
- package/dist/src/local-mcp-server/learning-usage-projection.js +79 -0
- package/dist/src/local-mcp-server/learning-usage-store.js +417 -0
- package/dist/src/local-mcp-server/stdio-server.js +43 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
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
|
|
50
|
-
// (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
//
|
|
513
|
-
|
|
514
|
-
|
|
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
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
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 =
|
|
541
|
-
|
|
542
|
-
if (
|
|
543
|
-
|
|
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
|
|
672
|
+
return [...globalFiles, ...domainFiles];
|
|
557
673
|
};
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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']) {
|