peaks-cli 1.2.9 → 1.3.1

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/hooks-commands.js +24 -9
  4. package/dist/src/cli/commands/progress-commands.js +26 -2
  5. package/dist/src/cli/commands/request-commands.js +5 -0
  6. package/dist/src/cli/commands/slice-commands.d.ts +3 -0
  7. package/dist/src/cli/commands/slice-commands.js +42 -0
  8. package/dist/src/cli/commands/workflow-commands.js +3 -3
  9. package/dist/src/cli/commands/workspace-commands.d.ts +63 -0
  10. package/dist/src/cli/commands/workspace-commands.js +347 -5
  11. package/dist/src/cli/program.js +4 -0
  12. package/dist/src/services/artifacts/artifact-prerequisites.d.ts +17 -1
  13. package/dist/src/services/artifacts/artifact-prerequisites.js +38 -5
  14. package/dist/src/services/artifacts/request-artifact-service.d.ts +22 -0
  15. package/dist/src/services/artifacts/request-artifact-service.js +172 -54
  16. package/dist/src/services/doctor/doctor-service.d.ts +7 -0
  17. package/dist/src/services/doctor/doctor-service.js +20 -2
  18. package/dist/src/services/progress/progress-service.d.ts +26 -0
  19. package/dist/src/services/progress/progress-service.js +25 -0
  20. package/dist/src/services/sc/sc-service.d.ts +52 -1
  21. package/dist/src/services/sc/sc-service.js +324 -17
  22. package/dist/src/services/scan/acceptance-coverage-service.js +6 -2
  23. package/dist/src/services/session/session-manager.d.ts +7 -5
  24. package/dist/src/services/session/session-manager.js +60 -16
  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/skills/skill-presence-service.js +102 -68
  28. package/dist/src/services/skills/skill-runbook-service.js +2 -1
  29. package/dist/src/services/skills/skill-statusline-service.js +13 -7
  30. package/dist/src/services/slice/slice-check-service.d.ts +2 -0
  31. package/dist/src/services/slice/slice-check-service.js +248 -0
  32. package/dist/src/services/slice/slice-check-types.d.ts +61 -0
  33. package/dist/src/services/slice/slice-check-types.js +18 -0
  34. package/dist/src/services/workflow/pipeline-verify-service.d.ts +5 -2
  35. package/dist/src/services/workflow/pipeline-verify-service.js +35 -35
  36. package/dist/src/services/workspace/migrate-service.d.ts +2 -0
  37. package/dist/src/services/workspace/migrate-service.js +484 -0
  38. package/dist/src/services/workspace/migrate-types.d.ts +84 -0
  39. package/dist/src/services/workspace/migrate-types.js +21 -0
  40. package/dist/src/services/workspace/reconcile-service.d.ts +119 -0
  41. package/dist/src/services/workspace/reconcile-service.js +464 -0
  42. package/dist/src/services/workspace/reconcile-types.d.ts +93 -0
  43. package/dist/src/services/workspace/reconcile-types.js +13 -0
  44. package/dist/src/services/workspace/workspace-service.d.ts +11 -0
  45. package/dist/src/services/workspace/workspace-service.js +87 -7
  46. package/dist/src/shared/change-id.d.ts +59 -0
  47. package/dist/src/shared/change-id.js +194 -16
  48. package/dist/src/shared/version.d.ts +1 -1
  49. package/dist/src/shared/version.js +1 -1
  50. package/package.json +13 -2
  51. package/skills/peaks-solo/SKILL.md +28 -4
  52. package/skills/peaks-solo/references/micro-cycle.md +155 -0
  53. package/skills/peaks-solo/references/runbook.md +2 -0
