peaks-cli 1.0.22 → 1.0.23

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/bin/peaks.js CHANGED
File without changes
@@ -1,5 +1,16 @@
1
1
  import { Command } from 'commander';
2
2
  import type { PeaksConfig } from '../../services/config/config-types.js';
3
+ import type { CapabilityMapSourceFilter } from '../../services/recommendations/recommendation-types.js';
3
4
  import { type ProgramIO } from '../cli-helpers.js';
5
+ type CapabilityMapOptions = {
6
+ json?: boolean;
7
+ source: string;
8
+ };
4
9
  export declare function registerCapabilityCommands(program: Command, io: ProgramIO): void;
10
+ export declare function runCapabilityStatus(io: ProgramIO, options: {
11
+ json?: boolean;
12
+ }): void;
13
+ export declare function runCapabilityMap(io: ProgramIO, options: CapabilityMapOptions): void;
5
14
  export declare function getInstalledCapabilityIds(_config: PeaksConfig): string[];
15
+ export declare function parseCapabilityMapSource(source: string): CapabilityMapSourceFilter | null;
16
+ export {};
@@ -7,17 +7,18 @@ import { addJsonOption, printResult } from '../cli-helpers.js';
7
7
  const CAPABILITY_SOURCE_FILTERS = new Set(['all', 'access-repo', 'mcp-server']);
8
8
  export function registerCapabilityCommands(program, io) {
9
9
  const capability = program.command('capability').description('Inspect Peaks capability catalog and runtime availability');
10
- addJsonOption(capability.command('status').description('Show seed capability availability')).action((options) => {
11
- const availability = resolveCapabilityAvailability(seedCapabilityItems);
12
- printResult(io, ok('capability.status', { sources: seedCapabilitySources, items: seedCapabilityItems, availability }), options.json);
13
- });
10
+ addJsonOption(capability.command("status").description("Show seed capability availability")).action((options) => runCapabilityStatus(io, options));
14
11
  addCapabilityMapOptions(capability.command('map').description('Show dry-run external capability landing map')).action((options) => runCapabilityMap(io, options));
15
12
  addCapabilityMapOptions(program.command('capabilities').description('Show dry-run external capability landing map')).action((options) => runCapabilityMap(io, options));
16
13
  }
14
+ export function runCapabilityStatus(io, options) {
15
+ const availability = resolveCapabilityAvailability(seedCapabilityItems);
16
+ printResult(io, ok("capability.status", { sources: seedCapabilitySources, items: seedCapabilityItems, availability }), options.json);
17
+ }
17
18
  function addCapabilityMapOptions(command) {
18
19
  return addJsonOption(command.option('--source <source>', 'Filter source group: all, access-repo, or mcp-server', 'all'));
19
20
  }
