peaks-loop 4.0.34 → 4.0.36

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 (72) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/dist/cli/commands/code-runtime-commands.d.ts +5 -2
  3. package/dist/cli/commands/code-runtime-commands.js +57 -2
  4. package/dist/cli/commands/core/doctor-command.d.ts +8 -0
  5. package/dist/cli/commands/core/doctor-command.js +44 -2
  6. package/dist/cli/commands/core/memory-command.js +65 -3
  7. package/dist/cli/commands/dispatch-commands.js +19 -5
  8. package/dist/cli/commands/dispatch-from-dag.js +17 -0
  9. package/dist/cli/commands/memory-commands.d.ts +59 -0
  10. package/dist/cli/commands/memory-commands.js +195 -19
  11. package/dist/cli/commands/request-commands.d.ts +8 -0
  12. package/dist/cli/commands/request-commands.js +23 -2
  13. package/dist/cli/commands/sub-agent-commands.js +2 -0
  14. package/dist/cli/commands/wave-plan-commands.d.ts +24 -0
  15. package/dist/cli/commands/wave-plan-commands.js +93 -0
  16. package/dist/services/context/build-dispatch-system-prompt.d.ts +66 -9
  17. package/dist/services/context/build-dispatch-system-prompt.js +132 -17
  18. package/dist/services/context/context-audit.d.ts +100 -0
  19. package/dist/services/context/context-audit.js +322 -0
  20. package/dist/services/context/context-schema.d.ts +1 -1
  21. package/dist/services/context/memory-index-reader.d.ts +26 -0
  22. package/dist/services/context/memory-index-reader.js +62 -30
  23. package/dist/services/context/memory-preflight-config.d.ts +33 -0
  24. package/dist/services/context/memory-preflight-config.js +32 -2
  25. package/dist/services/context/memory-preflight-service.d.ts +20 -1
  26. package/dist/services/context/memory-preflight-service.js +198 -31
  27. package/dist/services/context/summary-view.d.ts +54 -0
  28. package/dist/services/context/summary-view.js +114 -0
  29. package/dist/services/dispatch/file-overlap-wave-planner.d.ts +70 -0
  30. package/dist/services/dispatch/file-overlap-wave-planner.js +119 -0
  31. package/dist/services/dispatch/session-capsule.d.ts +23 -0
  32. package/dist/services/dispatch/session-capsule.js +56 -0
  33. package/dist/services/dispatch/slice-dag.d.ts +9 -0
  34. package/dist/services/dispatch/slice-dag.js +9 -1
  35. package/dist/services/dispatch/test-tool-detection.d.ts +12 -1
  36. package/dist/services/dispatch/test-tool-detection.js +14 -13
  37. package/dist/services/doctor/doctor-service/checks/l3-memory-health.d.ts +19 -2
  38. package/dist/services/doctor/doctor-service/checks/l3-memory-health.js +143 -19
  39. package/dist/services/ide/adapters/claude-code-adapter.d.ts +10 -0
  40. package/dist/services/ide/adapters/claude-code-adapter.js +20 -1
  41. package/dist/services/ide/ide-types.d.ts +15 -0
  42. package/dist/services/job/job-types.d.ts +3 -3
  43. package/dist/services/memory/memory-ingest-service.d.ts +79 -0
  44. package/dist/services/memory/memory-ingest-service.js +225 -0
  45. package/dist/services/memory/memory-rotate-service.d.ts +88 -0
  46. package/dist/services/memory/memory-rotate-service.js +373 -0
  47. package/dist/services/memory/project-memory-service/index/ranking.d.ts +9 -1
  48. package/dist/services/memory/project-memory-service/index/ranking.js +25 -13
  49. package/dist/services/memory/project-memory-service/index/reindex.d.ts +75 -0
  50. package/dist/services/memory/project-memory-service/index/reindex.js +207 -0
  51. package/dist/services/memory/project-memory-service/index/search.js +14 -24
  52. package/dist/services/memory/project-memory-service/index.d.ts +7 -3
  53. package/dist/services/memory/project-memory-service/index.js +6 -2
  54. package/dist/services/memory/project-memory-service/parsers/frontmatter.d.ts +80 -3
  55. package/dist/services/memory/project-memory-service/parsers/frontmatter.js +167 -28
  56. package/dist/services/memory/project-memory-service/types.d.ts +31 -1
  57. package/dist/services/memory/project-memory-service/types.js +76 -1
  58. package/dist/services/preferences/preferences-types.d.ts +14 -0
  59. package/dist/services/preferences/preferences-types.js +8 -0
  60. package/dist/services/share/run-state-contract.d.ts +1 -1
  61. package/package.json +5 -5
  62. package/skills/bee/peaks-qa/SKILL.md +2 -0
  63. package/skills/bee/peaks-qa/references/qa-sub-agent-dispatch.md +12 -0
  64. package/skills/bee/peaks-rd/SKILL.md +2 -0
  65. package/skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md +14 -0
  66. package/skills/bee/peaks-txt/SKILL.md +2 -0
  67. package/skills/bee/peaks-ui/SKILL.md +2 -0
  68. package/skills/peaks-code/SKILL.md +9 -1
  69. package/skills/peaks-code/references/context-governance.md +29 -0
  70. package/skills/peaks-code/references/runbook.md +6 -0
  71. package/skills/peaks-code/references/step-11-memory-sediment.md +35 -0
  72. package/skills/peaks-doctor/SKILL.md +2 -0
