peaks-cli 1.0.17 → 1.0.19

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 (39) hide show
  1. package/dist/src/cli/commands/request-commands.js +109 -3
  2. package/dist/src/cli/commands/scan-commands.d.ts +3 -0
  3. package/dist/src/cli/commands/scan-commands.js +194 -0
  4. package/dist/src/cli/commands/workspace-commands.d.ts +3 -0
  5. package/dist/src/cli/commands/workspace-commands.js +32 -0
  6. package/dist/src/cli/program.js +4 -0
  7. package/dist/src/services/artifacts/artifact-lint-service.d.ts +23 -0
  8. package/dist/src/services/artifacts/artifact-lint-service.js +80 -0
  9. package/dist/src/services/artifacts/artifact-prerequisites.d.ts +28 -0
  10. package/dist/src/services/artifacts/artifact-prerequisites.js +77 -0
  11. package/dist/src/services/artifacts/repair-cycle-service.d.ts +23 -0
  12. package/dist/src/services/artifacts/repair-cycle-service.js +52 -0
  13. package/dist/src/services/artifacts/request-artifact-service.d.ts +14 -0
  14. package/dist/src/services/artifacts/request-artifact-service.js +73 -21
  15. package/dist/src/services/scan/acceptance-coverage-service.d.ts +42 -0
  16. package/dist/src/services/scan/acceptance-coverage-service.js +135 -0
  17. package/dist/src/services/scan/archetype-service.d.ts +5 -0
  18. package/dist/src/services/scan/archetype-service.js +253 -0
  19. package/dist/src/services/scan/diff-scope-service.d.ts +40 -0
  20. package/dist/src/services/scan/diff-scope-service.js +198 -0
  21. package/dist/src/services/scan/existing-system-service.d.ts +7 -0
  22. package/dist/src/services/scan/existing-system-service.js +300 -0
  23. package/dist/src/services/scan/scan-types.d.ts +59 -0
  24. package/dist/src/services/scan/scan-types.js +1 -0
  25. package/dist/src/services/scan/type-sanity-service.d.ts +23 -0
  26. package/dist/src/services/scan/type-sanity-service.js +108 -0
  27. package/dist/src/services/standards/project-context.d.ts +24 -0
  28. package/dist/src/services/standards/project-context.js +318 -0
  29. package/dist/src/services/standards/project-standards-service.js +145 -40
  30. package/dist/src/services/workspace/workspace-service.d.ts +16 -0
  31. package/dist/src/services/workspace/workspace-service.js +66 -0
  32. package/dist/src/shared/version.d.ts +1 -1
  33. package/dist/src/shared/version.js +1 -1
  34. package/package.json +1 -1
  35. package/skills/peaks-qa/SKILL.md +56 -0
  36. package/skills/peaks-rd/SKILL.md +65 -2
  37. package/skills/peaks-solo/SKILL.md +307 -61
  38. package/skills/peaks-solo/references/existing-system-extraction.md +78 -0
  39. package/skills/peaks-ui/SKILL.md +9 -1
@@ -1,5 +1,7 @@
1
1
  import { InvalidArgumentError } from 'commander';
2
- import { allowedStatesForRole, createRequestArtifact, listRequestArtifacts, showRequestArtifact, transitionRequestArtifact } from '../../services/artifacts/request-artifact-service.js';
2
+ import { allowedStatesForRole, createRequestArtifact, listRequestArtifacts, showRequestArtifact, transitionRequestArtifact, PrerequisitesNotSatisfiedError, VALID_REQUEST_TYPES, isRequestType } from '../../services/artifacts/request-artifact-service.js';
3
+ import { lintRequestArtifact } from '../../services/artifacts/artifact-lint-service.js';
4
+ import { getRepairCycleStatus } from '../../services/artifacts/repair-cycle-service.js';
3
5
  import { fail, ok } from '../../shared/result.js';
4
6
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
5
7
  const VALID_ROLES = ['prd', 'ui', 'rd', 'qa', 'sc'];
@@ -16,6 +18,12 @@ function parseStateForRole(role, value) {
16
18
  }
17
19
  return value;
18
20
  }
