@orchestrator-claude/definitions 3.36.0 → 3.37.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/agents/adversarial-reviewer.md +6 -1
  2. package/agents/api-extractor.md +6 -0
  3. package/agents/azure-devops-engineer.md +1 -2
  4. package/agents/business-rule-miner.md +6 -0
  5. package/agents/cicd-platform-engineer.md +1 -2
  6. package/agents/ciclo-engineer.md +15 -2
  7. package/agents/code-archaeologist.md +6 -0
  8. package/agents/debug-sidecar.md +6 -0
  9. package/agents/doc-sidecar.md +6 -0
  10. package/agents/docs-guardian.md +9 -3
  11. package/agents/frontend-engineer.md +1 -2
  12. package/agents/{ai-architect.md → harness-architect.md} +26 -5
  13. package/agents/implementer.md +6 -1
  14. package/agents/legacy-discoverer.md +6 -0
  15. package/agents/legacy-synthesizer.md +6 -0
  16. package/agents/methodology-researcher.md +6 -1
  17. package/agents/operational-artifact-builder.md +6 -1
  18. package/agents/orchestrator.md +27 -87
  19. package/agents/planner.md +6 -1
  20. package/agents/presentation-engineer.md +5 -4
  21. package/agents/python-data-engineer.md +0 -1
  22. package/agents/researcher.md +6 -0
  23. package/agents/reviewer.md +6 -1
  24. package/agents/schema-extractor.md +6 -0
  25. package/agents/software-architect.md +30 -7
  26. package/agents/specifier.md +6 -1
  27. package/agents/task-generator.md +6 -1
  28. package/agents/test-sidecar.md +6 -0
  29. package/dist/scripts/generate-hooks-matcher.d.ts +106 -0
  30. package/dist/scripts/generate-hooks-matcher.d.ts.map +1 -0
  31. package/dist/scripts/generate-hooks-matcher.js +153 -0
  32. package/dist/scripts/generate-hooks-matcher.js.map +1 -0
  33. package/dist/scripts/generate-manifest.d.ts +22 -0
  34. package/dist/scripts/generate-manifest.d.ts.map +1 -1
  35. package/dist/scripts/generate-manifest.js +67 -2
  36. package/dist/scripts/generate-manifest.js.map +1 -1
  37. package/hooks/agent-git-guard.mjs +63 -11
  38. package/hooks/dangling-guard.mjs +42 -12
  39. package/hooks/gate-guardian.mjs +38 -8
  40. package/hooks/hooks.json +3 -9
  41. package/hooks/lib/api-client.ts +128 -9
  42. package/hooks/lib/capability-scanner.ts +391 -55
  43. package/hooks/lib/dispatch-marker.ts +214 -0
  44. package/hooks/lib/git-checkpoint.ts +34 -37
  45. package/hooks/lib/observations-helper.ts +14 -1
  46. package/hooks/mailbox-listener.mjs +69 -17
  47. package/hooks/playwright-output-guard.mjs +40 -10
  48. package/hooks/post-implement.mjs +38 -8
  49. package/hooks/pre-agent-dispatch.mjs +137 -16
  50. package/hooks/pre-agent-dispatch.ts +82 -15
  51. package/hooks/session-start.mjs +274 -103
  52. package/hooks/session-start.ts +250 -69
  53. package/hooks/subagent-start.mjs +7665 -22
  54. package/hooks/subagent-start.ts +367 -16
  55. package/hooks/subagent-stop.mjs +102 -62
  56. package/hooks/subagent-stop.ts +124 -28
  57. package/hooks/user-prompt.mjs +43 -13
  58. package/hooks/verification-before-completion.mjs +63 -11
  59. package/hooks/workflow-guard.mjs +45 -11
  60. package/hooks-settings-template.json +1 -7
  61. package/manifest.json +62 -62
  62. package/package.json +4 -2
  63. package/skills/checkpoint-protocol/SKILL.md +19 -4
  64. package/skills/ciclo-lane-dispatch/SKILL.md +24 -0
  65. package/skills/fase-1b-projecao/SKILL.md +146 -0
  66. package/skills/orchestrator/SKILL.md +11 -3
  67. package/skills/workflow-coordination/SKILL.md +3 -3
  68. package/hooks/post-compact.mjs +0 -7931
  69. package/hooks/post-compact.ts +0 -170
