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.
- package/README.md +62 -46
- package/bin/peaks.js +0 -0
- package/dist/src/cli/commands/hooks-commands.js +24 -9
- package/dist/src/cli/commands/progress-commands.js +26 -2
- package/dist/src/cli/commands/request-commands.js +5 -0
- package/dist/src/cli/commands/slice-commands.d.ts +3 -0
- package/dist/src/cli/commands/slice-commands.js +42 -0
- package/dist/src/cli/commands/workflow-commands.js +3 -3
- package/dist/src/cli/commands/workspace-commands.d.ts +63 -0
- package/dist/src/cli/commands/workspace-commands.js +347 -5
- package/dist/src/cli/program.js +4 -0
- package/dist/src/services/artifacts/artifact-prerequisites.d.ts +17 -1
- package/dist/src/services/artifacts/artifact-prerequisites.js +38 -5
- package/dist/src/services/artifacts/request-artifact-service.d.ts +22 -0
- package/dist/src/services/artifacts/request-artifact-service.js +172 -54
- package/dist/src/services/doctor/doctor-service.d.ts +7 -0
- package/dist/src/services/doctor/doctor-service.js +20 -2
- package/dist/src/services/progress/progress-service.d.ts +26 -0
- package/dist/src/services/progress/progress-service.js +25 -0
- package/dist/src/services/sc/sc-service.d.ts +52 -1
- package/dist/src/services/sc/sc-service.js +324 -17
- package/dist/src/services/scan/acceptance-coverage-service.js +6 -2
- package/dist/src/services/session/session-manager.d.ts +7 -5
- package/dist/src/services/session/session-manager.js +60 -16
- package/dist/src/services/skills/hooks-settings-service.d.ts +25 -3
- package/dist/src/services/skills/hooks-settings-service.js +57 -13
- package/dist/src/services/skills/skill-presence-service.js +102 -68
- package/dist/src/services/skills/skill-runbook-service.js +2 -1
- package/dist/src/services/skills/skill-statusline-service.js +13 -7
- package/dist/src/services/slice/slice-check-service.d.ts +2 -0
- package/dist/src/services/slice/slice-check-service.js +248 -0
- package/dist/src/services/slice/slice-check-types.d.ts +61 -0
- package/dist/src/services/slice/slice-check-types.js +18 -0
- package/dist/src/services/workflow/pipeline-verify-service.d.ts +5 -2
- package/dist/src/services/workflow/pipeline-verify-service.js +35 -35
- package/dist/src/services/workspace/migrate-service.d.ts +2 -0
- package/dist/src/services/workspace/migrate-service.js +484 -0
- package/dist/src/services/workspace/migrate-types.d.ts +84 -0
- package/dist/src/services/workspace/migrate-types.js +21 -0
- package/dist/src/services/workspace/reconcile-service.d.ts +119 -0
- package/dist/src/services/workspace/reconcile-service.js +464 -0
- package/dist/src/services/workspace/reconcile-types.d.ts +93 -0
- package/dist/src/services/workspace/reconcile-types.js +13 -0
- package/dist/src/services/workspace/workspace-service.d.ts +11 -0
- package/dist/src/services/workspace/workspace-service.js +87 -7
- package/dist/src/shared/change-id.d.ts +59 -0
- package/dist/src/shared/change-id.js +194 -16
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +13 -2
- package/skills/peaks-solo/SKILL.md +28 -4
- package/skills/peaks-solo/references/micro-cycle.md +155 -0
- 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 {};
|