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.
- package/CHANGELOG.md +34 -0
- package/dist/cli/commands/code-runtime-commands.d.ts +5 -2
- package/dist/cli/commands/code-runtime-commands.js +57 -2
- package/dist/cli/commands/core/doctor-command.d.ts +8 -0
- package/dist/cli/commands/core/doctor-command.js +44 -2
- package/dist/cli/commands/core/memory-command.js +65 -3
- package/dist/cli/commands/dispatch-commands.js +19 -5
- package/dist/cli/commands/dispatch-from-dag.js +17 -0
- package/dist/cli/commands/memory-commands.d.ts +59 -0
- package/dist/cli/commands/memory-commands.js +195 -19
- package/dist/cli/commands/request-commands.d.ts +8 -0
- package/dist/cli/commands/request-commands.js +23 -2
- package/dist/cli/commands/sub-agent-commands.js +2 -0
- package/dist/cli/commands/wave-plan-commands.d.ts +24 -0
- package/dist/cli/commands/wave-plan-commands.js +93 -0
- package/dist/services/context/build-dispatch-system-prompt.d.ts +66 -9
- package/dist/services/context/build-dispatch-system-prompt.js +132 -17
- package/dist/services/context/context-audit.d.ts +100 -0
- package/dist/services/context/context-audit.js +322 -0
- package/dist/services/context/context-schema.d.ts +1 -1
- package/dist/services/context/memory-index-reader.d.ts +26 -0
- package/dist/services/context/memory-index-reader.js +62 -30
- package/dist/services/context/memory-preflight-config.d.ts +33 -0
- package/dist/services/context/memory-preflight-config.js +32 -2
- package/dist/services/context/memory-preflight-service.d.ts +20 -1
- package/dist/services/context/memory-preflight-service.js +198 -31
- package/dist/services/context/summary-view.d.ts +54 -0
- package/dist/services/context/summary-view.js +114 -0
- package/dist/services/dispatch/file-overlap-wave-planner.d.ts +70 -0
- package/dist/services/dispatch/file-overlap-wave-planner.js +119 -0
- package/dist/services/dispatch/session-capsule.d.ts +23 -0
- package/dist/services/dispatch/session-capsule.js +56 -0
- package/dist/services/dispatch/slice-dag.d.ts +9 -0
- package/dist/services/dispatch/slice-dag.js +9 -1
- package/dist/services/dispatch/test-tool-detection.d.ts +12 -1
- package/dist/services/dispatch/test-tool-detection.js +14 -13
- package/dist/services/doctor/doctor-service/checks/l3-memory-health.d.ts +19 -2
- package/dist/services/doctor/doctor-service/checks/l3-memory-health.js +143 -19
- package/dist/services/ide/adapters/claude-code-adapter.d.ts +10 -0
- package/dist/services/ide/adapters/claude-code-adapter.js +20 -1
- package/dist/services/ide/ide-types.d.ts +15 -0
- package/dist/services/job/job-types.d.ts +3 -3
- package/dist/services/memory/memory-ingest-service.d.ts +79 -0
- package/dist/services/memory/memory-ingest-service.js +225 -0
- package/dist/services/memory/memory-rotate-service.d.ts +88 -0
- package/dist/services/memory/memory-rotate-service.js +373 -0
- package/dist/services/memory/project-memory-service/index/ranking.d.ts +9 -1
- package/dist/services/memory/project-memory-service/index/ranking.js +25 -13
- package/dist/services/memory/project-memory-service/index/reindex.d.ts +75 -0
- package/dist/services/memory/project-memory-service/index/reindex.js +207 -0
- package/dist/services/memory/project-memory-service/index/search.js +14 -24
- package/dist/services/memory/project-memory-service/index.d.ts +7 -3
- package/dist/services/memory/project-memory-service/index.js +6 -2
- package/dist/services/memory/project-memory-service/parsers/frontmatter.d.ts +80 -3
- package/dist/services/memory/project-memory-service/parsers/frontmatter.js +167 -28
- package/dist/services/memory/project-memory-service/types.d.ts +31 -1
- package/dist/services/memory/project-memory-service/types.js +76 -1
- package/dist/services/preferences/preferences-types.d.ts +14 -0
- package/dist/services/preferences/preferences-types.js +8 -0
- package/dist/services/share/run-state-contract.d.ts +1 -1
- package/package.json +5 -5
- package/skills/bee/peaks-qa/SKILL.md +2 -0
- package/skills/bee/peaks-qa/references/qa-sub-agent-dispatch.md +12 -0
- package/skills/bee/peaks-rd/SKILL.md +2 -0
- package/skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md +14 -0
- package/skills/bee/peaks-txt/SKILL.md +2 -0
- package/skills/bee/peaks-ui/SKILL.md +2 -0
- package/skills/peaks-code/SKILL.md +9 -1
- package/skills/peaks-code/references/context-governance.md +29 -0
- package/skills/peaks-code/references/runbook.md +6 -0
- package/skills/peaks-code/references/step-11-memory-sediment.md +35 -0
- 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
|
-
|
|
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
|
-
//
|
|
9
|
-
//
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
};
|
|
68
|
-
const warm = {
|
|
69
|
-
|
|
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
|
-
|
|
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;
|