peaks-cli 1.3.0 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +62 -46
  2. package/bin/peaks.js +0 -0
  3. package/dist/src/cli/commands/core-artifact-commands.js +49 -11
  4. package/dist/src/cli/commands/hooks-commands.js +24 -9
  5. package/dist/src/cli/commands/progress-commands.js +26 -2
  6. package/dist/src/cli/commands/request-commands.js +5 -0
  7. package/dist/src/cli/commands/slice-commands.d.ts +3 -0
  8. package/dist/src/cli/commands/slice-commands.js +44 -0
  9. package/dist/src/cli/commands/workflow-commands.js +3 -3
  10. package/dist/src/cli/commands/workspace-commands.d.ts +63 -0
  11. package/dist/src/cli/commands/workspace-commands.js +349 -12
  12. package/dist/src/cli/program.js +4 -0
  13. package/dist/src/services/artifacts/artifact-prerequisites.d.ts +29 -1
  14. package/dist/src/services/artifacts/artifact-prerequisites.js +69 -5
  15. package/dist/src/services/artifacts/request-artifact-service.d.ts +22 -0
  16. package/dist/src/services/artifacts/request-artifact-service.js +214 -56
  17. package/dist/src/services/doctor/doctor-service.d.ts +69 -0
  18. package/dist/src/services/doctor/doctor-service.js +296 -3
  19. package/dist/src/services/progress/progress-service.d.ts +26 -0
  20. package/dist/src/services/progress/progress-service.js +25 -0
  21. package/dist/src/services/sc/sc-service.js +71 -13
  22. package/dist/src/services/scan/acceptance-coverage-service.js +6 -2
  23. package/dist/src/services/session/session-manager.d.ts +22 -1
  24. package/dist/src/services/session/session-manager.js +149 -30
  25. package/dist/src/services/skills/hooks-settings-service.d.ts +25 -3
  26. package/dist/src/services/skills/hooks-settings-service.js +57 -13
  27. package/dist/src/services/slice/slice-check-service.d.ts +2 -0
  28. package/dist/src/services/slice/slice-check-service.js +267 -0
  29. package/dist/src/services/slice/slice-check-types.d.ts +70 -0
  30. package/dist/src/services/slice/slice-check-types.js +18 -0
  31. package/dist/src/services/workflow/pipeline-verify-service.d.ts +5 -2
  32. package/dist/src/services/workflow/pipeline-verify-service.js +35 -35
  33. package/dist/src/services/workspace/migrate-service.d.ts +2 -0
  34. package/dist/src/services/workspace/migrate-service.js +606 -0
  35. package/dist/src/services/workspace/migrate-types.d.ts +127 -0
  36. package/dist/src/services/workspace/migrate-types.js +21 -0
  37. package/dist/src/services/workspace/reconcile-service.d.ts +33 -0
  38. package/dist/src/services/workspace/reconcile-service.js +160 -42
  39. package/dist/src/services/workspace/reconcile-types.d.ts +25 -0
  40. package/dist/src/services/workspace/workspace-service.d.ts +11 -0
  41. package/dist/src/services/workspace/workspace-service.js +71 -24
  42. package/dist/src/shared/change-id.d.ts +59 -0
  43. package/dist/src/shared/change-id.js +194 -16
  44. package/dist/src/shared/version.d.ts +1 -1
  45. package/dist/src/shared/version.js +1 -1
  46. package/package.json +10 -2
  47. package/schemas/doctor-report.schema.json +2 -2
  48. package/skills/peaks-qa/SKILL.md +1 -0
  49. package/skills/peaks-rd/SKILL.md +2 -1
  50. package/skills/peaks-solo/SKILL.md +17 -1
  51. package/skills/peaks-solo/references/micro-cycle.md +155 -0
  52. package/skills/peaks-txt/SKILL.md +2 -0
  53. package/skills/peaks-ui/SKILL.md +1 -0
