peaks-cli 1.3.0 → 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 (42) hide show
  1. package/README.md +62 -46
  2. package/dist/src/cli/commands/hooks-commands.js +24 -9
  3. package/dist/src/cli/commands/progress-commands.js +26 -2
  4. package/dist/src/cli/commands/request-commands.js +5 -0
  5. package/dist/src/cli/commands/slice-commands.d.ts +3 -0
  6. package/dist/src/cli/commands/slice-commands.js +42 -0
  7. package/dist/src/cli/commands/workflow-commands.js +3 -3
  8. package/dist/src/cli/commands/workspace-commands.d.ts +63 -0
  9. package/dist/src/cli/commands/workspace-commands.js +288 -4
  10. package/dist/src/cli/program.js +4 -0
  11. package/dist/src/services/artifacts/artifact-prerequisites.d.ts +17 -1
  12. package/dist/src/services/artifacts/artifact-prerequisites.js +38 -5
  13. package/dist/src/services/artifacts/request-artifact-service.d.ts +22 -0
  14. package/dist/src/services/artifacts/request-artifact-service.js +172 -54
  15. package/dist/src/services/doctor/doctor-service.d.ts +7 -0
  16. package/dist/src/services/doctor/doctor-service.js +20 -2
  17. package/dist/src/services/progress/progress-service.d.ts +26 -0
  18. package/dist/src/services/progress/progress-service.js +25 -0
  19. package/dist/src/services/sc/sc-service.js +71 -13
  20. package/dist/src/services/scan/acceptance-coverage-service.js +6 -2
  21. package/dist/src/services/session/session-manager.js +12 -2
  22. package/dist/src/services/skills/hooks-settings-service.d.ts +25 -3
  23. package/dist/src/services/skills/hooks-settings-service.js +57 -13
  24. package/dist/src/services/slice/slice-check-service.d.ts +2 -0
  25. package/dist/src/services/slice/slice-check-service.js +248 -0
  26. package/dist/src/services/slice/slice-check-types.d.ts +61 -0
  27. package/dist/src/services/slice/slice-check-types.js +18 -0
  28. package/dist/src/services/workflow/pipeline-verify-service.d.ts +5 -2
  29. package/dist/src/services/workflow/pipeline-verify-service.js +35 -35
  30. package/dist/src/services/workspace/migrate-service.d.ts +2 -0
  31. package/dist/src/services/workspace/migrate-service.js +484 -0
  32. package/dist/src/services/workspace/migrate-types.d.ts +84 -0
  33. package/dist/src/services/workspace/migrate-types.js +21 -0
  34. package/dist/src/services/workspace/workspace-service.d.ts +11 -0
  35. package/dist/src/services/workspace/workspace-service.js +87 -7
  36. package/dist/src/shared/change-id.d.ts +59 -0
  37. package/dist/src/shared/change-id.js +194 -16
  38. package/dist/src/shared/version.d.ts +1 -1
  39. package/dist/src/shared/version.js +1 -1
  40. package/package.json +10 -2
  41. package/skills/peaks-solo/SKILL.md +11 -1
  42. package/skills/peaks-solo/references/micro-cycle.md +155 -0
@@ -1,15 +1,7 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
- import { readFile } from 'node:fs/promises';
4
3
  import { isRequestType } from '../artifacts/artifact-prerequisites.js';
