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.
- package/dist/registry/scripts/learning/completion-state.js +123 -4
- package/dist/registry/scripts/learning/corpus-audit.js +205 -0
- package/dist/registry/scripts/learning/entry-authoring.js +181 -0
- package/dist/registry/scripts/learning/entry-format.js +10 -1
- package/dist/registry/scripts/learning/outcome-assessment.js +162 -0
- package/dist/registry/scripts/learning/outcome-types.js +92 -0
- package/dist/registry/scripts/learning/owner-identity.js +148 -0
- package/dist/registry/scripts/learning/proposal-queue.js +81 -0
- package/dist/registry/scripts/learning/run-application.js +1003 -0
- package/dist/registry/scripts/learning/run-publication.js +364 -0
- package/dist/registry/scripts/learning/run-record.js +895 -0
- package/dist/registry/scripts/learning-usage/learning-source-record.js +131 -0
- package/dist/src/cli/commands/learning.js +64 -0
- package/dist/src/cli/doctor/checks/scripts-checks.js +53 -1
- package/dist/src/cli/fraim.js +2 -0
- package/dist/src/cli/learning/inventory.js +285 -0
- package/dist/src/cli/learning/queue-operations.js +199 -0
- package/dist/src/cli/learning/runtime.js +82 -0
- package/dist/src/cli/learning/usage.js +277 -0
- package/dist/src/core/fraim-config-schema.generated.js +0 -4
- package/dist/src/local-mcp-server/learning-context-builder.js +141 -477
- package/dist/src/local-mcp-server/learning-entry-format.js +25 -0
- package/dist/src/local-mcp-server/learning-usage-analysis.js +12 -410
- package/dist/src/local-mcp-server/learning-usage-eligibility.js +8 -14
- package/dist/src/local-mcp-server/learning-usage-projection.js +5 -22
- package/dist/src/local-mcp-server/learning-usage-store.js +93 -393
- package/dist/src/local-mcp-server/learning-usage-verify.js +9 -40
- package/dist/src/local-mcp-server/stdio-server.js +0 -48
- package/package.json +11 -1
- package/dist/src/local-mcp-server/learning-firing-parser.js +0 -247
- package/dist/src/local-mcp-server/learning-usage-attestation.js +0 -238
- 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
|
-
|
|
74
|
-
|
|
75
|
-
function
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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()
|
|
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
|
-
//
|
|
365
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
*
|
|
727
|
-
*
|
|
728
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
868
|
-
//
|
|
869
|
-
|
|
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:
|
|
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:
|
|
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,
|
|
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
|
-
//
|
|
1487
|
-
//
|
|
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
|
|
1492
|
-
const
|
|
1153
|
+
const seenFiles = new Set();
|
|
1154
|
+
const counts = { manager: 0, project: 0 };
|
|
1493
1155
|
for (const tier of tiers) {
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
|
|
1955
|
-
//
|
|
1956
|
-
//
|
|
1957
|
-
const
|
|
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,
|
|
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
|
-
//
|
|
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,
|
|
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
|
-
//
|
|
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
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
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
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
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 (
|
|
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') {
|