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
@@ -1,6 +1,5 @@
1
1
  /**
2
- * Check: `.peaks/memory/index.json` is well-formed JSON
3
- * (`L3:l3-memory-health`).
2
+ * Checks for `.peaks/memory/` health (`L3:l3-memory-health` and siblings).
4
3
  *
5
4
  * Slice 2026-06-13-repair-pre-existing-test-failures: the
6
5
  * production MemoryIndex schema (see
@@ -11,12 +10,122 @@
11
10
  *
12
11
  * When no `.peaks/memory/index.json` exists yet, the check passes
13
12
  * (fresh project — no memories have been extracted).
13
+ *
14
+ * Slice 2026-09-09-memory-system-overhaul (D) extends the check with the
15
+ * drift findings the original version could not see. It used to report
16
+ * `ok: true` for "index.json is well-formed JSON; 100 hot + 131 warm" and
17
+ * never looked at coverage, orphans, or unclassified files. It now emits,
18
+ * in addition to the unchanged well-formed-JSON assertion:
19
+ *
20
+ * - `L3:l3-memory-coverage` — disk files vs indexed entries (warning
21
+ * when the gap exceeds a small threshold)
22
+ * - `L3:l3-memory-orphans` — index entries whose `sourcePath` is gone
23
+ * (error) + disk files absent from the
24
+ * index (warning)
25
+ * - `L3:l3-memory-unclassified` — files with no resolvable kind (warning,
26
+ * count + first N names)
27
+ *
28
+ * All three are read-only and fail-soft: an inspection error degrades to a
29
+ * single warning instead of throwing, and none of them change the id or the
30
+ * `ok` semantics of the original `L3:l3-memory-health` assertion.
14
31
  */
15
32
  import { existsSync, readFileSync } from 'node:fs';
16
- import { join } from 'node:path';
33
+ import { basename, join } from 'node:path';
17
34
  import { getErrorMessage } from 'peaks-loop-shared/result';
