@orchestrator-claude/definitions 3.37.0 → 3.37.2

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 (73) 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 +50 -21
  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} +8 -3
  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 +29 -15
  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 +10 -5
  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 +462 -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/lib/security-gate-core.ts +271 -0
  47. package/hooks/mailbox-listener.mjs +69 -17
  48. package/hooks/playwright-output-guard.mjs +40 -10
  49. package/hooks/post-implement.mjs +38 -8
  50. package/hooks/pre-agent-dispatch.mjs +137 -16
  51. package/hooks/pre-agent-dispatch.ts +82 -15
  52. package/hooks/security-gate-honest.mjs +302 -0
  53. package/hooks/security-gate-honest.ts +148 -0
  54. package/hooks/session-start.mjs +304 -106
  55. package/hooks/session-start.ts +317 -71
  56. package/hooks/subagent-start.mjs +7673 -22
  57. package/hooks/subagent-start.ts +367 -16
  58. package/hooks/subagent-stop.mjs +102 -62
  59. package/hooks/subagent-stop.ts +124 -28
  60. package/hooks/user-prompt.mjs +43 -13
  61. package/hooks/verification-before-completion.mjs +63 -11
  62. package/hooks/workflow-guard.mjs +45 -11
  63. package/hooks-settings-template.json +1 -7
  64. package/manifest.json +77 -61
  65. package/package.json +4 -2
  66. package/skills/checkpoint-protocol/SKILL.md +19 -4
  67. package/skills/ciclo-lane-dispatch/SKILL.md +117 -8
  68. package/skills/fase-1b-projecao/SKILL.md +150 -0
  69. package/skills/lane-1-entrevista/SKILL.md +173 -0
  70. package/skills/orchestrator/SKILL.md +11 -3
  71. package/skills/workflow-coordination/SKILL.md +3 -3
  72. package/hooks/post-compact.mjs +0 -7931
  73. 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,202 @@ 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).
605
+ */
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
+ }
621
+
622
+ /**
623
+ * TD-318 (S514): the ONE field formatMemoryContext dereferences per entry.
624
+ * `readMemoryIndex`'s guard requires only `file:` (S480 contract, pinned by
625
+ * memory-index-silent-failure.test.ts case 5), so an entry with `file:` and
626
+ * no `summary:` reaches here — and `truncateSummary(undefined)` threw,
627
+ * the caller's try/catch swallowed it, and the WHOLE block vanished:
628
+ * measured on trc-platform-mobile, 1 of 140 entries → 0 injected, no
629
+ * INDISPONÍVEL line (S480 guards the read, not the format). One bad line
630
+ * must not zero the good ones beside it.
506
631
  */