@@ -0,0 +1,484 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { mkdir, readFile, readdir, 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
+ async function gitMv(from, to, projectRoot) {
329
+ const parentDir = join(to, '..');
330
+ await mkdir(parentDir, { recursive: true });
331
+ // Prefer plain fs.rename: it works regardless of git state, including
332
+ // for untracked files (which is the common case during a migrate).
333
+ // For tracked files in a real git repo, `git mv` would record the
334
+ // rename, but `git status` will still pick up the rename correctly
335
+ // because git auto-detects content-hash-matched renames at add time.
336
+ // Plain rename is sufficient for the migrate use case.
337
+ const { rename } = await import('node:fs/promises');
338
+ try {
339
+ await rename(from, to);
340
+ }
341
+ catch (error) {
342
+ // Last-resort: try git mv with the project's git cwd. The git
343
+ // command must be run from inside the project so it can locate
344
+ // .git/ (the migrate target may be a temp dir created by tests).
345
+ try {
346
+ execFileSync('git', ['mv', from, to], { cwd: projectRoot, stdio: 'pipe' });
347
+ }
348
+ catch {
349
+ throw error;
350
+ }
351
+ }
352
+ }
353
+ export async function migrateWorkspace(options) {
354
+ const peaksRoot = join(options.projectRoot, '.peaks');
355
+ if (!(await isDirectory(peaksRoot))) {
356
+ return {
357
+ projectRoot: options.projectRoot,
358
+ sessions: [],
359
+ wouldMove: [],
360
+ moved: [],
361
+ deletedSessions: [],
362
+ wouldDeleteSessions: [],
363
+ conflicts: [],
364
+ apply: options.apply,
365
+ totalFilesMoved: 0
366
+ };
367
+ }
368
+ const topLevel = await readdir(peaksRoot, { withFileTypes: true });
369
+ const sessionPlans = [];
370
+ for (const entry of topLevel) {
371
+ if (!entry.isDirectory())
372
+ continue;
373
+ if (PROTECTED_TOP_LEVEL_DIRS.has(entry.name))
374
+ continue;
375
+ // Only treat dirs matching the legacy session pattern as sessions.
376
+ if (!/^\d{4}-\d{2}-\d{2}-session-/.test(entry.name))
377
+ continue;
378
+ const sessionPath = join(peaksRoot, entry.name);
379
+ const plan = await planSession(entry.name, sessionPath);
380
+ sessionPlans.push(plan);
381
+ }
382
+ const wouldMove = [];
383
+ const moved = [];
384
+ const conflicts = [];
385
+ const willDeleteAfter = [];
386
+ // Dry-run pass: compute the moves, detect collisions, and bucket.
387
+ for (const session of sessionPlans) {
388
+ for (const file of session.files) {
389
+ if (file.skipped)
390
+ continue;
391
+ wouldMove.push(file);
392
+ if (options.apply) {
393
+ if (await pathExists(file.to)) {
394
+ // Collision: target already exists. Compare content; if
395
+ // identical, skip; otherwise warn and skip (refuse to
396
+ // overwrite without --force).
397
+ const sourceContent = await readFile(file.from, 'utf8').catch(() => null);
398
+ const targetContent = await readFile(file.to, 'utf8').catch(() => null);
399
+ if (sourceContent === targetContent) {
400
+ conflicts.push({ from: file.from, to: file.to, reason: 'identical-content-already-migrated' });
401
+ continue;
402
+ }
403
+ conflicts.push({ from: file.from, to: file.to, reason: 'target-exists-with-different-content' });
404
+ continue;
405
+ }
406
+ await gitMv(file.from, file.to, options.projectRoot);
407
+ moved.push(file);
408
+ }
409
+ }
410
+ // After the move, count remaining files (excluding session.json
411
+ // and the dirs we kept). If the session is empty, schedule deletion.
412
+ // The "remaining" counter is the number of files that are STILL on
413
+ // disk under the session dir post-migration: that includes transient
414
+ // skipped files (session.json, system/) AND conflict files whose
415
+ // source remains because the target was already taken.
416
+ let remaining = 0;
417
+ for (const file of session.files) {
418
+ if (file.skipped) {
419
+ // Skipped files (transient / cross-cutting) remain on disk.
420
+ remaining++;
421
+ continue;
422
+ }
423
+ if (options.apply) {
424
+ if (await pathExists(file.to)) {
425
+ // The target exists, so the source EITHER moved successfully
426
+ // (file is gone from session) OR was a conflict (source is
427
+ // still in session, which the existence-of-target proves the
428
+ // source was NOT moved to that target). Distinguish by
429
+ // checking the source path: if the source is still on disk,
430
+ // the move was a conflict and the file remains in the
431
+ // session.
432
+ if (await pathExists(file.from)) {
433
+ // Conflict: source still on disk, target exists with
434
+ // different/identical content. Counts as remaining.
435
+ remaining++;
436
+ }
437
+ // else: successful move, source gone — not remaining
438
+ continue;
439
+ }
440
+ // Target doesn't exist; move must have failed (shouldn't
441
+ // happen but be defensive). Count as remaining.
442
+ remaining++;
443
+ }
444
+ else {
445
+ // dry-run: every planned file is "remaining" in the session
446
+ // (it hasn't been moved yet).
447
+ remaining++;
448
+ }
449
+ }
450
+ if (remaining === 0) {
451
+ willDeleteAfter.push(session.sessionId);
452
+ }
453
+ }
454
+ const wouldDeleteSessions = options.apply ? [] : willDeleteAfter;
455
+ const deletedSessions = [];
456
+ if (options.apply) {
457
+ for (const session of sessionPlans) {
458
+ if (!willDeleteAfter.includes(session.sessionId))
459
+ continue;
460
+ // Only remove the session dir if every reviewable file was actually
461
+ // moved (or the dir was already empty). Use isPathInsideArtifactRoot
462
+ // as a safety check: never `rm -rf` a dir that isn't under the
463
+ // project's .peaks/ tree.
464
+ const sessionPath = session.path;
465
+ if (!isPathInsideArtifactRoot(sessionPath, peaksRoot))
466
+ continue;
467
+ // Remove the empty session dir (including its session.json +
468
+ // system/ subdirs that we explicitly skipped).
469
+ await rm(sessionPath, { recursive: true, force: true });
470
+ deletedSessions.push(session.sessionId);
471
+ }
472
+ }
473
+ return {
474
+ projectRoot: options.projectRoot,
475
+ sessions: sessionPlans,
476
+ wouldMove: wouldMove,
477
+ moved: options.apply ? moved : [],
478
+ deletedSessions: options.apply ? deletedSessions : [],
479
+ wouldDeleteSessions: wouldDeleteSessions,
480
+ conflicts,
481
+ apply: options.apply,
482
+ totalFilesMoved: options.apply ? moved.length : 0
483
+ };
484
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Type envelope for the `peaks workspace migrate` CLI command.
3
+ *
4
+ * The migrate command is the downstream-project counterpart to the
5
+ * one-time Phase 5 migration script that ran on the peaks-cli self-host
6
+ * for slice 2026-06-05-change-id-as-unit-of-work. Where
7
+ * `peaks workspace reconcile` only handles the top-level runtime state
8
+ * files (`.peaks/.session.json`, `.peaks/.active-skill.json`,
9
+ * `.peaks/sop-state/` → `.peaks/_runtime/`), `peaks workspace migrate`
10
+ * handles the much bigger case: legacy reviewable content under
11
+ * `.peaks/<session-id>/<role>/<file>` → `.peaks/retrospective/<change-id>/<role>/<file>`.
12
+ *
13
+ * Each legacy session dir typically contains multiple change-ids worth
14
+ * of work (the old layout allowed one session to host several slices).
15
+ * Change-id resolution uses a 4-tier per-file heuristic (filename
16
+ * regex → content H1 → body frontmatter → per-session fallback to the
17
+ * most-recent `<change-id>` in `rd/requests/`).
18
+ *
19
+ * Types are hand-rolled, no new top-level dependencies.
20
+ */
21
+ export type MigrateFilePlan = {
22
+ /** Absolute source path (under `.peaks/<session-id>/...`). */
23
+ from: string;
24
+ /** Absolute target path (under `.peaks/retrospective/<change-id>/...`). */
25
+ to: string;
26
+ /** The session dir this file came from. */
27
+ sessionId: string;
28
+ /** The change-id the file is being routed to. */
29
+ changeId: string;
30
+ /** The role inferred from the file's path (rd/qa/prd/ui/sc). */
31
+ role: 'prd' | 'ui' | 'rd' | 'qa' | 'sc' | 'system' | 'unknown';
32
+ /** Path of the file relative to the session dir, e.g. `rd/tech-doc.md`. */
33
+ relativePath: string;
34
+ /**
35
+ * Which tier of the 4-tier heuristic produced the change-id. Null
36
+ * when the file is not a per-slice artifact (e.g. cross-cutting
37
+ * `rd/project-scan.md` or `qa/.initiated`).
38
+ */
39
+ source: 'filename-regex' | 'content-h1' | 'content-frontmatter' | 'session-fallback' | 'cross-cutting' | null;
40
+ /** True if the file was skipped (e.g. `session.json`, cross-cutting, conflict). */
41
+ skipped?: boolean;
42
+ /** Why the file was skipped (only set when `skipped === true`). */
43
+ skipReason?: 'transient-runtime' | 'conflict' | 'no-change-id' | 'unsupported-role';
44
+ };
45
+ export type MigrateSessionPlan = {
46
+ sessionId: string;
47
+ /** Absolute path to the session dir. */
48
+ path: string;
49
+ /** True if the dir is empty / only has `session.json`. */
50
+ empty: boolean;
51
+ /** All files in the dir, planned. */
52
+ files: MigrateFilePlan[];
53
+ /** The fallback change-id derived from the session's most recent `rd/requests/` entry. Null if no requests exist. */
54
+ fallbackChangeId: string | null;
55
+ };
56
+ export type MigrateResult = {
57
+ /** Absolute project root the command operated on. */
58
+ projectRoot: string;
59
+ /** All discovered legacy session dirs, sorted by name. */
60
+ sessions: MigrateSessionPlan[];
61
+ /** All moves the apply step WOULD perform (only populated when `apply === false` as well, for symmetry). */
62
+ wouldMove: MigrateFilePlan[];
63
+ /** All moves actually performed (only populated when `apply === true`). */
64
+ moved: MigrateFilePlan[];
65
+ /** Sessions that became empty after the move and were/will be removed. */
66
+ deletedSessions: string[];
67
+ /** Sessions that WOULD become empty (dry-run only). */
68
+ wouldDeleteSessions: string[];
69
+ /** Files that already exist at the target (collision). Dry-run reports; apply skips + warns. */
70
+ conflicts: Array<{
71
+ from: string;
72
+ to: string;
73
+ reason: string;
74
+ }>;
75
+ /** Whether `--apply` was set. */
76
+ apply: boolean;
77
+ /** Total files moved or scheduled to move. */
78
+ totalFilesMoved: number;
79
+ };
80
+ export type MigrateOptions = {
81
+ projectRoot: string;
82
+ /** When true, actually `git mv` the files + `rm -rf` the emptied session dirs. */
83
+ apply: boolean;
84
+ };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Type envelope for the `peaks workspace migrate` CLI command.
3
+ *
4
+ * The migrate command is the downstream-project counterpart to the
5
+ * one-time Phase 5 migration script that ran on the peaks-cli self-host
6
+ * for slice 2026-06-05-change-id-as-unit-of-work. Where
7
+ * `peaks workspace reconcile` only handles the top-level runtime state
8
+ * files (`.peaks/.session.json`, `.peaks/.active-skill.json`,
9
+ * `.peaks/sop-state/` → `.peaks/_runtime/`), `peaks workspace migrate`
10
+ * handles the much bigger case: legacy reviewable content under
11
+ * `.peaks/<session-id>/<role>/<file>` → `.peaks/retrospective/<change-id>/<role>/<file>`.
12
+ *
13
+ * Each legacy session dir typically contains multiple change-ids worth
14
+ * of work (the old layout allowed one session to host several slices).
15
+ * Change-id resolution uses a 4-tier per-file heuristic (filename
16
+ * regex → content H1 → body frontmatter → per-session fallback to the
17
+ * most-recent `<change-id>` in `rd/requests/`).
18
+ *
19
+ * Types are hand-rolled, no new top-level dependencies.
20
+ */
21
+ export {};