@@ -9,8 +9,8 @@
9
9
  * All functions are fail-open: they return defaults on error, never throw.
10
10
  */
11
11
 
12
- import { readFileSync, readdirSync, existsSync, statSync, writeFileSync } from 'node:fs';
13
- import { join } from 'node:path';
12
+ import { readFileSync, readdirSync, existsSync, statSync, writeFileSync, mkdirSync } from 'node:fs';
13
+ import { join, dirname } from 'node:path';
14
14
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
15
15
 
16
16
  // ─── Types ───────────────────────────────────────────────
@@ -365,21 +365,64 @@ export function buildCapabilityIndex(opts: {
365
365
  };
366
366
  }
367
367
 
368
- // ─── Vault Persistence ───────────────────────────────────
368
+ // ─── Cache Persistence (TD-235 half B, S445 VETOR 1) ─────
369
+ //
370
+ // The scan cache used to be written INTO `project-guidelines/.vault-config.yaml`
371
+ // (`writeCapabilitiesToVault` / `readCapabilitiesFromVault`, removed in S445).
372
+ // That file is git-TRACKED and hand-written above the `capabilities:` block
373
+ // (governance lines 1-101, machine block from 102 — measured S445), so ordinary
374
+ // operation dirtied version control: `needsRescan` compares
375
+ // `~/.claude/plugins/installed_plugins.json`'s mtime, a trigger EXTERNAL to the
376
+ // repo that changes even mid-session.
377
+ //
378
+ // It was not merely noisy. S426 commit 16c8e936 zeroed `plugins:` and deleted
379
+ // 297 lines of that tracked file, invisibly, because the old writer parsed the
380
+ // WHOLE document and re-serialized it — a parse hiccup or a partial scan
381
+ // rewrote governance content as a side effect of a cache refresh.
382
+ //
383
+ // The cache is now a standalone file under the gitignored state dir. The
384
+ // tracked vault config is read-only from here on (`readVaultSummary`), which
385
+ // makes the S426 failure mode structurally impossible rather than merely
386
+ // unlikely: no code path in this module writes to it.
387
+
388
+ /** File name of the standalone cache inside the state directory. */
389
+ const CAPABILITY_CACHE_FILENAME = 'capability-cache.yaml';
390
+
391
+ type EnvLike = Record<string, string | undefined>;
369
392
 
370
393
  /**
371
- * Write CapabilityIndex to the `capabilities:` section of vault config YAML.
372
- * Performs surgical merge — only touches `capabilities:`, preserves all other sections.
373
- * Fails silently on error.
394
+ * Resolve where the capability cache lives.
395
+ *
396
+ * Precedence is deliberately IDENTICAL to every other state-dir consumer in
397
+ * the hook tree — `ORCHESTRATOR_STATE_DIR` wins outright, else
398
+ * `<projectRoot>/.orchestrator/.state` (see `lib/api-client.ts:51-55` and
399
+ * `subagent-start.ts:268-273`, both measured S445). `.orchestrator/.state/` is
400
+ * gitignored, so the cache is unversioned by construction.
401
+ *
402
+ * `env` is a parameter read at CALL time rather than a module-load `const`
403
+ * (the shape `api-client.ts` uses): this module is imported by test files that
404
+ * set the override per-case, and a module-load read would freeze the value
405
+ * before they run.
374
406
  */
