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.
Files changed (42) hide show
  1. package/README.md +62 -46
  2. package/dist/src/cli/commands/hooks-commands.js +24 -9
  3. package/dist/src/cli/commands/progress-commands.js +26 -2
  4. package/dist/src/cli/commands/request-commands.js +5 -0
  5. package/dist/src/cli/commands/slice-commands.d.ts +3 -0
  6. package/dist/src/cli/commands/slice-commands.js +42 -0
  7. package/dist/src/cli/commands/workflow-commands.js +3 -3
  8. package/dist/src/cli/commands/workspace-commands.d.ts +63 -0
  9. package/dist/src/cli/commands/workspace-commands.js +288 -4
  10. package/dist/src/cli/program.js +4 -0
  11. package/dist/src/services/artifacts/artifact-prerequisites.d.ts +17 -1
  12. package/dist/src/services/artifacts/artifact-prerequisites.js +38 -5
  13. package/dist/src/services/artifacts/request-artifact-service.d.ts +22 -0
  14. package/dist/src/services/artifacts/request-artifact-service.js +172 -54
  15. package/dist/src/services/doctor/doctor-service.d.ts +7 -0
  16. package/dist/src/services/doctor/doctor-service.js +20 -2
  17. package/dist/src/services/progress/progress-service.d.ts +26 -0
  18. package/dist/src/services/progress/progress-service.js +25 -0
  19. package/dist/src/services/sc/sc-service.js +71 -13
  20. package/dist/src/services/scan/acceptance-coverage-service.js +6 -2
  21. package/dist/src/services/session/session-manager.js +12 -2
  22. package/dist/src/services/skills/hooks-settings-service.d.ts +25 -3
  23. package/dist/src/services/skills/hooks-settings-service.js +57 -13
  24. package/dist/src/services/slice/slice-check-service.d.ts +2 -0
  25. package/dist/src/services/slice/slice-check-service.js +248 -0
  26. package/dist/src/services/slice/slice-check-types.d.ts +61 -0
  27. package/dist/src/services/slice/slice-check-types.js +18 -0
  28. package/dist/src/services/workflow/pipeline-verify-service.d.ts +5 -2
  29. package/dist/src/services/workflow/pipeline-verify-service.js +35 -35
  30. package/dist/src/services/workspace/migrate-service.d.ts +2 -0
  31. package/dist/src/services/workspace/migrate-service.js +484 -0
  32. package/dist/src/services/workspace/migrate-types.d.ts +84 -0
  33. package/dist/src/services/workspace/migrate-types.js +21 -0
  34. package/dist/src/services/workspace/workspace-service.d.ts +11 -0
  35. package/dist/src/services/workspace/workspace-service.js +87 -7
  36. package/dist/src/shared/change-id.d.ts +59 -0
  37. package/dist/src/shared/change-id.js +194 -16
  38. package/dist/src/shared/version.d.ts +1 -1
  39. package/dist/src/shared/version.js +1 -1
  40. package/package.json +10 -2
  41. package/skills/peaks-solo/SKILL.md +11 -1
  42. 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
- const sessionId = prdArtifact.sessionId;
108
- const testCasesPath = join(options.projectRoot, '.peaks', sessionId, 'qa', 'test-cases', `${options.requestId}.md`);
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
- return join(projectRoot, '.peaks', sessionId, 'rd', 'project-scan.md');
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
- const scanPath = join(projectRoot, '.peaks', info.sessionId, 'rd', 'project-scan.md');
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
- /** Substring that identifies a Peaks-managed PreToolUse hook entry. */
20
- export declare const HOOK_SENTINEL = "peaks gate enforce";
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
- /** Substring that identifies a Peaks-managed PreToolUse hook entry. */
8
- export const HOOK_SENTINEL = 'peaks gate enforce';
9
- const HOOK_MATCHER = 'Bash';
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
- return handlers.length > 0 && handlers.every((h) => typeof h?.command === 'string' && h.command.includes(HOOK_SENTINEL));
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 { scope, settingsPath, exists, alreadyInstalled: isInstalled(settings), desiredCommand: HOOK_ENFORCE_COMMAND };
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 our PreToolUse entry into settings, preserving all other keys and hooks. */
106
- function withHookInstalled(settings) {
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
- const ourEntry = { matcher: HOOK_MATCHER, hooks: [{ type: 'command', command: HOOK_ENFORCE_COMMAND }] };
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: [...preToolUse, ourEntry] }
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, withHookInstalled(settings));
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,2 @@
1
+ import type { SliceCheckOptions, SliceCheckResult } from './slice-check-types.js';
2
+ export declare function sliceCheck(options: SliceCheckOptions): Promise<SliceCheckResult>;
@@ -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
- 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>;