peaks-cli 1.3.0 → 1.3.2

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 (53) hide show
  1. package/README.md +62 -46
  2. package/bin/peaks.js +0 -0
  3. package/dist/src/cli/commands/core-artifact-commands.js +49 -11
  4. package/dist/src/cli/commands/hooks-commands.js +24 -9
  5. package/dist/src/cli/commands/progress-commands.js +26 -2
  6. package/dist/src/cli/commands/request-commands.js +5 -0
  7. package/dist/src/cli/commands/slice-commands.d.ts +3 -0
  8. package/dist/src/cli/commands/slice-commands.js +44 -0
  9. package/dist/src/cli/commands/workflow-commands.js +3 -3
  10. package/dist/src/cli/commands/workspace-commands.d.ts +63 -0
  11. package/dist/src/cli/commands/workspace-commands.js +349 -12
  12. package/dist/src/cli/program.js +4 -0
  13. package/dist/src/services/artifacts/artifact-prerequisites.d.ts +29 -1
  14. package/dist/src/services/artifacts/artifact-prerequisites.js +69 -5
  15. package/dist/src/services/artifacts/request-artifact-service.d.ts +22 -0
  16. package/dist/src/services/artifacts/request-artifact-service.js +214 -56
  17. package/dist/src/services/doctor/doctor-service.d.ts +69 -0
  18. package/dist/src/services/doctor/doctor-service.js +296 -3
  19. package/dist/src/services/progress/progress-service.d.ts +26 -0
  20. package/dist/src/services/progress/progress-service.js +25 -0
  21. package/dist/src/services/sc/sc-service.js +71 -13
  22. package/dist/src/services/scan/acceptance-coverage-service.js +6 -2
  23. package/dist/src/services/session/session-manager.d.ts +22 -1
  24. package/dist/src/services/session/session-manager.js +149 -30
  25. package/dist/src/services/skills/hooks-settings-service.d.ts +25 -3
  26. package/dist/src/services/skills/hooks-settings-service.js +57 -13
  27. package/dist/src/services/slice/slice-check-service.d.ts +2 -0
  28. package/dist/src/services/slice/slice-check-service.js +267 -0
  29. package/dist/src/services/slice/slice-check-types.d.ts +70 -0
  30. package/dist/src/services/slice/slice-check-types.js +18 -0
  31. package/dist/src/services/workflow/pipeline-verify-service.d.ts +5 -2
  32. package/dist/src/services/workflow/pipeline-verify-service.js +35 -35
  33. package/dist/src/services/workspace/migrate-service.d.ts +2 -0
  34. package/dist/src/services/workspace/migrate-service.js +606 -0
  35. package/dist/src/services/workspace/migrate-types.d.ts +127 -0
  36. package/dist/src/services/workspace/migrate-types.js +21 -0
  37. package/dist/src/services/workspace/reconcile-service.d.ts +33 -0
  38. package/dist/src/services/workspace/reconcile-service.js +160 -42
  39. package/dist/src/services/workspace/reconcile-types.d.ts +25 -0
  40. package/dist/src/services/workspace/workspace-service.d.ts +11 -0
  41. package/dist/src/services/workspace/workspace-service.js +71 -24
  42. package/dist/src/shared/change-id.d.ts +59 -0
  43. package/dist/src/shared/change-id.js +194 -16
  44. package/dist/src/shared/version.d.ts +1 -1
  45. package/dist/src/shared/version.js +1 -1
  46. package/package.json +10 -2
  47. package/schemas/doctor-report.schema.json +2 -2
  48. package/skills/peaks-qa/SKILL.md +1 -0
  49. package/skills/peaks-rd/SKILL.md +2 -1
  50. package/skills/peaks-solo/SKILL.md +17 -1
  51. package/skills/peaks-solo/references/micro-cycle.md +155 -0
  52. package/skills/peaks-txt/SKILL.md +2 -0
  53. package/skills/peaks-ui/SKILL.md +1 -0