@@ -0,0 +1,88 @@
1
+ /** User override 2026-09-10: the policy's 12-month window is now 6 months. */
2
+ export declare const MEMORY_ROTATION_RETENTION_MONTHS = 6;
3
+ export declare const MEMORY_ROTATION_ARCHIVED_DIRNAME = "archived";
4
+ /** Default reference-grep roots, relative to the project root. */
5
+ export declare const MEMORY_ROTATION_REFERENCE_ROOTS: readonly ["src", "skills"];
6
+ export type MemoryRotationTier = 'A' | 'B' | 'C' | 'D';
7
+ export type MemoryRotationAction = 'archive' | 'delete-candidate';
8
+ export interface MemoryRotationCandidate {
9
+ name: string;
10
+ filePath: string;
11
+ tier: MemoryRotationTier;
12
+ tierReason: string;
13
+ action: MemoryRotationAction;
14
+ reason: string;
15
+ ageDays: number;
16
+ ageBasis: 'frontmatter' | 'mtime';
17
+ }
18
+ export interface MemoryRotationExcluded {
19
+ name: string;
20
+ filePath: string;
21
+ tier: MemoryRotationTier;
22
+ reason: string;
23
+ }
24
+ export interface MemoryRotationReport {
25
+ apply: boolean;
26
+ projectRoot: string;
27
+ memoryDir: string;
28
+ archivedDir: string;
29
+ retentionMonths: number;
30
+ referenceRoots: string[];
31
+ /** Every memory file on disk, bucketed by resolved tier. */
32
+ tierCounts: Record<MemoryRotationTier, number>;
33
+ /** Concrete actionable list: tier-C archives + tier-D delete-candidates. */
34
+ candidates: MemoryRotationCandidate[];
35
+ /** Paths actually moved into `archived/` (apply only). */
36
+ archived: string[];
37
+ /** Candidates dropped by a safety gate, each with the reason. */
38
+ excluded: MemoryRotationExcluded[];
39
+ /** True when `--apply` declined to act. */
40
+ refused: boolean;
41
+ refusalReasons: string[];
42
+ /** Safety-gate failures that block `--apply` (e.g. unverifiable grep root). */
43
+ gateFailures: string[];
44
+ warnings: string[];
45
+ }
46
+ export interface MemoryRotateOptions {
47
+ projectRoot: string;
48
+ apply?: boolean;
49
+ /** Override the retention window (months). Defaults to 6. */
50
+ retentionMonths?: number;
51
+ /** Override the reference-grep roots (tests). Defaults to `<root>/src`, `<root>/skills`. */
52
+ referenceRoots?: string[];
53
+ /** Injectable clock (tests). */
54
+ now?: Date;
55
+ }
56
+ /**
57
+ * Read an explicit tier from raw frontmatter. Accepts nested
58
+ * `metadata.tier:` and top-level `tier:`; first valid value wins.
59
+ */
60
+ export declare function readExplicitTier(frontmatter: string): MemoryRotationTier | null;
61
+ /**
62
+ * Resolve the age of a memory. Prefers a frontmatter `updatedAt:` /
63
+ * `updated:` / `modified:` ISO date; falls back to file mtime. Returns the
64
+ * basis so the report can state which was used.
65
+ */
66
+ export declare function resolveMemoryAge(frontmatter: string, filePath: string): {
67
+ date: Date;
68
+ basis: 'frontmatter' | 'mtime';
69
+ };
70
+ /**
71
+ * Whether a memory is pinned in the generated `MEMORY.md` index. Pinning is
72
+ * substring-based on the filename stem: the generated index links every
73
+ * entry as `[<name>](<file>.md)`, so a stem hit means the entry is surfaced.
74
+ */
75
+ export declare function isPinnedInMemoryIndex(memoryIndexText: string, stem: string): boolean;
76
+ /**
77
+ * Reference grep: does any file under the given roots mention the memory's
78
+ * filename stem? Returns the matching absolute paths (sorted, deduped).
79
+ * A missing root is NOT silently a pass — the caller turns that into a gate
80
+ * failure so `--apply` refuses rather than archiving an unverified memory.
81
+ */
82
+ export declare function findReferenceHits(stem: string, roots: readonly string[]): string[];
83
+ /**
84
+ * Plan (and with `apply: true`, perform) a tier-driven rotation pass.
85
+ * Always returns the full envelope; `apply` only controls whether tier-C
86
+ * archives are moved.
87
+ */
88
+ export declare function executeMemoryRotate(options: MemoryRotateOptions): MemoryRotationReport;
@@ -0,0 +1,373 @@
1
+ // ---------------------------------------------------------------------------
2
+ // `peaks memory rotate` — tier-driven retention for `.peaks/memory/`.
3
+ //
4
+ // Implements the mechanism prescribed by
5
+ // `.peaks/memory/2026-07-24-sediment-pruning-policy.md` (tier 1: archive
6
+ // only, never hard-delete). The policy shipped without a mechanism; this
7
+ // service is that mechanism.
8
+ //
9
+ // Retention: 6 months (user override 2026-09-10 — the policy's original 12
10
+ // months is superseded). See `MEMORY_ROTATION_RETENTION_MONTHS`.
11
+ //
12
+ // Tier assignment (first match wins):
13
+ // 1. explicit `metadata.tier: A|B|C|D` (or top-level `tier:`) — wins.
14
+ // 2. file already under `archived/` → D
15
+ // 3. pinned in `MEMORY.md` (the policy's authoritative
16
+ // tier reference) → B
17
+ // 4. kind in {rule, convention, project-rule} → A
18
+ // 5. kind in {decision, reference, feedback, module,
19
+ // bug, investigation, technical-pattern} → B
20
+ // 6. everything else (retrospective-shaped, incl. no kind) → C
21
+ //
22
+ // Actions:
23
+ // - Tier C, older than the retention window, not pinned → archive (move
24
+ // into `.peaks/memory/archived/`). Age basis is frontmatter
25
+ // `updatedAt:` / `updated:` / `modified:` when parseable, else file
26
+ // mtime; every candidate reports which basis was used.
27
+ // - Tier D, not pinned → reported as a delete-candidate ONLY. Never
28
+ // deleted, even with `--apply` (tier-1 decision).
29
+ //
30
+ // Safety gates (all mandatory):
31
+ // - Tier A/B are never selected — an internal assertion turns a violation
32
+ // into a hard refusal rather than an archive.
33
+ // - Every candidate must pass a reference grep against `src/` + `skills/`;
34
+ // any hit excludes it with the referencing path.
35
+ // - `--apply` refuses when the candidate list is empty or any gate fails.
36
+ // - Dry-run is the default; nothing is written without `--apply`.
37
+ //
38
+ // Reads are fail-soft per file (an unreadable memory is reported, not
39
+ // dropped). Writes are rename-only and never overwrite an existing archive.
40
+ // ---------------------------------------------------------------------------
41
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync } from 'node:fs';
42
+ import { basename, join, relative, sep } from 'node:path';
43
+ import { isInsidePath, resolveInputPath, stablePath, stableRealPath } from '../../shared/path-utils.js';
44
+ import { parseMemoryFrontmatter } from './project-memory-service/parsers/frontmatter.js';
45
+ import { MEMORY_MD_FILENAME } from './project-memory-service/index/reindex.js';
46
+ import { listMarkdownFiles } from './project-memory-service/index/search.js';
47
+ import { assertSafeProjectMemoryDir, normalizeRoot } from './project-memory-service/store/paths.js';
48
+ /** User override 2026-09-10: the policy's 12-month window is now 6 months. */
49
+ export const MEMORY_ROTATION_RETENTION_MONTHS = 6;
50
+ export const MEMORY_ROTATION_ARCHIVED_DIRNAME = 'archived';
51
+ /** Default reference-grep roots, relative to the project root. */
52
+ export const MEMORY_ROTATION_REFERENCE_ROOTS = ['src', 'skills'];
53
+ /** Tier A — operational contracts. Never selected for rotation. */
54
+ const TIER_A_KINDS = new Set(['rule', 'convention', 'project-rule']);
55
+ /** Tier B — descriptive governance. Never selected for rotation. */
56
+ const TIER_B_KINDS = new Set([
57
+ 'decision',
58
+ 'reference',
59
+ 'feedback',
60
+ 'module',
61
+ 'bug',
62
+ 'investigation',
63
+ 'technical-pattern'
64
+ ]);
65
+ const EXPLICIT_TIERS = new Set(['A', 'B', 'C', 'D']);
66
+ /** Files we are willing to read during the reference grep. */
67
+ const REFERENCE_SCAN_EXTENSIONS = new Set([
68
+ '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.md', '.json', '.yaml', '.yml', '.txt'
69
+ ]);
70
+ const REFERENCE_SCAN_SKIP_DIRS = new Set([
71
+ 'node_modules', '.git', 'dist', 'build', 'coverage', '.next', '.turbo'
72
+ ]);
73
+ /** Bound the reference grep so a pathological tree cannot hang the command. */
74
+ const REFERENCE_SCAN_MAX_FILES = 20_000;
75
+ function isTier(value) {
76
+ return EXPLICIT_TIERS.has(value);
77
+ }
78
+ /**
79
+ * Read an explicit tier from raw frontmatter. Accepts nested
80
+ * `metadata.tier:` and top-level `tier:`; first valid value wins.
81
+ */
82
+ export function readExplicitTier(frontmatter) {
83
+ let inMetadata = false;
84
+ for (const rawLine of frontmatter.split('\n')) {
85
+ const indented = /^\s/.test(rawLine);
86
+ const line = rawLine.trim();
87
+ if (!indented)
88
+ inMetadata = line === 'metadata:';
89
+ if (!line.startsWith('tier:'))
90
+ continue;
91
+ if (indented && !inMetadata)
92
+ continue; // nested tier must sit under `metadata:`
93
+ const value = line.slice('tier:'.length).trim().toUpperCase();
94
+ if (isTier(value))
95
+ return value;
96
+ }
97
+ return null;
98
+ }
99
+ /**
100
+ * Resolve the age of a memory. Prefers a frontmatter `updatedAt:` /
101
+ * `updated:` / `modified:` ISO date; falls back to file mtime. Returns the
102
+ * basis so the report can state which was used.
103
+ */
104
+ export function resolveMemoryAge(frontmatter, filePath) {
105
+ for (const key of ['updatedAt', 'updated', 'modified']) {
106
+ const match = new RegExp(`^\\s*${key}:\\s*(\\S+)`, 'm').exec(frontmatter);
107
+ const raw = match?.[1];
108
+ if (raw === undefined)
109
+ continue;
110
+ const candidate = new Date(raw.slice(0, 10));
111
+ if (!Number.isNaN(candidate.getTime()))
112
+ return { date: candidate, basis: 'frontmatter' };
113
+ }
114
+ try {
115
+ return { date: statSync(filePath).mtime, basis: 'mtime' };
116
+ }
117
+ catch {
118
+ return { date: new Date(0), basis: 'mtime' };
119
+ }
120
+ }
121
+ /**
122
+ * Whether a memory is pinned in the generated `MEMORY.md` index. Pinning is
123
+ * substring-based on the filename stem: the generated index links every
124
+ * entry as `[<name>](<file>.md)`, so a stem hit means the entry is surfaced.
125
+ */
126
+ export function isPinnedInMemoryIndex(memoryIndexText, stem) {
127
+ return stem.length > 0 && memoryIndexText.includes(stem);
128
+ }
129
+ /**
130
+ * Reference grep: does any file under the given roots mention the memory's
131
+ * filename stem? Returns the matching absolute paths (sorted, deduped).
132
+ * A missing root is NOT silently a pass — the caller turns that into a gate
133
+ * failure so `--apply` refuses rather than archiving an unverified memory.
134
+ */
135
+ export function findReferenceHits(stem, roots) {
136
+ const hits = [];
137
+ if (stem.length === 0)
138
+ return hits;
139
+ const stack = [...roots];
140
+ let scanned = 0;
141
+ while (stack.length > 0 && scanned < REFERENCE_SCAN_MAX_FILES) {
142
+ const current = stack.pop();
143
+ let entries;
144
+ try {
145
+ entries = readdirSync(current, { withFileTypes: true });
146
+ }
147
+ catch {
148
+ continue;
149
+ }
150
+ for (const entry of entries) {
151
+ const entryPath = join(current, entry.name);
152
+ if (entry.isDirectory()) {
153
+ if (REFERENCE_SCAN_SKIP_DIRS.has(entry.name))
154
+ continue;
155
+ stack.push(entryPath);
156
+ continue;
157
+ }
158
+ if (!entry.isFile())
159
+ continue;
160
+ const dotIndex = entry.name.lastIndexOf('.');
161
+ if (dotIndex < 0 || !REFERENCE_SCAN_EXTENSIONS.has(entry.name.slice(dotIndex)))
162
+ continue;
163
+ if (++scanned > REFERENCE_SCAN_MAX_FILES)
164
+ break;
165
+ try {
166
+ if (readFileSync(entryPath, 'utf8').includes(stem))
167
+ hits.push(entryPath);
168
+ }
169
+ catch {
170
+ // Unreadable file: cannot prove absence, cannot prove presence.
171
+ // Treat as a hit so the candidate is excluded (fail-safe).
172
+ hits.push(entryPath);
173
+ }
174
+ }
175
+ }
176
+ return hits.sort((left, right) => left.localeCompare(right));
177
+ }
178
+ function resolveTier(input) {
179
+ if (input.explicitTier !== null) {
180
+ return { tier: input.explicitTier, reason: `explicit metadata.tier: ${input.explicitTier}` };
181
+ }
182
+ if (input.underArchived) {
183
+ return { tier: 'D', reason: 'already under archived/' };
184
+ }
185
+ // Pinning is the policy's authoritative tier reference (§2), so it wins
186
+ // over the kind heuristic. Pinned files are tier B — never selected.
187
+ if (input.pinned) {
188
+ return { tier: 'B', reason: 'pinned in MEMORY.md index' };
189
+ }
190
+ if (input.kind !== null && TIER_A_KINDS.has(input.kind)) {
191
+ return { tier: 'A', reason: `kind '${input.kind}' is an operational contract` };
192
+ }
193
+ if (input.kind !== null && TIER_B_KINDS.has(input.kind)) {
194
+ return { tier: 'B', reason: `kind '${input.kind}' is descriptive governance` };
195
+ }
196
+ if (input.kind !== null) {
197
+ return { tier: 'C', reason: `kind '${input.kind}' is retrospective-shaped` };
198
+ }
199
+ return { tier: 'C', reason: 'no resolvable kind; treated as retrospective (C)' };
200
+ }
201
+ function emptyTierCounts() {
202
+ return { A: 0, B: 0, C: 0, D: 0 };
203
+ }
204
+ /**
205
+ * Plan (and with `apply: true`, perform) a tier-driven rotation pass.
206
+ * Always returns the full envelope; `apply` only controls whether tier-C
207
+ * archives are moved.
208
+ */
209
+ export function executeMemoryRotate(options) {
210
+ const projectRoot = normalizeRoot(options.projectRoot);
211
+ const apply = options.apply ?? false;
212
+ const retentionMonths = options.retentionMonths ?? MEMORY_ROTATION_RETENTION_MONTHS;
213
+ const memoryDir = assertSafeProjectMemoryDir(projectRoot);
214
+ const archivedDir = join(memoryDir, MEMORY_ROTATION_ARCHIVED_DIRNAME);
215
+ const now = options.now ?? new Date();
216
+ const referenceRoots = options.referenceRoots
217
+ ?? MEMORY_ROTATION_REFERENCE_ROOTS.map((root) => join(projectRoot, root));
218
+ const report = {
219
+ apply,
220
+ projectRoot,
221
+ memoryDir,
222
+ archivedDir,
223
+ retentionMonths,
224
+ referenceRoots,
225
+ tierCounts: emptyTierCounts(),
226
+ candidates: [],
227
+ archived: [],
228
+ excluded: [],
229
+ refused: false,
230
+ refusalReasons: [],
231
+ gateFailures: [],
232
+ warnings: []
233
+ };
234
+ const memoryIndexPath = join(memoryDir, MEMORY_MD_FILENAME);
235
+ let memoryIndexText = '';
236
+ if (existsSync(memoryIndexPath)) {
237
+ try {
238
+ memoryIndexText = readFileSync(memoryIndexPath, 'utf8');
239
+ }
240
+ catch {
241
+ report.warnings.push(`Could not read ${MEMORY_MD_FILENAME}; pinning cannot be detected.`);
242
+ }
243
+ }
244
+ else {
245
+ report.warnings.push(`No ${MEMORY_MD_FILENAME} found; no memory is treated as pinned.`);
246
+ }
247
+ const stableMemoryDir = existsSync(memoryDir) ? stableRealPath(memoryDir) : null;
248
+ const archivedPrefix = `${archivedDir}${sep}`;
249
+ const cutoff = new Date(now);
250
+ cutoff.setMonth(cutoff.getMonth() - retentionMonths);
251
+ const diskFiles = listMarkdownFiles(memoryDir).filter((filePath) => basename(filePath) !== MEMORY_MD_FILENAME);
252
+ for (const filePath of diskFiles) {
253
+ const stem = basename(filePath, '.md');
254
+ let content;
255
+ try {
256
+ content = readFileSync(filePath, 'utf8');
257
+ }
258
+ catch {
259
+ report.excluded.push({ name: stem, filePath, tier: 'C', reason: 'file could not be read' });
260
+ continue;
261
+ }
262
+ const parsed = parseMemoryFrontmatter(content);
263
+ const kind = parsed.kind.kind;
264
+ const underArchived = filePath.startsWith(archivedPrefix);
265
+ const pinned = isPinnedInMemoryIndex(memoryIndexText, stem);
266
+ const explicitTier = readExplicitTier(parsed.frontmatter);
267
+ const { tier, reason: tierReason } = resolveTier({ explicitTier, underArchived, pinned, kind });
268
+ report.tierCounts[tier] += 1;
269
+ // Safety gate 1: tier A/B are never selected. Pinned files are reported
270
+ // (the interesting case); unpinned A/B files are simply not candidates.
271
+ if (tier === 'A' || tier === 'B') {
272
+ if (pinned) {
273
+ report.excluded.push({ name: parsed.name ?? stem, filePath, tier, reason: 'pinned in MEMORY.md index' });
274
+ }
275
+ continue;
276
+ }
277
+ const { date, basis } = resolveMemoryAge(parsed.frontmatter, filePath);
278
+ const ageDays = Math.max(0, Math.floor((now.getTime() - date.getTime()) / 86_400_000));
279
+ if (tier === 'D') {
280
+ // Report-only: never deleted, even with --apply (tier-1 decision).
281
+ report.candidates.push({
282
+ name: parsed.name ?? stem,
283
+ filePath,
284
+ tier,
285
+ tierReason,
286
+ action: 'delete-candidate',
287
+ reason: 'tier D (ephemeral/archived) — reported as a delete-candidate only; peaks never deletes',
288
+ ageDays,
289
+ ageBasis: basis
290
+ });
291
+ continue;
292
+ }
293
+ // Tier C from here on.
294
+ if (date.getTime() >= cutoff.getTime()) {
295
+ continue; // within the retention window — not a candidate yet
296
+ }
297
+ if (pinned) {
298
+ report.excluded.push({ name: parsed.name ?? stem, filePath, tier, reason: 'pinned in MEMORY.md index' });
299
+ continue;
300
+ }
301
+ const hits = findReferenceHits(stem, referenceRoots);
302
+ if (hits.length > 0) {
303
+ const preview = hits.slice(0, 2).map((hit) => relative(projectRoot, hit).replaceAll('\\', '/')).join(', ');
304
+ report.excluded.push({
305
+ name: parsed.name ?? stem,
306
+ filePath,
307
+ tier,
308
+ reason: `referenced by ${hits.length} file(s) (${preview}${hits.length > 2 ? ', …' : ''})`
309
+ });
310
+ continue;
311
+ }
312
+ report.candidates.push({
313
+ name: parsed.name ?? stem,
314
+ filePath,
315
+ tier,
316
+ tierReason,
317
+ action: 'archive',
318
+ reason: `tier C, ${ageDays} days old (${basis}) > ${retentionMonths}-month retention`,
319
+ ageDays,
320
+ ageBasis: basis
321
+ });
322
+ }
323
+ // Safety gate 2: every reference root must be verifiable before applying.
324
+ for (const root of referenceRoots) {
325
+ if (!existsSync(root)) {
326
+ report.gateFailures.push(`reference-grep root not found: ${root}`);
327
+ }
328
+ }
329
+ const archiveCandidates = report.candidates.filter((candidate) => candidate.action === 'archive');
330
+ if (apply) {
331
+ if (report.candidates.length === 0) {
332
+ report.refused = true;
333
+ report.refusalReasons.push('no rotation candidates; refusing to apply an empty plan');
334
+ }
335
+ if (report.gateFailures.length > 0) {
336
+ report.refused = true;
337
+ report.refusalReasons.push(...report.gateFailures);
338
+ }
339
+ if (!report.refused && archiveCandidates.length > 0) {
340
+ mkdirSync(archivedDir, { recursive: true });
341
+ const stableArchivedDir = stableRealPath(archivedDir);
342
+ for (const candidate of archiveCandidates) {
343
+ const source = stablePath(resolveInputPath(candidate.filePath));
344
+ if (stableMemoryDir === null || !isInsidePath(source, stableMemoryDir)) {
345
+ report.gateFailures.push(`candidate escapes the memory directory: ${candidate.filePath}`);
346
+ report.refused = true;
347
+ report.refusalReasons.push(`candidate escapes the memory directory: ${candidate.filePath}`);
348
+ break;
349
+ }
350
+ const destination = join(archivedDir, basename(candidate.filePath));
351
+ const stableDestination = stablePath(resolveInputPath(destination));
352
+ if (!isInsidePath(stableDestination, stableArchivedDir)) {
353
+ report.gateFailures.push(`archive destination escapes archived/: ${destination}`);
354
+ report.refused = true;
355
+ report.refusalReasons.push(`archive destination escapes archived/: ${destination}`);
356
+ break;
357
+ }
358
+ if (existsSync(destination)) {
359
+ report.excluded.push({
360
+ name: candidate.name,
361
+ filePath: candidate.filePath,
362
+ tier: candidate.tier,
363
+ reason: `archive destination already exists: ${basename(destination)}`
364
+ });
365
+ continue;
366
+ }
367
+ renameSync(candidate.filePath, destination);
368
+ report.archived.push(destination);
369
+ }
370
+ }
371
+ }
372
+ return report;
373
+ }
@@ -1,6 +1,14 @@
1
1
  import type { MemoryIndex } from '../types.js';