20
- function runCapabilityMap(io, options) {
21
+ export function runCapabilityMap(io, options) {
21
22
  const source = parseCapabilityMapSource(options.source);
22
23
  if (!source) {
23
24
  printResult(io, fail('capabilities.map', 'UNSUPPORTED_CAPABILITY_SOURCE', 'Supported capability sources are all, access-repo, and mcp-server', { source: options.source }, ['Rerun with --source all, --source access-repo, or --source mcp-server']), options.json);
@@ -35,7 +36,7 @@ function runCapabilityMap(io, options) {
35
36
  export function getInstalledCapabilityIds(_config) {
36
37
  return [];
37
38
  }
38
- function parseCapabilityMapSource(source) {
39
+ export function parseCapabilityMapSource(source) {
39
40
  if (CAPABILITY_SOURCE_FILTERS.has(source)) {
40
41
  return source;
41
42
  }
@@ -10,6 +10,7 @@ import { validateChangeIdOrThrow } from '../../shared/change-id.js';
10
10
  import { getEconomyAwareExecutionModelId } from '../../services/config/model-routing.js';
11
11
  import { getLocalArtifactPath } from '../../services/artifacts/workspace-service.js';
12
12
  import { getSessionId } from '../../services/session/session-manager.js';
13
+ import { verifyPipeline } from '../../services/workflow/pipeline-verify-service.js';
13
14
  import { fail, ok } from '../../shared/result.js';
14
15
  import { addJsonOption, failUnsupportedNonDryRun, getErrorMessage, isRecommendationWorkflow, printResult } from '../cli-helpers.js';
15
16
  function getCurrentWorkspaceContext() {
@@ -303,6 +304,31 @@ export function registerWorkflowCommands(program, io) {
303
304
  addAutonomousResumeInitOptions(autonomousResume.command('init')).action((options) => runAutonomousResumeInit(io, options));
304
305
  const autonomousResumeAlias = program.command('autonomous-resume').description('Manage autonomous workflow resume artifacts');
305
306
  addAutonomousResumeInitOptions(autonomousResumeAlias.command('init')).action((options) => runAutonomousResumeInit(io, options));
307
+ addJsonOption(workflow
308
+ .command('verify-pipeline')
309
+ .description('Verify the complete rd→qa pipeline was followed for a request')
310
+ .requiredOption('--rid <rid>', 'request identifier')
311
+ .requiredOption('--project <path>', 'project root path')
312
+ .requiredOption('--session-id <id>', 'session identifier')
313
+ .option('--type <type>', 'request type: feature, bugfix, refactor, docs, config, chore', 'feature')).action(async (options) => {
314
+ try {
315
+ const result = await verifyPipeline({
316
+ projectRoot: options.project,
317
+ rid: options.rid,
318
+ sessionId: options.sessionId,
319
+ ...(options.type ? { requestType: options.type } : {})
320
+ });
321
+ const exitOk = result.complete ? 0 : 1;
322
+ printResult(io, result.complete
323
+ ? ok('workflow.verify-pipeline', result)
324
+ : fail('workflow.verify-pipeline', 'PIPELINE_INCOMPLETE', `${result.violations.length} violation(s): ${result.violations.join('; ')}`, result, result.nextActions), options.json);
325
+ process.exitCode = exitOk;
326
+ }
327
+ catch (error) {
328
+ printResult(io, fail('workflow.verify-pipeline', 'VERIFY_FAILED', getErrorMessage(error), {}, ['Check that --project, --rid, and --session-id are correct']), options.json);
329
+ process.exitCode = 1;
330
+ }
331
+ });
306
332
  const swarm = program.command('swarm').description('Plan RD swarm dry-run graphs');
307
333
  addSwarmPlanOptions(swarm.command('plan'), true).action((options) => runSwarmPlan(io, options));
308
334
  addSwarmPlanOptions(program.command('swarm-plan'), false).action((options) => runSwarmPlan(io, options));
@@ -20,7 +20,7 @@ export type SyncResult = {
20
20
  error?: string;
21
21
  };
22
22
  export declare function getLocalArtifactPath(workspace: WorkspaceConfig): string;
23
- export declare function isArtifactWorkspaceOutsideTarget(workspace: WorkspaceConfig, artifactWorkspacePath?: string): boolean;
23
+ export declare function isArtifactWorkspaceOutsideTarget(_workspace: WorkspaceConfig, _artifactWorkspacePath?: string): boolean;
24
24
  export declare function hasValidArtifactWorkspace(workspace: WorkspaceConfig, artifactWorkspacePath?: string): boolean;
25
25
  export declare function getArtifactRemoteRepo(workspace: WorkspaceConfig): WorkspaceConfig['artifactRepo'] | null;
26
26
  export declare function executeArtifactSync(workspaceId?: string): Promise<SyncResult>;
@@ -17,10 +17,8 @@ export function getLocalArtifactPath(workspace) {
17
17
  }
18
18
  return resolve(workspace.rootPath, '.peaks', 'artifacts');
19
19
  }
20
- export function isArtifactWorkspaceOutsideTarget(workspace, artifactWorkspacePath = getLocalArtifactPath(workspace)) {
21
- const targetRoot = canonicalPath(workspace.rootPath);
22
- const artifactRoot = canonicalPath(artifactWorkspacePath);
23
- return !isInsidePath(artifactRoot, targetRoot);
20
+ export function isArtifactWorkspaceOutsideTarget(_workspace, _artifactWorkspacePath) {
21
+ return true;
24
22
  }
25
23
  export function hasValidArtifactWorkspace(workspace, artifactWorkspacePath = getLocalArtifactPath(workspace)) {
26
24
  if (!isArtifactWorkspaceOutsideTarget(workspace, artifactWorkspacePath))
@@ -29,7 +27,6 @@ export function hasValidArtifactWorkspace(workspace, artifactWorkspacePath = get
29
27
  const peaksRoot = canonicalChildPath(artifactWorkspacePath, '.peaks');
30
28
  const changesRoot = canonicalChildPath(artifactWorkspacePath, '.peaks', 'changes');
31
29
  const configPath = canonicalChildPath(artifactWorkspacePath, '.peaks', 'config.json');
32
- const targetRoot = canonicalPath(workspace.rootPath);
33
30
  if (!existsSync(resolve(artifactWorkspacePath, '.peaks', 'config.json')))
34
31
  return false;
35
32
  if (!isInsidePath(peaksRoot, artifactRoot))
@@ -38,12 +35,6 @@ export function hasValidArtifactWorkspace(workspace, artifactWorkspacePath = get
38
35
  return false;
39
36
  if (!isInsidePath(configPath, artifactRoot))
40
37
  return false;
41
- if (isInsidePath(peaksRoot, targetRoot))
42
- return false;
43
- if (isInsidePath(changesRoot, targetRoot))
44
- return false;
45
- if (isInsidePath(configPath, targetRoot))
46
- return false;
47
38
  return true;
48
39
  }
49
40
  export function getArtifactRemoteRepo(workspace) {
@@ -6,7 +6,7 @@ export declare function getProjectConfigPath(projectRoot: string | null): string
6
6
  export declare function getProjectBootstrapConfigPath(projectRoot: string): string;
7
7
  export declare function validateProjectBootstrapConfigPathForWrite(projectRoot: string, configPath: string): void;
8
8
  export declare function validateUserConfigPathForWrite(configPath: string): void;
9
- export declare function validateArtifactWorkspaceRoot(artifactRoot: string, workspaceRoot: string): void;
9
+ export declare function validateArtifactWorkspaceRoot(artifactRoot: string, _workspaceRoot: string): void;
10
10
  export declare function validateArtifactWorkspaceMarkerPath(artifactRoot: string, peaksPath: string, markerPath: string): void;
11
11
  export declare function readConfigFileSafely(configPath: string, errorMessage: string): string;
12
12
  export declare function writeConfigFileSafely(configPath: string, content: string, validateBeforeWrite: () => void, errorMessage: string): void;
@@ -54,6 +54,9 @@ export function findProjectRoot(startPath) {
54
54
  if (existsSync(resolve(current, '.peaks', 'config.json')) && isSafeProjectConfigMarker(current)) {
55
55
  return current;
56
56
  }
57
+ if (existsSync(resolve(current, 'package.json')) || existsSync(resolve(current, '.git'))) {
58
+ return current;
59
+ }
57
60
  parent = current;
58
61
  current = dirname(parent);
59
62
  }
@@ -154,16 +157,11 @@ export function validateUserConfigPathForWrite(configPath) {
154
157
  }
155
158
  }
156
159
  }
157
- export function validateArtifactWorkspaceRoot(artifactRoot, workspaceRoot) {
160
+ export function validateArtifactWorkspaceRoot(artifactRoot, _workspaceRoot) {
158
161
  const artifactStats = lstatSync(artifactRoot);
159
162
  if (!artifactStats.isDirectory() || artifactStats.isSymbolicLink()) {
160
163
  throw new Error('Artifact workspace marker must stay inside the artifact workspace');
161
164
  }
162
- const artifactRootReal = realpathSync(artifactRoot);
163
- const workspaceRootReal = realpathSync(workspaceRoot);
164
- if (isInsidePath(artifactRootReal, workspaceRootReal)) {
165
- throw new Error('Artifact workspace must stay outside the project root');
166
- }
167
165
  }
168
166
  export function validateArtifactWorkspaceMarkerPath(artifactRoot, peaksPath, markerPath) {
169
167
  const artifactStats = lstatSync(artifactRoot);
@@ -23,7 +23,7 @@ export declare function readConfig(projectRoot?: string | null): PeaksConfig;
23
23
  export declare function writeConfig(partial: Partial<PeaksConfig>, layer?: ConfigLayer): void;
24
24
  export declare function getConfig(options?: ConfigGetOptions): unknown;
25
25
  export declare function setConfig(options: ConfigSetOptions): void;
26
- export declare function getWorkspaceConfig(workspaceId: string, projectRoot?: string | null): WorkspaceConfig | null;
26
+ export declare function getWorkspaceConfig(workspaceId: string, _projectRoot?: string | null): WorkspaceConfig | null;
27
27
  export declare function addWorkspace(workspace: WorkspaceConfig, layer?: ConfigLayer): void;
28
28
  export declare function removeWorkspace(workspaceId: string, layer?: ConfigLayer): boolean;
29
29
  export declare function setCurrentWorkspace(workspaceId: string, layer?: ConfigLayer): boolean;
@@ -530,8 +530,21 @@ function writeRawWorkspaceData(data, layer) {
530
530
  writeUserConfigFile(targetPath, content);
531
531
  }
532
532
  }
533
- export function getWorkspaceConfig(workspaceId, projectRoot) {
534
- const { workspaces } = readRawWorkspaceData('user');
533
+ function readAllWorkspaces() {
534
+ const userData = readRawWorkspaceData('user');
535
+ const projectData = readRawWorkspaceData('project');
536
+ const mergedWorkspaces = new Map();
537
+ for (const w of userData.workspaces)
538
+ mergedWorkspaces.set(w.workspaceId, w);
539
+ for (const w of projectData.workspaces)
540
+ mergedWorkspaces.set(w.workspaceId, w);
541
+ return {
542
+ currentWorkspace: projectData.currentWorkspace ?? userData.currentWorkspace,
543
+ workspaces: [...mergedWorkspaces.values()]
544
+ };
545
+ }
546
+ export function getWorkspaceConfig(workspaceId, _projectRoot) {
547
+ const { workspaces } = readAllWorkspaces();
535
548
  return workspaces.find((w) => w.workspaceId === workspaceId) ?? null;
536
549
  }
537
550
  function readLayerConfig(layer) {
@@ -574,13 +587,13 @@ export function setCurrentWorkspace(workspaceId, layer = 'user') {
574
587
  return true;
575
588
  }
576
589
  export function getCurrentWorkspaceConfig() {
577
- const { currentWorkspace, workspaces } = readRawWorkspaceData('user');
590
+ const { currentWorkspace, workspaces } = readAllWorkspaces();
578
591
  if (!currentWorkspace)
579
592
  return null;
580
593
  return workspaces.find((w) => w.workspaceId === currentWorkspace) ?? null;
581
594
  }
582
595
  export function getWorkspaceConfigForPath(path = process.cwd()) {
583
- const { workspaces } = readRawWorkspaceData('user');
596
+ const { workspaces } = readAllWorkspaces();
584
597
  return findWorkspaceForPath(workspaces, path);
585
598
  }
586
599
  function findWorkspaceForPath(workspaces, path) {
@@ -9,16 +9,13 @@ function extractAcceptanceItems(prdBody) {
9
9
  return [];
10
10
  }
11
11
  // Find the line where the header starts.
12
- let headerLine = -1;
12
+ let headerLine = 0;
13
13
  for (let i = 0; i < lines.length; i += 1) {
14
14
  if (ACCEPTANCE_SECTION_PATTERN.test((lines[i] ?? '') + '\n')) {
15
15
  headerLine = i;
16
16
  break;
17
17
  }
18
18
  }
19
- if (headerLine === -1) {
20
- return [];
21
- }
22
19
  const items = [];
23
20
  let counter = 0;
24
21
  for (let i = headerLine + 1; i < lines.length; i += 1) {
@@ -101,13 +101,7 @@ async function countSrcFiles(projectRoot, max = 500) {
101
101
  const current = queue.shift();
102
102
  if (current === undefined)
103
103
  break;
104
- let entries;
105
- try {
106
- entries = await readdir(current, { withFileTypes: true });
107
- }
108
- catch {
109
- continue;
110
- }
104
+ const entries = await readdir(current, { withFileTypes: true });
111
105
  for (const entry of entries) {
112
106
  if (entry.name.startsWith('.') || entry.name === 'node_modules')
113
107
  continue;
@@ -129,14 +123,9 @@ async function lockfileAgeDays(projectRoot) {
129
123
  for (const candidate of candidates) {
130
124
  const full = join(projectRoot, candidate);
131
125
  if (await pathExists(full)) {
132
- try {
133
- const stats = await stat(full);
134
- const ageMs = Date.now() - stats.mtimeMs;
135
- return Math.floor(ageMs / (1000 * 60 * 60 * 24));
136
- }
137
- catch {
138
- return null;
139
- }
126
+ const stats = await stat(full);
127
+ const ageMs = Date.now() - stats.mtimeMs;
128
+ return Math.floor(ageMs / (1000 * 60 * 60 * 24));
140
129
  }
141
130
  }
142
131
  return null;
@@ -57,9 +57,9 @@ export function globToRegex(pattern) {
57
57
  }
58
58
  // If the pattern ends with no trailing slash and no extension wildcard, also allow it to match files under the path (treat as dir prefix)
59
59
  // E.g. `src/services/login` should match `src/services/login/handler.ts`.
60
- if (!trimmed.includes('*') && !trimmed.includes('?') && !trimmed.includes('.')) {
61
- body = `${body}(?:/.*)?`;
62
- }
60
+ body = (!trimmed.includes("*") && !trimmed.includes("?") && !trimmed.includes("."))
61
+ ? `${body}(?:/.*)?`
62
+ : body;
63
63
  return new RegExp(`^${body}$`);
64
64
  }
65
65
  function classifyPatternLine(raw) {
@@ -15,13 +15,8 @@ function getChangedFiles(projectRoot, baseRef) {
15
15
  }
16
16
  }
17
17
  function countLines(filePath) {
18
- try {
19
- const content = readFileSync(filePath, 'utf8');
20
- return content.split(/\r?\n/).length;
21
- }
22
- catch {
23
- return 0;
24
- }
18
+ const content = readFileSync(filePath, 'utf8');
19
+ return content.split(/\r?\n/).length;
25
20
  }
26
21
  export function scanFileSize(options) {
27
22
  const baseRef = options.baseRef ?? 'HEAD';
@@ -78,9 +78,7 @@ function isConsistent(declared, suggested) {
78
78
  return suggested.includes(declared);
79
79
  }
80
80
  function buildRationale(declared, breakdown, suggested, consistent) {
81
- const summary = breakdown.length === 0
82
- ? 'no changed files detected'
83
- : breakdown.map((entry) => `${entry.category}=${entry.count}`).join(', ');
81
+ const summary = breakdown.map((entry) => `${entry.category}=${entry.count}`).join(', ');
84
82
  if (consistent) {
85
83
  return `declared --type=${declared} is consistent with the changed files (${summary})`;
86
84
  }
@@ -0,0 +1,31 @@
1
+ import type { RequestType } from '../artifacts/artifact-prerequisites.js';
2
+ export type PipelineGate = {
3
+ name: string;
4
+ description: string;
5
+ passed: boolean;
6
+ detail: string;
7
+ };
8
+ export type PipelineVerification = {
9
+ rid: string;
10
+ sessionId: string;
11
+ requestType: RequestType;
12
+ complete: boolean;
13
+ rdPhase: {
14
+ invoked: boolean;
15
+ state: string;
16
+ gates: PipelineGate[];
17
+ };
18
+ qaPhase: {
19
+ invoked: boolean;
20
+ state: string;
21
+ gates: PipelineGate[];
22
+ };
23
+ violations: string[];
24
+ nextActions: string[];
25
+ };
26
+ export declare function verifyPipeline(options: {
27
+ projectRoot: string;
28
+ rid: string;
29
+ sessionId: string;
30
+ requestType?: string;
31
+ }): Promise<PipelineVerification>;
@@ -0,0 +1,180 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { readFile } from 'node:fs/promises';
4
+ 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
+ }
13
+ function extractState(markdown) {
14
+ for (const rawLine of markdown.split(/\r?\n/)) {
15
+ const match = /^-\s*state:\s*(.+?)\s*$/.exec(rawLine.trim());
16
+ if (match?.[1])
17
+ return match[1];
18
+ }
19
+ return 'unknown';
20
+ }
21
+ async function findRequestFile(projectRoot, sessionId, role, rid) {
22
+ const dir = join(projectRoot, '.peaks', sessionId, role, 'requests');
23
+ if (!existsSync(dir))
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;
38
+ }
39
+ function rdGatesForType(requestType) {
40
+ const gates = [
41
+ { name: 'rd-request-exists', description: 'RD request artifact created', passed: false, detail: '' }
42
+ ];
43
+ if (requestType === 'feature' || requestType === 'refactor') {
44
+ gates.push({ name: 'tech-doc', description: 'Technical design doc', passed: false, detail: '' });
45
+ }
46
+ if (requestType === 'bugfix') {
47
+ gates.push({ name: 'bug-analysis', description: 'Bug root-cause analysis', passed: false, detail: '' });
48
+ }
49
+ if (requestType !== 'docs' && requestType !== 'chore' && requestType !== 'config') {
50
+ gates.push({ name: 'code-review', description: 'Code review evidence', passed: false, detail: '' });
51
+ }
52
+ if (requestType === 'feature' || requestType === 'refactor' || requestType === 'bugfix' || requestType === 'config') {
53
+ gates.push({ name: 'security-review', description: 'Security review evidence', passed: false, detail: '' });
54
+ }
55
+ return gates;
56
+ }
57
+ function qaGatesForType(requestType) {
58
+ const gates = [
59
+ { name: 'qa-request-exists', description: 'QA request artifact created', passed: false, detail: '' }
60
+ ];
61
+ if (requestType === 'feature' || requestType === 'refactor' || requestType === 'bugfix') {
62
+ gates.push({ name: 'test-cases', description: 'QA test cases', passed: false, detail: '' });
63
+ gates.push({ name: 'test-report', description: 'QA test report with execution results', passed: false, detail: '' });
64
+ }
65
+ if (requestType === 'feature' || requestType === 'refactor' || requestType === 'bugfix' || requestType === 'config') {
66
+ gates.push({ name: 'security-findings', description: 'QA security findings', passed: false, detail: '' });
67
+ }
68
+ if (requestType === 'feature' || requestType === 'refactor') {
69
+ gates.push({ name: 'performance-findings', description: 'QA performance findings', passed: false, detail: '' });
70
+ }
71
+ return gates;
72
+ }
73
+ const RD_QA_HANDOFF_STATES = new Set(['qa-handoff', 'handed-off', 'implemented']);
74
+ const QA_COMPLETE_STATES = new Set(['verdict-issued']);
75
+ export async function verifyPipeline(options) {
76
+ const requestType = isRequestType(options.requestType ?? '') ? options.requestType : 'feature';
77
+ const violations = [];
78
+ const nextActions = [];
79
+ const rdGates = rdGatesForType(requestType);
80
+ const qaGates = qaGatesForType(requestType);
81
+ // Check RD phase
82
+ const rdFile = await findRequestFile(options.projectRoot, options.sessionId, 'rd', options.rid);
83
+ let rdInvoked = false;
84
+ let rdState = 'missing';
85
+ if (rdFile) {
86
+ rdInvoked = true;
87
+ rdState = extractState(rdFile.content);
88
+ rdGates[0].passed = true;
89
+ rdGates[0].detail = `found at ${rdFile.path}`;
90
+ }
91
+ else {
92
+ violations.push('RD phase skipped: peaks-rd was never invoked for this request (no RD request artifact found)');
93
+ nextActions.push('Invoke Skill(skill="peaks-rd") with the request-id, then run unit tests + code review + security review');
94
+ rdGates[0].detail = 'not found';
95
+ }
96
+ // Check RD evidence files
97
+ const RD_EVIDENCE_FILE = {
98
+ 'tech-doc': 'tech-doc.md',
99
+ 'bug-analysis': 'bug-analysis.md',
100
+ 'code-review': 'code-review.md',
101
+ 'security-review': 'security-review.md'
102
+ };
103
+ for (const gate of rdGates.slice(1)) {
104
+ const fileName = RD_EVIDENCE_FILE[gate.name];
105
+ const evidencePath = join(options.projectRoot, '.peaks', options.sessionId, 'rd', fileName);
106
+ if (existsSync(evidencePath)) {
107
+ gate.passed = true;
108
+ gate.detail = evidencePath;
109
+ }
110
+ else {
111
+ gate.detail = `missing: ${evidencePath}`;
112
+ violations.push(`RD evidence missing: ${gate.description} (${fileName})`);
113
+ nextActions.push(`Create .peaks/${options.sessionId}/rd/${fileName}`);
114
+ }
115
+ }
116
+ // Check if RD reached qa-handoff
117
+ if (rdInvoked && !RD_QA_HANDOFF_STATES.has(rdState)) {
118
+ violations.push(`RD not ready for QA: state is "${rdState}" — must reach "qa-handoff" (unit tests, karpathy standards, code review, security review complete)`);
119
+ nextActions.push(`Complete RD gates → peaks request transition ${options.rid} --role rd --state qa-handoff`);
120
+ }
121
+ // Check QA phase
122
+ const qaFile = await findRequestFile(options.projectRoot, options.sessionId, 'qa', options.rid);
123
+ let qaInvoked = false;
124
+ let qaState = 'missing';
125
+ if (qaFile) {
126
+ qaInvoked = true;
127
+ qaState = extractState(qaFile.content);
128
+ qaGates[0].passed = true;
129
+ qaGates[0].detail = `found at ${qaFile.path}`;
130
+ }
131
+ else {
132
+ violations.push('QA phase skipped: peaks-qa was never invoked for this request (no QA request artifact found)');
133
+ nextActions.push('Invoke Skill(skill="peaks-qa") with the request-id for functional/performance/security testing');
134
+ qaGates[0].detail = 'not found';
135
+ }
136
+ // Check QA evidence files
137
+ const QA_EVIDENCE_FILE = {
138
+ 'test-cases': `test-cases/${options.rid}.md`,
139
+ 'test-report': `test-reports/${options.rid}.md`,
140
+ 'security-findings': 'security-findings.md',
141
+ 'performance-findings': 'performance-findings.md'
142
+ };
143
+ for (const gate of qaGates.slice(1)) {
144
+ const fileName = QA_EVIDENCE_FILE[gate.name];
145
+ const evidencePath = join(options.projectRoot, '.peaks', options.sessionId, 'qa', fileName);
146
+ if (existsSync(evidencePath)) {
147
+ gate.passed = true;
148
+ gate.detail = evidencePath;
149
+ }
150
+ else {
151
+ gate.detail = `missing: ${evidencePath}`;
152
+ violations.push(`QA evidence missing: ${gate.description} (${fileName})`);
153
+ nextActions.push(`Create .peaks/${options.sessionId}/qa/${fileName}`);
154
+ }
155
+ }
156
+ // Check if QA reached verdict-issued
157
+ if (qaInvoked && !QA_COMPLETE_STATES.has(qaState)) {
158
+ violations.push(`QA not complete: state is "${qaState}" — must reach "verdict-issued" (functional + performance + security checks done)`);
159
+ nextActions.push(`Complete QA gates → peaks request transition ${options.rid} --role qa --state verdict-issued`);
160
+ }
161
+ // RD invoked without QA
162
+ if (rdInvoked && !qaInvoked) {
163
+ violations.push('CRITICAL: peaks-rd was invoked but peaks-qa was NOT — QA functional/performance/security testing is mandatory after all RD work');
164
+ nextActions.push('MUST invoke Skill(skill="peaks-qa") before declaring workflow complete');
165
+ }
166
+ const allRdGatesPassed = rdGates.every((g) => g.passed);
167
+ const allQaGatesPassed = qaGates.every((g) => g.passed);
168
+ const complete = rdInvoked && qaInvoked && allRdGatesPassed && allQaGatesPassed
169
+ && RD_QA_HANDOFF_STATES.has(rdState) && QA_COMPLETE_STATES.has(qaState);
170
+ return {
171
+ rid: options.rid,
172
+ sessionId: options.sessionId,
173
+ requestType,
174
+ complete,
175
+ rdPhase: { invoked: rdInvoked, state: rdState, gates: rdGates },
176
+ qaPhase: { invoked: qaInvoked, state: qaState, gates: qaGates },
177
+ violations,
178
+ nextActions
179
+ };
180
+ }
@@ -85,9 +85,6 @@ export function buildArtifactRelativePath(changeId, ...segments) {
85
85
  const number = getNextNumber(dirPath);
86
86
  const filename = buildNumberedFilename(number, changeId);
87
87
  const candidatePath = `.peaks/${sessionId}/${role}/${filename}`;
88
- if (isUnsafeArtifactPath(candidatePath)) {
89
- throw new ChangeIdValidationError(changeId);
90
- }
91
88
  return normalizeArtifactPath(candidatePath);
92
89
  }
93
90
  // Fallback: no session or no segments - use legacy behavior
@@ -1 +1 @@
1
- export declare const CLI_VERSION = "1.0.22";
1
+ export declare const CLI_VERSION = "1.0.23";
@@ -1 +1 @@
1
- export const CLI_VERSION = "1.0.22";
1
+ export const CLI_VERSION = "1.0.23";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-cli",
3
- "version": "1.0.22",
3
+ "version": "1.0.23",
4
4
  "description": "Peaks CLI and short skill family for Claude Code automation.",
5
5
  "author": "SquabbyZ",
6
6
  "license": "MIT",
@@ -35,6 +35,7 @@
35
35
  "dev:watch": "node ./scripts/watch.mjs",
36
36
  "test": "vitest run",
37
37
  "test:coverage": "vitest run --coverage",
38
+ "pretest:coverage": "pkill -f vitest 2>/dev/null; rm -rf coverage; exit 0",
38
39
  "typecheck": "tsc -p tsconfig.json --noEmit"
39
40
  },
40
41
  "engines": {
@@ -9,6 +9,39 @@ Peaks Solo is the orchestration facade for the Peaks short skill family.
9
9
 
10
10
  Use this skill to identify the user scenario, recommend an execution mode, coordinate role skills, and produce the final handoff report. Do not collapse role responsibilities into this skill.
11
11
 
12
+ ## Code-Change Red Line (BLOCKING — read before ANY tool call)
13
+
14
+ **Peaks Solo is an orchestrator, NOT an implementer. You MUST NOT write, edit, or modify any application source code directly.**
15
+
16
+ Every code change — bugfix, feature, refactor, or config — MUST go through the full pipeline:
17
+
18
+ ```
19
+ peaks-solo (orchestrate only)
20
+ → Skill(skill="peaks-rd") ← ALL code changes happen HERE
21
+ → Unit tests written + pass (Gate B2)
22
+ → Karpathy standards enforced (file-size ≤800 lines, TypeScript rules)
23
+ → Code review evidence (Gate B3)
24
+ → Security review evidence (Gate B4)
25
+ → Skill(skill="peaks-qa") ← ALL validation happens HERE
26
+ → Functional test execution (Gate A2)
27
+ → Performance check (Gate A4)
28
+ → Security test (Gate A3)
29
+ → Browser E2E (when frontend; Gate D)
30
+ → Verdict: pass | return-to-rd | blocked
31
+ ```
32
+
33
+ **Violations (BLOCKING — Solo must refuse to proceed):**
34
+
35
+ 1. Writing implementation code directly instead of calling `Skill(skill="peaks-rd")`
36
+ 2. Declaring work "done" without invoking `Skill(skill="peaks-qa")` after RD
37
+ 3. Skipping unit tests ("it's a small change")
38
+ 4. Skipping code review or security review
39
+ 5. Skipping QA functional/performance/security validation
40
+
41
+ **If you catch yourself about to write code in this skill, STOP. Call `Skill(skill="peaks-rd")` instead.**
42
+
43
+ **Before declaring workflow complete, run:** `peaks workflow verify-pipeline --rid <rid> --project <repo> --json`
44
+
12
45
  ## Startup sequence (MANDATORY — execute in order)
13
46
 
14
47
  ### Step 1: Mode selection (MUST run before presence:set)