35
+ import { listMarkdownFiles, MEMORY_MD_FILENAME, parseMemoryFrontmatter } from '../../../memory/project-memory-service/index.js';
36
+ /** Warn when |disk - indexed| exceeds this. Small enough to catch real drift. */
37
+ const COVERAGE_GAP_WARN_THRESHOLD = 2;
38
+ /** How many offending names to inline before truncating the message. */
39
+ const MAX_NAMES_IN_MESSAGE = 5;
40
+ function countEntries(bucket) {
41
+ return Object.values(bucket ?? {}).reduce((sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0), 0);
42
+ }
43
+ function previewNames(names) {
44
+ const shown = names.slice(0, MAX_NAMES_IN_MESSAGE);
45
+ const suffix = names.length > shown.length ? ` (+${names.length - shown.length} more)` : '';
46
+ return shown.join(', ') + suffix;
47
+ }
48
+ function readIndexSourcePaths(indexPath) {
49
+ const parsed = JSON.parse(readFileSync(indexPath, 'utf8'));
50
+ const fromBucket = (bucket) => Object.values(bucket ?? {}).flatMap((arr) => (Array.isArray(arr) ? arr : []));
51
+ return [...fromBucket(parsed.hot), ...fromBucket(parsed.warm), ...(parsed.cold ?? [])];
52
+ }
53
+ function inspectDrift(memoryDir, memoryIndexPath, indexedCount) {
54
+ const checks = [];
55
+ const diskFiles = listMarkdownFiles(memoryDir).filter((filePath) => basename(filePath) !== MEMORY_MD_FILENAME);
56
+ // --- coverage ---------------------------------------------------------
57
+ const gap = diskFiles.length - indexedCount;
58
+ if (Math.abs(gap) > COVERAGE_GAP_WARN_THRESHOLD) {
59
+ const direction = gap > 0
60
+ ? `${gap} file(s) on disk are not in the index`
61
+ : `${-gap} index entr(ies) have no matching file`;
62
+ checks.push({
63
+ id: 'L3:l3-memory-coverage',
64
+ ok: false,
65
+ severity: 'warning',
66
+ message: `Memory index coverage gap: ${diskFiles.length} file(s) on disk vs ${indexedCount} indexed — ${direction}. Run \`peaks memory reindex\` for the full drift report.`
67
+ });
68
+ }
69
+ else {
70
+ checks.push({
71
+ id: 'L3:l3-memory-coverage',
72
+ ok: true,
73
+ message: `Memory index coverage: ${diskFiles.length} file(s) on disk, ${indexedCount} indexed (within threshold ${COVERAGE_GAP_WARN_THRESHOLD})`
74
+ });
75
+ }
76
+ // --- orphans (both directions) ---------------------------------------
77
+ const missingSources = readIndexSourcePaths(memoryIndexPath)
78
+ .filter((entry) => typeof entry.sourcePath !== 'string' || entry.sourcePath.length === 0 || !existsSync(entry.sourcePath))
79
+ .map((entry) => entry.name ?? entry.sourcePath ?? '<unnamed>')
80
+ .sort((left, right) => left.localeCompare(right));
81
+ if (missingSources.length > 0) {
82
+ checks.push({
83
+ id: 'L3:l3-memory-orphans',
84
+ ok: false,
85
+ severity: 'error',
86
+ message: `${missingSources.length} index entr(ies) point at a missing sourcePath: ${previewNames(missingSources)}. Run \`peaks memory reindex\` to rebuild the index.`
87
+ });
88
+ }
89
+ else {
90
+ checks.push({
91
+ id: 'L3:l3-memory-orphans',
92
+ ok: true,
93
+ message: 'No memory index entries point at missing files'
94
+ });
95
+ }
96
+ // --- unclassified -----------------------------------------------------
97
+ const unclassified = [];
98
+ for (const filePath of diskFiles) {
99
+ try {
100
+ if (parseMemoryFrontmatter(readFileSync(filePath, 'utf8')).kind.kind === null) {
101
+ unclassified.push(basename(filePath, '.md'));
102
+ }
103
+ }
104
+ catch {
105
+ unclassified.push(`${basename(filePath, '.md')} (unreadable)`);
106
+ }
107
+ }
108
+ unclassified.sort((left, right) => left.localeCompare(right));
109
+ if (unclassified.length > 0) {
110
+ checks.push({
111
+ id: 'L3:l3-memory-unclassified',
112
+ ok: false,
113
+ severity: 'warning',
114
+ message: `${unclassified.length} memory file(s) have no resolvable kind (no metadata.type / kind / type): ${previewNames(unclassified)}. Add \`metadata.type\` then run \`peaks memory reindex\`.`
115
+ });
116
+ }
117
+ else {
118
+ checks.push({
119
+ id: 'L3:l3-memory-unclassified',
120
+ ok: true,
121
+ message: 'Every memory file on disk has a resolvable kind'
122
+ });
123
+ }
124
+ return checks;
125
+ }
18
126
  function run({ resolvedL3Root }) {
19
- const memoryIndexPath = join(resolvedL3Root, '.peaks/memory/index.json');
127
+ const memoryDir = join(resolvedL3Root, '.peaks/memory');
128
+ const memoryIndexPath = join(memoryDir, 'index.json');
20
129
  if (!existsSync(memoryIndexPath)) {
21
130
  return [{
22
131
  id: 'L3:l3-memory-health',
@@ -24,32 +133,47 @@ function run({ resolvedL3Root }) {
24
133
  message: 'No .peaks/memory/index.json yet (no memories extracted)'
25
134
  }];
26
135
  }
136
+ let parsed;
27
137
  try {
28
138
  const raw = readFileSync(memoryIndexPath, 'utf8');
29
- const parsed = JSON.parse(raw);
30
- const schemaMarker = parsed.schema_version ?? parsed.version;
31
- if (schemaMarker === undefined) {
32
- return [{
33
- id: 'L3:l3-memory-health',
34
- ok: false,
35
- message: '.peaks/memory/index.json missing schema_version / version field'
36
- }];
37
- }
38
- const hotCount = Object.values(parsed.hot ?? {}).reduce((sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0), 0);
39
- const warmCount = Object.values(parsed.warm ?? {}).reduce((sum, arr) => sum + (Array.isArray(arr) ? arr.length : 0), 0);
139
+ parsed = JSON.parse(raw);
140
+ }
141
+ catch (parseError) {
40
142
  return [{
41
143
  id: 'L3:l3-memory-health',
42
- ok: true,
43
- message: `.peaks/memory/index.json is well-formed JSON; version=${schemaMarker}; ${hotCount} hot + ${warmCount} warm memory entries`
144
+ ok: false,
145
+ message: `.peaks/memory/index.json is not valid JSON: ${getErrorMessage(parseError)}`
44
146
  }];
45
147
  }
46
- catch (parseError) {
148
+ const schemaMarker = parsed.schema_version ?? parsed.version;
149
+ if (schemaMarker === undefined) {
47
150
  return [{
48
151
  id: 'L3:l3-memory-health',
49
152
  ok: false,
50
- message: `.peaks/memory/index.json is not valid JSON: ${getErrorMessage(parseError)}`
153
+ message: '.peaks/memory/index.json missing schema_version / version field'
51
154
  }];
52
155
  }
156
+ const hotCount = countEntries(parsed.hot);
157
+ const warmCount = countEntries(parsed.warm);
158
+ const checks = [{
159
+ id: 'L3:l3-memory-health',
160
+ ok: true,
161
+ message: `.peaks/memory/index.json is well-formed JSON; version=${schemaMarker}; ${hotCount} hot + ${warmCount} warm memory entries`
162
+ }];
163
+ // Drift inspection is best-effort: a scan failure must not turn a
164
+ // well-formed index into a hard failure.
165
+ try {
166
+ checks.push(...inspectDrift(memoryDir, memoryIndexPath, hotCount + warmCount));
167
+ }
168
+ catch (error) {
169
+ checks.push({
170
+ id: 'L3:l3-memory-coverage',
171
+ ok: true,
172
+ severity: 'warning',
173
+ message: `Memory drift inspection skipped: ${getErrorMessage(error)}`
174
+ });
175
+ }
176
+ return checks;
53
177
  }
54
178
  export const check = {
55
179
  name: 'l3-memory-health',
@@ -1,4 +1,14 @@
1
1
  import type { IdeAdapter } from '../ide-types.js';
2
+ /**
3
+ * Resolve the absolute path of a Claude Code transcript jsonl by OUTER
4
+ * session id, searching `~/.claude/projects/**` recursively.
5
+ *
6
+ * Exported (slice 2026-09-10-context-audit-and-discipline, Slice A) so
7
+ * `peaks code context-audit` reuses THIS locator instead of re-implementing
8
+ * the recursive find. Returns `null` when the transcript does not exist —
9
+ * callers MUST treat that as "unavailable", never as an error.
10
+ */
11
+ export declare function resolveClaudeTranscriptPath(outerSessionId: string, projectsDir?: string): string | null;
2
12
  /**
3
13
  * Resolve the currently-active Claude Code model id from a runtime env map.
4
14
  * Returns the first non-empty `CLAUDE_CODE_MODEL_ENV_VARS` value (trimmed), or
@@ -90,6 +90,20 @@ function findTranscriptJsonl(projectsDir, outerSessionId) {
90
90
  }
91
91
  return null;
92
92
  }
93
+ /**
94
+ * Resolve the absolute path of a Claude Code transcript jsonl by OUTER
95
+ * session id, searching `~/.claude/projects/**` recursively.
96
+ *
97
+ * Exported (slice 2026-09-10-context-audit-and-discipline, Slice A) so
98
+ * `peaks code context-audit` reuses THIS locator instead of re-implementing
99
+ * the recursive find. Returns `null` when the transcript does not exist —
100
+ * callers MUST treat that as "unavailable", never as an error.
101
+ */
102
+ export function resolveClaudeTranscriptPath(outerSessionId, projectsDir = join(homedir(), '.claude', 'projects')) {
103
+ if (typeof outerSessionId !== 'string' || outerSessionId.length === 0)
104
+ return null;
105
+ return findTranscriptJsonl(projectsDir, outerSessionId);
106
+ }
93
107
  /** 1M-context window size in tokens (documented single choice: 1,000,000). */
94
108
  const ONE_MILLION_CONTEXT_TOKENS = 1_000_000;
95
109
  /** Safe-default (non-1M) context window size in tokens. */
@@ -483,7 +497,12 @@ export const CLAUDE_CODE_ADAPTER = {
483
497
  compactCommand: 'claude --compact',
484
498
  compactPathway: 'ide-native',
485
499
  postCompactDetectCommand: 'peaks code auto-compact --json',
486
- readContextPercentFallback
500
+ readContextPercentFallback,
501
+ // Slice 2026-09-10-context-audit-and-discipline (Slice A): the vendor
502
+ // layout knowledge (`~/.claude/projects/**/<outerSessionId>.jsonl`)
503
+ // stays here; `peaks code context-audit` resolves it through the
504
+ // adapter registry, never by naming this adapter directly.
505
+ resolveTranscriptPath: (outerSessionId) => resolveClaudeTranscriptPath(outerSessionId),
487
506
  },
488
507
  // Slice #011: standards profile. Claude Code reads its constitution at
489
508
  // CLAUDE.md + module-level rules under .claude/rules/**. The values mirror
@@ -217,6 +217,21 @@ export interface IdeCompactProfile {
217
217
  * Added in slice 2026-09-02-vendor-neutral-context-probe.
218
218
  */
219
219
  readonly readContextPercentFallback?: (input: ContextPercentFallbackInput) => ContextPercentProbe | null;
220
+ /**
221
+ * Optional locator for the IDE's per-session transcript file (jsonl),
222
+ * keyed by the OUTER (harness) session id. `peaks code context-audit`
223
+ * (slice 2026-09-10-context-audit-and-discipline, Slice A) uses it to
224
+ * group the session's tool results by tool + short input key, so the
225
+ * generic audit service never learns any vendor's on-disk layout.
226
+ *
227
+ * Returns the absolute path, or `null` when the transcript does not
228
+ * exist. Adapters MUST NOT throw on a missing file — the audit treats
229
+ * `null` as `available: false` and continues.
230
+ *
231
+ * Adapters that do not opt in simply omit the field; the audit then
232
+ * reports `transcript-locator-unavailable`.
233
+ */
234
+ readonly resolveTranscriptPath?: (outerSessionId: string) => string | null;
220
235
  }
221
236
  /**
222
237
  * Input the generic `readContextPercent` reader passes to an adapter's
@@ -6,8 +6,8 @@ export declare const SliceStateSchema: z.ZodObject<{
6
6
  pending: "pending";
7
7
  blocked: "blocked";
8
8
  failed: "failed";
9
- skipped: "skipped";
10
9
  done: "done";
10
+ skipped: "skipped";
11
11
  "in-progress": "in-progress";
12
12
  }>;
13
13
  commitSha: z.ZodOptional<z.ZodString>;
@@ -48,8 +48,8 @@ export declare const JobStateSchema: z.ZodObject<{
48
48
  pending: "pending";
49
49
  blocked: "blocked";
50
50
  failed: "failed";
51
- skipped: "skipped";
52
51
  done: "done";
52
+ skipped: "skipped";
53
53
  "in-progress": "in-progress";
54
54
  }>;
55
55
  commitSha: z.ZodOptional<z.ZodString>;
@@ -118,8 +118,8 @@ export declare const JobCheckpointInputSchema: z.ZodObject<{
118
118
  sliceId: z.ZodString;
119
119
  state: z.ZodEnum<{
120
120
  failed: "failed";
121
- skipped: "skipped";
122
121
  done: "done";
122
+ skipped: "skipped";
123
123
  }>;
124
124
  commitSha: z.ZodOptional<z.ZodString>;
125
125
  reason: z.ZodOptional<z.ZodString>;
@@ -0,0 +1,79 @@
1
+ import type { ProjectMemoryKind } from './project-memory-service/types.js';
2
+ export interface MemoryIngestOptions {
3
+ projectRoot: string;
4
+ /** Override the IDE-side source dir (defaults to `~/.claude/projects/<encoded>/memory`). */
5
+ sourceDir?: string;
6
+ /** Injectable home dir (tests); defaults to `os.homedir()`. */
7
+ homeDir?: string;
8
+ apply?: boolean;
9
+ }
10
+ export interface MemoryIngestImported {
11
+ name: string;
12
+ kind: ProjectMemoryKind;
13
+ sourcePath: string;
14
+ targetPath: string;
15
+ }
16
+ export interface MemoryIngestSkipped {
17
+ name: string;
18
+ sourcePath: string;
19
+ targetPath: string;
20
+ }
21
+ export interface MemoryIngestConflict {
22
+ name: string;
23
+ sourcePath: string;
24
+ targetPath: string;
25
+ reason: string;
26
+ }
27
+ export interface MemoryIngestNeedsClassification {
28
+ name: string;
29
+ sourcePath: string;
30
+ rawKind: string | null;
31
+ reason: string;
32
+ }
33
+ export interface MemoryIngestRefused {
34
+ name: string;
35
+ sourcePath: string;
36
+ reason: string;
37
+ }
38
+ export interface MemoryIngestReport {
39
+ apply: boolean;
40
+ projectRoot: string;
41
+ sourceDir: string;
42
+ sourceExists: boolean;
43
+ memoryDir: string;
44
+ scannedFiles: number;
45
+ imported: MemoryIngestImported[];
46
+ skippedIdentical: MemoryIngestSkipped[];
47
+ conflicts: MemoryIngestConflict[];
48
+ needsClassification: MemoryIngestNeedsClassification[];
49
+ refused: MemoryIngestRefused[];
50
+ writtenFiles: string[];
51
+ warnings: string[];
52
+ }
53
+ /**
54
+ * Claude Code encodes a project cwd into its `~/.claude/projects/<name>/`
55
+ * directory by replacing every non-alphanumeric character with `-`
56
+ * (`D:\peaks-loop` → `D--peaks-loop`). Separator-agnostic, so the same
57
+ * encoding holds for POSIX paths.
58
+ */
59
+ export declare function encodeIdeProjectDir(projectRoot: string): string;
60
+ /** Default IDE-side memory dir for a project: `~/.claude/projects/<encoded>/memory`. */
61
+ export declare function defaultIdeMemoryDir(projectRoot: string, homeDir?: string): string;
62
+ /**
63
+ * Rewrite a source file's frontmatter to the peaks contract: `name` pinned to
64
+ * the destination filename stem, and `metadata.type` set to the resolved
65
+ * kind. Non-contract keys (e.g. `originSessionId`, `modified`, `node_type`)
66
+ * are preserved verbatim so provenance survives the import; `type` / `kind`
67
+ * are consumed by the normalization and not duplicated.
68
+ */
69
+ export declare function renderNormalizedMemory(input: {
70
+ stem: string;
71
+ kind: ProjectMemoryKind;
72
+ frontmatter: string;
73
+ body: string;
74
+ }): string;
75
+ /**
76
+ * Import IDE-side memories into `.peaks/memory/`. Always returns the full
77
+ * envelope; `apply` only controls whether files are actually written.
78
+ */
79
+ export declare function executeMemoryIngest(options: MemoryIngestOptions): MemoryIngestReport;
@@ -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
+ }