2
2
  export declare function readMemoryFileMtime(filePath: string): string;
3
3
  export declare function readStoredMemoryNames(memoryDir: string): Set<string>;
4
- export declare function generateMemoryIndexFile(projectRoot: string, memoryDir: string, indexPath: string): void;
4
+ /**
5
+ * Build the hot/warm index object from the current `.peaks/memory/` contents
6
+ * without touching disk. Pure read + in-memory assembly; `generateMemoryIndexFile`
7
+ * serializes the result. Exposed so `peaks memory reindex` can reuse the exact
8
+ * same entry construction for its counts + `MEMORY.md` regeneration instead of
9
+ * duplicating it.
10
+ */
11
+ export declare function buildMemoryIndex(projectRoot: string): MemoryIndex;
12
+ export declare function generateMemoryIndexFile(projectRoot: string, memoryDir: string, indexPath: string, prebuilt?: MemoryIndex): void;
5
13
  export declare function readExistingIndex(indexPath: string): MemoryIndex | null;
6
14
  export declare function readMemoryIndex(projectRoot: string): MemoryIndex | null;
@@ -4,9 +4,9 @@
4
4
  // The memory index is the always-available summary of every memory in
5
5
  // `.peaks/memory/`. This module owns its write path:
6
6
  //
7
- // - `HOT_KINDS` — the kinds whose full body is kept in the index
8
- // (`feedback`, `decision`, `rule`, `convention`, `module`, `lesson`).
9
- // Anything not in `HOT_KINDS` lands in `warm` with the same shape.
7
+ // - `HOT_KINDS` — the kinds whose full body is kept in the index,
8
+ // derived from `MEMORY_KIND_TIER` in `../types.ts`. Anything not in
9
+ // `HOT_KINDS` lands in `warm` with the same shape.
10
10
  // - `readMemoryFileMtime` / `readStoredMemoryNames` — small stat /
