fraim-framework 2.0.160 → 2.0.162

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.
@@ -235,7 +235,13 @@ const runInitProject = async (options = {}) => {
235
235
  }
236
236
  result.warnings.push(`${configDisplayPath} was not created by init-project. The manager \`project-onboarding\` job is the only supported config-writing path.`);
237
237
  }
238
- ['jobs', 'ai-employee/jobs', 'ai-employee/skills', 'ai-manager/jobs', 'personalized-employee'].forEach((dir) => {
238
+ [
239
+ 'jobs',
240
+ 'ai-employee/jobs',
241
+ 'ai-employee/skills',
242
+ 'ai-manager/jobs',
243
+ 'personalized-employee',
244
+ ].forEach((dir) => {
239
245
  const dirPath = path_1.default.join(fraimDir, dir);
240
246
  if (!fs_1.default.existsSync(dirPath)) {
241
247
  fs_1.default.mkdirSync(dirPath, { recursive: true });
@@ -71,7 +71,7 @@ This repository uses FRAIM.
71
71
  - When users ask for next step recommendations, use recommend-next-job skill under \`${employeeSkillsPath}/\` to gather context before suggesting jobs.
72
72
 
73
73
  > [!IMPORTANT]
74
- > **Job stubs are for discovery only.** When a user @mentions or references any file under \`${employeeJobsPath}/\` or \`${managerJobsPath}/\`, do NOT attempt to execute the job from the stub content. The stub only shows intent and phase names. Always call \`get_fraim_job({ job: "<job-name>" })\` first to get the full phased instructions before doing any work.
74
+ > **Job stubs are for discovery only.** When a user mentions or references any file under \`${employeeJobsPath}/\` or \`${managerJobsPath}/\`, do NOT attempt to execute the job from the stub content. The stub only shows intent and phase names. Always call \`get_fraim_job({ job: "<job-name>" })\` first to get the full phased instructions before doing any work.
75
75
  `);
76
76
  const cursorManagedBody = buildManagedSection(`
77
77
  # FRAIM
@@ -349,6 +349,51 @@ exports.FRAIM_CONFIG_SCHEMA = {
349
349
  }
350
350
  }
351
351
  },
352
+ "playbooks": {
353
+ "kind": "object",
354
+ "properties": {
355
+ "directory": {
356
+ "kind": "string"
357
+ },
358
+ "decisionCommand": {
359
+ "kind": "string"
360
+ },
361
+ "decisionTimeoutMs": {
362
+ "kind": "number"
363
+ }
364
+ }
365
+ },
366
+ "actionAdapters": {
367
+ "kind": "record",
368
+ "value": {
369
+ "kind": "object",
370
+ "properties": {
371
+ "type": {
372
+ "kind": "enum",
373
+ "values": [
374
+ "command",
375
+ "manual",
376
+ "handoff"
377
+ ]
378
+ },
379
+ "command": {
380
+ "kind": "string"
381
+ },
382
+ "timeoutMs": {
383
+ "kind": "number"
384
+ },
385
+ "targetSystem": {
386
+ "kind": "string"
387
+ },
388
+ "targetIdentityProvider": {
389
+ "kind": "string"
390
+ },
391
+ "instructions": {
392
+ "kind": "string"
393
+ }
394
+ }
395
+ }
396
+ },
352
397
  "queue": {
353
398
  "kind": "object",
354
399
  "properties": {
@@ -407,17 +452,6 @@ exports.FRAIM_CONFIG_SCHEMA = {
407
452
  "requireApprovalState": {
408
453
  "kind": "string"
409
454
  },
410
- "selector": {
411
- "kind": "object",
412
- "properties": {
413
- "shortDescriptionContains": {
414
- "kind": "array",
415
- "element": {
416
- "kind": "string"
417
- }
418
- }
419
- }
420
- },
421
455
  "microsoftPasswordReset": {
422
456
  "kind": "object",
423
457
  "properties": {
@@ -564,6 +598,11 @@ exports.SUPPORTED_FRAIM_CONFIG_PATHS = [
564
598
  "automation.support.contextResolver.scriptPath",
565
599
  "automation.support.contextResolver.arguments",
566
600
  "automation.support.contextResolver.timeoutMs",
601
+ "automation.support.playbooks",
602
+ "automation.support.playbooks.directory",
603
+ "automation.support.playbooks.decisionCommand",
604
+ "automation.support.playbooks.decisionTimeoutMs",
605
+ "automation.support.actionAdapters",
567
606
  "automation.support.queue",
568
607
  "automation.support.queue.provider",
569
608
  "automation.support.queue.table",
@@ -579,8 +618,6 @@ exports.SUPPORTED_FRAIM_CONFIG_PATHS = [
579
618
  "automation.support.requestTypes.password_reset",
580
619
  "automation.support.requestTypes.password_reset.mode",
581
620
  "automation.support.requestTypes.password_reset.requireApprovalState",
582
- "automation.support.requestTypes.password_reset.selector",
583
- "automation.support.requestTypes.password_reset.selector.shortDescriptionContains",
584
621
  "automation.support.requestTypes.password_reset.microsoftPasswordReset",
585
622
  "automation.support.requestTypes.password_reset.microsoftPasswordReset.resetType",
586
623
  "automation.support.requestTypes.password_reset.microsoftPasswordReset.providedPasswordEnvVar",
@@ -267,6 +267,7 @@ function buildQualityRejectionMessage(jobName, currentPhase, errors) {
267
267
  ? [
268
268
  '```javascript',
269
269
  'evidence: {',
270
+ ' artifactPath: "<project-relative quality artifact path>",',
270
271
  ' quality: {',
271
272
  ' gateDecision: "<pass|flag|fail>",',
272
273
  ' interviewsAnalyzed: <number>,',
@@ -281,6 +282,7 @@ function buildQualityRejectionMessage(jobName, currentPhase, errors) {
281
282
  ? [
282
283
  '```javascript',
283
284
  'evidence: {',
285
+ ' artifactPath: "<project-relative quality artifact path>",',
284
286
  ' quality: {',
285
287
  ' composite: <number 0-10>,',
286
288
  ' participant: { fit: <number 1-10>, urgency: <number 1-10>, authority: <number 1-10> },',
@@ -298,6 +300,7 @@ function buildQualityRejectionMessage(jobName, currentPhase, errors) {
298
300
  : [
299
301
  '```javascript',
300
302
  'evidence: {',
303
+ ' artifactPath: "<project-relative quality artifact path>",',
301
304
  ' quality: {',
302
305
  ' composite: <number 0-10>,',
303
306
  ' coaching: "<actionable recommendation>",',
@@ -315,7 +318,7 @@ function buildQualityRejectionMessage(jobName, currentPhase, errors) {
315
318
  return [
316
319
  `❌ **Job completion rejected** for \`${jobName}\`.`,
317
320
  '',
318
- `This job is required to emit a quality score on its final \`seekMentoring\` call so the result is captured in \`fraim_quality_scores\` for the quality dashboard. The following problems were found in \`evidence.quality\`:`,
321
+ `This job is required to emit quality evidence on its final \`seekMentoring\` call so the result is captured in \`fraim_quality_scores\` for the quality dashboard. The following problems were found:`,
319
322
  '',
320
323
  errorBullets,
321
324
  ...importantNote,
@@ -86,6 +86,29 @@ exports.AGENT_TOKEN_PRICES = [
86
86
  source: 'https://openai.com/api/pricing/ (gpt-5 row, applied to gpt-5.5 as nearest published rate)',
87
87
  verifiedOn: '2026-04-30',
88
88
  },
89
+ // Google — Gemini CLI. Standard paid-tier text/image/video token rates.
90
+ // Context caching maps to cache reads; the published storage-hour price
91
+ // cannot be derived from the CLI token stats and is intentionally omitted.
92
+ {
93
+ agent: 'gemini',
94
+ model: 'gemini-3.1-flash-lite',
95
+ inputPerMTok: 0.25,
96
+ outputPerMTok: 1.50,
97
+ cacheReadPerMTok: 0.025,
98
+ cacheCreationPerMTok: 0,
99
+ source: 'https://ai.google.dev/gemini-api/docs/pricing',
100
+ verifiedOn: '2026-06-02',
101
+ },
102
+ {
103
+ agent: 'gemini',
104
+ model: 'gemini-3-flash-preview',
105
+ inputPerMTok: 0.50,
106
+ outputPerMTok: 3.00,
107
+ cacheReadPerMTok: 0.05,
108
+ cacheCreationPerMTok: 0,
109
+ source: 'https://ai.google.dev/gemini-api/docs/pricing',
110
+ verifiedOn: '2026-06-02',
111
+ },
89
112
  ];
90
113
  /**
91
114
  * Look up the price entry for an agent + model. Agent is matched
@@ -6,8 +6,18 @@
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.LEARNING_PRIORITIES = void 0;
10
+ exports.learningEntryHeadingRegex = learningEntryHeadingRegex;
9
11
  exports.computeEffectiveScore = computeEffectiveScore;
10
12
  exports.buildLearningContextSection = buildLearningContextSection;
13
+ exports.buildTeamContextSection = buildTeamContextSection;
14
+ exports.resolveTeamContextFiles = resolveTeamContextFiles;
15
+ exports.isTeamContextKey = isTeamContextKey;
16
+ exports.resolveTeamContextFile = resolveTeamContextFile;
17
+ exports.countPreservedLearnings = countPreservedLearnings;
18
+ exports.readPreservedLearnings = readPreservedLearnings;
19
+ exports.applyLearningEntryChange = applyLearningEntryChange;
20
+ exports.isTruthyFlag = isTruthyFlag;
11
21
  const fs_1 = require("fs");
12
22
  const path_1 = require("path");
13
23
  const project_fraim_paths_1 = require("../core/utils/project-fraim-paths");
@@ -17,6 +27,22 @@ const AGING_HORIZON_DAYS = 7;
17
27
  const MAX_ENTRIES_SCANNED = 200;
18
28
  const BACKLOG_MIN = 5;
19
29
  const OLDEST_AGE_DAYS_TRIGGER = 3;
30
+ // ── Single source of truth for the learning-entry format contract (#533) ──────
31
+ // The `## [P-…] <title>` entry format is a tight contract shared by three sides:
32
+ // 1. EMIT — the synthesis jobs (sleep-on-learnings, organizational-learning-
33
+ // synthesis) instruct the agent to write entries in this shape.
34
+ // 2. SCORE — the decay scorer below reads severity + Last seen + Recurrences to
35
+ // decide which learnings stay active and get injected into agent context.
36
+ // 3. READ — the counters and the Hub (readPreservedLearnings) display them.
37
+ // These previously drifted (each accepted a different priority set / heading
38
+ // level), which silently dropped P-CRITICAL learnings. Everything now derives
39
+ // from LEARNING_PRIORITIES, and a build-time contract test
40
+ // (tests/isolated/test-learning-format-contract.ts) asserts all three sides agree.
41
+ exports.LEARNING_PRIORITIES = ['P-CRITICAL', 'P-HIGH', 'P-MED', 'P-LOW'];
42
+ /** Canonical entry-heading matcher: `##`..`######` + `[P-X]` + optional title. */
43
+ function learningEntryHeadingRegex(flags = '') {
44
+ return new RegExp(`^#{2,}\\s+\\[(${exports.LEARNING_PRIORITIES.join('|')})\\]\\s*(.*)$`, flags);
45
+ }
20
46
  function getLearningRoots(workspaceRoot) {
21
47
  return {
22
48
  globalPersonalBase: (0, project_fraim_paths_1.getConfiguredPortableLearningsDir)(workspaceRoot),
@@ -152,7 +178,7 @@ function getScoreThreshold(workspaceRoot) {
152
178
  * calculations). Defaults to the current wall clock.
153
179
  */
154
180
  function computeEffectiveScore(severity, lastSeenDate, recurrences, fileType, now = new Date()) {
155
- const baseScore = severity === 'P-HIGH' ? 8 : severity === 'P-MED' ? 5 : 3;
181
+ const baseScore = severity === 'P-CRITICAL' ? 10 : severity === 'P-HIGH' ? 8 : severity === 'P-MED' ? 5 : 3;
156
182
  // Mistake patterns decay faster (90d) — they're tied to environments that change.
157
183
  // Preferences, manager-coaching, and validated-patterns express durable judgment (180d half-life).
158
184
  const halfLife = fileType === 'mistake-patterns' ? 90 : 180;
@@ -210,7 +236,7 @@ function scanMistakePatternFile(filePath, threshold, fileType = 'mistake-pattern
210
236
  for (const line of lines) {
211
237
  if (scanned >= MAX_ENTRIES_SCANNED)
212
238
  break;
213
- const headerMatch = line.match(/^## \[(P-HIGH|P-MED|P-LOW)\]/);
239
+ const headerMatch = line.match(learningEntryHeadingRegex());
214
240
  if (headerMatch) {
215
241
  flush();
216
242
  inEntry = true;
@@ -438,6 +464,416 @@ function buildLearningContextSection(workspaceRoot, userId, forJob) {
438
464
  }
439
465
  return section;
440
466
  }
467
+ /**
468
+ * Resolve an organization/manager-scope context file. These are user-level and
469
+ * portable across repos, but a repo-local copy (under `fraim/…`) wins when
470
+ * present — mirroring how personal learnings layer (repo-local shadows
471
+ * user-level) in `resolvePersonalLearningFile`.
472
+ */
473
+ function resolveOrgContextFile(workspaceRoot, relativePath) {
474
+ try {
475
+ const repoPath = (0, path_1.join)((0, project_fraim_paths_1.getWorkspaceFraimDir)(workspaceRoot), 'personalized-employee', relativePath);
476
+ if ((0, fs_1.existsSync)(repoPath)) {
477
+ return {
478
+ present: true,
479
+ displayPath: (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`personalized-employee/${relativePath}`)
480
+ };
481
+ }
482
+ const userPath = (0, path_1.join)((0, project_fraim_paths_1.getUserFraimDirPath)(), 'personalized-employee', relativePath);
483
+ if ((0, fs_1.existsSync)(userPath)) {
484
+ return {
485
+ present: true,
486
+ displayPath: (0, project_fraim_paths_1.getUserFraimDisplayPath)(`personalized-employee/${relativePath}`)
487
+ };
488
+ }
489
+ }
490
+ catch {
491
+ // Fall through to absent.
492
+ }
493
+ return { present: false, displayPath: '' };
494
+ }
495
+ /**
496
+ * Resolve a project-scope context file. These are repo-local only — they
497
+ * describe a single engagement and never layer up to the user level.
498
+ */
499
+ function resolveProjectContextFile(workspaceRoot, relativePath) {
500
+ try {
501
+ const repoPath = (0, path_1.join)((0, project_fraim_paths_1.getWorkspaceFraimDir)(workspaceRoot), 'personalized-employee', relativePath);
502
+ if ((0, fs_1.existsSync)(repoPath)) {
503
+ return {
504
+ present: true,
505
+ displayPath: (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`personalized-employee/${relativePath}`)
506
+ };
507
+ }
508
+ }
509
+ catch {
510
+ // Fall through to absent.
511
+ }
512
+ return { present: false, displayPath: '' };
513
+ }
514
+ /**
515
+ * Builds the team-context section pointing the agent at the org/manager/project
516
+ * context files that exist on disk (issue #512, three-layer Context
517
+ * Architecture). Mirrors `buildLearningContextSection`: pure, side-effect-free,
518
+ * lists only present files, and returns '' when none exist.
519
+ */
520
+ function buildTeamContextSection(workspaceRoot, forJob) {
521
+ // Organization layer (user-level, portable; repo-local override wins).
522
+ const orgContext = resolveOrgContextFile(workspaceRoot, 'context/org_context.md');
523
+ const managerContext = resolveOrgContextFile(workspaceRoot, 'context/manager_context.md');
524
+ const orgRules = resolveOrgContextFile(workspaceRoot, 'rules/org_rules.md');
525
+ const managerRules = resolveOrgContextFile(workspaceRoot, 'rules/manager_rules.md');
526
+ // Project layer (repo-local only).
527
+ const projectContext = resolveProjectContextFile(workspaceRoot, 'context/project_context.md');
528
+ const projectBrief = resolveProjectContextFile(workspaceRoot, 'context/project_brief.md');
529
+ const projectRules = resolveProjectContextFile(workspaceRoot, 'rules/project_rules.md');
530
+ const projectQa = resolveProjectContextFile(workspaceRoot, 'context/project_qa.md');
531
+ const hasOrg = orgContext.present || managerContext.present || orgRules.present || managerRules.present;
532
+ const hasProject = projectContext.present || projectBrief.present || projectRules.present || projectQa.present;
533
+ if (!hasOrg && !hasProject)
534
+ return '';
535
+ let section = forJob
536
+ ? '\n\n## Team Context for This Job\n\n'
537
+ : '\n\n## Team Context\n\n';
538
+ if (hasOrg) {
539
+ section += '### Organization\n';
540
+ if (orgContext.present)
541
+ section += `\`${orgContext.displayPath}\`\n`;
542
+ if (managerContext.present)
543
+ section += `\`${managerContext.displayPath}\`\n`;
544
+ if (orgRules.present)
545
+ section += `\`${orgRules.displayPath}\`\n`;
546
+ if (managerRules.present)
547
+ section += `\`${managerRules.displayPath}\`\n`;
548
+ section += '\n';
549
+ }
550
+ if (hasProject) {
551
+ section += '### Project\n';
552
+ if (projectContext.present)
553
+ section += `\`${projectContext.displayPath}\`\n`;
554
+ if (projectBrief.present)
555
+ section += `\`${projectBrief.displayPath}\`\n`;
556
+ if (projectRules.present)
557
+ section += `\`${projectRules.displayPath}\`\n`;
558
+ if (projectQa.present)
559
+ section += `\`${projectQa.displayPath}\`\n`;
560
+ section += '\n';
561
+ }
562
+ section += 'Read the relevant context before doing work.\n';
563
+ return section;
564
+ }
565
+ /**
566
+ * Resolve presence + display paths for every three-layer Team Context file
567
+ * (org/manager/project). Reuses resolveOrgContextFile / resolveProjectContextFile
568
+ * so layering (repo-local shadows user-level for org/manager; project is
569
+ * repo-local only) matches buildTeamContextSection exactly.
570
+ */
571
+ function resolveTeamContextFiles(workspaceRoot) {
572
+ return {
573
+ orgContext: resolveOrgContextFile(workspaceRoot, 'context/org_context.md'),
574
+ managerContext: resolveOrgContextFile(workspaceRoot, 'context/manager_context.md'),
575
+ orgRules: resolveOrgContextFile(workspaceRoot, 'rules/org_rules.md'),
576
+ managerRules: resolveOrgContextFile(workspaceRoot, 'rules/manager_rules.md'),
577
+ projectContext: resolveProjectContextFile(workspaceRoot, 'context/project_context.md'),
578
+ projectBrief: resolveProjectContextFile(workspaceRoot, 'context/project_brief.md'),
579
+ projectQa: resolveProjectContextFile(workspaceRoot, 'context/project_qa.md'),
580
+ projectRules: resolveProjectContextFile(workspaceRoot, 'rules/project_rules.md')
581
+ };
582
+ }
583
+ /** Map a key to its (relativePath, scope). Single source of truth. */
584
+ const TEAM_CONTEXT_FILE_MAP = {
585
+ org: { relativePath: 'context/org_context.md', scope: 'org' },
586
+ manager: { relativePath: 'context/manager_context.md', scope: 'org' },
587
+ orgRules: { relativePath: 'rules/org_rules.md', scope: 'org' },
588
+ managerRules: { relativePath: 'rules/manager_rules.md', scope: 'org' },
589
+ projectContext: { relativePath: 'context/project_context.md', scope: 'project' },
590
+ projectBrief: { relativePath: 'context/project_brief.md', scope: 'project' },
591
+ projectRules: { relativePath: 'rules/project_rules.md', scope: 'project' },
592
+ projectQa: { relativePath: 'context/project_qa.md', scope: 'project' }
593
+ };
594
+ function isTeamContextKey(value) {
595
+ return typeof value === 'string' && Object.prototype.hasOwnProperty.call(TEAM_CONTEXT_FILE_MAP, value);
596
+ }
597
+ /**
598
+ * Resolve the read + write locations for one editable context file.
599
+ *
600
+ * - Org scope (org/manager/orgRules/managerRules): a repo-local copy under `fraim/…` wins for
601
+ * both read and write when present (so the edit updates the file that actually
602
+ * shadows user-level for this repo); otherwise read AND write target the
603
+ * user-level `~/.fraim/…` path (where org onboarding writes).
604
+ * - Project scope (projectContext/projectBrief/projectRules/projectQa): repo-local only.
605
+ */
606
+ function resolveTeamContextFile(workspaceRoot, key) {
607
+ const { relativePath, scope } = TEAM_CONTEXT_FILE_MAP[key];
608
+ const repoPath = (0, path_1.join)((0, project_fraim_paths_1.getWorkspaceFraimDir)(workspaceRoot), 'personalized-employee', relativePath);
609
+ const repoDisplay = (0, project_fraim_paths_1.getWorkspaceFraimDisplayPath)(`personalized-employee/${relativePath}`);
610
+ if (scope === 'project') {
611
+ // Repo-local only — read and write are the same path.
612
+ return {
613
+ present: (0, fs_1.existsSync)(repoPath),
614
+ readPath: (0, fs_1.existsSync)(repoPath) ? repoPath : '',
615
+ writePath: repoPath,
616
+ displayPath: repoDisplay,
617
+ scope
618
+ };
619
+ }
620
+ // Org scope: repo-local override wins when present.
621
+ if ((0, fs_1.existsSync)(repoPath)) {
622
+ return {
623
+ present: true,
624
+ readPath: repoPath,
625
+ writePath: repoPath,
626
+ displayPath: repoDisplay,
627
+ scope
628
+ };
629
+ }
630
+ const userPath = (0, path_1.join)((0, project_fraim_paths_1.getUserFraimDirPath)(), 'personalized-employee', relativePath);
631
+ const userDisplay = (0, project_fraim_paths_1.getUserFraimDisplayPath)(`personalized-employee/${relativePath}`);
632
+ return {
633
+ present: (0, fs_1.existsSync)(userPath),
634
+ readPath: (0, fs_1.existsSync)(userPath) ? userPath : '',
635
+ // No repo-local override exists → write to the portable user-level path,
636
+ // matching where org onboarding persists these files.
637
+ writePath: userPath,
638
+ displayPath: userDisplay,
639
+ scope
640
+ };
641
+ }
642
+ /**
643
+ * Count the number of `## [P-...]`-style entries inside a preserved learning
644
+ * file. Returns 0 when the file is absent or unreadable. Counts ALL entries
645
+ * (not score-gated) — the Brain summary reports preserved learnings, not the
646
+ * subset above the auto-load threshold.
647
+ */
648
+ function countLearningEntries(filePath) {
649
+ if (!(0, fs_1.existsSync)(filePath))
650
+ return 0;
651
+ let content;
652
+ try {
653
+ content = (0, fs_1.readFileSync)(filePath, 'utf8');
654
+ }
655
+ catch {
656
+ return 0;
657
+ }
658
+ let count = 0;
659
+ const headingRe = learningEntryHeadingRegex();
660
+ for (const line of content.split(/\r?\n/)) {
661
+ if (headingRe.test(line))
662
+ count++;
663
+ }
664
+ return count;
665
+ }
666
+ /**
667
+ * Count preserved learnings by scope for the Brain summary (R14). Organization
668
+ * = L2 org-* files; manager = the personal manager-coaching file (reverse
669
+ * mentoring); project = personal mistake/preferences/validated patterns. Raw =
670
+ * un-dismissed L0 signals still awaiting synthesis. Reuses the same root/file
671
+ * resolution as buildLearningContextSection so the counts line up with what the
672
+ * agent actually auto-loads.
673
+ */
674
+ function countPreservedLearnings(workspaceRoot, userId) {
675
+ const roots = getLearningRoots(workspaceRoot);
676
+ const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
677
+ // L2 organization-scope preserved files (repo-local org-* files).
678
+ const organization = countLearningEntries((0, path_1.join)(roots.repoLearningsBase, 'org-mistake-patterns.md')) +
679
+ countLearningEntries((0, path_1.join)(roots.repoLearningsBase, 'org-preferences.md')) +
680
+ countLearningEntries((0, path_1.join)(roots.repoLearningsBase, 'org-manager-coaching.md')) +
681
+ countLearningEntries((0, path_1.join)(roots.repoLearningsBase, 'org-validated-patterns.md'));
682
+ const resolve = (fileName) => resolvePersonalLearningFile(roots.repoLearningsBase, roots.globalPersonalBase, roots.globalPersonalDisplayBase, fileName);
683
+ // L1 manager-facing reverse-mentoring file.
684
+ const manager = countLearningEntries(resolve(`${resolvedUserId}-manager-coaching.md`).path);
685
+ // L1 personal work patterns (project scope).
686
+ const project = countLearningEntries(resolve(`${resolvedUserId}-mistake-patterns.md`).path) +
687
+ countLearningEntries(resolve(`${resolvedUserId}-preferences.md`).path) +
688
+ countLearningEntries(resolve(`${resolvedUserId}-validated-patterns.md`).path);
689
+ // L0 raw signals still awaiting synthesis (not dismissed).
690
+ let rawSignals = 0;
691
+ const rawDir = (0, path_1.join)(roots.repoLearningsBase, 'raw');
692
+ if ((0, fs_1.existsSync)(rawDir)) {
693
+ try {
694
+ for (const fileName of (0, fs_1.readdirSync)(rawDir)) {
695
+ if (!fileName.endsWith('.md'))
696
+ continue;
697
+ const fm = readFrontmatter((0, fs_1.readFileSync)((0, path_1.join)(rawDir, fileName), 'utf8'));
698
+ if (isTruthyFlag(fm.dismissed))
699
+ continue;
700
+ rawSignals++;
701
+ }
702
+ }
703
+ catch {
704
+ // ignore unreadable raw dir
705
+ }
706
+ }
707
+ return { organization, manager, project, rawSignals };
708
+ }
709
+ exports.CATEGORY_TO_FILETYPE = {
710
+ avoid: 'mistake-patterns',
711
+ preference: 'preferences',
712
+ repeat: 'validated-patterns',
713
+ coaching: 'manager-coaching',
714
+ };
715
+ const FILETYPE_TO_CATEGORY = {
716
+ 'mistake-patterns': 'avoid',
717
+ 'preferences': 'preference',
718
+ 'validated-patterns': 'repeat',
719
+ 'manager-coaching': 'coaching',
720
+ };
721
+ // Parse the `## [P-…] Title` entries (and their body prose) out of one learning
722
+ // file. Score/Last seen/Recurrences metadata lines are dropped — the Hub shows
723
+ // the human-readable learning, not the bookkeeping.
724
+ function parseLearningEntries(filePath, displayPath, category, level) {
725
+ if (!(0, fs_1.existsSync)(filePath))
726
+ return [];
727
+ let content;
728
+ try {
729
+ content = (0, fs_1.readFileSync)(filePath, 'utf8');
730
+ }
731
+ catch {
732
+ return [];
733
+ }
734
+ const out = [];
735
+ let current = null;
736
+ let bodyLines = [];
737
+ const flush = () => {
738
+ if (current) {
739
+ current.body = bodyLines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
740
+ out.push(current);
741
+ }
742
+ current = null;
743
+ bodyLines = [];
744
+ };
745
+ const headingRe = learningEntryHeadingRegex();
746
+ for (const line of content.split(/\r?\n/)) {
747
+ const header = line.match(headingRe);
748
+ if (header) {
749
+ flush();
750
+ current = { severity: header[1], title: header[2].trim(), body: '', source: displayPath, category, level };
751
+ continue;
752
+ }
753
+ if (!current)
754
+ continue;
755
+ if (line.trim() === '---') {
756
+ flush();
757
+ continue;
758
+ }
759
+ // Drop the bookkeeping lines — the Hub shows the human-readable learning,
760
+ // not the scoring metadata.
761
+ const t = line.trim();
762
+ if (/^\*\*(Score|Last seen|Recurrences|Technical trace|Users|First synthesized)\*\*:/i.test(t))
763
+ continue;
764
+ if (/^(First|Last) synthesized:/i.test(t))
765
+ continue;
766
+ bodyLines.push(line);
767
+ }
768
+ flush();
769
+ return out;
770
+ }
771
+ function levelDir(roots, level) {
772
+ if (level === 'machine')
773
+ return { dir: roots.globalPersonalBase, displayBase: roots.globalPersonalDisplayBase.replace(/\/$/, '') };
774
+ return { dir: roots.repoLearningsBase, displayBase: REPO_LEARNINGS_REL };
775
+ }
776
+ function readPreservedLearnings(workspaceRoot, userId, scope, level = 'machine') {
777
+ const roots = getLearningRoots(workspaceRoot);
778
+ const out = [];
779
+ if (scope === 'org') {
780
+ for (const cat of ['avoid', 'preference', 'repeat', 'coaching']) {
781
+ const f = `org-${exports.CATEGORY_TO_FILETYPE[cat]}.md`;
782
+ out.push(...parseLearningEntries((0, path_1.join)(roots.repoLearningsBase, f), `${REPO_LEARNINGS_REL}/${f}`, cat, 'org'));
783
+ }
784
+ return out;
785
+ }
786
+ const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
787
+ const { dir, displayBase } = levelDir(roots, level);
788
+ const cats = scope === 'reverse' ? ['coaching'] : ['avoid', 'preference', 'repeat'];
789
+ for (const cat of cats) {
790
+ const f = `${resolvedUserId}-${exports.CATEGORY_TO_FILETYPE[cat]}.md`;
791
+ out.push(...parseLearningEntries((0, path_1.join)(dir, f), `${displayBase}/${f}`, cat, level));
792
+ }
793
+ return out;
794
+ }
795
+ function resolveLearningFilePath(workspaceRoot, userId, ref) {
796
+ const roots = getLearningRoots(workspaceRoot);
797
+ const fileType = exports.CATEGORY_TO_FILETYPE[ref.category];
798
+ if (ref.scope === 'org')
799
+ return (0, path_1.join)(roots.repoLearningsBase, `org-${fileType}.md`);
800
+ const resolvedUserId = resolveLearningUserId(workspaceRoot, userId, roots);
801
+ const base = ref.level === 'project' ? roots.repoLearningsBase : roots.globalPersonalBase;
802
+ return (0, path_1.join)(base, `${resolvedUserId}-${fileType}.md`);
803
+ }
804
+ function titleCaseFromFileType(fileType) {
805
+ return fileType.split('-').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
806
+ }
807
+ function applyLearningEntryChange(workspaceRoot, userId, ref, action, payload) {
808
+ const filePath = resolveLearningFilePath(workspaceRoot, userId, ref);
809
+ const existing = (0, fs_1.existsSync)(filePath) ? (0, fs_1.readFileSync)(filePath, 'utf8') : '';
810
+ const nl = existing.includes('\r\n') ? '\r\n' : '\n';
811
+ const lines = existing.length ? existing.split(/\r?\n/) : [];
812
+ const headingRe = learningEntryHeadingRegex();
813
+ const starts = [];
814
+ lines.forEach((l, i) => { if (headingRe.test(l))
815
+ starts.push(i); });
816
+ const write = (text) => {
817
+ (0, fs_1.mkdirSync)((0, path_1.dirname)(filePath), { recursive: true });
818
+ (0, fs_1.writeFileSync)(filePath, text.replace(/\s*$/, '') + nl, 'utf8');
819
+ };
820
+ if (action === 'add') {
821
+ const sev = (payload.severity || 'P-MED');
822
+ const title = (payload.title || '').trim() || 'Untitled learning';
823
+ const body = String(payload.body || '').trim();
824
+ const block = [`#### [${sev}] ${title}`, '', body, '', '---'].join(nl);
825
+ const fileType = exports.CATEGORY_TO_FILETYPE[ref.category];
826
+ const preamble = existing.trim()
827
+ ? existing.replace(/\s*$/, '')
828
+ : `# ${ref.scope === 'org' ? 'Org ' : ''}${titleCaseFromFileType(fileType)}`;
829
+ write(preamble + nl + nl + block);
830
+ return { ok: true, path: filePath };
831
+ }
832
+ const targetIdx = starts.findIndex((s) => {
833
+ const m = lines[s].match(headingRe);
834
+ return m && m[2].trim() === (payload.originalTitle || '').trim();
835
+ });
836
+ if (targetIdx === -1)
837
+ throw new Error(`Learning entry not found: "${payload.originalTitle}"`);
838
+ const start = starts[targetIdx];
839
+ const end = targetIdx + 1 < starts.length ? starts[targetIdx + 1] : lines.length;
840
+ if (action === 'delete') {
841
+ const next = [...lines.slice(0, start), ...lines.slice(end)];
842
+ write(next.join(nl).replace(/\n{3,}/g, '\n\n'));
843
+ return { ok: true, path: filePath };
844
+ }
845
+ // edit: keep the entry's metadata lines (Score/Last seen/Recurrences/...) intact,
846
+ // replace only the heading (priority + title) and the prose body.
847
+ const headerMatch = lines[start].match(headingRe);
848
+ const sev = (payload.severity || headerMatch[1]);
849
+ const title = (payload.title !== undefined ? payload.title : headerMatch[2]).trim();
850
+ const meta = [];
851
+ for (const l of lines.slice(start + 1, end)) {
852
+ const t = l.trim();
853
+ if (t === '---')
854
+ continue;
855
+ if (/^\*\*(Score|Last seen|Recurrences|Technical trace|Users|First synthesized)\*\*:/i.test(t) || /^(First|Last) synthesized:/i.test(t))
856
+ meta.push(l);
857
+ }
858
+ const body = String(payload.body !== undefined ? payload.body : '').trim();
859
+ const rebuilt = [`#### [${sev}] ${title}`, '', ...meta, ...(meta.length ? [''] : []), body, '', '---'];
860
+ const next = [...lines.slice(0, start), ...rebuilt, ...lines.slice(end)];
861
+ write(next.join(nl).replace(/\n{3,}/g, '\n\n'));
862
+ return { ok: true, path: filePath };
863
+ }
864
+ /**
865
+ * Interpret a frontmatter flag value (string) as a boolean. Mirrors how the
866
+ * synthesis jobs treat `dismissed:`/`blessed:` — only an explicit truthy value
867
+ * counts as set.
868
+ */
869
+ function isTruthyFlag(value) {
870
+ if (value === undefined)
871
+ return false;
872
+ const normalized = value.trim().toLowerCase();
873
+ if (!normalized)
874
+ return false;
875
+ return normalized === 'true' || normalized === 'yes' || normalized === '1';
876
+ }
441
877
  function renderBacklogDetail(oldestAgeDays, agingRisk) {
442
878
  if (oldestAgeDays <= 0 && agingRisk <= 0)
443
879
  return '';