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
|
@@ -104,8 +104,12 @@ export async function getAcceptanceCoverage(options) {
|
|
|
104
104
|
if (prdArtifact === null) {
|
|
105
105
|
return { kind: 'prd-not-found' };
|
|
106
106
|
}
|
|
107
|
-
|
|
108
|
-
|
|
107
|
+
// As of slice 2026-06-05-change-id-as-unit-of-work, test-cases live
|
|
108
|
+
// under the same change-id dir as the PRD itself (the on-disk scope),
|
|
109
|
+
// not under the body's `- session:` line. The `prdArtifact.changeId`
|
|
110
|
+
// is the dir the PRD was found in.
|
|
111
|
+
const changeId = prdArtifact.changeId;
|
|
112
|
+
const testCasesPath = join(options.projectRoot, '.peaks', changeId, 'qa', 'test-cases', `${options.requestId}.md`);
|
|
109
113
|
if (!(await pathExists(testCasesPath))) {
|
|
110
114
|
return { kind: 'test-cases-not-found', expectedPath: testCasesPath };
|
|
111
115
|
}
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Each session gets a unique directory under .peaks/ with incrementing numbered files.
|
|
7
7
|
*/
|
|
8
8
|
import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { mkdir as mkdirAsync } from 'node:fs/promises';
|
|
9
10
|
import { dirname, join, resolve } from 'node:path';
|
|
10
11
|
import { randomBytes } from 'node:crypto';
|
|
11
12
|
import { initWorkspace } from '../workspace/workspace-service.js';
|
|
@@ -421,7 +422,15 @@ export function listSessions(projectRoot) {
|
|
|
421
422
|
*/
|
|
422
423
|
export async function getProjectScanPath(projectRoot) {
|
|
423
424
|
const sessionId = await ensureSession(projectRoot);
|
|
424
|
-
|
|
425
|
+
// As of slice 2026-06-05-change-id-as-unit-of-work the session dir
|
|
426
|
+
// is at the canonical runtime location (gitignored). The scan is a
|
|
427
|
+
// session-local artifact; it lives alongside the rest of the
|
|
428
|
+
// ephemeral state under `_runtime/`. The parent `rd/` subdir is
|
|
429
|
+
// created on demand so the first scanner call has a place to land
|
|
430
|
+
// (consistent with the legacy behavior pre-1.3.1).
|
|
431
|
+
const scanPath = join(projectRoot, '.peaks', '_runtime', sessionId, 'rd', 'project-scan.md');
|
|
432
|
+
await mkdirAsync(dirname(scanPath), { recursive: true });
|
|
433
|
+
return scanPath;
|
|
425
434
|
}
|
|
426
435
|
/**
|
|
427
436
|
* Check if project-scan.md exists for the current session.
|
|
@@ -433,6 +442,7 @@ export function hasProjectScan(projectRoot) {
|
|
|
433
442
|
const info = readSessionFile(projectRoot);
|
|
434
443
|
if (!info)
|
|
435
444
|
return false;
|
|
436
|
-
|
|
445
|
+
// Canonical runtime location of the session dir (slice 2026-06-05).
|
|
446
|
+
const scanPath = join(projectRoot, '.peaks', '_runtime', info.sessionId, 'rd', 'project-scan.md');
|
|
437
447
|
return existsSync(scanPath);
|
|
438
448
|
}
|
|
@@ -14,16 +14,31 @@
|
|
|
14
14
|
* removes only our own entry.
|
|
15
15
|
*/
|
|
16
16
|
export type HookScope = 'project' | 'global';
|
|
17
|
-
/** The hook command written into settings. `${CLAUDE_PROJECT_DIR}` is injected by Claude Code. */
|
|
17
|
+
/** The hook command written into settings for the gate-enforce PreToolUse hook. `${CLAUDE_PROJECT_DIR}` is injected by Claude Code. */
|
|
18
18
|
export declare const HOOK_ENFORCE_COMMAND = "peaks gate enforce --project \"${CLAUDE_PROJECT_DIR}\"";
|
|
19
|
-
/**
|
|
20
|
-
|
|
19
|
+
/**
|
|
20
|
+
* Hook command for the sub-agent progress auto-spawn. Fires on every Task
|
|
21
|
+
* tool call (the harness-enforced mechanism for "sub-agent dispatch"). The
|
|
22
|
+
* command itself is non-blocking: `peaks progress start` is idempotent
|
|
23
|
+
* (5-minute TTL on the spawn record) so the LLM does not see a fresh
|
|
24
|
+
* terminal per Task. The `--quiet` flag keeps the LLM context clean — the
|
|
25
|
+
* hook output otherwise adds ~500 tokens per Task call.
|
|
26
|
+
*/
|
|
27
|
+
export declare const HOOK_PROGRESS_COMMAND = "peaks progress start --project \"${CLAUDE_PROJECT_DIR}\" --reason \"auto-spawn for sub-agent Task\" --quiet";
|
|
28
|
+
/** Substring that identifies a Peaks-managed PreToolUse gate-enforce hook entry. */
|
|
29
|
+
export declare const HOOK_ENFORCE_SENTINEL = "peaks gate enforce";
|
|
30
|
+
/** Substring that identifies a Peaks-managed PreToolUse sub-agent-progress hook entry. */
|
|
31
|
+
export declare const HOOK_PROGRESS_SENTINEL = "peaks progress start";
|
|
21
32
|
export type HookInstallPlan = {
|
|
22
33
|
scope: HookScope;
|
|
23
34
|
settingsPath: string;
|
|
24
35
|
exists: boolean;
|
|
25
36
|
alreadyInstalled: boolean;
|
|
26
37
|
desiredCommand: string;
|
|
38
|
+
/** Substring sentinel used to detect the entry. */
|
|
39
|
+
sentinel: string;
|
|
40
|
+
/** Tool name (Bash | Task) the PreToolUse hook is keyed on. */
|
|
41
|
+
matcher: string;
|
|
27
42
|
};
|
|
28
43
|
export type HookInstallResult = HookInstallPlan & {
|
|
29
44
|
applied: boolean;
|
|
@@ -39,6 +54,13 @@ export type HookStatus = {
|
|
|
39
54
|
exists: boolean;
|
|
40
55
|
installed: boolean;
|
|
41
56
|
};
|
|
57
|
+
/** A typed descriptor for a single peaks-managed hook entry. */
|
|
58
|
+
export type PeaksHookEntry = {
|
|
59
|
+
sentinel: string;
|
|
60
|
+
matcher: string;
|
|
61
|
+
command: string;
|
|
62
|
+
};
|
|
63
|
+
export declare const PEAKS_HOOK_ENTRIES: ReadonlyArray<PeaksHookEntry>;
|
|
42
64
|
export declare function planHookInstall(scope: HookScope, projectRoot?: string): HookInstallPlan;
|
|
43
65
|
export declare function applyHookInstall(scope: HookScope, projectRoot?: string): HookInstallResult;
|
|
44
66
|
export declare function removeHookInstall(scope: HookScope, projectRoot?: string): HookRemoveResult;
|
|
@@ -2,11 +2,33 @@ import { closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readF
|
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
3
|
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
4
4
|
import { homedir } from 'node:os';
|
|
5
|
-
/** The hook command written into settings. `${CLAUDE_PROJECT_DIR}` is injected by Claude Code. */
|
|
5
|
+
/** The hook command written into settings for the gate-enforce PreToolUse hook. `${CLAUDE_PROJECT_DIR}` is injected by Claude Code. */
|
|
6
6
|
export const HOOK_ENFORCE_COMMAND = 'peaks gate enforce --project "${CLAUDE_PROJECT_DIR}"';
|
|
7
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
/**
|
|
8
|
+
* Hook command for the sub-agent progress auto-spawn. Fires on every Task
|
|
9
|
+
* tool call (the harness-enforced mechanism for "sub-agent dispatch"). The
|
|
10
|
+
* command itself is non-blocking: `peaks progress start` is idempotent
|
|
11
|
+
* (5-minute TTL on the spawn record) so the LLM does not see a fresh
|
|
12
|
+
* terminal per Task. The `--quiet` flag keeps the LLM context clean — the
|
|
13
|
+
* hook output otherwise adds ~500 tokens per Task call.
|
|
14
|
+
*/
|
|
15
|
+
export const HOOK_PROGRESS_COMMAND = 'peaks progress start --project "${CLAUDE_PROJECT_DIR}" --reason "auto-spawn for sub-agent Task" --quiet';
|
|
16
|
+
/** Substring that identifies a Peaks-managed PreToolUse gate-enforce hook entry. */
|
|
17
|
+
export const HOOK_ENFORCE_SENTINEL = 'peaks gate enforce';
|
|
18
|
+
/** Substring that identifies a Peaks-managed PreToolUse sub-agent-progress hook entry. */
|
|
19
|
+
export const HOOK_PROGRESS_SENTINEL = 'peaks progress start';
|
|
20
|
+
const HOOK_GATE_MATCHER = 'Bash';
|
|
21
|
+
const HOOK_PROGRESS_MATCHER = 'Task';
|
|
22
|
+
/**
|
|
23
|
+
* Substring sentinels that identify a Peaks-managed PreToolUse hook entry.
|
|
24
|
+
* Used to keep `uninstall` and `isInstalled` checks tight: we only touch
|
|
25
|
+
* entries we wrote, never third-party hooks.
|
|
26
|
+
*/
|
|
27
|
+
const PEAKS_HOOK_SENTINELS = [HOOK_ENFORCE_SENTINEL, HOOK_PROGRESS_SENTINEL];
|
|
28
|
+
export const PEAKS_HOOK_ENTRIES = [
|
|
29
|
+
{ sentinel: HOOK_ENFORCE_SENTINEL, matcher: HOOK_GATE_MATCHER, command: HOOK_ENFORCE_COMMAND },
|
|
30
|
+
{ sentinel: HOOK_PROGRESS_SENTINEL, matcher: HOOK_PROGRESS_MATCHER, command: HOOK_PROGRESS_COMMAND }
|
|
31
|
+
];
|
|
10
32
|
function isInsidePath(childPath, parentPath) {
|
|
11
33
|
const rel = relative(parentPath, childPath);
|
|
12
34
|
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
@@ -87,9 +109,17 @@ function readPreToolUse(settings) {
|
|
|
87
109
|
const pre = hooks.PreToolUse;
|
|
88
110
|
return Array.isArray(pre) ? pre : [];
|
|
89
111
|
}
|
|
112
|
+
/** True when every command handler in the entry matches a known peaks sentinel. */
|
|
90
113
|
function entryIsPeaksManaged(entry) {
|
|
91
114
|
const handlers = Array.isArray(entry?.hooks) ? entry.hooks : [];
|
|
92
|
-
|
|
115
|
+
if (handlers.length === 0)
|
|
116
|
+
return false;
|
|
117
|
+
return handlers.every((h) => {
|
|
118
|
+
if (typeof h?.command !== 'string')
|
|
119
|
+
return false;
|
|
120
|
+
const cmd = h.command;
|
|
121
|
+
return PEAKS_HOOK_SENTINELS.some((sentinel) => cmd.includes(sentinel));
|
|
122
|
+
});
|
|
93
123
|
}
|
|
94
124
|
function isInstalled(settings) {
|
|
95
125
|
return readPreToolUse(settings).some(entryIsPeaksManaged);
|
|
@@ -100,18 +130,32 @@ export function planHookInstall(scope, projectRoot) {
|
|
|
100
130
|
assertSafeSettingsPath(scope, root, settingsPath);
|
|
101
131
|
const exists = existsSync(settingsPath);
|
|
102
132
|
const settings = readSettings(settingsPath);
|
|
103
|
-
return {
|
|
133
|
+
return {
|
|
134
|
+
scope,
|
|
135
|
+
settingsPath,
|
|
136
|
+
exists,
|
|
137
|
+
alreadyInstalled: isInstalled(settings),
|
|
138
|
+
desiredCommand: HOOK_ENFORCE_COMMAND,
|
|
139
|
+
sentinel: HOOK_ENFORCE_SENTINEL,
|
|
140
|
+
matcher: HOOK_GATE_MATCHER
|
|
141
|
+
};
|
|
104
142
|
}
|
|
105
|
-
/** Merge
|
|
106
|
-
function
|
|
143
|
+
/** Merge all peaks-managed PreToolUse entries into settings, preserving all other keys and hooks. */
|
|
144
|
+
function withHooksInstalled(settings) {
|
|
107
145
|
const existingHooks = (settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks))
|
|
108
146
|
? settings.hooks
|
|
109
147
|
: {};
|
|
110
148
|
const preToolUse = readPreToolUse(settings);
|
|
111
|
-
|
|
149
|
+
// Drop any existing peaks-managed entries first so re-runs are idempotent
|
|
150
|
+
// even if the command string changed (e.g. a bug fix in the command).
|
|
151
|
+
const nonPeaks = preToolUse.filter((entry) => !entryIsPeaksManaged(entry));
|
|
152
|
+
const ourEntries = PEAKS_HOOK_ENTRIES.map((spec) => ({
|
|
153
|
+
matcher: spec.matcher,
|
|
154
|
+
hooks: [{ type: 'command', command: spec.command }]
|
|
155
|
+
}));
|
|
112
156
|
return {
|
|
113
157
|
...settings,
|
|
114
|
-
hooks: { ...existingHooks, PreToolUse: [...
|
|
158
|
+
hooks: { ...existingHooks, PreToolUse: [...nonPeaks, ...ourEntries] }
|
|
115
159
|
};
|
|
116
160
|
}
|
|
117
161
|
export function applyHookInstall(scope, projectRoot) {
|
|
@@ -121,10 +165,10 @@ export function applyHookInstall(scope, projectRoot) {
|
|
|
121
165
|
const exists = existsSync(settingsPath);
|
|
122
166
|
const settings = readSettings(settingsPath);
|
|
123
167
|
if (isInstalled(settings)) {
|
|
124
|
-
return { scope, settingsPath, exists, alreadyInstalled: true, desiredCommand: HOOK_ENFORCE_COMMAND, applied: false };
|
|
168
|
+
return { scope, settingsPath, exists, alreadyInstalled: true, desiredCommand: HOOK_ENFORCE_COMMAND, applied: false, sentinel: HOOK_ENFORCE_SENTINEL, matcher: HOOK_GATE_MATCHER };
|
|
125
169
|
}
|
|
126
|
-
atomicWriteJson(settingsPath,
|
|
127
|
-
return { scope, settingsPath, exists, alreadyInstalled: false, desiredCommand: HOOK_ENFORCE_COMMAND, applied: true };
|
|
170
|
+
atomicWriteJson(settingsPath, withHooksInstalled(settings));
|
|
171
|
+
return { scope, settingsPath, exists, alreadyInstalled: false, desiredCommand: HOOK_ENFORCE_COMMAND, applied: true, sentinel: HOOK_ENFORCE_SENTINEL, matcher: HOOK_GATE_MATCHER };
|
|
128
172
|
}
|
|
129
173
|
export function removeHookInstall(scope, projectRoot) {
|
|
130
174
|
const root = resolveSettingsRoot(scope, projectRoot);
|
|
@@ -0,0 +1,248 @@
|
|
|
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
|
+
stages.push(await runUnitTests(options.projectRoot));
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
stages.push({
|
|
217
|
+
name: 'unit-tests',
|
|
218
|
+
description: 'npx vitest run (skipped per --skip-tests)',
|
|
219
|
+
status: 'skipped',
|
|
220
|
+
durationMs: 0,
|
|
221
|
+
detail: 'Skipped: --skip-tests was set.'
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
// Stage 3: 3-way review fanout check
|
|
225
|
+
stages.push(await runReviewFanout(options.projectRoot, rid, options.refreshFanout));
|
|
226
|
+
// Stage 4: gate verify-pipeline
|
|
227
|
+
stages.push(await runGateVerifyPipeline(options.projectRoot, rid, rid));
|
|
228
|
+
const boundaryReady = stages.every((s) => s.status === 'pass' || s.status === 'skipped');
|
|
229
|
+
const nextActions = [];
|
|
230
|
+
if (!boundaryReady) {
|
|
231
|
+
const failed = stages.filter((s) => s.status === 'fail');
|
|
232
|
+
for (const f of failed) {
|
|
233
|
+
nextActions.push(`Fix ${f.name}: ${f.detail.split('\n')[0]}`);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
else {
|
|
237
|
+
nextActions.push(`peaks request transition ${rid} --role rd --state qa-handoff --confirm --project <path>`);
|
|
238
|
+
nextActions.push(`peaks request transition ${rid} --role qa --state verdict-issued --confirm --project <path>`);
|
|
239
|
+
}
|
|
240
|
+
return {
|
|
241
|
+
projectRoot: options.projectRoot,
|
|
242
|
+
rid,
|
|
243
|
+
stages,
|
|
244
|
+
boundaryReady,
|
|
245
|
+
totalDurationMs: Date.now() - totalStart,
|
|
246
|
+
nextActions
|
|
247
|
+
};
|
|
248
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
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
|
+
};
|
|
@@ -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>;
|