@@ -0,0 +1,267 @@
1
+ import { execFileSync } from 'node:child_process';
2
+ import { existsSync, statSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ import { isDirectory } from '../../shared/fs.js';
5
+ import { getCurrentChangeId } from '../../shared/change-id.js';
6
+ import { verifyPipeline } from '../workflow/pipeline-verify-service.js';
7
+ function runCommand(command, args, cwd, timeoutMs) {
8
+ const start = Date.now();
9
+ try {
10
+ const stdout = execFileSync(command, args, {
11
+ cwd,
12
+ stdio: ['ignore', 'pipe', 'pipe'],
13
+ timeout: timeoutMs,
14
+ maxBuffer: 32 * 1024 * 1024
15
+ }).toString('utf8');
16
+ return {
17
+ status: 'pass',
18
+ stdout,
19
+ stderr: '',
20
+ exitCode: 0,
21
+ durationMs: Date.now() - start
22
+ };
23
+ }
24
+ catch (error) {
25
+ const stdout = (error?.stdout ?? '').toString('utf8');
26
+ const stderr = (error?.stderr ?? '').toString('utf8');
27
+ return {
28
+ status: 'fail',
29
+ stdout,
30
+ stderr,
31
+ exitCode: typeof error?.status === 'number' ? error.status : 1,
32
+ durationMs: Date.now() - start
33
+ };
34
+ }
35
+ }
36
+ function tailLines(text, max) {
37
+ const lines = text.split('\n').filter((l) => l.trim().length > 0);
38
+ if (lines.length <= max)
39
+ return lines.join('\n');
40
+ return [...lines.slice(0, 3), `... (${lines.length - max} more lines) ...`, ...lines.slice(-max + 3)].join('\n');
41
+ }
42
+ async function runTypecheck(projectRoot) {
43
+ const start = Date.now();
44
+ const result = runCommand('npx', ['tsc', '--noEmit'], projectRoot, 180_000);
45
+ const testFiles = result.stdout.match(/(tests?\/.*\.test\.ts)/g) ?? [];
46
+ return {
47
+ name: 'typecheck',
48
+ description: 'npx tsc --noEmit (no JS emit, type-only check)',
49
+ status: result.status,
50
+ durationMs: result.durationMs,
51
+ detail: result.status === 'pass'
52
+ ? `Typecheck passed in ${result.durationMs}ms.`
53
+ : tailLines(result.stdout + result.stderr, 10) || `tsc exited with code ${result.exitCode}.`,
54
+ data: { exitCode: result.exitCode }
55
+ };
56
+ }
57
+ function parseVitestSummary(stdout, fallbackDuration) {
58
+ // Vitest 2.x prints e.g. "Test Files 1 passed (1)" and "Tests 1 passed (1)"
59
+ // and "Duration 0.50s" or "Duration 1.23s". Be lenient with regex.
60
+ const testsMatch = /Tests?\s+(\d+)\s+(?:passed|run)/.exec(stdout);
61
+ const failedMatch = /Tests?\s+(\d+)\s+failed/.exec(stdout);
62
+ const skippedMatch = /Tests?\s+(\d+)\s+skipped/.exec(stdout);
63
+ const durationMatch = /Duration[^\d]*(\d+(?:\.\d+)?)\s*s/.exec(stdout);
64
+ return {
65
+ tests: testsMatch ? parseInt(testsMatch[1], 10) : 0,
66
+ passed: 0,
67
+ failed: failedMatch ? parseInt(failedMatch[1], 10) : 0,
68
+ skipped: skippedMatch ? parseInt(skippedMatch[1], 10) : 0,
69
+ durationMs: durationMatch ? Math.round(parseFloat(durationMatch[1]) * 1000) : fallbackDuration
70
+ };
71
+ }
72
+ async function runUnitTests(projectRoot) {
73
+ const start = Date.now();
74
+ const result = runCommand('npx', ['vitest', 'run', '--reporter=default', '--coverage=false'], projectRoot, 600_000);
75
+ const summary = parseVitestSummary(result.stdout, result.durationMs);
76
+ // Vitest doesn't always print the per-bucket counts cleanly; infer "passed"
77
+ // as total - failed - skipped when failed/skipped buckets are present.
78
+ const passed = Math.max(summary.tests - summary.failed - summary.skipped, 0);
79
+ return {
80
+ name: 'unit-tests',
81
+ description: 'npx vitest run (full test suite, coverage off)',
82
+ status: result.status,
83
+ durationMs: result.durationMs,
84
+ detail: result.status === 'pass'
85
+ ? `All tests passed in ${result.durationMs}ms.`
86
+ : tailLines(result.stdout + result.stderr, 12) || `vitest exited with code ${result.exitCode}.`,
87
+ data: {
88
+ tests: summary.tests,
89
+ passed,
90
+ failed: summary.failed,
91
+ skipped: summary.skipped,
92
+ exitCode: result.exitCode
93
+ }
94
+ };
95
+ }
96
+ const REVIEW_FILES = [
97
+ { name: 'code-review', path: 'rd/code-review.md', label: 'code-review' },
98
+ { name: 'security-review', path: 'rd/security-review.md', label: 'security-review' },
99
+ { name: 'perf-baseline', path: 'rd/perf-baseline.md', label: 'perf-baseline' }
100
+ ];
101
+ async function runReviewFanout(projectRoot, rid, refresh) {
102
+ const start = Date.now();
103
+ if (refresh) {
104
+ // `peaks-rd` does the 3-way fan-out when the slice is in `spec-locked` or
105
+ // `implemented` state. The actual fan-out is invoked via the `peaks-rd`
106
+ // skill body, not via a CLI subcommand (each sub-agent is invoked with
107
+ // its own prompt). When `--refresh-fanout` is set, we emit a
108
+ // nextAction that tells the caller to invoke `Skill(skill="peaks-rd")`
109
+ // (the role skill owns the 3 review artifact writes).
110
+ return {
111
+ name: 'review-fanout',
112
+ description: '3-way review fan-out (code-review + security-review + perf baseline)',
113
+ status: 'skipped',
114
+ durationMs: Date.now() - start,
115
+ detail: '3-way fan-out is dispatched via Skill(skill="peaks-rd"); invoke it to regenerate the review artifacts.',
116
+ data: { refresh: true, rid }
117
+ };
118
+ }
119
+ // Default: verify all 3 review files exist with non-empty content. The
120
+ // files can live under EITHER `.peaks/<rid>/rd/` (active change-id) or
121
+ // `.peaks/retrospective/<rid>/rd/` (shipped). The boundary check
122
+ // accepts either — the LLM may be at a slice that's still active
123
+ // (not yet archived) or one that just shipped.
124
+ const scopes = [rid, `retrospective/${rid}`];
125
+ const missing = [];
126
+ const found = [];
127
+ for (const review of REVIEW_FILES) {
128
+ let hit = null;
129
+ for (const scope of scopes) {
130
+ const abs = join(projectRoot, '.peaks', scope, review.path);
131
+ if (existsSync(abs)) {
132
+ const bytes = statSync(abs).size;
133
+ if (bytes >= 20) {
134
+ hit = { abs, scope, bytes };
135
+ break;
136
+ }
137
+ }
138
+ }
139
+ if (hit === null) {
140
+ missing.push(review.label);
141
+ continue;
142
+ }
143
+ found.push({ name: review.name, path: hit.abs, bytes: hit.bytes, scope: hit.scope });
144
+ }
145
+ const status = missing.length === 0 ? 'pass' : 'fail';
146
+ return {
147
+ name: 'review-fanout',
148
+ description: '3-way review fan-out (code-review + security-review + perf baseline)',
149
+ status,
150
+ durationMs: Date.now() - start,
151
+ detail: status === 'pass'
152
+ ? `All 3 review artifacts present (${found.map((f) => f.name).join(', ')}; scope: ${found[0]?.scope}).`
153
+ : `Missing or empty: ${missing.join(', ')}. Re-run with --refresh-fanout or invoke Skill(skill="peaks-rd") to regenerate.`,
154
+ data: { found, missing }
155
+ };
156
+ }
157
+ async function runGateVerifyPipeline(projectRoot, rid, changeId) {
158
+ const start = Date.now();
159
+ try {
160
+ const result = await verifyPipeline({ projectRoot, rid, changeId });
161
+ const duration = Date.now() - start;
162
+ return {
163
+ name: 'gate-verify-pipeline',
164
+ description: 'peaks workflow verify-pipeline (RD/QA gate checks against .peaks/<changeId>/)',
165
+ status: result.complete ? 'pass' : 'fail',
166
+ durationMs: duration,
167
+ detail: result.complete
168
+ ? `All gates passed in ${duration}ms.`
169
+ : `${result.violations.length} violation(s): ${result.violations.join('; ')}`,
170
+ data: {
171
+ rdGates: result.rdPhase.gates.length,
172
+ qaGates: result.qaPhase.gates.length,
173
+ rdState: result.rdPhase.state,
174
+ qaState: result.qaPhase.state,
175
+ violations: result.violations,
176
+ nextActions: result.nextActions
177
+ }
178
+ };
179
+ }
180
+ catch (error) {
181
+ return {
182
+ name: 'gate-verify-pipeline',
183
+ description: 'peaks workflow verify-pipeline (RD/QA gate checks against .peaks/<changeId>/)',
184
+ status: 'fail',
185
+ durationMs: Date.now() - start,
186
+ detail: error?.message ?? 'verify-pipeline threw',
187
+ data: {}
188
+ };
189
+ }
190
+ }
191
+ export async function sliceCheck(options) {
192
+ const peaksRoot = join(options.projectRoot, '.peaks');
193
+ if (!(await isDirectory(peaksRoot))) {
194
+ throw new Error(`.peaks/ not found at ${options.projectRoot}. Run peaks workspace init first.`);
195
+ }
196
+ // Resolve rid: explicit > current-change binding > null
197
+ let rid = options.rid;
198
+ if (rid === undefined) {
199
+ const bound = getCurrentChangeId(options.projectRoot);
200
+ if (bound !== null) {
201
+ rid = bound;
202
+ }
203
+ }
204
+ if (rid === undefined) {
205
+ throw new Error('No --rid and no current-change binding. Pass --rid <id> or run peaks workspace init --change-id <id> first.');
206
+ }
207
+ const totalStart = Date.now();
208
+ const stages = [];
209
+ // Stage 1: typecheck
210
+ stages.push(await runTypecheck(options.projectRoot));
211
+ // Stage 2: full vitest
212
+ if (!options.skipTests) {
213
+ const unitTests = await runUnitTests(options.projectRoot);
214
+ // Opt-in override: if --allow-pre-existing-failures is set AND the
215
+ // unit-test stage failed, downgrade `failed` to `skipped` with a
216
+ // reason that names the failure count and points to the long-term
217
+ // fix. Does NOT affect the other 3 stages.
218
+ if (options.allowPreExistingFailures === true &&
219
+ unitTests.status === 'fail') {
220
+ const failureCount = unitTests.data?.failed ?? 0;
221
+ stages.push({
222
+ name: 'unit-tests',
223
+ description: 'npx vitest run (overridden via --allow-pre-existing-failures)',
224
+ status: 'skipped',
225
+ durationMs: unitTests.durationMs,
226
+ detail: `pre-existing failures: ${failureCount} failing test(s) under coverage.exclude or unrelated to this slice; user opted in via --allow-pre-existing-failures. For the long-term fix, mark these tests .skip or move to coverage.exclude (see dogfood-2-f1-f4.md F17c).`,
227
+ data: { ...(unitTests.data ?? {}), overriddenFrom: 'fail', failureCount }
228
+ });
229
+ }
230
+ else {
231
+ stages.push(unitTests);
232
+ }
233
+ }
234
+ else {
235
+ stages.push({
236
+ name: 'unit-tests',
237
+ description: 'npx vitest run (skipped per --skip-tests)',
238
+ status: 'skipped',
239
+ durationMs: 0,
240
+ detail: 'Skipped: --skip-tests was set.'
241
+ });
242
+ }
243
+ // Stage 3: 3-way review fanout check
244
+ stages.push(await runReviewFanout(options.projectRoot, rid, options.refreshFanout));
245
+ // Stage 4: gate verify-pipeline
246
+ stages.push(await runGateVerifyPipeline(options.projectRoot, rid, rid));
247
+ const boundaryReady = stages.every((s) => s.status === 'pass' || s.status === 'skipped');
248
+ const nextActions = [];
249
+ if (!boundaryReady) {
250
+ const failed = stages.filter((s) => s.status === 'fail');
251
+ for (const f of failed) {
252
+ nextActions.push(`Fix ${f.name}: ${f.detail.split('\n')[0]}`);
253
+ }
254
+ }
255
+ else {
256
+ nextActions.push(`peaks request transition ${rid} --role rd --state qa-handoff --confirm --project <path>`);
257
+ nextActions.push(`peaks request transition ${rid} --role qa --state verdict-issued --confirm --project <path>`);
258
+ }
259
+ return {
260
+ projectRoot: options.projectRoot,
261
+ rid,
262
+ stages,
263
+ boundaryReady,
264
+ totalDurationMs: Date.now() - totalStart,
265
+ nextActions
266
+ };
267
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Type envelope for the `peaks slice check` CLI command.
3
+ *
4
+ * `peaks slice check` is the boundary check for the RD micro-cycle
5
+ * (see `skills/peaks-solo/references/micro-cycle.md`). It bundles the
6
+ * 4 self-checks that must pass at slice end before the slice is handed
7
+ * off to peaks-qa:
8
+ *
9
+ * 1. typecheck (`npx tsc --noEmit`)
10
+ * 2. unit tests (`npx vitest run`)
11
+ * 3. 3-way review fan-out (code-review + security-review + perf-baseline)
12
+ * 4. gate machinery (`peaks workflow verify-pipeline --rid <rid>`)
13
+ *
14
+ * The micro-cycle itself (per-bug TDD) runs OUTSIDE slice check — only
15
+ * single-test runs (`vitest -t "<name>"`) are allowed in micro-cycles.
16
+ * This command is for the BOUNDARY, not the inner loop.
17
+ */
18
+ export type SliceCheckStageStatus = 'pass' | 'fail' | 'skipped';
19
+ export type SliceCheckStage = {
20
+ /** Stable id for the stage (matches the runbook's check list). */
21
+ name: 'typecheck' | 'unit-tests' | 'review-fanout' | 'gate-verify-pipeline';
22
+ /** Human-readable description. */
23
+ description: string;
24
+ status: SliceCheckStageStatus;
25
+ /** Wall-clock duration in ms; null if skipped. */
26
+ durationMs: number | null;
27
+ /** Free-form detail (summary line + last error line). */
28
+ detail: string;
29
+ /** Optional structured data (e.g. test counts, gate counts). */
30
+ data?: Record<string, unknown>;
31
+ };
32
+ export type SliceCheckResult = {
33
+ /** Absolute project root the command operated on. */
34
+ projectRoot: string;
35
+ /** Request id the boundary check applies to; null if no slice is active. */
36
+ rid: string | null;
37
+ /** All stages in execution order. */
38
+ stages: SliceCheckStage[];
39
+ /** True iff every stage passed (or was skipped) and the boundary is OK to hand off. */
40
+ boundaryReady: boolean;
41
+ /** Total wall-clock duration in ms. */
42
+ totalDurationMs: number;
43
+ /** Next steps suggested when boundaryReady is false. */
44
+ nextActions: string[];
45
+ };
46
+ export type SliceCheckOptions = {
47
+ projectRoot: string;
48
+ /** When omitted, slice check inspects `.peaks/_runtime/current-change` to find the active rid. */
49
+ rid?: string;
50
+ /**
51
+ * When true, re-run the 3-way review fan-out (peaks-rd's code-review +
52
+ * security-review + perf-baseline sub-agents) even if the review files
53
+ * already exist. The default is to verify presence and skip if all 3 are present.
54
+ */
55
+ refreshFanout: boolean;
56
+ /**
57
+ * When true, skip the unit-test stage. Useful when a slice has no unit
58
+ * tests (e.g. a docs-only or config-only slice).
59
+ */
60
+ skipTests: boolean;
61
+ /**
62
+ * When true, an `unit-tests` stage that fails is reported as `skipped`
63
+ * (with a `reason` naming the pre-existing failure count) instead of
64
+ * `failed`. Used to opt in to bypassing the 28 pre-existing Windows
65
+ * test failures documented in dogfood-2-f1-f4.md F17. Does NOT affect
66
+ * the other 3 stages (typecheck / review-fanout / gate-verify-pipeline).
67
+ * Default: false. The service treats `undefined` the same as `false`.
68
+ */
69
+ allowPreExistingFailures?: boolean;
70
+ };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Type envelope for the `peaks slice check` CLI command.
3
+ *
4
+ * `peaks slice check` is the boundary check for the RD micro-cycle
5
+ * (see `skills/peaks-solo/references/micro-cycle.md`). It bundles the
6
+ * 4 self-checks that must pass at slice end before the slice is handed
7
+ * off to peaks-qa:
8
+ *
9
+ * 1. typecheck (`npx tsc --noEmit`)
10
+ * 2. unit tests (`npx vitest run`)
11
+ * 3. 3-way review fan-out (code-review + security-review + perf-baseline)
12
+ * 4. gate machinery (`peaks workflow verify-pipeline --rid <rid>`)
13
+ *
14
+ * The micro-cycle itself (per-bug TDD) runs OUTSIDE slice check — only
15
+ * single-test runs (`vitest -t "<name>"`) are allowed in micro-cycles.
16
+ * This command is for the BOUNDARY, not the inner loop.
17
+ */
18
+ export {};
@@ -7,7 +7,7 @@ export type PipelineGate = {
7
7
  };
8
8
  export type PipelineVerification = {
9
9
  rid: string;
10
- sessionId: string;
10
+ changeId: string;
11
11
  requestType: RequestType;
12
12
  complete: boolean;
13
13
  rdPhase: {
@@ -26,6 +26,9 @@ export type PipelineVerification = {
26
26
  export declare function verifyPipeline(options: {
27
27
  projectRoot: string;
28
28
  rid: string;
29
- sessionId: string;
29
+ /** Optional explicit change-id; when omitted, the RD/QA on-disk location
30
+ * is resolved via showRequestArtifact (which scans all top-level dirs and
31
+ * returns the actual change-id the file lives in). */
32
+ changeId?: string;
30
33
  requestType?: string;
31
34
  }): Promise<PipelineVerification>;
@@ -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>;