peaks-loop 4.0.34 → 4.0.35
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 +18 -0
- package/dist/cli/commands/core/memory-command.js +61 -3
- package/dist/cli/commands/dispatch-commands.js +4 -2
- package/dist/cli/commands/memory-commands.d.ts +35 -0
- package/dist/cli/commands/memory-commands.js +119 -10
- 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/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/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 +75 -3
- package/dist/services/memory/project-memory-service/parsers/frontmatter.js +113 -24
- 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/peaks-code/SKILL.md +1 -1
- package/skills/peaks-code/references/runbook.md +6 -0
- package/skills/peaks-code/references/step-11-memory-sediment.md +35 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// `peaks memory ingest` — pull memories written by the IDE-side agent into
|
|
3
|
+
// the peaks-owned store.
|
|
4
|
+
//
|
|
5
|
+
// Slice 2026-09-09-memory-system-overhaul (A). Before this, "沉淀记忆" in a
|
|
6
|
+
// peaks-code workflow landed in Claude Code's own per-project memory dir
|
|
7
|
+
// (`~/.claude/projects/<hash>/memory/`) and never reached `.peaks/memory/`
|
|
8
|
+
// — a split-brain write path with no single authority.
|
|
9
|
+
//
|
|
10
|
+
// Authority contract:
|
|
11
|
+
// - `.peaks/memory/` is the authoritative, peaks-owned store.
|
|
12
|
+
// - `~/.claude/**` is READ-ONLY. This module never writes there (same
|
|
13
|
+
// rule the project already applies to `~/.claude/agents/`). The only
|
|
14
|
+
// write targets are inside the project's `.peaks/memory/`.
|
|
15
|
+
// - The IDE-side memory dir is a session note, not the authority.
|
|
16
|
+
//
|
|
17
|
+
// Idempotency: identity is the filename stem. Re-running skips destination
|
|
18
|
+
// files that are byte-identical; when the destination exists but differs it
|
|
19
|
+
// is reported as a conflict and BOTH copies are left untouched (never
|
|
20
|
+
// overwrite user content).
|
|
21
|
+
//
|
|
22
|
+
// Classification: a memory is imported only when its kind resolves through
|
|
23
|
+
// the shared `resolveMemoryKind` rule (`metadata.type` → `kind:` → `type:`).
|
|
24
|
+
// Files with no resolvable kind are reported as needing classification —
|
|
25
|
+
// no type is invented silently.
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
28
|
+
import { homedir } from 'node:os';
|
|
29
|
+
import { basename, join } from 'node:path';
|
|
30
|
+
import { isInsidePath, resolveInputPath, stablePath, stableRealPath } from '../../shared/path-utils.js';
|
|
31
|
+
import { parseMemoryFrontmatter } from './project-memory-service/parsers/frontmatter.js';
|
|
32
|
+
import { summarizeMemoryBody } from './project-memory-service/parsers/markdown-pure.js';
|
|
33
|
+
import { assertSafeProjectMemoryDir, normalizeRoot } from './project-memory-service/store/paths.js';
|
|
34
|
+
import { assertSafeMemoryFileContent, writeNewFile } from './project-memory-service/store/atomic-write.js';
|
|
35
|
+
import { listMarkdownFiles } from './project-memory-service/index/search.js';
|
|
36
|
+
/** `MEMORY.md` is the IDE's own generated index, not a memory source. */
|
|
37
|
+
const IDE_INDEX_FILENAME = 'MEMORY.md';
|
|
38
|
+
const RESERVED_TOP_LEVEL_KEYS = new Set(['name', 'description', 'metadata', 'type', 'kind']);
|
|
39
|
+
const RESERVED_METADATA_KEYS = new Set(['type', 'kind', 'description']);
|
|
40
|
+
/**
|
|
41
|
+
* Claude Code encodes a project cwd into its `~/.claude/projects/<name>/`
|
|
42
|
+
* directory by replacing every non-alphanumeric character with `-`
|
|
43
|
+
* (`D:\peaks-loop` → `D--peaks-loop`). Separator-agnostic, so the same
|
|
44
|
+
* encoding holds for POSIX paths.
|
|
45
|
+
*/
|
|
46
|
+
export function encodeIdeProjectDir(projectRoot) {
|
|
47
|
+
return projectRoot.replace(/[^A-Za-z0-9]/g, '-');
|
|
48
|
+
}
|
|
49
|
+
/** Default IDE-side memory dir for a project: `~/.claude/projects/<encoded>/memory`. */
|
|
50
|
+
export function defaultIdeMemoryDir(projectRoot, homeDir) {
|
|
51
|
+
return join(homeDir ?? homedir(), '.claude', 'projects', encodeIdeProjectDir(projectRoot), 'memory');
|
|
52
|
+
}
|
|
53
|
+
function parseFrontmatterDoc(frontmatter) {
|
|
54
|
+
const top = [];
|
|
55
|
+
const metadata = [];
|
|
56
|
+
let inMetadata = false;
|
|
57
|
+
for (const rawLine of frontmatter.split('\n')) {
|
|
58
|
+
if (rawLine.trim() === '')
|
|
59
|
+
continue;
|
|
60
|
+
const indented = /^\s/.test(rawLine);
|
|
61
|
+
const line = rawLine.trim();
|
|
62
|
+
const separator = line.indexOf(':');
|
|
63
|
+
if (separator < 0)
|
|
64
|
+
continue;
|
|
65
|
+
const key = line.slice(0, separator).trim();
|
|
66
|
+
const value = line.slice(separator + 1).trim();
|
|
67
|
+
if (indented) {
|
|
68
|
+
if (inMetadata)
|
|
69
|
+
metadata.push([key, value]);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
inMetadata = key === 'metadata';
|
|
73
|
+
if (!inMetadata)
|
|
74
|
+
top.push([key, value]);
|
|
75
|
+
}
|
|
76
|
+
return { top, metadata };
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Rewrite a source file's frontmatter to the peaks contract: `name` pinned to
|
|
80
|
+
* the destination filename stem, and `metadata.type` set to the resolved
|
|
81
|
+
* kind. Non-contract keys (e.g. `originSessionId`, `modified`, `node_type`)
|
|
82
|
+
* are preserved verbatim so provenance survives the import; `type` / `kind`
|
|
83
|
+
* are consumed by the normalization and not duplicated.
|
|
84
|
+
*/
|
|
85
|
+
export function renderNormalizedMemory(input) {
|
|
86
|
+
const doc = parseFrontmatterDoc(input.frontmatter);
|
|
87
|
+
const topMap = new Map(doc.top);
|
|
88
|
+
const metaMap = new Map(doc.metadata);
|
|
89
|
+
const description = topMap.get('description')
|
|
90
|
+
?? metaMap.get('description')
|
|
91
|
+
?? summarizeMemoryBody(input.body);
|
|
92
|
+
const lines = ['---', `name: ${input.stem}`, `description: ${description}`];
|
|
93
|
+
for (const [key, value] of doc.top) {
|
|
94
|
+
if (RESERVED_TOP_LEVEL_KEYS.has(key))
|
|
95
|
+
continue;
|
|
96
|
+
lines.push(`${key}: ${value}`);
|
|
97
|
+
}
|
|
98
|
+
lines.push('metadata:');
|
|
99
|
+
lines.push(` type: ${input.kind}`);
|
|
100
|
+
for (const [key, value] of doc.metadata) {
|
|
101
|
+
if (RESERVED_METADATA_KEYS.has(key))
|
|
102
|
+
continue;
|
|
103
|
+
lines.push(` ${key}: ${value}`);
|
|
104
|
+
}
|
|
105
|
+
lines.push('---', '', input.body, '');
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
108
|
+
function emptyReport(overrides) {
|
|
109
|
+
return {
|
|
110
|
+
apply: false,
|
|
111
|
+
sourceExists: false,
|
|
112
|
+
scannedFiles: 0,
|
|
113
|
+
imported: [],
|
|
114
|
+
skippedIdentical: [],
|
|
115
|
+
conflicts: [],
|
|
116
|
+
needsClassification: [],
|
|
117
|
+
refused: [],
|
|
118
|
+
writtenFiles: [],
|
|
119
|
+
warnings: [],
|
|
120
|
+
...overrides
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Import IDE-side memories into `.peaks/memory/`. Always returns the full
|
|
125
|
+
* envelope; `apply` only controls whether files are actually written.
|
|
126
|
+
*/
|
|
127
|
+
export function executeMemoryIngest(options) {
|
|
128
|
+
const projectRoot = normalizeRoot(options.projectRoot);
|
|
129
|
+
const memoryDir = assertSafeProjectMemoryDir(projectRoot);
|
|
130
|
+
const apply = options.apply ?? false;
|
|
131
|
+
const sourceDir = options.sourceDir !== undefined
|
|
132
|
+
? resolveInputPath(options.sourceDir)
|
|
133
|
+
: defaultIdeMemoryDir(projectRoot, options.homeDir);
|
|
134
|
+
if (!existsSync(sourceDir)) {
|
|
135
|
+
return emptyReport({
|
|
136
|
+
apply,
|
|
137
|
+
projectRoot,
|
|
138
|
+
sourceDir,
|
|
139
|
+
memoryDir,
|
|
140
|
+
warnings: [`No IDE-side memory directory at ${sourceDir}; nothing to ingest.`]
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
const report = emptyReport({ apply, projectRoot, sourceDir, memoryDir, sourceExists: true });
|
|
144
|
+
const sourceFiles = listMarkdownFiles(sourceDir);
|
|
145
|
+
const indexFiles = sourceFiles.filter((filePath) => basename(filePath) === IDE_INDEX_FILENAME);
|
|
146
|
+
if (indexFiles.length > 0) {
|
|
147
|
+
report.warnings.push(`Skipped ${indexFiles.length} IDE-side index file(s) named ${IDE_INDEX_FILENAME} (generated by the IDE, not a memory).`);
|
|
148
|
+
}
|
|
149
|
+
const memoryFiles = sourceFiles.filter((filePath) => basename(filePath) !== IDE_INDEX_FILENAME);
|
|
150
|
+
report.scannedFiles = memoryFiles.length;
|
|
151
|
+
// Only materialise the destination directory once we know there is at
|
|
152
|
+
// least one candidate and we are actually applying.
|
|
153
|
+
if (apply && memoryFiles.length > 0) {
|
|
154
|
+
mkdirSync(memoryDir, { recursive: true });
|
|
155
|
+
}
|
|
156
|
+
const stableMemoryDir = existsSync(memoryDir) ? stableRealPath(memoryDir) : null;
|
|
157
|
+
for (const sourcePath of memoryFiles) {
|
|
158
|
+
const stem = basename(sourcePath, '.md');
|
|
159
|
+
let content;
|
|
160
|
+
try {
|
|
161
|
+
content = readFileSync(sourcePath, 'utf8');
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
report.refused.push({ name: stem, sourcePath, reason: 'source file could not be read' });
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const parsed = parseMemoryFrontmatter(content);
|
|
168
|
+
const kind = parsed.kind.kind;
|
|
169
|
+
if (kind === null) {
|
|
170
|
+
report.needsClassification.push({
|
|
171
|
+
name: parsed.name ?? stem,
|
|
172
|
+
sourcePath,
|
|
173
|
+
rawKind: parsed.kind.rawKind,
|
|
174
|
+
reason: parsed.kind.rawKind === null
|
|
175
|
+
? 'no metadata.type / kind / type field in frontmatter'
|
|
176
|
+
: `unrecognized kind value: ${parsed.kind.rawKind}`
|
|
177
|
+
});
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const normalized = renderNormalizedMemory({ stem, kind, frontmatter: parsed.frontmatter, body: parsed.body });
|
|
181
|
+
// Never import secrets into the peaks store. Fail-soft: report, skip.
|
|
182
|
+
try {
|
|
183
|
+
assertSafeMemoryFileContent(normalized);
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
report.refused.push({ name: stem, sourcePath, reason: 'refused: sensitive content pattern' });
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
const targetPath = join(memoryDir, `${stem}.md`);
|
|
190
|
+
const entry = { name: stem, kind, sourcePath, targetPath };
|
|
191
|
+
if (existsSync(targetPath)) {
|
|
192
|
+
let existing;
|
|
193
|
+
try {
|
|
194
|
+
existing = readFileSync(targetPath, 'utf8');
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
report.conflicts.push({ name: stem, sourcePath, targetPath, reason: 'destination exists but could not be read' });
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (existing === normalized) {
|
|
201
|
+
report.skippedIdentical.push({ name: stem, sourcePath, targetPath });
|
|
202
|
+
}
|
|
203
|
+
else {
|
|
204
|
+
report.conflicts.push({ name: stem, sourcePath, targetPath, reason: 'destination exists with different content; both copies left untouched' });
|
|
205
|
+
}
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (!apply) {
|
|
209
|
+
report.imported.push(entry);
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
// Defence in depth: the write target must resolve inside the project's
|
|
213
|
+
// `.peaks/memory/`. This is the guard that makes "never write to
|
|
214
|
+
// ~/.claude/**" structural rather than a convention.
|
|
215
|
+
const stableTargetPath = stablePath(resolveInputPath(targetPath));
|
|
216
|
+
if (stableMemoryDir === null || !isInsidePath(stableTargetPath, stableMemoryDir)) {
|
|
217
|
+
report.refused.push({ name: stem, sourcePath, reason: 'refused: target path escapes the project memory directory' });
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
writeNewFile(targetPath, normalized);
|
|
221
|
+
report.imported.push(entry);
|
|
222
|
+
report.writtenFiles.push(targetPath);
|
|
223
|
+
}
|
|
224
|
+
return report;
|
|
225
|
+
}
|
|
@@ -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
|
+
}
|