11
11
  // filename helpers used by the index generator and by the slug-
12
12
  // collision idempotency check.
@@ -22,12 +22,13 @@
22
22
  // ---------------------------------------------------------------------------
23
23
  import { closeSync, constants, existsSync, openSync, readFileSync, statSync, writeFileSync } from 'node:fs';
24
24
  import { basename, join } from 'node:path';
25
+ import { HOT_MEMORY_KINDS, MEMORY_KIND_TIER, PROJECT_MEMORY_KINDS } from '../types.js';
25
26
  import { parseStoredMemoryFile } from '../parsers/frontmatter.js';
26
27
  import { summarizeMemoryBody } from '../parsers/markdown-pure.js';
27
28
  import { assertSafeProjectMemoryDir, normalizeRoot } from '../store/paths.js';
28
29
  import { ensureMemoryBootstrap, listMarkdownFiles, readProjectMemories } from './search.js';
29
- // Hot kinds: full body kept in index for always-available context
30
- const HOT_KINDS = new Set(['feedback', 'decision', 'rule', 'convention', 'module', 'lesson']);
30
+ // Hot kinds: full body kept in index for always-available context.
31
+ const HOT_KINDS = new Set(HOT_MEMORY_KINDS);
31
32
  export function readMemoryFileMtime(filePath) {
32
33
  try {
33
34
  return statSync(filePath).mtime.toISOString().slice(0, 10);
@@ -60,14 +61,22 @@ export function readStoredMemoryNames(memoryDir) {
60
61
  }
61
62
  return names;
62
63
  }
63
- export function generateMemoryIndexFile(projectRoot, memoryDir, indexPath) {
64
+ /**
65
+ * Build the hot/warm index object from the current `.peaks/memory/` contents
66
+ * without touching disk. Pure read + in-memory assembly; `generateMemoryIndexFile`
67
+ * serializes the result. Exposed so `peaks memory reindex` can reuse the exact
68
+ * same entry construction for its counts + `MEMORY.md` regeneration instead of
69
+ * duplicating it.
70
+ */
71
+ export function buildMemoryIndex(projectRoot) {
64
72
  const memories = readProjectMemories(projectRoot);
65
- const hot = {
66
- feedback: [], decision: [], rule: [], convention: [], module: [], lesson: []
67
- };
68
- const warm = {
69
- project: [], reference: []
70
- };
73
+ // Full-shape hot/warm buckets, derived from the canonical tier map so a
74
+ // newly accepted kind cannot be silently absent from the index.
75
+ const hot = {};
76
+ const warm = {};
77
+ for (const kind of PROJECT_MEMORY_KINDS) {
78
+ (MEMORY_KIND_TIER[kind] === 'hot' ? hot : warm)[kind] = [];
79
+ }
71
80
  for (const memory of memories.memories) {
72
81
  const entry = {
73
82
  name: memory.name,
@@ -89,12 +98,15 @@ export function generateMemoryIndexFile(projectRoot, memoryDir, indexPath) {
89
98
  if (arr)
90
99
  arr.sort((a, b) => a.name.localeCompare(b.name));
91
100
  }
92
- const index = {
101
+ return {
93
102
  version: 1,
94
103
  updatedAt: new Date().toISOString(),
95
104
  hot: hot,
96
105
  warm: warm
97
106
  };
107
+ }
108
+ export function generateMemoryIndexFile(projectRoot, memoryDir, indexPath, prebuilt) {
109
+ const index = prebuilt ?? buildMemoryIndex(projectRoot);
98
110
  const fd = openSync(indexPath, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC, 0o644);
99
111
  try {
100
112
  writeFileSync(fd, JSON.stringify(index, null, 2), 'utf8');
@@ -0,0 +1,75 @@
1
+ import type { MemoryIndex, ProjectMemoryKind } from '../types.js';
2
+ /** Marker line that owns `MEMORY.md`. Anything above it is machine output. */
3
+ export declare const MEMORY_MD_BANNER = "<!-- generated by `peaks memory reindex` \u2014 do not edit -->";
4
+ /** `MEMORY.md` is the generated index itself — never a memory source. */
5
+ export declare const MEMORY_MD_FILENAME = "MEMORY.md";
6
+ /**
7
+ * Deterministic section order for the generated `MEMORY.md`: hot kinds
8
+ * first (mirrors `HOT_KINDS` in `ranking.ts`), warm kinds last. Derived
9
+ * from the insertion order of `MEMORY_KIND_TIER` so the vocabulary has
10
+ * exactly one definition.
11
+ */
12
+ export declare const KIND_ORDER: readonly ProjectMemoryKind[];
13
+ export interface ReindexUnclassified {
14
+ name: string;
15
+ filePath: string;
16
+ /** The first raw type/kind value found in the frontmatter (null when absent). */
17
+ rawKind: string | null;
18
+ reason: string;
19
+ }
20
+ export interface ReindexOrphanEntry {
21
+ name: string;
22
+ kind: string;
23
+ /** The `sourcePath` recorded in the previous index that no longer exists on disk. */
24
+ sourcePath: string;
25
+ }
26
+ /**
27
+ * Two or more different files resolve to the same memory name (after the
28
+ * `name:` → `title:` → filename-stem fallback chain). Reported, never
29
+ * resolved by overwriting: both files keep their own index entry, and the
30
+ * collision is surfaced so a human/LLM can rename one of them.
31
+ */
32
+ export interface ReindexNameConflict {
33
+ name: string;
34
+ /** Every file that resolves to this name, sorted. Always length >= 2. */
35
+ filePaths: string[];
36
+ }
37
+ export interface MemoryReindexReport {
38
+ apply: boolean;
39
+ projectRoot: string;
40
+ memoryDir: string;
41
+ indexPath: string;
42
+ memoryMdPath: string;
43
+ /** Markdown files scanned (excludes the generated `MEMORY.md`). */
44
+ scannedFiles: number;
45
+ /** Files represented in the rebuilt index. */
46
+ indexed: number;
47
+ indexedByKind: Record<string, number>;
48
+ /** Files on disk with no resolvable kind — reported, never invented. */
49
+ unclassified: ReindexUnclassified[];
50
+ /** Distinct files that resolve to the same index name — reported, never overwritten. */
51
+ nameConflicts: ReindexNameConflict[];
52
+ /** Previous index entries whose `sourcePath` no longer exists (error-class drift). */
53
+ orphanIndex: ReindexOrphanEntry[];
54
+ /** Files on disk that the rebuilt index does not contain (warn-class drift). */
55
+ orphanDisk: string[];
56
+ memoryMd: {
57
+ path: string;
58
+ regenerated: boolean;
59
+ };
60
+ writtenFiles: string[];
61
+ }
62
+ export interface MemoryReindexOptions {
63
+ projectRoot: string;
64
+ apply?: boolean;
65
+ }
66
+ /**
67
+ * Render the human/LLM-facing `MEMORY.md` from a built index. Deterministic:
68
+ * kinds in `KIND_ORDER`, entries sorted by name, no timestamps.
69
+ */
70
+ export declare function renderMemoryMarkdown(index: MemoryIndex, memoryDir: string): string;
71
+ /**
72
+ * Rebuild `.peaks/memory/index.json` from disk and (on `--apply`) regenerate
73
+ * `MEMORY.md`. Always returns the drift report, even on a dry run.
74
+ */
75
+ export declare function executeMemoryReindex(options: MemoryReindexOptions): MemoryReindexReport;