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.
- package/README.md +62 -46
- package/bin/peaks.js +0 -0
- package/dist/src/cli/commands/core-artifact-commands.js +49 -11
- 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 +44 -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 +349 -12
- package/dist/src/cli/program.js +4 -0
- package/dist/src/services/artifacts/artifact-prerequisites.d.ts +29 -1
- package/dist/src/services/artifacts/artifact-prerequisites.js +69 -5
- package/dist/src/services/artifacts/request-artifact-service.d.ts +22 -0
- package/dist/src/services/artifacts/request-artifact-service.js +214 -56
- package/dist/src/services/doctor/doctor-service.d.ts +69 -0
- package/dist/src/services/doctor/doctor-service.js +296 -3
- 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.d.ts +22 -1
- package/dist/src/services/session/session-manager.js +149 -30
- 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 +267 -0
- package/dist/src/services/slice/slice-check-types.d.ts +70 -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 +606 -0
- package/dist/src/services/workspace/migrate-types.d.ts +127 -0
- package/dist/src/services/workspace/migrate-types.js +21 -0
- package/dist/src/services/workspace/reconcile-service.d.ts +33 -0
- package/dist/src/services/workspace/reconcile-service.js +160 -42
- package/dist/src/services/workspace/reconcile-types.d.ts +25 -0
- package/dist/src/services/workspace/workspace-service.d.ts +11 -0
- package/dist/src/services/workspace/workspace-service.js +71 -24
- 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/schemas/doctor-report.schema.json +2 -2
- package/skills/peaks-qa/SKILL.md +1 -0
- package/skills/peaks-rd/SKILL.md +2 -1
- package/skills/peaks-solo/SKILL.md +17 -1
- package/skills/peaks-solo/references/micro-cycle.md +155 -0
- package/skills/peaks-txt/SKILL.md +2 -0
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 },
|