@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.
- package/agents/adversarial-reviewer.md +6 -1
- package/agents/api-extractor.md +6 -0
- package/agents/azure-devops-engineer.md +1 -2
- package/agents/business-rule-miner.md +6 -0
- package/agents/cicd-platform-engineer.md +1 -2
- package/agents/ciclo-engineer.md +15 -2
- package/agents/code-archaeologist.md +6 -0
- package/agents/debug-sidecar.md +6 -0
- package/agents/doc-sidecar.md +6 -0
- package/agents/docs-guardian.md +9 -3
- package/agents/frontend-engineer.md +1 -2
- package/agents/{ai-architect.md → harness-architect.md} +26 -5
- package/agents/implementer.md +6 -1
- package/agents/legacy-discoverer.md +6 -0
- package/agents/legacy-synthesizer.md +6 -0
- package/agents/methodology-researcher.md +6 -1
- package/agents/operational-artifact-builder.md +6 -1
- package/agents/orchestrator.md +27 -87
- package/agents/planner.md +6 -1
- package/agents/presentation-engineer.md +5 -4
- package/agents/python-data-engineer.md +0 -1
- package/agents/researcher.md +6 -0
- package/agents/reviewer.md +6 -1
- package/agents/schema-extractor.md +6 -0
- package/agents/software-architect.md +30 -7
- package/agents/specifier.md +6 -1
- package/agents/task-generator.md +6 -1
- package/agents/test-sidecar.md +6 -0
- package/dist/scripts/generate-hooks-matcher.d.ts +106 -0
- package/dist/scripts/generate-hooks-matcher.d.ts.map +1 -0
- package/dist/scripts/generate-hooks-matcher.js +153 -0
- package/dist/scripts/generate-hooks-matcher.js.map +1 -0
- package/dist/scripts/generate-manifest.d.ts +22 -0
- package/dist/scripts/generate-manifest.d.ts.map +1 -1
- package/dist/scripts/generate-manifest.js +67 -2
- package/dist/scripts/generate-manifest.js.map +1 -1
- package/hooks/agent-git-guard.mjs +63 -11
- package/hooks/dangling-guard.mjs +42 -12
- package/hooks/gate-guardian.mjs +38 -8
- package/hooks/hooks.json +3 -9
- package/hooks/lib/api-client.ts +128 -9
- package/hooks/lib/capability-scanner.ts +391 -55
- package/hooks/lib/dispatch-marker.ts +214 -0
- package/hooks/lib/git-checkpoint.ts +34 -37
- package/hooks/lib/observations-helper.ts +14 -1
- package/hooks/mailbox-listener.mjs +69 -17
- package/hooks/playwright-output-guard.mjs +40 -10
- package/hooks/post-implement.mjs +38 -8
- package/hooks/pre-agent-dispatch.mjs +137 -16
- package/hooks/pre-agent-dispatch.ts +82 -15
- package/hooks/session-start.mjs +274 -103
- package/hooks/session-start.ts +250 -69
- package/hooks/subagent-start.mjs +7665 -22
- package/hooks/subagent-start.ts +367 -16
- package/hooks/subagent-stop.mjs +102 -62
- package/hooks/subagent-stop.ts +124 -28
- package/hooks/user-prompt.mjs +43 -13
- package/hooks/verification-before-completion.mjs +63 -11
- package/hooks/workflow-guard.mjs +45 -11
- package/hooks-settings-template.json +1 -7
- package/manifest.json +62 -62
- package/package.json +4 -2
- package/skills/checkpoint-protocol/SKILL.md +19 -4
- package/skills/ciclo-lane-dispatch/SKILL.md +24 -0
- package/skills/fase-1b-projecao/SKILL.md +146 -0
- package/skills/orchestrator/SKILL.md +11 -3
- package/skills/workflow-coordination/SKILL.md +3 -3
- package/hooks/post-compact.mjs +0 -7931
- 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
|
-
// ───
|
|
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
|
-
*
|
|
372
|
-
*
|
|
373
|
-
*
|
|
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
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
410
|
-
*
|
|
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
|
|
468
|
+
export function readCapabilityCache(
|
|
469
|
+
cachePath: string,
|
|
470
|
+
vaultConfigPath: string
|
|
471
|
+
): CapabilityIndex | null {
|
|
413
472
|
try {
|
|
414
|
-
if (!existsSync(
|
|
415
|
-
const raw = readFileSync(
|
|
416
|
-
const
|
|
417
|
-
|
|
418
|
-
if (
|
|
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(
|
|
422
|
-
? (
|
|
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: (
|
|
436
|
-
installedPluginsMtime: (
|
|
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: (
|
|
441
|
-
gaps: (
|
|
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
|
-
*
|
|
505
|
-
*
|
|
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
|
-
|
|
508
|
-
|
|
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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
.join(', ');
|
|
627
|
+
return counts;
|
|
628
|
+
}
|
|
517
629
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
/**
|