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.
- package/README.md +62 -46
- 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 +288 -4
- 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.js +71 -13
- package/dist/src/services/scan/acceptance-coverage-service.js +6 -2
- package/dist/src/services/session/session-manager.js +12 -2
- 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/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/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 +10 -2
- package/skills/peaks-solo/SKILL.md +11 -1
- 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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
82
|
-
|
|
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',
|
|
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/${
|
|
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,
|
|
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',
|
|
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/${
|
|
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
|
-
|
|
172
|
+
changeId: resolvedChangeId,
|
|
173
173
|
requestType,
|
|
174
174
|
complete,
|
|
175
175
|
rdPhase: { invoked: rdInvoked, state: rdState, gates: rdGates },
|
|
@@ -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
|
+
}
|