@@ -0,0 +1,606 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { mkdir, readFile, readdir, rename, rm } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { isDirectory, pathExists } from '../../shared/fs.js';
5
+ import { isPathInsideArtifactRoot, validateChangeIdOrThrow } from '../../shared/change-id.js';
6
+ const ROLE_DIRS = new Set(['prd', 'ui', 'rd', 'qa', 'sc', 'system']);
7
+ /** Top-level dirs in `.peaks/` that are NEVER legacy session dirs
8
+ * (regardless of mtime/name) and must be skipped by the migration scan. */
9
+ const PROTECTED_TOP_LEVEL_DIRS = new Set([
10
+ '_runtime',
11
+ 'retrospective',
12
+ 'issues',
13
+ '_dogfood',
14
+ 'memory',
15
+ 'sops',
16
+ 'project-scan',
17
+ 'perf-baseline',
18
+ ]);
19
+ /** Files inside a session dir that are transient runtime state, not reviewable. */
20
+ const TRANSIENT_FILES = new Set(['session.json']);
21
+ /** Per-role subdir inside a change-id dir (mirrors the canonical layout). */
22
+ function dirToRole(subdir) {
23
+ if (subdir === 'prd' || subdir === 'ui' || subdir === 'rd' || subdir === 'qa' || subdir === 'sc') {
24
+ return subdir;
25
+ }
26
+ if (subdir === 'system') {
27
+ return 'system';
28
+ }
29
+ return 'unknown';
30
+ }
31
+ /**
32
+ * Tier 1 — filename regex: REQUIRES a 1-3 digit number prefix.
33
+ * 4-digit year prefixes (e.g. `2026-...`) are part of the change-id,
34
+ * NOT a sequence number, so they don't trigger tier 1.
35
+ *
36
+ * `001-2026-05-29-custom-sop-gate-metering.md` → `2026-05-29-custom-sop-gate-metering`
37
+ * `2026-05-29-default-session.md` → null (4-digit prefix; fall through to H1 / frontmatter)
38
+ * `tech-doc.md` → null (no number prefix; fall through)
39
+ */
40
+ const FILENAME_CHANGE_ID_RE = /^\d{1,3}-([A-Za-z0-9][A-Za-z0-9._-]*)\.md$/;
41
+ /** Tier 2 — content H1: `# Tech Doc: <change-id>`, `# Code Review <change-id>`,
42
+ * `# QA Security Findings: <change-id>`, `# Project Scan: <name>`, etc.
43
+ * The exact prefix varies by file role and the slice's life stage, so
44
+ * we match "H1 ends with `: <something>`" OR "H1 starts with a known
45
+ * role prefix and ends with an identifier".
46
+ */
47
+ const H1_CHANGE_ID_PATTERNS = [
48
+ // "# Tech Doc: <change-id>" / "# Tech Doc — RD <change-id>"
49
+ { test: (h) => /^#\s*Tech\s*Doc[\s:—–-]+(?:RD\s+)?(.+)$/i.test(h), extract: (h) => /^#\s*Tech\s*Doc[\s:—–-]+(?:RD\s+)?(.+)$/i.exec(h)?.[1]?.trim() ?? null },
50
+ // "# Code Review <change-id>"
51
+ { test: (h) => /^#\s*Code\s+Review\s+(.+)$/i.test(h), extract: (h) => /^#\s*Code\s+Review\s+(.+)$/i.exec(h)?.[1]?.trim() ?? null },
52
+ // "# Security Review <change-id>" / "# Security Review: <change-id>"
53
+ { test: (h) => /^#\s*Security\s+Review[\s:—–-]+(.+)$/i.test(h), extract: (h) => /^#\s*Security\s+Review[\s:—–-]+(.+)$/i.exec(h)?.[1]?.trim() ?? null },
54
+ // "# Bug Analysis: <change-id>" / "# Bug Analysis — <change-id>"
55
+ { test: (h) => /^#\s*Bug\s+Analysis[\s:—–-]+(.+)$/i.test(h), extract: (h) => /^#\s*Bug\s+Analysis[\s:—–-]+(.+)$/i.exec(h)?.[1]?.trim() ?? null },
56
+ // "# Performance Baseline" / "# Perf Baseline" / "# Perf Baseline: <slice>"
57
+ // → cross-cutting, NOT a per-slice change-id (return null)
58
+ { test: (h) => /^#\s*(?:Performance|Perf)\s+Baseline(?:\s*:.*)?$/i.test(h), extract: () => null },
59
+ // "# Project Scan: <name>" → cross-cutting, returns the name but caller treats as cross-cutting
60
+ { test: (h) => /^#\s*Project\s+Scan(?:\s*:.*)?$/i.test(h), extract: () => null },
61
+ // "# Handoff: <slice>" / "# Handoff — RD <slice>"
62
+ { test: (h) => /^#\s*Handoff[\s:—–-]+(?:RD\s+)?(.+)$/i.test(h), extract: (h) => /^#\s*Handoff[\s:—–-]+(?:RD\s+)?(.+)$/i.exec(h)?.[1]?.trim() ?? null },
63
+ // "# Test Cases: <slice>" / "# Test Report: <slice>" / "# Security Findings: <slice>"
64
+ { test: (h) => /^#\s*(?:Test\s+Cases|Test\s+Report|Security\s+Findings)[\s:—–-]+(.+)$/i.test(h), extract: (h) => /^#\s*(?:Test\s+Cases|Test\s+Report|Security\s+Findings)[\s:—–-]+(.+)$/i.exec(h)?.[1]?.trim() ?? null },
65
+ // "# PRD Request <change-id>" / "# PRD Request: <change-id>" (legacy request artifact H1)
66
+ { test: (h) => /^#\s*PRD\s+Request[\s:—–-]+(.+)$/i.test(h), extract: (h) => /^#\s*PRD\s+Request[\s:—–-]+(.+)$/i.exec(h)?.[1]?.trim() ?? null },
67
+ // "# RD Request <change-id>" / "# RD Request: <change-id>" (legacy RD request artifact H1)
68
+ { test: (h) => /^#\s*RD\s+Request[\s:—–-]+(.+)$/i.test(h), extract: (h) => /^#\s*RD\s+Request[\s:—–-]+(.+)$/i.exec(h)?.[1]?.trim() ?? null },
69
+ // "# QA Request <change-id>" / "# QA Request: <change-id>"
70
+ { test: (h) => /^#\s*QA\s+Request[\s:—–-]+(.+)$/i.test(h), extract: (h) => /^#\s*QA\s+Request[\s:—–-]+(.+)$/i.exec(h)?.[1]?.trim() ?? null },
71
+ // "# UI Request <change-id>" / "# SC Request <change-id>"
72
+ { test: (h) => /^#\s*(?:UI|SC)\s+Request[\s:—–-]+(.+)$/i.test(h), extract: (h) => /^#\s*(?:UI|SC)\s+Request[\s:—–-]+(.+)$/i.exec(h)?.[1]?.trim() ?? null },
73
+ ];
74
+ /** Tier 3 — body frontmatter: `- rid: <change-id>` OR `- linked-rd: .peaks/<sid>/<role>/<num>-<change-id>.md`
75
+ * The legacy request artifact template writes `- rid:` and the linked-* lines.
76
+ */
77
+ const FRONTMATTER_RID_RE = /^-\s*rid\s*:\s*([A-Za-z0-9][A-Za-z0-9._-]*)\s*$/m;
78
+ const FRONTMATTER_LINKED_RE = /-\s*linked-(?:prd|rd|qa|sc|ui)\s*:\s*\.peaks\/[^/]+\/[^/]+\/\d+[-/]([A-Za-z0-9][A-Za-z0-9._-]*)\.md/m;
79
+ async function collectFiles(sessionPath) {
80
+ const out = [];
81
+ const roleDirs = await readdir(sessionPath, { withFileTypes: true });
82
+ for (const roleEntry of roleDirs) {
83
+ if (!roleEntry.isDirectory())
84
+ continue;
85
+ const role = dirToRole(roleEntry.name);
86
+ if (role === 'unknown')
87
+ continue;
88
+ const rolePath = join(sessionPath, roleEntry.name);
89
+ await collectFilesRecursive(rolePath, role, roleEntry.name, out);
90
+ }
91
+ return out;
92
+ }
93
+ async function collectFilesRecursive(basePath, role, relativeBase, out) {
94
+ const entries = await readdir(basePath, { withFileTypes: true });
95
+ for (const entry of entries) {
96
+ const abs = join(basePath, entry.name);
97
+ const rel = `${relativeBase}/${entry.name}`;
98
+ if (entry.isDirectory()) {
99
+ await collectFilesRecursive(abs, role, rel, out);
100
+ }
101
+ else if (entry.isFile()) {
102
+ // Read all files (not just .md/.json) so that JSON `system/`
103
+ // files get their content checked for change-id metadata too.
104
+ // Reading is cheap and we already do try/catch.
105
+ let content = null;
106
+ try {
107
+ content = await readFile(abs, 'utf8');
108
+ }
109
+ catch {
110
+ content = null;
111
+ }
112
+ out.push({ role, relativePath: rel, absPath: abs, content });
113
+ }
114
+ }
115
+ }
116
+ /** Try the 4 tiers in order and return the first non-null result. */
117
+ function extractChangeId(fileName, content, fallbackChangeId) {
118
+ // Tier 1: filename regex
119
+ const baseName = fileName;
120
+ const m = FILENAME_CHANGE_ID_RE.exec(baseName);
121
+ if (m && m[1] && m[1].length > 0) {
122
+ try {
123
+ validateChangeIdOrThrow(m[1]);
124
+ return { changeId: m[1], source: 'filename-regex' };
125
+ }
126
+ catch {
127
+ // fall through to H1
128
+ }
129
+ }
130
+ // Tier 2: content H1
131
+ if (content !== null) {
132
+ const h1Match = content.split(/\r?\n/, 1)[0] ?? '';
133
+ for (const { test, extract } of H1_CHANGE_ID_PATTERNS) {
134
+ if (test(h1Match)) {
135
+ const cid = extract(h1Match);
136
+ if (cid !== null) {
137
+ try {
138
+ validateChangeIdOrThrow(cid);
139
+ return { changeId: cid, source: 'content-h1' };
140
+ }
141
+ catch {
142
+ // fall through to frontmatter
143
+ }
144
+ }
145
+ }
146
+ }
147
+ }
148
+ // Tier 3: body frontmatter
149
+ if (content !== null) {
150
+ const ridMatch = FRONTMATTER_RID_RE.exec(content);
151
+ if (ridMatch && ridMatch[1]) {
152
+ try {
153
+ validateChangeIdOrThrow(ridMatch[1]);
154
+ return { changeId: ridMatch[1], source: 'content-frontmatter' };
155
+ }
156
+ catch {
157
+ // fall through
158
+ }
159
+ }
160
+ const linkedMatch = FRONTMATTER_LINKED_RE.exec(content);
161
+ if (linkedMatch && linkedMatch[1]) {
162
+ try {
163
+ validateChangeIdOrThrow(linkedMatch[1]);
164
+ return { changeId: linkedMatch[1], source: 'content-frontmatter' };
165
+ }
166
+ catch {
167
+ // fall through
168
+ }
169
+ }
170
+ }
171
+ // Tier 4: per-session fallback (most recent change-id from rd/requests/)
172
+ if (fallbackChangeId !== null) {
173
+ return { changeId: fallbackChangeId, source: 'session-fallback' };
174
+ }
175
+ return null;
176
+ }
177
+ /** Per-session fallback: read every file under `<session>/rd/requests/` and
178
+ * pick the most-recent (by filename lexicographic order, which puts newer
179
+ * 3-digit prefixes later) and extract its change-id. If the session has
180
+ * no rd/requests/ at all, returns null. */
181
+ async function deriveFallbackChangeId(sessionPath) {
182
+ const requestsPath = join(sessionPath, 'rd', 'requests');
183
+ if (!(await pathExists(requestsPath)))
184
+ return null;
185
+ const files = await readdir(requestsPath, { withFileTypes: true });
186
+ const requestFiles = files
187
+ .filter((e) => e.isFile() && e.name.endsWith('.md'))
188
+ .map((e) => e.name)
189
+ .sort()
190
+ .reverse(); // most-recent first
191
+ for (const fileName of requestFiles) {
192
+ const content = await readFile(join(requestsPath, fileName), 'utf8').catch(() => null);
193
+ const extracted = extractChangeId(fileName, content, null);
194
+ if (extracted !== null)
195
+ return extracted.changeId;
196
+ }
197
+ return null;
198
+ }
199
+ function isCrossCuttingFile(relativePath) {
200
+ // `rd/project-scan.md` and `rd/perf-baseline.md` (and the `rd/perf baseline.md`
201
+ // variant with a space — observed in some downstream trees) are
202
+ // cross-cutting artifacts. They belong at the TOP of `.peaks/`
203
+ // (e.g. `.peaks/project-scan/rd/project-scan.md`), not under
204
+ // retrospective/. They never carry a per-slice change-id.
205
+ if (relativePath === 'rd/project-scan.md')
206
+ return true;
207
+ if (relativePath === 'rd/perf-baseline.md')
208
+ return true;
209
+ if (relativePath === 'rd/perf baseline.md')
210
+ return true;
211
+ return false;
212
+ }
213
+ /** Map a cross-cutting file's relative path to its dedicated top-level
214
+ * dir name (the change-id field for cross-cutting routing). */
215
+ function deriveCrossCuttingDirName(relativePath) {
216
+ if (relativePath === 'rd/project-scan.md')
217
+ return 'project-scan';
218
+ if (relativePath === 'rd/perf-baseline.md')
219
+ return 'perf-baseline';
220
+ if (relativePath === 'rd/perf baseline.md')
221
+ return 'perf-baseline';
222
+ return 'unknown-cross-cutting';
223
+ }
224
+ function isTransientRuntimeFile(relativePath) {
225
+ if (relativePath === 'session.json')
226
+ return true;
227
+ if (relativePath === '.gitkeep')
228
+ return true;
229
+ return false;
230
+ }
231
+ async function planSession(sessionId, sessionPath) {
232
+ const fallback = await deriveFallbackChangeId(sessionPath);
233
+ const files = await collectFiles(sessionPath);
234
+ const plans = [];
235
+ let empty = true;
236
+ for (const f of files) {
237
+ if (isTransientRuntimeFile(f.relativePath)) {
238
+ plans.push({
239
+ from: f.absPath,
240
+ to: f.absPath, // no move
241
+ sessionId,
242
+ changeId: '',
243
+ role: f.role,
244
+ relativePath: f.relativePath,
245
+ source: null,
246
+ skipped: true,
247
+ skipReason: 'transient-runtime'
248
+ });
249
+ continue;
250
+ }
251
+ if (isCrossCuttingFile(f.relativePath)) {
252
+ // Cross-cutting files (rd/project-scan.md, rd/perf-baseline.md) belong
253
+ // at the TOP level of `.peaks/` (e.g. `.peaks/project-scan/rd/project-scan.md`).
254
+ // They are single artifacts that span every slice, not tied to any
255
+ // change-id. Move them to their dedicated top-level dir as part of the
256
+ // same migration pass.
257
+ const crossCuttingDir = deriveCrossCuttingDirName(f.relativePath);
258
+ const to = join(sessionPath, '..', crossCuttingDir, f.relativePath);
259
+ empty = false;
260
+ plans.push({
261
+ from: f.absPath,
262
+ to,
263
+ sessionId,
264
+ changeId: crossCuttingDir, // the top-level dir name acts as the change-id
265
+ role: f.role,
266
+ relativePath: f.relativePath,
267
+ source: 'cross-cutting'
268
+ });
269
+ continue;
270
+ }
271
+ if (f.role === 'system') {
272
+ plans.push({
273
+ from: f.absPath,
274
+ to: f.absPath,
275
+ sessionId,
276
+ changeId: '',
277
+ role: 'system',
278
+ relativePath: f.relativePath,
279
+ source: null,
280
+ skipped: true,
281
+ skipReason: 'transient-runtime'
282
+ });
283
+ continue;
284
+ }
285
+ if (f.role === 'unknown') {
286
+ plans.push({
287
+ from: f.absPath,
288
+ to: f.absPath,
289
+ sessionId,
290
+ changeId: '',
291
+ role: 'unknown',
292
+ relativePath: f.relativePath,
293
+ source: null,
294
+ skipped: true,
295
+ skipReason: 'unsupported-role'
296
+ });
297
+ continue;
298
+ }
299
+ const extracted = extractChangeId(f.relativePath.split('/').pop() ?? '', f.content, fallback);
300
+ if (extracted === null) {
301
+ plans.push({
302
+ from: f.absPath,
303
+ to: f.absPath,
304
+ sessionId,
305
+ changeId: '',
306
+ role: f.role,
307
+ relativePath: f.relativePath,
308
+ source: null,
309
+ skipped: true,
310
+ skipReason: 'no-change-id'
311
+ });
312
+ continue;
313
+ }
314
+ empty = false;
315
+ const to = join(sessionPath, '..', 'retrospective', extracted.changeId, f.relativePath);
316
+ plans.push({
317
+ from: f.absPath,
318
+ to,
319
+ sessionId,
320
+ changeId: extracted.changeId,
321
+ role: f.role,
322
+ relativePath: f.relativePath,
323
+ source: extracted.source
324
+ });
325
+ }
326
+ return { sessionId, path: sessionPath, empty: empty && plans.every((p) => p.skipped), files: plans, fallbackChangeId: fallback };
327
+ }
328
+ /**
329
+ * Slice 003 (2026-06-06-session-layout-canonicalize): one-shot
330
+ * consolidation of every top-level `.peaks/<sid>/` into
331
+ * `.peaks/_runtime/<sid>/`. Idempotent:
332
+ *
333
+ * - If `.peaks/_runtime/<sid>/` does not exist → `fs.rename` the
334
+ * top-level dir to the runtime location.
335
+ * - If `.peaks/_runtime/<sid>/` already exists with the same
336
+ * content → no-op, reported as `skipped-already-canonical`.
337
+ * - If `.peaks/_runtime/<sid>/` already exists with different
338
+ * content → log a conflict, do NOT overwrite.
339
+ * - **F15 carve-out**: if `<sid>/rd/project-scan.md` differs from
340
+ * the runtime copy already in place, log a
341
+ * `f15-conflict-project-scan` and leave the file in place.
342
+ *
343
+ * Path-traversal is impossible because the target is always
344
+ * `peaks/_runtime/<sid>/` and the directory listing only returns
345
+ * names matching the session-id regex.
346
+ */
347
+ async function migrateToRuntime(projectRoot, peaksRoot, apply) {
348
+ void projectRoot;
349
+ const plans = [];
350
+ const moved = [];
351
+ const skipped = [];
352
+ const conflicts = [];
353
+ const runtimeRoot = join(peaksRoot, '_runtime');
354
+ let topLevelEntries;
355
+ try {
356
+ topLevelEntries = await readdir(peaksRoot, { withFileTypes: true });
357
+ }
358
+ catch {
359
+ return { plans, moved, skipped, conflicts };
360
+ }
361
+ for (const entry of topLevelEntries) {
362
+ if (!entry.isDirectory())
363
+ continue;
364
+ if (PROTECTED_TOP_LEVEL_DIRS.has(entry.name))
365
+ continue;
366
+ if (!/^\d{4}-\d{2}-\d{2}-session-/.test(entry.name))
367
+ continue;
368
+ const sessionId = entry.name;
369
+ const fromPath = join(peaksRoot, sessionId);
370
+ const toPath = join(runtimeRoot, sessionId);
371
+ if (await isDirectory(toPath)) {
372
+ // F15 carve-out check
373
+ const fromScan = join(fromPath, 'rd', 'project-scan.md');
374
+ const toScan = join(toPath, 'rd', 'project-scan.md');
375
+ if (await pathExists(fromScan) && await pathExists(toScan)) {
376
+ const fromContent = await readFile(fromScan, 'utf8').catch(() => null);
377
+ const toContent = await readFile(toScan, 'utf8').catch(() => null);
378
+ if (fromContent !== null && toContent !== null && fromContent !== toContent) {
379
+ plans.push({
380
+ from: fromPath,
381
+ to: toPath,
382
+ sessionId,
383
+ action: 'f15-conflict-project-scan',
384
+ reason: 'F15 carve-out: top-level rd/project-scan.md differs from runtime copy; left in place.'
385
+ });
386
+ conflicts.push({
387
+ from: fromScan,
388
+ to: toScan,
389
+ reason: 'f15-conflict-project-scan'
390
+ });
391
+ continue;
392
+ }
393
+ }
394
+ plans.push({
395
+ from: fromPath,
396
+ to: toPath,
397
+ sessionId,
398
+ action: 'skipped-already-canonical',
399
+ reason: 'target _runtime/<sid>/ already exists'
400
+ });
401
+ skipped.push(sessionId);
402
+ continue;
403
+ }
404
+ plans.push({
405
+ from: fromPath,
406
+ to: toPath,
407
+ sessionId,
408
+ action: 'moved',
409
+ reason: 'top-level session dir will be moved to _runtime/'
410
+ });
411
+ if (apply) {
412
+ try {
413
+ await mkdir(runtimeRoot, { recursive: true });
414
+ await rename(fromPath, toPath);
415
+ moved.push(sessionId);
416
+ }
417
+ catch (error) {
418
+ const msg = error instanceof Error ? error.message : String(error);
419
+ conflicts.push({
420
+ from: fromPath,
421
+ to: toPath,
422
+ reason: `rename failed: ${msg}`
423
+ });
424
+ }
425
+ }
426
+ }
427
+ return { plans, moved, skipped, conflicts };
428
+ }
429
+ async function gitMv(from, to, projectRoot) {
430
+ const parentDir = join(to, '..');
431
+ await mkdir(parentDir, { recursive: true });
432
+ // Prefer plain fs.rename: it works regardless of git state, including
433
+ // for untracked files (which is the common case during a migrate).
434
+ // For tracked files in a real git repo, `git mv` would record the
435
+ // rename, but `git status` will still pick up the rename correctly
436
+ // because git auto-detects content-hash-matched renames at add time.
437
+ // Plain rename is sufficient for the migrate use case.
438
+ const { rename } = await import('node:fs/promises');
439
+ try {
440
+ await rename(from, to);
441
+ }
442
+ catch (error) {
443
+ // Last-resort: try git mv with the project's git cwd. The git
444
+ // command must be run from inside the project so it can locate
445
+ // .git/ (the migrate target may be a temp dir created by tests).
446
+ try {
447
+ execFileSync('git', ['mv', from, to], { cwd: projectRoot, stdio: 'pipe' });
448
+ }
449
+ catch {
450
+ throw error;
451
+ }
452
+ }
453
+ }
454
+ export async function migrateWorkspace(options) {
455
+ const peaksRoot = join(options.projectRoot, '.peaks');
456
+ if (!(await isDirectory(peaksRoot))) {
457
+ return {
458
+ projectRoot: options.projectRoot,
459
+ sessions: [],
460
+ wouldMove: [],
461
+ moved: [],
462
+ deletedSessions: [],
463
+ wouldDeleteSessions: [],
464
+ conflicts: [],
465
+ apply: options.apply,
466
+ totalFilesMoved: 0
467
+ };
468
+ }
469
+ const topLevel = await readdir(peaksRoot, { withFileTypes: true });
470
+ const sessionPlans = [];
471
+ for (const entry of topLevel) {
472
+ if (!entry.isDirectory())
473
+ continue;
474
+ if (PROTECTED_TOP_LEVEL_DIRS.has(entry.name))
475
+ continue;
476
+ // Only treat dirs matching the legacy session pattern as sessions.
477
+ if (!/^\d{4}-\d{2}-\d{2}-session-/.test(entry.name))
478
+ continue;
479
+ const sessionPath = join(peaksRoot, entry.name);
480
+ const plan = await planSession(entry.name, sessionPath);
481
+ sessionPlans.push(plan);
482
+ }
483
+ const wouldMove = [];
484
+ const moved = [];
485
+ const conflicts = [];
486
+ const willDeleteAfter = [];
487
+ // Dry-run pass: compute the moves, detect collisions, and bucket.
488
+ for (const session of sessionPlans) {
489
+ for (const file of session.files) {
490
+ if (file.skipped)
491
+ continue;
492
+ wouldMove.push(file);
493
+ if (options.apply) {
494
+ if (await pathExists(file.to)) {
495
+ // Collision: target already exists. Compare content; if
496
+ // identical, skip; otherwise warn and skip (refuse to
497
+ // overwrite without --force).
498
+ const sourceContent = await readFile(file.from, 'utf8').catch(() => null);
499
+ const targetContent = await readFile(file.to, 'utf8').catch(() => null);
500
+ if (sourceContent === targetContent) {
501
+ conflicts.push({ from: file.from, to: file.to, reason: 'identical-content-already-migrated' });
502
+ continue;
503
+ }
504
+ conflicts.push({ from: file.from, to: file.to, reason: 'target-exists-with-different-content' });
505
+ continue;
506
+ }
507
+ await gitMv(file.from, file.to, options.projectRoot);
508
+ moved.push(file);
509
+ }
510
+ }
511
+ // After the move, count remaining files (excluding session.json
512
+ // and the dirs we kept). If the session is empty, schedule deletion.
513
+ // The "remaining" counter is the number of files that are STILL on
514
+ // disk under the session dir post-migration: that includes transient
515
+ // skipped files (session.json, system/) AND conflict files whose
516
+ // source remains because the target was already taken.
517
+ let remaining = 0;
518
+ for (const file of session.files) {
519
+ if (file.skipped) {
520
+ // Skipped files (transient / cross-cutting) remain on disk.
521
+ remaining++;
522
+ continue;
523
+ }
524
+ if (options.apply) {
525
+ if (await pathExists(file.to)) {
526
+ // The target exists, so the source EITHER moved successfully
527
+ // (file is gone from session) OR was a conflict (source is
528
+ // still in session, which the existence-of-target proves the
529
+ // source was NOT moved to that target). Distinguish by
530
+ // checking the source path: if the source is still on disk,
531
+ // the move was a conflict and the file remains in the
532
+ // session.
533
+ if (await pathExists(file.from)) {
534
+ // Conflict: source still on disk, target exists with
535
+ // different/identical content. Counts as remaining.
536
+ remaining++;
537
+ }
538
+ // else: successful move, source gone — not remaining
539
+ continue;
540
+ }
541
+ // Target doesn't exist; move must have failed (shouldn't
542
+ // happen but be defensive). Count as remaining.
543
+ remaining++;
544
+ }
545
+ else {
546
+ // dry-run: every planned file is "remaining" in the session
547
+ // (it hasn't been moved yet).
548
+ remaining++;
549
+ }
550
+ }
551
+ if (remaining === 0) {
552
+ willDeleteAfter.push(session.sessionId);
553
+ }
554
+ }
555
+ const wouldDeleteSessions = options.apply ? [] : willDeleteAfter;
556
+ const deletedSessions = [];
557
+ if (options.apply) {
558
+ for (const session of sessionPlans) {
559
+ if (!willDeleteAfter.includes(session.sessionId))
560
+ continue;
561
+ // Only remove the session dir if every reviewable file was actually
562
+ // moved (or the dir was already empty). Use isPathInsideArtifactRoot
563
+ // as a safety check: never `rm -rf` a dir that isn't under the
564
+ // project's .peaks/ tree.
565
+ const sessionPath = session.path;
566
+ if (!isPathInsideArtifactRoot(sessionPath, peaksRoot))
567
+ continue;
568
+ // Remove the empty session dir (including its session.json +
569
+ // system/ subdirs that we explicitly skipped).
570
+ await rm(sessionPath, { recursive: true, force: true });
571
+ deletedSessions.push(session.sessionId);
572
+ }
573
+ }
574
+ // Slice 003: the `--to-runtime` step. When set, move every
575
+ // top-level `.peaks/<sid>/` to `.peaks/_runtime/<sid>/`. Idempotent.
576
+ // The F15 carve-out (rd/project-scan.md) is honored: when the
577
+ // top-level `<sid>/rd/project-scan.md` differs from the runtime
578
+ // `<sid>/rd/project-scan.md` already in place, the file is
579
+ // left at the top-level and a conflict is recorded.
580
+ let toRuntimePlans = [];
581
+ let toRuntimeMoved = [];
582
+ let toRuntimeSkipped = [];
583
+ let toRuntimeConflicts = [];
584
+ if (options.toRuntime === true) {
585
+ const result = await migrateToRuntime(options.projectRoot, peaksRoot, options.apply);
586
+ toRuntimePlans = result.plans;
587
+ toRuntimeMoved = result.moved;
588
+ toRuntimeSkipped = result.skipped;
589
+ toRuntimeConflicts = result.conflicts;
590
+ }
591
+ return {
592
+ projectRoot: options.projectRoot,
593
+ sessions: sessionPlans,
594
+ wouldMove: wouldMove,
595
+ moved: options.apply ? moved : [],
596
+ deletedSessions: options.apply ? deletedSessions : [],
597
+ wouldDeleteSessions: wouldDeleteSessions,
598
+ conflicts,
599
+ apply: options.apply,
600
+ totalFilesMoved: options.apply ? moved.length : 0,
601
+ toRuntimePlans,
602
+ toRuntimeMoved,
603
+ toRuntimeSkipped,
604
+ toRuntimeConflicts
605
+ };
606
+ }