5
- async function readFileContent(path) {
6
- try {
7
- return await readFile(path, 'utf8');
8
- }
9
- catch {
10
- return null;
11
- }
12
- }
4
+ import { showRequestArtifact } from '../artifacts/request-artifact-service.js';
13
5
  function extractState(markdown) {
14
6
  for (const rawLine of markdown.split(/\r?\n/)) {
15
7
  const match = /^-\s*state:\s*(.+?)\s*$/.exec(rawLine.trim());
@@ -18,23 +10,19 @@ function extractState(markdown) {
18
10
  }
19
11
  return 'unknown';
20
12
  }
21
- async function findRequestFile(projectRoot, sessionId, role, rid) {
22
- const dir = join(projectRoot, '.peaks', sessionId, role, 'requests');
23
- if (!existsSync(dir))
13
+ /**
14
+ * As of slice 2026-06-05-change-id-as-unit-of-work, the file's durable
15
+ * scope is the change-id (the `.peaks/<changeId>/` dir the file lives
16
+ * in), NOT the session-id. We resolve the on-disk location via
17
+ * `showRequestArtifact` (which scans all top-level dirs and returns the
18
+ * actual dir the file was found in) instead of assuming
19
+ * `.peaks/<sessionId>/<role>/requests/`.
20
+ */
21
+ async function findRequestFile(projectRoot, role, rid) {
22
+ const artifact = await showRequestArtifact({ projectRoot, role: role, requestId: rid });
23
+ if (artifact === null)
24
24
  return null;
25
- const { readdir } = await import('node:fs/promises');
26
- const entries = await readdir(dir, { withFileTypes: true });
27
- for (const entry of entries) {
28
- if (!entry.isFile() || !entry.name.endsWith('.md'))
29
- continue;
30
- if (entry.name === `${rid}.md` || (/^\d+-/.test(entry.name) && entry.name.endsWith(`-${rid}.md`))) {
31
- const path = join(dir, entry.name);
32
- const content = await readFileContent(path);
33
- if (content)
34
- return { path, content };
35
- }
36
- }
37
- return null;
25
+ return { path: artifact.path, content: artifact.content, changeId: artifact.changeId };
38
26
  }
39
27
  function rdGatesForType(requestType) {
40
28
  const gates = [
@@ -78,31 +66,42 @@ export async function verifyPipeline(options) {
78
66
  const nextActions = [];
79
67
  const rdGates = rdGatesForType(requestType);
80
68
  const qaGates = qaGatesForType(requestType);
81
- // Check RD phase
82
- const rdFile = await findRequestFile(options.projectRoot, options.sessionId, 'rd', options.rid);
69
+ // Resolve RD + QA on-disk locations via showRequestArtifact (the change-id
70
+ // is whatever dir the file actually lives in, not the caller's session-id).
71
+ const rdFile = await findRequestFile(options.projectRoot, 'rd', options.rid);
83
72
  let rdInvoked = false;
84
73
  let rdState = 'missing';
74
+ // The resolved change-id is the on-disk location the file actually
75
+ // lives in. The caller's `options.changeId` is a hint used for
76
+ // path construction (nextActions strings), NOT for the resolved
77
+ // changeId field — the on-disk location is the source of truth.
78
+ let resolvedChangeId = '';
85
79
  if (rdFile) {
86
80
  rdInvoked = true;
87
81
  rdState = extractState(rdFile.content);
88
82
  rdGates[0].passed = true;
89
83
  rdGates[0].detail = `found at ${rdFile.path}`;
84
+ resolvedChangeId = rdFile.changeId;
90
85
  }
91
86
  else {
92
87
  violations.push('RD phase skipped: peaks-rd was never invoked for this request (no RD request artifact found)');
93
88
  nextActions.push('Invoke Skill(skill="peaks-rd") with the request-id, then run unit tests + code review + security review');
94
89
  rdGates[0].detail = 'not found';
95
90
  }
96
- // Check RD evidence files
91
+ // Check RD evidence files (under the change-id dir the RD request lives in)
97
92
  const RD_EVIDENCE_FILE = {
98
93
  'tech-doc': 'tech-doc.md',
99
94
  'bug-analysis': 'bug-analysis.md',
100
95
  'code-review': 'code-review.md',
101
96
  'security-review': 'security-review.md'
102
97
  };
98
+ // The evidence dir: prefer the on-disk changeId; fall back to the
99
+ // caller's hint; final fallback to the requestId (back-compat for
100
+ // pre-1.3.0 trees where the file lived under .peaks/<rid>/).
101
+ const rdEvidenceDir = resolvedChangeId || options.changeId || options.rid;
103
102
  for (const gate of rdGates.slice(1)) {
104
103
  const fileName = RD_EVIDENCE_FILE[gate.name];
105
- const evidencePath = join(options.projectRoot, '.peaks', options.sessionId, 'rd', fileName);
104
+ const evidencePath = join(options.projectRoot, '.peaks', rdEvidenceDir, 'rd', fileName);
106
105
  if (existsSync(evidencePath)) {
107
106
  gate.passed = true;
108
107
  gate.detail = evidencePath;
@@ -110,7 +109,7 @@ export async function verifyPipeline(options) {
110
109
  else {
111
110
  gate.detail = `missing: ${evidencePath}`;
112
111
  violations.push(`RD evidence missing: ${gate.description} (${fileName})`);
113
- nextActions.push(`Create .peaks/${options.sessionId}/rd/${fileName}`);
112
+ nextActions.push(`Create .peaks/${rdEvidenceDir}/rd/${fileName}`);
114
113
  }
115
114
  }
116
115
  // Check if RD reached qa-handoff
@@ -119,7 +118,7 @@ export async function verifyPipeline(options) {
119
118
  nextActions.push(`Complete RD gates → peaks request transition ${options.rid} --role rd --state qa-handoff`);
120
119
  }
121
120
  // Check QA phase
122
- const qaFile = await findRequestFile(options.projectRoot, options.sessionId, 'qa', options.rid);
121
+ const qaFile = await findRequestFile(options.projectRoot, 'qa', options.rid);
123
122
  let qaInvoked = false;
124
123
  let qaState = 'missing';
125
124
  if (qaFile) {
@@ -127,13 +126,14 @@ export async function verifyPipeline(options) {
127
126
  qaState = extractState(qaFile.content);
128
127
  qaGates[0].passed = true;
129
128
  qaGates[0].detail = `found at ${qaFile.path}`;
129
+ resolvedChangeId = qaFile.changeId || resolvedChangeId;
130
130
  }
131
131
  else {
132
132
  violations.push('QA phase skipped: peaks-qa was never invoked for this request (no QA request artifact found)');
133
133
  nextActions.push('Invoke Skill(skill="peaks-qa") with the request-id for functional/performance/security testing');
134
134
  qaGates[0].detail = 'not found';
135
135
  }
136
- // Check QA evidence files
136
+ // Check QA evidence files (under the same change-id dir)
137
137
  const QA_EVIDENCE_FILE = {
138
138
  'test-cases': `test-cases/${options.rid}.md`,
139
139
  'test-report': `test-reports/${options.rid}.md`,
@@ -142,7 +142,7 @@ export async function verifyPipeline(options) {
142
142
  };
143
143
  for (const gate of qaGates.slice(1)) {
144
144
  const fileName = QA_EVIDENCE_FILE[gate.name];
145
- const evidencePath = join(options.projectRoot, '.peaks', options.sessionId, 'qa', fileName);
145
+ const evidencePath = join(options.projectRoot, '.peaks', rdEvidenceDir, 'qa', fileName);
146
146
  if (existsSync(evidencePath)) {
147
147
  gate.passed = true;
148
148
  gate.detail = evidencePath;
@@ -150,7 +150,7 @@ export async function verifyPipeline(options) {
150
150
  else {
151
151
  gate.detail = `missing: ${evidencePath}`;
152
152
  violations.push(`QA evidence missing: ${gate.description} (${fileName})`);
153
- nextActions.push(`Create .peaks/${options.sessionId}/qa/${fileName}`);
153
+ nextActions.push(`Create .peaks/${rdEvidenceDir}/qa/${fileName}`);
154
154
  }
155
155
  }
156
156
  // Check if QA reached verdict-issued
@@ -169,7 +169,7 @@ export async function verifyPipeline(options) {
169
169
  && RD_QA_HANDOFF_STATES.has(rdState) && QA_COMPLETE_STATES.has(qaState);
170
170
  return {
171
171
  rid: options.rid,
172
- sessionId: options.sessionId,
172
+ changeId: resolvedChangeId,
173
173
  requestType,
174
174
  complete,
175
175
  rdPhase: { invoked: rdInvoked, state: rdState, gates: rdGates },
@@ -0,0 +1,2 @@
1
+ import type { MigrateOptions, MigrateResult } from './migrate-types.js';
2
+ export declare function migrateWorkspace(options: MigrateOptions): Promise<MigrateResult>;
@@ -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
+ }