375
- export function writeCapabilitiesToVault(vaultConfigPath: string, index: CapabilityIndex): void {
376
- try {
377
- let config: Record<string, unknown> = {};
378
- if (existsSync(vaultConfigPath)) {
379
- const raw = readFileSync(vaultConfigPath, 'utf-8');
380
- config = (parseYaml(raw) as Record<string, unknown>) ?? {};
381
- }
407
+ export function resolveCapabilityCachePath(
408
+ projectRoot: string,
409
+ env: EnvLike = process.env
410
+ ): string {
411
+ const override = env.ORCHESTRATOR_STATE_DIR;
412
+ const stateDir =
413
+ override !== undefined && override !== ''
414
+ ? override
415
+ : join(projectRoot, '.orchestrator', '.state');
416
+ return join(stateDir, CAPABILITY_CACHE_FILENAME);
417
+ }
382
418
 
419
+ /**
420
+ * Write the CapabilityIndex to its own cache file, creating the state
421
+ * directory if needed. Never reads or writes any other file — in particular
422
+ * never the vault config. Fails silently (fail-open, module-wide posture).
423
+ */
424
+ export function writeCapabilityCache(cachePath: string, index: CapabilityIndex): void {
425
+ try {
383
426
  const pluginEntries = index.plugins.map((p) => {
384
427
  const entry: Record<string, unknown> = { name: p.name };
385
428
  if (p.version !== 'unknown') entry.version = p.version;
@@ -391,7 +434,8 @@ export function writeCapabilitiesToVault(vaultConfigPath: string, index: Capabil
391
434
  return entry;
392
435
  });
393
436
 
394
- config.capabilities = {
437
+ const payload = {
438
+ schema: 'capability-cache/v1',
395
439
  last_scanned: index.lastScanned,
396
440
  installed_plugins_mtime: index.installedPluginsMtime,
397
441
  plugins: pluginEntries,
@@ -399,27 +443,42 @@ export function writeCapabilitiesToVault(vaultConfigPath: string, index: Capabil
399
443
  gaps: index.gaps,
400
444
  };
401
445
 
402
- writeFileSync(vaultConfigPath, stringifyYaml(config, { lineWidth: 120 }));
446
+ mkdirSync(dirname(cachePath), { recursive: true });
447
+ writeFileSync(cachePath, stringifyYaml(payload, { lineWidth: 120 }));
403
448
  } catch {
404
449
  // fail-open
405
450
  }
406
451
  }
407
452
 
408
453
  /**
409
- * Read cached CapabilityIndex from vault config's `capabilities:` section.
410
- * Returns null if section doesn't exist or on any error.
454
+ * Read the cached CapabilityIndex from its standalone cache file.
455
+ *
456
+ * Returns `null` when the cache is absent, unreadable or corrupt. That `null`
457
+ * is load-bearing: the caller (`buildMatrixLine` in session-start.ts — its
458
+ * post-compact.ts twin was retired in S454) treats it as "rescan", so a
459
+ * missing cache produces a FRESH
460
+ * scan — never an empty matrix that reads like a legitimate one. That
461
+ * hollow-but-plausible matrix is the original defect; it must not return
462
+ * through this door.
463
+ *
464
+ * `vaultConfigPath` is used READ-ONLY, for the vault summary (tier/stack/
465
+ * maturity/pack), which stays sourced from the tracked governance file rather
466
+ * than being frozen into the cache.
411
467
  */
412
- export function readCapabilitiesFromVault(vaultConfigPath: string): CapabilityIndex | null {
468
+ export function readCapabilityCache(
469
+ cachePath: string,
470
+ vaultConfigPath: string
471
+ ): CapabilityIndex | null {
413
472
  try {
414
- if (!existsSync(vaultConfigPath)) return null;
415
- const raw = readFileSync(vaultConfigPath, 'utf-8');
416
- const config = parseYaml(raw) as Record<string, unknown>;
417
- const caps = config?.capabilities as Record<string, unknown> | undefined;
418
- if (!caps) return null;
473
+ if (!existsSync(cachePath)) return null;
474
+ const raw = readFileSync(cachePath, 'utf-8');
475
+ const cache = parseYaml(raw) as Record<string, unknown> | null;
476
+ if (!cache || typeof cache !== 'object') return null;
477
+ if (cache.last_scanned === undefined && cache.plugins === undefined) return null;
419
478
 
420
479
  // Guard against malformed YAML where plugins is not an array (IMP-002)
421
- const rawPlugins = Array.isArray(caps.plugins)
422
- ? (caps.plugins as Record<string, unknown>[])
480
+ const rawPlugins = Array.isArray(cache.plugins)
481
+ ? (cache.plugins as Record<string, unknown>[])
423
482
  : [];
424
483
  const plugins: PluginCapability[] = rawPlugins.map((p) => ({
425
484
  name: (p.name as string) ?? '',
@@ -432,13 +491,13 @@ export function readCapabilitiesFromVault(vaultConfigPath: string): CapabilityIn
432
491
  }));
433
492
 
434
493
  return {
435
- lastScanned: (caps.last_scanned as string) ?? '',
436
- installedPluginsMtime: (caps.installed_plugins_mtime as string) ?? '',
494
+ lastScanned: (cache.last_scanned as string) ?? '',
495
+ installedPluginsMtime: (cache.installed_plugins_mtime as string) ?? '',
437
496
  plugins,
438
497
  agents: [], // agents are always scanned fresh (fast, <10ms)
439
498
  vault: readVaultSummary(vaultConfigPath),
440
- routing: (caps.routing as Record<string, string>) ?? {},
441
- gaps: (caps.gaps as string[]) ?? [],
499
+ routing: (cache.routing as Record<string, string>) ?? {},
500
+ gaps: (cache.gaps as string[]) ?? [],
442
501
  };
443
502
  } catch {
444
503
  return null;
@@ -501,29 +560,131 @@ export interface MemoryEntry {
501
560
  }
502
561
 
503
562
  /**
504
- * Format filtered memory entries as compact injection.
505
- * Returns empty string if no entries (skip injection).
563
+ * RF-010 Parte 2 (ADR-P4 v2): per-entry summary truncation cap, applied
564
+ * unconditionally to every `selected` entry — regardless of how long the
565
+ * source `summary` really is. The v1.0 plan assumed ~150 chars; the real
566
+ * fleet (measured S436 across all `.claude/agent-memory/*\/memory-index.yaml`
567
+ * indexes) has summaries up to ~3720 chars, median ~280-900 depending on
568
+ * agent, with `orchestrator`'s own index alone at p50=902/p90=1505. See
569
+ * implementation-report.md (TASK-009) for the full distribution and the
570
+ * explicit answer to "does 200 preserve signal or decapitate the memory".
571
+ *
572
+ * 200 → 300 (S436, TASK-010): gate decision among 280/300/311/200. 311 is
573
+ * the highest cap that still emits all 20 `filterRelevantMemories`-selected
574
+ * entries with zero AGGREGATE_CHAR_BUDGET drops against the real,
575
+ * currently-binding corpus (`orchestrator/memory-index.yaml`, 596 entries,
576
+ * the fleet's largest/most-consulted index) as of 2026-08-01 — verified via
577
+ * a byte-matched reimplementation, not the raw `20 * (cap + ~60 overhead) =
578
+ * 8000` arithmetic that first (wrongly) produced 340 and was found to drop
579
+ * 2/20 (see project_s436_cap340_orchestrator_index_overflow.md). 300 sits
580
+ * 11 chars below that boundary. Past 311 the aggregate budget starts
581
+ * dropping the tail, and — because dropped entries stop contributing chars
582
+ * at all — a still-higher cap delivers LESS total injected content, not
583
+ * more (measured: 311 → 20 entries/7924 chars; 312 → 19/1 dropped; 340 →
584
+ * 18 entries/7731 chars, 2 dropped).
585
+ *
586
+ * This is a real-corpus guarantee, not a guarantee for every conceivable
587
+ * input shape: `capability-scanner.test.ts` also documents two verified
588
+ * synthetic worst cases (the fleet-wide-longest filename applied uniformly
589
+ * across all 20 entries; orchestrator's own 20 longest-named entries) that
590
+ * already exceed 300 with a single drop each. 300 was accepted anyway
591
+ * because neither shape has ever been the real `filterRelevantMemories`
592
+ * output for any live index — see the regression test's own comment block
593
+ * for the exact boundaries (284 and 292 respectively) and margins.
594
+ */
595
+ export const PER_ENTRY_SUMMARY_CHAR_CAP = 300;
596
+
597
+ /**
598
+ * Aggregate output budget — defense-in-depth safety net UNDER the
599
+ * externally-verified 10.000-char `additionalContext` ceiling (three
600
+ * independent sources, S433/S434 Ressalva 1 — treated as verified, not
601
+ * re-checked here beyond the cheap `<=10000` assertion in the test suite).
602
+ * Even with the per-entry cap above, this still stops the line-building
603
+ * loop early if the running total approaches it (e.g. many entries, or
604
+ * unexpectedly wide multibyte/unicode expansion).
506
605
  */
507
- export function formatMemoryContext(entries: MemoryEntry[]): string {
508
- if (entries.length === 0) return '';
606
+ const AGGREGATE_CHAR_BUDGET = 8000;
607
+
608
+ /** Reserve headroom for the optional "+K truncadas..." header suffix so
609
+ * that appending it after the line-budget loop can never push the final
610
+ * output past AGGREGATE_CHAR_BUDGET. Generous relative to the suffix's
611
+ * actual worst-case length (~50 chars even at 4-digit K). */
612
+ const SUFFIX_RESERVE = 64;
613
+
614
+ /** Canonical type ordering for the per-type header — mirrors the
615
+ * feedback→project→reference→user priority `filterRelevantMemories` uses. */
616
+ const MEMORY_TYPES = ['feedback', 'project', 'reference', 'user'] as const;
617
+
618
+ function truncateSummary(summary: string, cap: number): string {
619
+ return summary.length > cap ? `${summary.slice(0, cap)}…` : summary;
620
+ }
509
621
 
510
- const byType: Record<string, number> = {};
511
- for (const e of entries) {
512
- byType[e.type] = (byType[e.type] ?? 0) + 1;
622
+ function countByType(list: MemoryEntry[]): Record<string, number> {
623
+ const counts: Record<string, number> = {};
624
+ for (const e of list) {
625
+ counts[e.type] = (counts[e.type] ?? 0) + 1;
513
626
  }
514
- const typeSummary = Object.entries(byType)
515
- .map(([t, n]) => `${n} ${t}`)
516
- .join(', ');
627
+ return counts;
628
+ }
517
629
 
518
- const recentNames = entries
519
- .slice(0, 5)
520
- .map((e) => e.file.replace(/\.md$/, '').replace(/^(feedback|project|reference|user)_/, ''))
521
- .join(', ');
630
+ /**
631
+ * Format the composition-approved memory entries (`selected` — the output
632
+ * of `filterRelevantMemories`/TASK-008) as a compact `[MEMORY-CONTEXT]`
633
+ * injection, against the backdrop of the full unfiltered index
634
+ * (`allEntries`) used only to derive the per-type `M` denominators.
635
+ *
636
+ * Each entry's `summary` is truncated to `PER_ENTRY_SUMMARY_CHAR_CAP`
637
+ * (200) chars with an ellipsis. The whole output additionally respects
638
+ * `AGGREGATE_CHAR_BUDGET` (8000 chars): if that budget would be exceeded
639
+ * before every `selected` entry has been rendered, the loop stops and the
640
+ * header names how many were dropped this way
641
+ * (`; +K truncadas por orçamento de caracteres`) — distinguishing "the
642
+ * composition (TASK-008) approved N" from "N minus what the char budget
643
+ * still had to cut".
644
+ *
645
+ * Returns empty string if `selected` is empty (skip injection), even when
646
+ * `allEntries` is not — an empty composition means nothing relevant
647
+ * survived TASK-008's filter/quota, independent of the source index size.
648
+ */
649
+ export function formatMemoryContext(selected: MemoryEntry[], allEntries: MemoryEntry[]): string {
650
+ if (selected.length === 0) return '';
651
+
652
+ const selectedCounts = countByType(selected);
653
+ const totalCounts = countByType(allEntries);
654
+ const perTypeSummary = MEMORY_TYPES.map(
655
+ (t) => `${t}: ${selectedCounts[t] ?? 0}/${totalCounts[t] ?? 0}`
656
+ ).join(', ');
657
+
658
+ const headerBase =
659
+ `[MEMORY-CONTEXT] ${selected.length} de ${allEntries.length} memórias ` +
660
+ `(${perTypeSummary}); critério: cota mínima de 2 por tipo não-feedback + ` +
661
+ `prioridade feedback→recência no restante`;
662
+
663
+ const availableForLines = AGGREGATE_CHAR_BUDGET - headerBase.length - SUFFIX_RESERVE;
664
+ const lines: string[] = [];
665
+ let used = 0;
666
+ let droppedByBudget = 0;
667
+
668
+ for (let i = 0; i < selected.length; i++) {
669
+ const entry = selected[i]!;
670
+ const name = entry.file.replace(/\.md$/, '');
671
+ const truncated = truncateSummary(entry.summary, PER_ENTRY_SUMMARY_CHAR_CAP);
672
+ const line = `- [${entry.type}] ${name}: ${truncated}`;
673
+
674
+ if (used + line.length + 1 > availableForLines) {
675
+ droppedByBudget = selected.length - i;
676
+ break;
677
+ }
678
+ lines.push(line);
679
+ used += line.length + 1;
680
+ }
522
681
 
523
- return [
524
- `[MEMORY-CONTEXT] ${entries.length} entries: ${typeSummary}`,
525
- `recent: ${recentNames}`,
526
- ].join('\n');
682
+ const header =
683
+ droppedByBudget > 0
684
+ ? `${headerBase}; +${droppedByBudget} truncadas por orçamento de caracteres`
685
+ : headerBase;
686
+
687
+ return [header, ...lines].join('\n');
527
688
  }
528
689
 
529
690
  // ─── Memory Index Reading ────────────────────────────────
@@ -533,17 +694,124 @@ interface MemoryIndex {
533
694
  entries: MemoryEntry[];
534
695
  }
535
696
 
697
+ /**
698
+ * Type guard: is `value` a well-formed enough MemoryEntry to keep?
699
+ * Minimal bar (just `file`, a required string) — good enough to avoid
700
+ * downstream crashes in formatMemoryContext/filterRelevantMemories, without
701
+ * this module taking on full schema validation.
702
+ */
703
+ function isWellFormedMemoryEntry(value: unknown): value is MemoryEntry {
704
+ return (
705
+ typeof value === 'object' && value !== null && typeof (value as MemoryEntry).file === 'string'
706
+ );
707
+ }
708
+
536
709
  /**
537
710
  * Read memory-index.yaml and return parsed entries.
538
- * Returns null if file missing or corrupt (fail-open).
711
+ *
712
+ * Accepts TWO on-disk forms (TASK-026/RF-010 Parte 0):
713
+ * 1. Flat top-level array — `- file: ...` at the YAML root. This is the
714
+ * CANONICAL form per CLAUDE.md's Memory Protocol and what 17/19 real
715
+ * `.claude/agent-memory/*\/memory-index.yaml` indexes use.
716
+ * 2. Nested `entries:` array — kept for backward compatibility with the
717
+ * 2 real indexes still using it (`orchestrator`, `azure-devops-engineer`).
718
+ *
719
+ * `Array.isArray(parsed)` MUST be checked before touching `parsed.entries`:
720
+ * on a real Array, `.entries` resolves to `Array.prototype.entries` (a
721
+ * function), which is truthy and silently survives a naive
722
+ * `parsed?.entries ?? null` (or `?? parsed`) fallback — the historical bug
723
+ * this function fixes. Never reintroduce that shape.
724
+ *
725
+ * Malformed individual entries inside an otherwise-valid array (not an
726
+ * object, or missing the required `file` key) are SKIPPED rather than
727
+ * nulling the whole index — one bad `- ` line in one contributor's index
728
+ * should not blank out every valid entry that sits beside it.
729
+ *
730
+ * Returns null if the file is missing, the YAML is invalid, or the parsed
731
+ * shape is neither a flat array nor a dict with an `entries` array
732
+ * (fail-open).
539
733
  */
540
- export function readMemoryIndex(indexPath: string): MemoryEntry[] | null {
734
+ export interface MemoryIndexDiagnostic {
735
+ /**
736
+ * Por que um índice QUE EXISTE não produziu entradas utilizáveis.
737
+ * Ausente = nada a relatar (arquivo inexistente, ou leitura bem-sucedida).
738
+ * NUNCA é preenchido quando o arquivo simplesmente não existe: ausência de
739
+ * memória é legítima e deve seguir silenciosa.
740
+ */
741
+ reason?: string;
742
+ }
743
+
744
+ export function readMemoryIndex(
745
+ indexPath: string,
746
+ diag?: MemoryIndexDiagnostic
747
+ ): MemoryEntry[] | null {
541
748
  try {
749
+ // Arquivo ausente = ausência LEGÍTIMA. Sem diagnóstico, sem ruído.
542
750
  if (!existsSync(indexPath)) return null;
543
751
  const raw = readFileSync(indexPath, 'utf-8');
544
- const parsed = parseYaml(raw) as MemoryIndex;
545
- return parsed?.entries ?? null;
546
- } catch {
752
+
753
+ let parsed: unknown;
754
+ try {
755
+ parsed = parseYaml(raw);
756
+ } catch (err) {
757
+ // Ponto mudo nº 1 (S480): o catch externo engolia isto e devolvia null,
758
+ // então o `Memory context failed` do chamador NUNCA disparava — ele só
759
+ // vê exceção, e aqui nenhuma escapa. Um índice de 8 KB que quebrou por
760
+ // dois espaços de indentação ficava indistinguível de índice inexistente.
761
+ if (diag) diag.reason = `YAML inválido em ${indexPath}: ${String(err).split('\n')[0]}`;
762
+ return null;
763
+ }
764
+
765
+ let candidate: unknown[] | null = null;
766
+ if (Array.isArray(parsed)) {
767
+ candidate = parsed;
768
+ } else if (
769
+ typeof parsed === 'object' &&
770
+ parsed !== null &&
771
+ Array.isArray((parsed as MemoryIndex).entries)
772
+ ) {
773
+ candidate = (parsed as MemoryIndex).entries;
774
+ }
775
+ if (candidate === null) {
776
+ // Ponto mudo nº 2 (S480): forma não reconhecida devolvia null calado.
777
+ // Medido: o índice da Root usava a chave `memories:` e 25 memórias nunca
778
+ // foram lidas, nem uma vez. O diagnóstico NOMEIA AS CHAVES encontradas --
779
+ // é isso que diz ao autor o que corrigir. Aceitar `memories:` como
780
+ // terceira forma foi considerado e RECUSADO: cada apelido novo torna o
781
+ // contrato mais frouxo e convida a uma quarta chave. O leitor recusa alto;
782
+ // quem conserta é quem escreve.
783
+ const chaves =
784
+ typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
785
+ ? Object.keys(parsed as Record<string, unknown>).join(', ')
786
+ : typeof parsed;
787
+ if (diag) {
788
+ diag.reason =
789
+ `formato não reconhecido em ${indexPath} — topo tem [${chaves}]; ` +
790
+ `esperado uma lista no topo ou a chave 'entries:'`;
791
+ }
792
+ return null;
793
+ }
794
+
795
+ const bemFormadas = candidate.filter(isWellFormedMemoryEntry);
796
+ // Ponto mudo nº 3 (S480): descartar entradas mal-formadas é o desenho certo
797
+ // (uma linha ruim não pode zerar as boas ao lado), mas descartar TODAS saía
798
+ // como lista vazia -- e lista vazia é truthy, então o `if (!memoryEntries)`
799
+ // do chamador não pegava e a linha sumia no filter(Boolean).
800
+ if (diag && candidate.length > 0) {
801
+ const descartadas = candidate.length - bemFormadas.length;
802
+ if (bemFormadas.length === 0) {
803
+ diag.reason =
804
+ `${candidate.length} entrada(s) lidas e NENHUMA bem-formada em ${indexPath} ` +
805
+ `— toda entrada precisa da chave 'file:'`;
806
+ } else if (descartadas > 0) {
807
+ diag.reason =
808
+ `${descartadas} de ${candidate.length} entrada(s) descartadas em ${indexPath} ` +
809
+ `(falta a chave 'file:')`;
810
+ }
811
+ }
812
+ return bemFormadas;
813
+ } catch (err) {
814
+ if (diag) diag.reason = `falha ao ler ${indexPath}: ${String(err).split('\n')[0]}`;
547
815
  return null;
548
816
  }
549
817
  }
@@ -552,6 +820,41 @@ export function readMemoryIndex(indexPath: string): MemoryEntry[] | null {
552
820
  * Filter memory entries by relevance for session injection.
553
821
  * Keeps: recent (14 days), all feedback, tag matches.
554
822
  * Returns max 20 entries, sorted by type priority then recency.
823
+ *
824
+ * QUOTA (HIGH-001, RF-010 Parte 1, S435): a flat `feedback`-first priority
825
+ * sort followed by a hard 20-entry cap made `project`/`reference`/`user`
826
+ * types MATHEMATICALLY unreachable whenever relevant `feedback` entries
827
+ * alone met or exceeded 20 — not because they were less relevant, but
828
+ * purely from cut-off arithmetic. Measured on the real fleet pre-fix:
829
+ * `orchestrator` (593 entries) selected 20/20 feedback, losing `project`,
830
+ * `user` and `reference` entirely; `implementer` (70) and
831
+ * `orchestrator-implementer` (30) each lost `project`.
832
+ *
833
+ * Fix: reserve up to `RESERVED_SLOTS_PER_TYPE` of the most-recent relevant
834
+ * entries for every non-`feedback` type that has at least one relevant
835
+ * entry (never forces in an irrelevant entry just to fill the quota — a
836
+ * type with 0 relevant entries gets 0 reserved slots, unchanged). The
837
+ * remaining budget is filled by walking the entries in the ORIGINAL
838
+ * priority order (feedback → project → reference → user, recency),
839
+ * skipping anything already reserved, exactly as before this fix — so an
840
+ * index small enough that nothing overflows the cap produces the identical
841
+ * selected set and order as the pre-fix implementation.
842
+ *
843
+ * TD-248 (S442): the fix above still computed the quota over `relevant` —
844
+ * i.e. AFTER the 14-day recency filter below had already run. A type whose
845
+ * every entry happened to be older than 14 days (and matched no
846
+ * contextTags) never entered `relevant` at all, so it never entered
847
+ * `nonFeedbackTypes` either, and got 0 reserved slots — the exact
848
+ * starvation the quota exists to prevent, just moved one step later
849
+ * (temporal starvation instead of feedback-volume starvation). Fixed by
850
+ * computing `reserved` over `entries` (the full, unfiltered index): every
851
+ * non-feedback type gets its `RESERVED_SLOTS_PER_TYPE` most-recent entries
852
+ * reserved regardless of age, and a reserved entry is admitted to the
853
+ * final selection even when `relevant` would have excluded it. MAX_TOTAL
854
+ * stays 20 — feedback cedes slots to the reservation, as before; the cap
855
+ * was not raised. contextTags plumbing is untouched (still unreachable at
856
+ * every production call site — see call-site note at the bottom of this
857
+ * function).
555
858
  */
556
859
  export function filterRelevantMemories(
557
860
  entries: MemoryEntry[],
@@ -559,6 +862,8 @@ export function filterRelevantMemories(
559
862
  ): MemoryEntry[] {
560
863
  const now = Date.now();
561
864
  const fourteenDays = 14 * 24 * 60 * 60 * 1000;
865
+ const MAX_TOTAL = 20;
866
+ const RESERVED_SLOTS_PER_TYPE = 2;
562
867
 
563
868
  const relevant = entries.filter((e) => {
564
869
  if (e.type === 'feedback') return true;
@@ -574,14 +879,45 @@ export function filterRelevantMemories(
574
879
  reference: 2,
575
880
  user: 3,
576
881
  };
577
- relevant.sort((a, b) => {
882
+ const byPriorityThenRecency = (a: MemoryEntry, b: MemoryEntry): number => {
578
883
  const pa = typePriority[a.type] ?? 9;
579
884
  const pb = typePriority[b.type] ?? 9;
580
885
  if (pa !== pb) return pa - pb;
581
886
  return b.last_accessed.localeCompare(a.last_accessed);
582
- });
887
+ };
888
+ relevant.sort(byPriorityThenRecency);
889
+
890
+ // Reserve the most-recent RESERVED_SLOTS_PER_TYPE entries for each
891
+ // non-feedback type present in the FULL index (TD-248), not just
892
+ // `relevant` — see the TD-248 doc-comment above for why. A reserved
893
+ // entry is admitted to the final selection even when it fails the
894
+ // `relevant` filter (older than 14 days, no contextTags match).
895
+ const reserved = new Set<MemoryEntry>();
896
+ const nonFeedbackTypes = new Set(entries.filter((e) => e.type !== 'feedback').map((e) => e.type));
897
+ for (const type of nonFeedbackTypes) {
898
+ const ofType = entries
899
+ .filter((e) => e.type === type)
900
+ .sort((a, b) => b.last_accessed.localeCompare(a.last_accessed));
901
+ for (const entry of ofType.slice(0, RESERVED_SLOTS_PER_TYPE)) {
902
+ reserved.add(entry);
903
+ }
904
+ }
905
+
906
+ const selected = new Set<MemoryEntry>(reserved);
907
+ for (const entry of relevant) {
908
+ if (selected.size >= MAX_TOTAL) break;
909
+ if (reserved.has(entry)) continue;
910
+ selected.add(entry);
911
+ }
583
912
 
584
- return relevant.slice(0, 20);
913
+ // Final ordering: priority then recency. Reserved entries pulled in from
914
+ // OUTSIDE `relevant` (their type had zero entries inside the 14-day
915
+ // window) are not present in `relevant`, so `relevant.filter` can no
916
+ // longer be the source of the returned array (pre-TD-248 approach) —
917
+ // sort `selected` directly instead. For entries that WERE already in
918
+ // `relevant`, this produces the identical order as before (same
919
+ // comparator, stable sort).
920
+ return [...selected].sort(byPriorityThenRecency);
585
921
  }
586
922
 
587
923
  /**