507
- export function formatMemoryContext(entries: MemoryEntry[]): string {
508
- if (entries.length === 0) return '';
632
+ function hasRenderableSummary(entry: MemoryEntry): boolean {
633
+ return typeof entry.summary === 'string';
634
+ }
509
635
 
510
- const byType: Record<string, number> = {};
511
- for (const e of entries) {
512
- byType[e.type] = (byType[e.type] ?? 0) + 1;
636
+ /** Header line of the warning appended when entries were skipped (TD-318). */
637
+ export const MEMORY_SKIPPED_WARNING_PREFIX = '- [AVISO] ';
638
+
639
+ function countByType(list: MemoryEntry[]): Record<string, number> {
640
+ const counts: Record<string, number> = {};
641
+ for (const e of list) {
642
+ counts[e.type] = (counts[e.type] ?? 0) + 1;
513
643
  }
514
- const typeSummary = Object.entries(byType)
515
- .map(([t, n]) => `${n} ${t}`)
516
- .join(', ');
644
+ return counts;
645
+ }
517
646
 
518
- const recentNames = entries
519
- .slice(0, 5)
520
- .map((e) => e.file.replace(/\.md$/, '').replace(/^(feedback|project|reference|user)_/, ''))
521
- .join(', ');
647
+ /**
648
+ * Format the composition-approved memory entries (`selected` — the output
649
+ * of `filterRelevantMemories`/TASK-008) as a compact `[MEMORY-CONTEXT]`
650
+ * injection, against the backdrop of the full unfiltered index
651
+ * (`allEntries`) used only to derive the per-type `M` denominators.
652
+ *
653
+ * Each entry's `summary` is truncated to `PER_ENTRY_SUMMARY_CHAR_CAP`
654
+ * (200) chars with an ellipsis. The whole output additionally respects
655
+ * `AGGREGATE_CHAR_BUDGET` (8000 chars): if that budget would be exceeded
656
+ * before every `selected` entry has been rendered, the loop stops and the
657
+ * header names how many were dropped this way
658
+ * (`; +K truncadas por orçamento de caracteres`) — distinguishing "the
659
+ * composition (TASK-008) approved N" from "N minus what the char budget
660
+ * still had to cut".
661
+ *
662
+ * Returns empty string if `selected` is empty (skip injection), even when
663
+ * `allEntries` is not — an empty composition means nothing relevant
664
+ * survived TASK-008's filter/quota, independent of the source index size.
665
+ */
666
+ export function formatMemoryContext(selected: MemoryEntry[], allEntries: MemoryEntry[]): string {
667
+ // TD-318: split BEFORE anything dereferences `summary`. Skipped entries
668
+ // are named in a trailing warning line so the author sees what to fix —
669
+ // the previous behaviour was a swallowed throw and an empty block.
670
+ const renderable = selected.filter(hasRenderableSummary);
671
+ const skipped = selected.filter((e) => !hasRenderableSummary(e));
672
+ const warning =
673
+ skipped.length > 0
674
+ ? `${MEMORY_SKIPPED_WARNING_PREFIX}${String(skipped.length)} entrada(s) sem 'summary:' pulada(s) ` +
675
+ `— conserte o índice: ${skipped.map((e) => e.file).join(', ')}`
676
+ : '';
677
+
678
+ if (renderable.length === 0) return warning ? `[MEMORY-CONTEXT] 0 memórias\n${warning}` : '';
679
+
680
+ const selectedCounts = countByType(renderable);
681
+ const totalCounts = countByType(allEntries);
682
+ const perTypeSummary = MEMORY_TYPES.map(
683
+ (t) => `${t}: ${selectedCounts[t] ?? 0}/${totalCounts[t] ?? 0}`
684
+ ).join(', ');
685
+
686
+ const headerBase =
687
+ `[MEMORY-CONTEXT] ${renderable.length} de ${allEntries.length} memórias ` +
688
+ `(${perTypeSummary}); critério: cota mínima de 2 por tipo não-feedback + ` +
689
+ `prioridade feedback→recência no restante`;
690
+
691
+ const availableForLines =
692
+ AGGREGATE_CHAR_BUDGET - headerBase.length - SUFFIX_RESERVE - (warning ? warning.length + 1 : 0);
693
+ const lines: string[] = [];
694
+ let used = 0;
695
+ let droppedByBudget = 0;
696
+
697
+ for (let i = 0; i < renderable.length; i++) {
698
+ const entry = renderable[i]!;
699
+ const name = entry.file.replace(/\.md$/, '');
700
+ const truncated = truncateSummary(entry.summary, PER_ENTRY_SUMMARY_CHAR_CAP);
701
+ const line = `- [${entry.type}] ${name}: ${truncated}`;
702
+
703
+ if (used + line.length + 1 > availableForLines) {
704
+ droppedByBudget = renderable.length - i;
705
+ break;
706
+ }
707
+ lines.push(line);
708
+ used += line.length + 1;
709
+ }
522
710
 
523
- return [
524
- `[MEMORY-CONTEXT] ${entries.length} entries: ${typeSummary}`,
525
- `recent: ${recentNames}`,
526
- ].join('\n');
711
+ const header =
712
+ droppedByBudget > 0
713
+ ? `${headerBase}; +${droppedByBudget} truncadas por orçamento de caracteres`
714
+ : headerBase;
715
+
716
+ return [header, ...lines, warning].filter(Boolean).join('\n');
717
+ }
718
+
719
+ // ─── Memory Index Validation (TD-318) ────────────────────
720
+
721
+ /** One defect found by `validateMemoryIndexEntries`. */
722
+ export interface MemoryIndexDefect {
723
+ /** 0-based position in the index; the file name when the entry has one. */
724
+ position: number;
725
+ file: string | null;
726
+ /** Field names that are missing or of the wrong type. */
727
+ missing: string[];
728
+ }
729
+
730
+ /**
731
+ * Full-shape validator for a memory index, meant to FAIL LOUD in CI/lint
732
+ * (TD-318) — the read-side guard (`isWellFormedMemoryEntry`) is
733
+ * deliberately minimal (S480), so this is where an author learns that an
734
+ * entry is missing `summary:` BEFORE it reaches a session. Checks every
735
+ * field the injection path dereferences: `file`, `type`, `summary`,
736
+ * `last_accessed` (strings) and `tags` (array). Pure: takes the parsed
737
+ * entries (either on-disk form, already unwrapped), returns defects, never
738
+ * throws.
739
+ */
740
+ export function validateMemoryIndexEntries(entries: unknown[]): MemoryIndexDefect[] {
741
+ const defects: MemoryIndexDefect[] = [];
742
+ entries.forEach((value, position) => {
743
+ if (typeof value !== 'object' || value === null) {
744
+ defects.push({ position, file: null, missing: ['(não é um objeto)'] });
745
+ return;
746
+ }
747
+ const e = value as Record<string, unknown>;
748
+ const missing: string[] = [];
749
+ for (const field of ['file', 'type', 'summary', 'last_accessed']) {
750
+ const v = e[field];
751
+ if (typeof v !== 'string' || v.length === 0) missing.push(field);
752
+ }
753
+ if (!Array.isArray(e.tags)) missing.push('tags');
754
+ if (missing.length > 0) {
755
+ defects.push({ position, file: typeof e.file === 'string' ? e.file : null, missing });
756
+ }
757
+ });
758
+ return defects;
527
759
  }
528
760
 
529
761
  // ─── Memory Index Reading ────────────────────────────────
@@ -533,17 +765,124 @@ interface MemoryIndex {
533
765
  entries: MemoryEntry[];
534
766
  }
535
767
 
768
+ /**
769
+ * Type guard: is `value` a well-formed enough MemoryEntry to keep?
770
+ * Minimal bar (just `file`, a required string) — good enough to avoid
771
+ * downstream crashes in formatMemoryContext/filterRelevantMemories, without
772
+ * this module taking on full schema validation.
773
+ */
774
+ function isWellFormedMemoryEntry(value: unknown): value is MemoryEntry {
775
+ return (
776
+ typeof value === 'object' && value !== null && typeof (value as MemoryEntry).file === 'string'
777
+ );
778
+ }
779
+
536
780
  /**
537
781
  * Read memory-index.yaml and return parsed entries.
538
- * Returns null if file missing or corrupt (fail-open).
782
+ *
783
+ * Accepts TWO on-disk forms (TASK-026/RF-010 Parte 0):
784
+ * 1. Flat top-level array — `- file: ...` at the YAML root. This is the
785
+ * CANONICAL form per CLAUDE.md's Memory Protocol and what 17/19 real
786
+ * `.claude/agent-memory/*\/memory-index.yaml` indexes use.
787
+ * 2. Nested `entries:` array — kept for backward compatibility with the
788
+ * 2 real indexes still using it (`orchestrator`, `azure-devops-engineer`).
789
+ *
790
+ * `Array.isArray(parsed)` MUST be checked before touching `parsed.entries`:
791
+ * on a real Array, `.entries` resolves to `Array.prototype.entries` (a
792
+ * function), which is truthy and silently survives a naive
793
+ * `parsed?.entries ?? null` (or `?? parsed`) fallback — the historical bug
794
+ * this function fixes. Never reintroduce that shape.
795
+ *
796
+ * Malformed individual entries inside an otherwise-valid array (not an
797
+ * object, or missing the required `file` key) are SKIPPED rather than
798
+ * nulling the whole index — one bad `- ` line in one contributor's index
799
+ * should not blank out every valid entry that sits beside it.
800
+ *
801
+ * Returns null if the file is missing, the YAML is invalid, or the parsed
802
+ * shape is neither a flat array nor a dict with an `entries` array
803
+ * (fail-open).
539
804
  */
540
- export function readMemoryIndex(indexPath: string): MemoryEntry[] | null {
805
+ export interface MemoryIndexDiagnostic {
806
+ /**
807
+ * Por que um índice QUE EXISTE não produziu entradas utilizáveis.
808
+ * Ausente = nada a relatar (arquivo inexistente, ou leitura bem-sucedida).
809
+ * NUNCA é preenchido quando o arquivo simplesmente não existe: ausência de
810
+ * memória é legítima e deve seguir silenciosa.
811
+ */
812
+ reason?: string;
813
+ }
814
+
815
+ export function readMemoryIndex(
816
+ indexPath: string,
817
+ diag?: MemoryIndexDiagnostic
818
+ ): MemoryEntry[] | null {
541
819
  try {
820
+ // Arquivo ausente = ausência LEGÍTIMA. Sem diagnóstico, sem ruído.
542
821
  if (!existsSync(indexPath)) return null;
543
822
  const raw = readFileSync(indexPath, 'utf-8');
544
- const parsed = parseYaml(raw) as MemoryIndex;
545
- return parsed?.entries ?? null;
546
- } catch {
823
+
824
+ let parsed: unknown;
825
+ try {
826
+ parsed = parseYaml(raw);
827
+ } catch (err) {
828
+ // Ponto mudo nº 1 (S480): o catch externo engolia isto e devolvia null,
829
+ // então o `Memory context failed` do chamador NUNCA disparava — ele só
830
+ // vê exceção, e aqui nenhuma escapa. Um índice de 8 KB que quebrou por
831
+ // dois espaços de indentação ficava indistinguível de índice inexistente.
832
+ if (diag) diag.reason = `YAML inválido em ${indexPath}: ${String(err).split('\n')[0]}`;
833
+ return null;
834
+ }
835
+
836
+ let candidate: unknown[] | null = null;
837
+ if (Array.isArray(parsed)) {
838
+ candidate = parsed;
839
+ } else if (
840
+ typeof parsed === 'object' &&
841
+ parsed !== null &&
842
+ Array.isArray((parsed as MemoryIndex).entries)
843
+ ) {
844
+ candidate = (parsed as MemoryIndex).entries;
845
+ }
846
+ if (candidate === null) {
847
+ // Ponto mudo nº 2 (S480): forma não reconhecida devolvia null calado.
848
+ // Medido: o índice da Root usava a chave `memories:` e 25 memórias nunca
849
+ // foram lidas, nem uma vez. O diagnóstico NOMEIA AS CHAVES encontradas --
850
+ // é isso que diz ao autor o que corrigir. Aceitar `memories:` como
851
+ // terceira forma foi considerado e RECUSADO: cada apelido novo torna o
852
+ // contrato mais frouxo e convida a uma quarta chave. O leitor recusa alto;
853
+ // quem conserta é quem escreve.
854
+ const chaves =
855
+ typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
856
+ ? Object.keys(parsed as Record<string, unknown>).join(', ')
857
+ : typeof parsed;
858
+ if (diag) {
859
+ diag.reason =
860
+ `formato não reconhecido em ${indexPath} — topo tem [${chaves}]; ` +
861
+ `esperado uma lista no topo ou a chave 'entries:'`;
862
+ }
863
+ return null;
864
+ }
865
+
866
+ const bemFormadas = candidate.filter(isWellFormedMemoryEntry);
867
+ // Ponto mudo nº 3 (S480): descartar entradas mal-formadas é o desenho certo
868
+ // (uma linha ruim não pode zerar as boas ao lado), mas descartar TODAS saía
869
+ // como lista vazia -- e lista vazia é truthy, então o `if (!memoryEntries)`
870
+ // do chamador não pegava e a linha sumia no filter(Boolean).
871
+ if (diag && candidate.length > 0) {
872
+ const descartadas = candidate.length - bemFormadas.length;
873
+ if (bemFormadas.length === 0) {
874
+ diag.reason =
875
+ `${candidate.length} entrada(s) lidas e NENHUMA bem-formada em ${indexPath} ` +
876
+ `— toda entrada precisa da chave 'file:'`;
877
+ } else if (descartadas > 0) {
878
+ diag.reason =
879
+ `${descartadas} de ${candidate.length} entrada(s) descartadas em ${indexPath} ` +
880
+ `(falta a chave 'file:')`;
881
+ }
882
+ }
883
+ return bemFormadas;
884
+ } catch (err) {
885
+ if (diag) diag.reason = `falha ao ler ${indexPath}: ${String(err).split('\n')[0]}`;
547
886
  return null;
548
887
  }
549
888
  }
@@ -552,6 +891,41 @@ export function readMemoryIndex(indexPath: string): MemoryEntry[] | null {
552
891
  * Filter memory entries by relevance for session injection.
553
892
  * Keeps: recent (14 days), all feedback, tag matches.
554
893
  * Returns max 20 entries, sorted by type priority then recency.
894
+ *
895
+ * QUOTA (HIGH-001, RF-010 Parte 1, S435): a flat `feedback`-first priority
896
+ * sort followed by a hard 20-entry cap made `project`/`reference`/`user`
897
+ * types MATHEMATICALLY unreachable whenever relevant `feedback` entries
898
+ * alone met or exceeded 20 — not because they were less relevant, but
899
+ * purely from cut-off arithmetic. Measured on the real fleet pre-fix:
900
+ * `orchestrator` (593 entries) selected 20/20 feedback, losing `project`,
901
+ * `user` and `reference` entirely; `implementer` (70) and
902
+ * `orchestrator-implementer` (30) each lost `project`.
903
+ *
904
+ * Fix: reserve up to `RESERVED_SLOTS_PER_TYPE` of the most-recent relevant
905
+ * entries for every non-`feedback` type that has at least one relevant
906
+ * entry (never forces in an irrelevant entry just to fill the quota — a
907
+ * type with 0 relevant entries gets 0 reserved slots, unchanged). The
908
+ * remaining budget is filled by walking the entries in the ORIGINAL
909
+ * priority order (feedback → project → reference → user, recency),
910
+ * skipping anything already reserved, exactly as before this fix — so an
911
+ * index small enough that nothing overflows the cap produces the identical
912
+ * selected set and order as the pre-fix implementation.
913
+ *
914
+ * TD-248 (S442): the fix above still computed the quota over `relevant` —
915
+ * i.e. AFTER the 14-day recency filter below had already run. A type whose
916
+ * every entry happened to be older than 14 days (and matched no
917
+ * contextTags) never entered `relevant` at all, so it never entered
918
+ * `nonFeedbackTypes` either, and got 0 reserved slots — the exact
919
+ * starvation the quota exists to prevent, just moved one step later
920
+ * (temporal starvation instead of feedback-volume starvation). Fixed by
921
+ * computing `reserved` over `entries` (the full, unfiltered index): every
922
+ * non-feedback type gets its `RESERVED_SLOTS_PER_TYPE` most-recent entries
923
+ * reserved regardless of age, and a reserved entry is admitted to the
924
+ * final selection even when `relevant` would have excluded it. MAX_TOTAL
925
+ * stays 20 — feedback cedes slots to the reservation, as before; the cap
926
+ * was not raised. contextTags plumbing is untouched (still unreachable at
927
+ * every production call site — see call-site note at the bottom of this
928
+ * function).
555
929
  */
556
930
  export function filterRelevantMemories(
557
931
  entries: MemoryEntry[],
@@ -559,6 +933,8 @@ export function filterRelevantMemories(
559
933
  ): MemoryEntry[] {
560
934
  const now = Date.now();
561
935
  const fourteenDays = 14 * 24 * 60 * 60 * 1000;
936
+ const MAX_TOTAL = 20;
937
+ const RESERVED_SLOTS_PER_TYPE = 2;
562
938
 
563
939
  const relevant = entries.filter((e) => {
564
940
  if (e.type === 'feedback') return true;
@@ -574,14 +950,45 @@ export function filterRelevantMemories(
574
950
  reference: 2,
575
951
  user: 3,
576
952
  };
577
- relevant.sort((a, b) => {
953
+ const byPriorityThenRecency = (a: MemoryEntry, b: MemoryEntry): number => {
578
954
  const pa = typePriority[a.type] ?? 9;
579
955
  const pb = typePriority[b.type] ?? 9;
580
956
  if (pa !== pb) return pa - pb;
581
957
  return b.last_accessed.localeCompare(a.last_accessed);
582
- });
958
+ };
959
+ relevant.sort(byPriorityThenRecency);
960
+
961
+ // Reserve the most-recent RESERVED_SLOTS_PER_TYPE entries for each
962
+ // non-feedback type present in the FULL index (TD-248), not just
963
+ // `relevant` — see the TD-248 doc-comment above for why. A reserved
964
+ // entry is admitted to the final selection even when it fails the
965
+ // `relevant` filter (older than 14 days, no contextTags match).
966
+ const reserved = new Set<MemoryEntry>();
967
+ const nonFeedbackTypes = new Set(entries.filter((e) => e.type !== 'feedback').map((e) => e.type));
968
+ for (const type of nonFeedbackTypes) {
969
+ const ofType = entries
970
+ .filter((e) => e.type === type)
971
+ .sort((a, b) => b.last_accessed.localeCompare(a.last_accessed));
972
+ for (const entry of ofType.slice(0, RESERVED_SLOTS_PER_TYPE)) {
973
+ reserved.add(entry);
974
+ }
975
+ }
976
+
977
+ const selected = new Set<MemoryEntry>(reserved);
978
+ for (const entry of relevant) {
979
+ if (selected.size >= MAX_TOTAL) break;
980
+ if (reserved.has(entry)) continue;
981
+ selected.add(entry);
982
+ }
583
983
 
584
- return relevant.slice(0, 20);
984
+ // Final ordering: priority then recency. Reserved entries pulled in from
985
+ // OUTSIDE `relevant` (their type had zero entries inside the 14-day
986
+ // window) are not present in `relevant`, so `relevant.filter` can no
987
+ // longer be the source of the returned array (pre-TD-248 approach) —
988
+ // sort `selected` directly instead. For entries that WERE already in
989
+ // `relevant`, this produces the identical order as before (same
990
+ // comparator, stable sort).
991
+ return [...selected].sort(byPriorityThenRecency);
585
992
  }
586
993
 
587
994
  /**