21
+ function parseRequestType(value) {
22
+ if (!isRequestType(value)) {
23
+ throw new InvalidArgumentError(`must be one of ${VALID_REQUEST_TYPES.join(', ')}`);
24
+ }
25
+ return value;
26
+ }
19
27
  export function registerRequestCommands(program, io) {
20
28
  const request = program.command('request').description('Manage per-request Peaks role artifacts (PRD / UI / RD / QA)');
21
29
  addJsonOption(request
@@ -25,7 +33,8 @@ export function registerRequestCommands(program, io) {
25
33
  .requiredOption('--id <request-id>', 'request id, e.g. 2026-05-23-add-foo')
26
34
  .requiredOption('--project <path>', 'target project root')
27
35
  .option('--session-id <session>', 'override the default date-stamped session id')
28
- .option('--apply', 'write the artifact file (default: preview only)')).action(async (options) => {
36
+ .option('--apply', 'write the artifact file (default: preview only)')
37
+ .option('--type <type>', `request type (${VALID_REQUEST_TYPES.join(' | ')}); default: feature`, parseRequestType)).action(async (options) => {
29
38
  try {
30
39
  const serviceOptions = {
31
40
  role: options.role,
@@ -38,6 +47,9 @@ export function registerRequestCommands(program, io) {
38
47
  if (options.apply === true) {
39
48
  serviceOptions.apply = true;
40
49
  }
50
+ if (options.type !== undefined) {
51
+ serviceOptions.requestType = options.type;
52
+ }
41
53
  const result = await createRequestArtifact(serviceOptions);
42
54
  printResult(io, ok('request.init', result, [], result.applied ? [] : [`Re-run with --apply to write ${result.path}`]), options.json);
43
55
  }
@@ -105,10 +117,16 @@ export function registerRequestCommands(program, io) {
105
117
  .requiredOption('--state <state>', 'new state name; allowed values depend on role')
106
118
  .requiredOption('--project <path>', 'target project root')
107
119
  .option('--session-id <session>', 'restrict to a specific session id')
108
- .option('--reason <text>', 'optional reason appended as a transition note')).action(async (requestId, options) => {
120
+ .option('--reason <text>', 'reason appended as a transition note; required when --allow-incomplete is set')
121
+ .option('--allow-incomplete', 'bypass artifact prerequisite checks; requires --reason and records the bypass in the artifact')).action(async (requestId, options) => {
109
122
  try {
110
123
  const role = options.role;
111
124
  const newState = parseStateForRole(role, options.state);
125
+ if (options.allowIncomplete === true && (options.reason === undefined || options.reason.trim().length === 0)) {
126
+ printResult(io, fail('request.transition', 'BYPASS_REASON_REQUIRED', '--allow-incomplete requires --reason explaining why prerequisites are skipped', { role, requestId }, ['Add --reason "<short justification>" or remove --allow-incomplete and produce the missing artifacts']), options.json);
127
+ process.exitCode = 1;
128
+ return;
129
+ }
112
130
  const transitionOptions = {
113
131
  role,
114
132
  requestId,
@@ -121,6 +139,9 @@ export function registerRequestCommands(program, io) {
121
139
  if (options.reason !== undefined) {
122
140
  transitionOptions.reason = options.reason;
123
141
  }
142
+ if (options.allowIncomplete === true) {
143
+ transitionOptions.allowIncomplete = true;
144
+ }
124
145
  const result = await transitionRequestArtifact(transitionOptions);
125
146
  if (result === null) {
126
147
  printResult(io, fail('request.transition', 'REQUEST_NOT_FOUND', `No artifact found for role=${role} requestId=${requestId}`, { role, requestId }, ['Verify the request id, role, and session id']), options.json);
@@ -133,8 +154,93 @@ export function registerRequestCommands(program, io) {
133
154
  if (error instanceof InvalidArgumentError) {
134
155
  throw error;
135
156
  }
157
+ if (error instanceof PrerequisitesNotSatisfiedError) {
158
+ printResult(io, fail('request.transition', error.code, error.message, { role: error.role, newState: error.newState, sessionId: error.sessionId, missing: error.missing }, [
159
+ ...error.missing.map((entry) => `Produce ${entry.path}: ${entry.description}`),
160
+ 'Once every required artifact exists, rerun this transition.',
161
+ 'For exceptional cases (docs-only / config-only change), bypass with: --allow-incomplete --reason "<justification>"'
162
+ ]), options.json);
163
+ process.exitCode = 1;
164
+ return;
165
+ }
136
166
  printResult(io, fail('request.transition', 'REQUEST_TRANSITION_FAILED', getErrorMessage(error), { role: options.role, requestId }, ['Check role, request id, state, and project path before retrying']), options.json);
137
167
  process.exitCode = 1;
138
168
  }
139
169
  });
170
+ addJsonOption(request
171
+ .command('lint')
172
+ .description('Scan a request artifact body for unfilled placeholders (<...>, TBD, bare bullets) before declaring it complete')
173
+ .argument('<request-id>', 'request id')
174
+ .requiredOption('--role <role>', `target role (${VALID_ROLES.join(' | ')})`, parseRole)
175
+ .requiredOption('--project <path>', 'target project root')
176
+ .option('--session-id <session>', 'restrict to a specific session id')).action(async (requestId, options) => {
177
+ try {
178
+ const lintOptions = {
179
+ projectRoot: options.project,
180
+ role: options.role,
181
+ requestId
182
+ };
183
+ if (options.sessionId !== undefined) {
184
+ lintOptions.sessionId = options.sessionId;
185
+ }
186
+ const report = await lintRequestArtifact(lintOptions);
187
+ if (report === null) {
188
+ printResult(io, fail('request.lint', 'REQUEST_NOT_FOUND', `No artifact found for role=${options.role} requestId=${requestId}`, { role: options.role, requestId }, ['Verify the request id, role, and session id']), options.json);
189
+ process.exitCode = 1;
190
+ return;
191
+ }
192
+ const nextActions = [];
193
+ if (!report.ok) {
194
+ nextActions.push(`Fix ${report.findings.filter((f) => f.severity === 'error').length} error finding(s) before transitioning this artifact.`);
195
+ }
196
+ printResult(io, ok('request.lint', report, [], nextActions), options.json);
197
+ if (!report.ok) {
198
+ process.exitCode = 1;
199
+ }
200
+ }
201
+ catch (error) {
202
+ printResult(io, fail('request.lint', 'REQUEST_LINT_FAILED', getErrorMessage(error), { role: options.role, requestId }, ['Verify the artifact path before retrying']), options.json);
203
+ process.exitCode = 1;
204
+ }
205
+ });
206
+ addJsonOption(request
207
+ .command('repair-status')
208
+ .description('Count RD↔QA repair cycles for a request from its RD artifact transition notes; reports cycle count and whether the 3-cycle cap is reached')
209
+ .argument('<request-id>', 'request id')
210
+ .requiredOption('--project <path>', 'target project root')
211
+ .option('--session-id <session>', 'restrict to a specific session id')
212
+ .option('--max-cycles <n>', 'override the default max cycle cap (default 3)')).action(async (requestId, options) => {
213
+ try {
214
+ const max = options.maxCycles !== undefined && /^\d+$/.test(options.maxCycles) ? Number(options.maxCycles) : 3;
215
+ const statusOptions = {
216
+ projectRoot: options.project,
217
+ requestId,
218
+ maxCycles: max
219
+ };
220
+ if (options.sessionId !== undefined) {
221
+ statusOptions.sessionId = options.sessionId;
222
+ }
223
+ const report = await getRepairCycleStatus(statusOptions);
224
+ if (report === null) {
225
+ printResult(io, fail('request.repair-status', 'REQUEST_NOT_FOUND', `No RD artifact found for requestId=${requestId}`, { requestId }, ['Verify the request id and session id']), options.json);
226
+ process.exitCode = 1;
227
+ return;
228
+ }
229
+ const nextActions = [];
230
+ if (report.atCap) {
231
+ nextActions.push(`Repair cap reached (${report.cycleCount}/${report.maxCycles}). Emit a blocked TXT handoff and stop the loop.`);
232
+ }
233
+ else if (report.cycleCount > 0) {
234
+ nextActions.push(`${report.remaining} repair cycle(s) remaining before block.`);
235
+ }
236
+ printResult(io, ok('request.repair-status', report, [], nextActions), options.json);
237
+ if (report.atCap) {
238
+ process.exitCode = 1;
239
+ }
240
+ }
241
+ catch (error) {
242
+ printResult(io, fail('request.repair-status', 'REQUEST_REPAIR_STATUS_FAILED', getErrorMessage(error), { requestId }, ['Verify the artifact path before retrying']), options.json);
243
+ process.exitCode = 1;
244
+ }
245
+ });
140
246
  }
@@ -0,0 +1,3 @@
1
+ import { Command } from 'commander';
2
+ import { type ProgramIO } from '../cli-helpers.js';
3
+ export declare function registerScanCommands(program: Command, io: ProgramIO): void;
@@ -0,0 +1,194 @@
1
+ import { InvalidArgumentError } from 'commander';
2
+ import { scanArchetype } from '../../services/scan/archetype-service.js';
3
+ import { scanExistingSystem } from '../../services/scan/existing-system-service.js';
4
+ import { checkTypeSanity } from '../../services/scan/type-sanity-service.js';
5
+ import { getAcceptanceCoverage, isAcceptanceCoverageError } from '../../services/scan/acceptance-coverage-service.js';
6
+ import { getDiffVsScope, isDiffScopeError } from '../../services/scan/diff-scope-service.js';
7
+ import { isRequestType, VALID_REQUEST_TYPES } from '../../services/artifacts/artifact-prerequisites.js';
8
+ import { fail, ok } from '../../shared/result.js';
9
+ import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
10
+ function parsePositiveInt(value, fallback) {
11
+ if (value === undefined)
12
+ return fallback;
13
+ if (!/^\d+$/.test(value))
14
+ return fallback;
15
+ const parsed = Number(value);
16
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
17
+ }
18
+ function parseRequestType(value) {
19
+ if (!isRequestType(value)) {
20
+ throw new InvalidArgumentError(`must be one of ${VALID_REQUEST_TYPES.join(', ')}`);
21
+ }
22
+ return value;
23
+ }
24
+ export function registerScanCommands(program, io) {
25
+ const scan = program.command('scan').description('Deterministic project scans (archetype, existing system) for Peaks workflows');
26
+ addJsonOption(scan
27
+ .command('archetype')
28
+ .description('Detect project archetype, frontend-only mode, and supporting signals from the filesystem (read-only)')
29
+ .requiredOption('--project <path>', 'target project root')).action(async (options) => {
30
+ try {
31
+ const report = await scanArchetype({ projectRoot: options.project });
32
+ const nextActions = [];
33
+ if (report.archetype === 'unknown') {
34
+ nextActions.push('Archetype could not be determined; surface to user before proceeding.');
35
+ }
36
+ else if (report.archetype === 'legacy-frontend' || report.archetype === 'legacy-fullstack' || report.archetype === 'frontend-monorepo') {
37
+ nextActions.push('Run `peaks scan existing-system --project <path>` to extract visual tokens and conventions.');
38
+ }
39
+ printResult(io, ok('scan.archetype', report, [], nextActions), options.json);
40
+ }
41
+ catch (error) {
42
+ printResult(io, fail('scan.archetype', 'SCAN_ARCHETYPE_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Verify the project path exists and is readable']), options.json);
43
+ process.exitCode = 1;
44
+ }
45
+ });
46
+ addJsonOption(scan
47
+ .command('existing-system')
48
+ .description('Extract visual tokens (colors/spacing/typography/radii) and code conventions from a legacy project (read-only)')
49
+ .requiredOption('--project <path>', 'target project root')
50
+ .option('--max-tokens <n>', 'maximum tokens to return per category (default 40)')
51
+ .option('--max-samples <n>', 'maximum convention samples per kind (default 5)')).action(async (options) => {
52
+ try {
53
+ const report = await scanExistingSystem({
54
+ projectRoot: options.project,
55
+ maxTokens: parsePositiveInt(options.maxTokens, 40),
56
+ maxSamplesPerKind: parsePositiveInt(options.maxSamples, 5)
57
+ });
58
+ const nextActions = [];
59
+ if (!report.scanned) {
60
+ nextActions.push(report.scanSkippedReason ?? 'Extraction skipped.');
61
+ }
62
+ else if (report.inconsistencies.length > 0) {
63
+ nextActions.push('Surface inconsistencies in the TXT handoff before proceeding.');
64
+ }
65
+ printResult(io, ok('scan.existing-system', report, [], nextActions), options.json);
66
+ }
67
+ catch (error) {
68
+ printResult(io, fail('scan.existing-system', 'SCAN_EXISTING_SYSTEM_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Verify the project path exists and is readable']), options.json);
69
+ process.exitCode = 1;
70
+ }
71
+ });
72
+ addJsonOption(scan
73
+ .command('request-type-sanity')
74
+ .description('Cross-verify a declared --type against the actual git diff file mix (catches "feature mis-declared as docs" workflow violations)')
75
+ .requiredOption('--project <path>', 'target project root')
76
+ .requiredOption('--type <type>', `declared request type (${VALID_REQUEST_TYPES.join(' | ')})`, parseRequestType)
77
+ .option('--base-ref <ref>', 'compare working tree against this git ref (default: HEAD)')).action((options) => {
78
+ try {
79
+ const serviceOptions = { projectRoot: options.project, declaredType: options.type };
80
+ if (options.baseRef !== undefined) {
81
+ serviceOptions.baseRef = options.baseRef;
82
+ }
83
+ const report = checkTypeSanity(serviceOptions);
84
+ const nextActions = [];
85
+ if (!report.consistent) {
86
+ nextActions.push(`Re-classify the request — likely correct type: ${report.suggestedTypes.join(' | ')}`);
87
+ nextActions.push('Or, if the declared type is correct, surface the mismatch reason to the user in the TXT handoff.');
88
+ }
89
+ if (!report.gitAvailable) {
90
+ nextActions.push('git not available; manual cross-check required.');
91
+ }
92
+ printResult(io, ok('scan.request-type-sanity', report, [], nextActions), options.json);
93
+ if (!report.consistent) {
94
+ process.exitCode = 1;
95
+ }
96
+ }
97
+ catch (error) {
98
+ if (error instanceof InvalidArgumentError)
99
+ throw error;
100
+ printResult(io, fail('scan.request-type-sanity', 'REQUEST_TYPE_SANITY_FAILED', getErrorMessage(error), { projectRoot: options.project, type: options.type }, ['Verify the project path is a git repository or omit the check']), options.json);
101
+ process.exitCode = 1;
102
+ }
103
+ });
104
+ addJsonOption(scan
105
+ .command('acceptance-coverage')
106
+ .description('Verify every PRD "Acceptance criteria" item has at least one linked QA test case (via `- **Acceptance:** A1, A2` field in qa/test-cases/<rid>.md)')
107
+ .requiredOption('--rid <request-id>', 'request id')
108
+ .requiredOption('--project <path>', 'target project root')
109
+ .option('--session-id <session>', 'restrict to a specific session id')).action(async (options) => {
110
+ try {
111
+ const coverageOptions = { projectRoot: options.project, requestId: options.rid };
112
+ if (options.sessionId !== undefined) {
113
+ coverageOptions.sessionId = options.sessionId;
114
+ }
115
+ const result = await getAcceptanceCoverage(coverageOptions);
116
+ if (isAcceptanceCoverageError(result)) {
117
+ const code = result.kind === 'prd-not-found' ? 'PRD_NOT_FOUND' : 'TEST_CASES_NOT_FOUND';
118
+ const message = result.kind === 'prd-not-found'
119
+ ? `PRD artifact for requestId=${options.rid} not found`
120
+ : `QA test-cases file not found at ${result.expectedPath}`;
121
+ printResult(io, fail('scan.acceptance-coverage', code, message, { requestId: options.rid }, [
122
+ result.kind === 'prd-not-found'
123
+ ? 'Run `peaks request init --role prd --id <rid> --apply --type <type>` first.'
124
+ : 'Generate qa/test-cases/<rid>.md before running this check.'
125
+ ]), options.json);
126
+ process.exitCode = 1;
127
+ return;
128
+ }
129
+ const nextActions = [];
130
+ if (result.acceptanceItems.length === 0) {
131
+ nextActions.push('PRD has no "## Acceptance criteria" bullets. Fill them in before running the coverage check.');
132
+ }
133
+ if (result.uncovered.length > 0) {
134
+ nextActions.push(`${result.uncovered.length} acceptance item(s) have no linked test case. Add a "- **Acceptance:** ${result.uncovered.map((u) => u.id).join(', ')}" field to the relevant test cases.`);
135
+ }
136
+ if (result.invalidReferences.length > 0) {
137
+ nextActions.push(`${result.invalidReferences.length} test case(s) reference an acceptance id that does not exist in the PRD. Fix or remove these references.`);
138
+ }
139
+ if (result.unlinkedTestCases.length > 0) {
140
+ nextActions.push(`${result.unlinkedTestCases.length} test case(s) have no Acceptance: field. Link them to acceptance items, or document why they exist (e.g. defense-in-depth regressions).`);
141
+ }
142
+ printResult(io, ok('scan.acceptance-coverage', result, [], nextActions), options.json);
143
+ if (!result.ok) {
144
+ process.exitCode = 1;
145
+ }
146
+ }
147
+ catch (error) {
148
+ printResult(io, fail('scan.acceptance-coverage', 'ACCEPTANCE_COVERAGE_FAILED', getErrorMessage(error), { requestId: options.rid }, ['Verify the project path and artifacts before retrying']), options.json);
149
+ process.exitCode = 1;
150
+ }
151
+ });
152
+ addJsonOption(scan
153
+ .command('diff-vs-scope')
154
+ .description('Verify every file in the git diff matches the RD artifact "Red-line scope" patterns; flags out-of-scope writes and unclassified files')
155
+ .requiredOption('--rid <request-id>', 'request id')
156
+ .requiredOption('--project <path>', 'target project root')
157
+ .option('--session-id <session>', 'restrict to a specific session id')
158
+ .option('--base-ref <ref>', 'compare working tree against this git ref (default: HEAD)')).action(async (options) => {
159
+ try {
160
+ const scopeOptions = { projectRoot: options.project, requestId: options.rid };
161
+ if (options.sessionId !== undefined)
162
+ scopeOptions.sessionId = options.sessionId;
163
+ if (options.baseRef !== undefined)
164
+ scopeOptions.baseRef = options.baseRef;
165
+ const result = await getDiffVsScope(scopeOptions);
166
+ if (isDiffScopeError(result)) {
167
+ printResult(io, fail('scan.diff-vs-scope', 'RD_NOT_FOUND', `RD artifact for requestId=${options.rid} not found`, { requestId: options.rid }, ['Run `peaks request init --role rd --id <rid> --apply --type <type>` first.']), options.json);
168
+ process.exitCode = 1;
169
+ return;
170
+ }
171
+ const nextActions = [];
172
+ if (!result.gitAvailable) {
173
+ nextActions.push('git not available; scope check skipped. Cross-check the diff manually.');
174
+ }
175
+ if (!result.patternsDeclared) {
176
+ nextActions.push('RD artifact has no in-scope or out-of-scope patterns under "## Red-line scope". Add concrete path/glob patterns (e.g. `src/services/login/**`) before re-running.');
177
+ }
178
+ if (result.violations.length > 0) {
179
+ nextActions.push(`${result.violations.length} file(s) match an explicit out-of-scope pattern. Revert these or expand the RD red-line scope with PRD approval.`);
180
+ }
181
+ if (result.unclassified.length > 0) {
182
+ nextActions.push(`${result.unclassified.length} changed file(s) do not match any declared scope pattern. Either add them to the in-scope list (if intentional) or revert them.`);
183
+ }
184
+ printResult(io, ok('scan.diff-vs-scope', result, [], nextActions), options.json);
185
+ if (!result.ok) {
186
+ process.exitCode = 1;
187
+ }
188
+ }
189
+ catch (error) {
190
+ printResult(io, fail('scan.diff-vs-scope', 'DIFF_VS_SCOPE_FAILED', getErrorMessage(error), { requestId: options.rid }, ['Verify the project path is a git repository and the RD artifact exists']), options.json);
191
+ process.exitCode = 1;
192
+ }
193
+ });
194
+ }
@@ -0,0 +1,3 @@
1
+ import { Command } from 'commander';
2
+ import { type ProgramIO } from '../cli-helpers.js';
3
+ export declare function registerWorkspaceCommands(program: Command, io: ProgramIO): void;
@@ -0,0 +1,32 @@
1
+ import { initWorkspace, InvalidSessionIdError } from '../../services/workspace/workspace-service.js';
2
+ import { fail, ok } from '../../shared/result.js';
3
+ import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
4
+ export function registerWorkspaceCommands(program, io) {
5
+ const workspace = program.command('workspace').description('Manage the Peaks per-session artifact workspace (.peaks/<session-id>/)');
6
+ addJsonOption(workspace
7
+ .command('init')
8
+ .description('Create the .peaks/<session-id>/ directory structure (prd, ui, rd, qa, sc, txt, system) — validates the session id format')
9
+ .requiredOption('--project <path>', 'target project root')
10
+ .requiredOption('--session-id <id>', 'session id in YYYY-MM-DD-<kebab-slug> format')).action(async (options) => {
11
+ try {
12
+ const report = await initWorkspace({ projectRoot: options.project, sessionId: options.sessionId });
13
+ const nextActions = [];
14
+ if (report.created.length === 0) {
15
+ nextActions.push('Workspace already initialized — proceed to project scan.');
16
+ }
17
+ else {
18
+ nextActions.push('Run `peaks scan archetype --project <path> --json` next to populate rd/project-scan.md.');
19
+ }
20
+ printResult(io, ok('workspace.init', report, [], nextActions), options.json);
21
+ }
22
+ catch (error) {
23
+ if (error instanceof InvalidSessionIdError) {
24
+ printResult(io, fail('workspace.init', error.code, error.message, { sessionId: options.sessionId }, ['Use a date-prefixed kebab slug like 2026-05-25-add-user-auth']), options.json);
25
+ process.exitCode = 1;
26
+ return;
27
+ }
28
+ printResult(io, fail('workspace.init', 'WORKSPACE_INIT_FAILED', getErrorMessage(error), { projectRoot: options.project, sessionId: options.sessionId }, ['Verify the project path exists and is writable']), options.json);
29
+ process.exitCode = 1;
30
+ }
31
+ });
32
+ }
@@ -8,8 +8,10 @@ import { registerMcpCommands } from './commands/mcp-commands.js';
8
8
  import { registerOpenSpecCommands } from './commands/openspec-commands.js';
9
9
  import { registerProjectCommands } from './commands/project-commands.js';
10
10
  import { registerRequestCommands } from './commands/request-commands.js';
11
+ import { registerScanCommands } from './commands/scan-commands.js';
11
12
  import { registerShadcnCommands } from './commands/shadcn-commands.js';
12
13
  import { registerUnderstandCommands } from './commands/understand-commands.js';
14
+ import { registerWorkspaceCommands } from './commands/workspace-commands.js';
13
15
  export { printResult } from './cli-helpers.js';
14
16
  export function createProgram(io = { stdout: (text) => console.log(text), stderr: (text) => console.error(text) }) {
15
17
  const program = new Command();
@@ -36,7 +38,9 @@ export function createProgram(io = { stdout: (text) => console.log(text), stderr
36
38
  registerOpenSpecCommands(program, io);
37
39
  registerProjectCommands(program, io);
38
40
  registerRequestCommands(program, io);
41
+ registerScanCommands(program, io);
39
42
  registerShadcnCommands(program, io);
40
43
  registerUnderstandCommands(program, io);
44
+ registerWorkspaceCommands(program, io);
41
45
  return program;
42
46
  }
@@ -0,0 +1,23 @@
1
+ import { type RequestArtifactRole } from './request-artifact-service.js';
2
+ export type ArtifactLintSeverity = 'error' | 'warning';
3
+ export type ArtifactLintFinding = {
4
+ line: number;
5
+ text: string;
6
+ reason: string;
7
+ severity: ArtifactLintSeverity;
8
+ };
9
+ export type ArtifactLintReport = {
10
+ ok: boolean;
11
+ role: RequestArtifactRole;
12
+ requestId: string;
13
+ path: string;
14
+ totalLines: number;
15
+ findings: ArtifactLintFinding[];
16
+ };
17
+ export type LintArtifactOptions = {
18
+ projectRoot: string;
19
+ role: RequestArtifactRole;
20
+ requestId: string;
21
+ sessionId?: string;
22
+ };
23
+ export declare function lintRequestArtifact(options: LintArtifactOptions): Promise<ArtifactLintReport | null>;
@@ -0,0 +1,80 @@
1
+ import { showRequestArtifact } from './request-artifact-service.js';
2
+ const RULES = [
3
+ {
4
+ test: (line) => /<[A-Za-z][^>]*>/.test(line.trim()) && !/^\s*-?\s*linked-/i.test(line),
5
+ reason: 'Contains an unfilled <placeholder> token',
6
+ severity: 'error'
7
+ },
8
+ {
9
+ test: (line) => /^\s*-\s*\.\.\.\s*$/.test(line),
10
+ reason: 'Bullet point is only "..." — replace with real content',
11
+ severity: 'error'
12
+ },
13
+ {
14
+ test: (line) => /\bTBD\b|\bTODO\b|\bFIXME\b|\bXXX\b/.test(line),
15
+ reason: 'Contains TBD/TODO/FIXME/XXX marker',
16
+ severity: 'warning'
17
+ },
18
+ {
19
+ test: (line) => /^\s*-\s*$/.test(line),
20
+ reason: 'Empty bullet point — replace with real content or remove',
21
+ severity: 'warning'
22
+ }
23
+ ];
24
+ // Lines that should never be flagged even if they match a rule (template scaffolding).
25
+ const ALLOWLIST_PATTERNS = [
26
+ /^#+\s/, // markdown headers
27
+ /^\s*```/, // code fences
28
+ /^\s*-\s*last update:/i, // metadata
29
+ /^\s*-\s*created:/i,
30
+ /^\s*-\s*state:/i,
31
+ /^\s*-\s*type:\s*(feature|bugfix|refactor|docs|config|chore)\s*$/i,
32
+ /^\s*-\s*session:\s*[a-z0-9-]+/i,
33
+ /^\s*-\s*transition note/i // bypass / repair notes are not placeholders
34
+ ];
35
+ function isAllowlisted(line) {
36
+ return ALLOWLIST_PATTERNS.some((pattern) => pattern.test(line));
37
+ }
38
+ export async function lintRequestArtifact(options) {
39
+ const showOptions = {
40
+ projectRoot: options.projectRoot,
41
+ role: options.role,
42
+ requestId: options.requestId
43
+ };
44
+ if (options.sessionId !== undefined) {
45
+ showOptions.sessionId = options.sessionId;
46
+ }
47
+ const artifact = await showRequestArtifact(showOptions);
48
+ if (artifact === null) {
49
+ return null;
50
+ }
51
+ const lines = artifact.content.split(/\r?\n/);
52
+ const findings = [];
53
+ for (let index = 0; index < lines.length; index += 1) {
54
+ const rawLine = lines[index];
55
+ if (rawLine === undefined)
56
+ continue;
57
+ if (isAllowlisted(rawLine))
58
+ continue;
59
+ for (const rule of RULES) {
60
+ if (rule.test(rawLine)) {
61
+ findings.push({
62
+ line: index + 1,
63
+ text: rawLine.trim(),
64
+ reason: rule.reason,
65
+ severity: rule.severity
66
+ });
67
+ break;
68
+ }
69
+ }
70
+ }
71
+ const hasError = findings.some((finding) => finding.severity === 'error');
72
+ return {
73
+ ok: !hasError,
74
+ role: options.role,
75
+ requestId: options.requestId,
76
+ path: artifact.path,
77
+ totalLines: lines.length,
78
+ findings
79
+ };
80
+ }
@@ -0,0 +1,28 @@
1
+ import type { RequestArtifactRole, RequestArtifactState } from './request-artifact-service.js';
2
+ export type RequestType = 'feature' | 'bugfix' | 'refactor' | 'docs' | 'config' | 'chore';
3
+ export declare const VALID_REQUEST_TYPES: ReadonlyArray<RequestType>;
4
+ export declare const DEFAULT_REQUEST_TYPE: RequestType;
5
+ export declare function isRequestType(value: string): value is RequestType;
6
+ export type ArtifactPrerequisite = {
7
+ /** Relative path under `.peaks/<session-id>/`. May contain `<rid>` placeholder. */
8
+ relativePath: string;
9
+ /** Human-readable description of what this artifact represents. */
10
+ description: string;
11
+ };
12
+ export type PrerequisiteCheckResult = {
13
+ ok: boolean;
14
+ missing: Array<{
15
+ path: string;
16
+ description: string;
17
+ }>;
18
+ };
19
+ export type CheckPrerequisitesOptions = {
20
+ projectRoot: string;
21
+ sessionId: string;
22
+ role: RequestArtifactRole;
23
+ newState: RequestArtifactState;
24
+ requestId: string;
25
+ requestType?: RequestType;
26
+ };
27
+ export declare function getPrerequisitesFor(role: RequestArtifactRole, newState: RequestArtifactState, requestType?: RequestType): ReadonlyArray<ArtifactPrerequisite>;
28
+ export declare function checkPrerequisites(options: CheckPrerequisitesOptions): Promise<PrerequisiteCheckResult>;
@@ -0,0 +1,77 @@
1
+ import { join } from 'node:path';
2
+ import { pathExists } from '../../shared/fs.js';
3
+ export const VALID_REQUEST_TYPES = [
4
+ 'feature',
5
+ 'bugfix',
6
+ 'refactor',
7
+ 'docs',
8
+ 'config',
9
+ 'chore'
10
+ ];
11
+ export const DEFAULT_REQUEST_TYPE = 'feature';
12
+ export function isRequestType(value) {
13
+ return VALID_REQUEST_TYPES.includes(value);
14
+ }
15
+ // Shared prerequisite fragments
16
+ const TECH_DOC = { relativePath: 'rd/tech-doc.md', description: 'RD technical design doc (architecture, files changed, data flow)' };
17
+ const BUG_ANALYSIS = { relativePath: 'rd/bug-analysis.md', description: 'Bug root-cause analysis (reproduction, affected paths, fix approach, regression test plan)' };
18
+ const CODE_REVIEW = { relativePath: 'rd/code-review.md', description: 'Code review evidence (CRITICAL/HIGH must be fixed before handoff)' };
19
+ const SECURITY_REVIEW = { relativePath: 'rd/security-review.md', description: 'Security review evidence for the changed surface' };
20
+ const TEST_CASES = { relativePath: 'qa/test-cases/<rid>.md', description: 'Generated test cases (unit / integration / UI regression)' };
21
+ const TEST_REPORT = { relativePath: 'qa/test-reports/<rid>.md', description: 'Test execution report with actual pass/fail/coverage results' };
22
+ const SECURITY_FINDINGS = { relativePath: 'qa/security-findings.md', description: 'Security test findings (record "no findings" inside if truly clean)' };
23
+ const PERFORMANCE_FINDINGS = { relativePath: 'qa/performance-findings.md', description: 'Performance test findings (record baseline/after numbers or explicit "not applicable" rationale)' };
24
+ const FEATURE_TABLE = {
25
+ 'rd:implemented': [TECH_DOC],
26
+ 'rd:qa-handoff': [TECH_DOC, CODE_REVIEW, SECURITY_REVIEW],
27
+ 'qa:running': [TEST_CASES],
28
+ 'qa:verdict-issued': [TEST_CASES, TEST_REPORT, SECURITY_FINDINGS, PERFORMANCE_FINDINGS]
29
+ };
30
+ // Bugfix: lighter planning artifact (bug-analysis instead of tech-doc), still requires code review + security review + regression test.
31
+ // Performance findings not mandatory for non-perf bugs (use --allow-incomplete --reason if a perf bug requires it).
32
+ const BUGFIX_TABLE = {
33
+ 'rd:implemented': [BUG_ANALYSIS],
34
+ 'rd:qa-handoff': [BUG_ANALYSIS, CODE_REVIEW, SECURITY_REVIEW],
35
+ 'qa:running': [TEST_CASES],
36
+ 'qa:verdict-issued': [TEST_CASES, TEST_REPORT, SECURITY_FINDINGS]
37
+ };
38
+ // Refactor: same as feature; refactor hard gates (coverage ≥ 95%) are enforced separately in peaks-rd SKILL.
39
+ const REFACTOR_TABLE = FEATURE_TABLE;
40
+ // Docs / chore: no artifact gates. Use sparingly — for genuinely doc-only or formatter-only changes.
41
+ const NO_GATES = {};
42
+ // Config: security review is the only mandatory check (config changes can break auth, CORS, CSP, secrets handling).
43
+ const CONFIG_TABLE = {
44
+ 'rd:qa-handoff': [SECURITY_REVIEW],
45
+ 'qa:verdict-issued': [SECURITY_FINDINGS]
46
+ };
47
+ const PREREQUISITES_BY_TYPE = {
48
+ feature: FEATURE_TABLE,
49
+ bugfix: BUGFIX_TABLE,
50
+ refactor: REFACTOR_TABLE,
51
+ docs: NO_GATES,
52
+ config: CONFIG_TABLE,
53
+ chore: NO_GATES
54
+ };
55
+ export function getPrerequisitesFor(role, newState, requestType = DEFAULT_REQUEST_TYPE) {
56
+ const table = PREREQUISITES_BY_TYPE[requestType];
57
+ return table[`${role}:${newState}`] ?? [];
58
+ }
59
+ function resolvePrerequisitePath(prerequisite, requestId) {
60
+ return prerequisite.relativePath.replace('<rid>', requestId);
61
+ }
62
+ export async function checkPrerequisites(options) {
63
+ const requirements = getPrerequisitesFor(options.role, options.newState, options.requestType);
64
+ if (requirements.length === 0) {
65
+ return { ok: true, missing: [] };
66
+ }
67
+ const sessionRoot = join(options.projectRoot, '.peaks', options.sessionId);
68
+ const missing = [];
69
+ for (const prerequisite of requirements) {
70
+ const relative = resolvePrerequisitePath(prerequisite, options.requestId);
71
+ const absolute = join(sessionRoot, relative);
72
+ if (!(await pathExists(absolute))) {
73
+ missing.push({ path: relative, description: prerequisite.description });
74
+ }
75
+ }
76
+ return { ok: missing.length === 0, missing };
77
+ }
@@ -0,0 +1,23 @@
1
+ export type RepairCycleEntry = {
2
+ cycle: number;
3
+ timestamp: string;
4
+ reason: string;
5
+ };
6
+ export type RepairCycleReport = {
7
+ requestId: string;
8
+ sessionId: string;
9
+ path: string;
10
+ cycleCount: number;
11
+ maxCycles: number;
12
+ remaining: number;
13
+ atCap: boolean;
14
+ blocked: boolean;
15
+ entries: RepairCycleEntry[];
16
+ };
17
+ export type RepairCycleStatusOptions = {
18
+ projectRoot: string;
19
+ requestId: string;
20
+ sessionId?: string;
21
+ maxCycles?: number;
22
+ };
23
+ export declare function getRepairCycleStatus(options: RepairCycleStatusOptions): Promise<RepairCycleReport | null>;