peaks-cli 1.0.20 → 1.0.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +42 -375
- package/bin/peaks.js +0 -0
- package/dist/src/cli/commands/capability-commands.d.ts +1 -1
- package/dist/src/cli/commands/capability-commands.js +2 -5
- package/dist/src/cli/commands/config-commands.js +2 -85
- package/dist/src/cli/commands/core-artifact-commands.js +6 -1
- package/dist/src/cli/commands/request-commands.js +82 -2
- package/dist/src/cli/commands/scan-commands.js +30 -0
- package/dist/src/cli/commands/workflow-commands.js +9 -5
- package/dist/src/services/artifacts/artifact-prerequisites.js +53 -13
- package/dist/src/services/artifacts/artifact-service.js +2 -2
- package/dist/src/services/artifacts/request-artifact-service.d.ts +32 -0
- package/dist/src/services/artifacts/request-artifact-service.js +148 -16
- package/dist/src/services/artifacts/workspace-service.js +8 -9
- package/dist/src/services/config/config-service.js +54 -69
- package/dist/src/services/config/config-types.d.ts +0 -2
- package/dist/src/services/config/config-types.js +0 -2
- package/dist/src/services/mode/bypass-tracker.d.ts +4 -0
- package/dist/src/services/mode/bypass-tracker.js +31 -0
- package/dist/src/services/mode/mode-enforcement.d.ts +14 -0
- package/dist/src/services/mode/mode-enforcement.js +81 -0
- package/dist/src/services/sc/sc-service.js +5 -5
- package/dist/src/services/scan/file-size-scan.d.ts +19 -0
- package/dist/src/services/scan/file-size-scan.js +44 -0
- package/dist/src/services/session/index.d.ts +1 -0
- package/dist/src/services/session/index.js +1 -0
- package/dist/src/services/session/session-manager.d.ts +60 -0
- package/dist/src/services/session/session-manager.js +150 -0
- package/dist/src/services/skills/skill-presence-service.d.ts +4 -1
- package/dist/src/services/skills/skill-presence-service.js +11 -1
- package/dist/src/services/workspace/workspace-service.js +6 -0
- package/dist/src/shared/change-id.d.ts +13 -0
- package/dist/src/shared/change-id.js +32 -1
- package/dist/src/shared/incrementing-number.d.ts +31 -0
- package/dist/src/shared/incrementing-number.js +58 -0
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +1 -1
- package/skills/peaks-rd/SKILL.md +3 -0
- package/skills/peaks-solo/SKILL.md +9 -11
- package/skills/peaks-ui/SKILL.md +3 -0
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { InvalidArgumentError } from 'commander';
|
|
2
|
-
import { allowedStatesForRole, createRequestArtifact, listRequestArtifacts, showRequestArtifact, transitionRequestArtifact, PrerequisitesNotSatisfiedError, VALID_REQUEST_TYPES, isRequestType } from '../../services/artifacts/request-artifact-service.js';
|
|
2
|
+
import { allowedStatesForRole, createRequestArtifact, listRequestArtifacts, showRequestArtifact, transitionRequestArtifact, PrerequisitesNotSatisfiedError, LintGateError, TypeSanityViolationError, FileSizeViolationError, VALID_REQUEST_TYPES, isRequestType } from '../../services/artifacts/request-artifact-service.js';
|
|
3
|
+
import { ConfirmationRequiredError } from '../../services/mode/mode-enforcement.js';
|
|
4
|
+
import { recordBypass, isBypassLimitReached, MAX_BYPASSES_PER_SESSION } from '../../services/mode/bypass-tracker.js';
|
|
3
5
|
import { lintRequestArtifact } from '../../services/artifacts/artifact-lint-service.js';
|
|
4
6
|
import { getRepairCycleStatus } from '../../services/artifacts/repair-cycle-service.js';
|
|
5
7
|
import { fail, ok } from '../../shared/result.js';
|
|
@@ -118,7 +120,9 @@ export function registerRequestCommands(program, io) {
|
|
|
118
120
|
.requiredOption('--project <path>', 'target project root')
|
|
119
121
|
.option('--session-id <session>', 'restrict to a specific session id')
|
|
120
122
|
.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')
|
|
123
|
+
.option('--allow-incomplete', 'bypass artifact prerequisite checks; requires --reason and records the bypass in the artifact')
|
|
124
|
+
.option('--confirm', 'skip interactive confirmation prompt (for non-interactive / LLM contexts)')
|
|
125
|
+
.option('--force-confirm', 'bypass mode-enforced confirmation (use with caution)')).action(async (requestId, options) => {
|
|
122
126
|
try {
|
|
123
127
|
const role = options.role;
|
|
124
128
|
const newState = parseStateForRole(role, options.state);
|
|
@@ -127,6 +131,26 @@ export function registerRequestCommands(program, io) {
|
|
|
127
131
|
process.exitCode = 1;
|
|
128
132
|
return;
|
|
129
133
|
}
|
|
134
|
+
// Restrict --allow-incomplete in assisted/strict modes: require --confirm
|
|
135
|
+
if (options.allowIncomplete === true && options.forceConfirm !== true) {
|
|
136
|
+
const { getSkillPresence } = await import('../../services/skills/skill-presence-service.js');
|
|
137
|
+
const presence = getSkillPresence();
|
|
138
|
+
if (presence?.mode === 'assisted' || presence?.mode === 'strict') {
|
|
139
|
+
if (options.confirm !== true) {
|
|
140
|
+
printResult(io, fail('request.transition', 'ALLOW_INCOMPLETE_RESTRICTED', `--allow-incomplete requires --confirm in ${presence.mode} mode`, { role, requestId, mode: presence.mode }, ['Add --confirm to proceed non-interactively, or run in an interactive terminal.']), options.json);
|
|
141
|
+
process.exitCode = 1;
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
// Check bypass count
|
|
145
|
+
const sessionRoot = (await import('node:path')).join(options.project, '.peaks', options.sessionId ?? 'default');
|
|
146
|
+
if (isBypassLimitReached(sessionRoot)) {
|
|
147
|
+
printResult(io, fail('request.transition', 'BYPASS_LIMIT_REACHED', `--allow-incomplete limit reached (${MAX_BYPASSES_PER_SESSION} per session)`, { role, requestId, limit: MAX_BYPASSES_PER_SESSION }, ['Produce the missing artifacts instead of bypassing.']), options.json);
|
|
148
|
+
process.exitCode = 1;
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
recordBypass(sessionRoot);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
130
154
|
const transitionOptions = {
|
|
131
155
|
role,
|
|
132
156
|
requestId,
|
|
@@ -142,6 +166,28 @@ export function registerRequestCommands(program, io) {
|
|
|
142
166
|
if (options.allowIncomplete === true) {
|
|
143
167
|
transitionOptions.allowIncomplete = true;
|
|
144
168
|
}
|
|
169
|
+
if (options.confirm === true) {
|
|
170
|
+
transitionOptions.confirmed = true;
|
|
171
|
+
}
|
|
172
|
+
if (options.forceConfirm === true) {
|
|
173
|
+
transitionOptions.forceConfirm = true;
|
|
174
|
+
}
|
|
175
|
+
// Type sanity check for PRD handoff
|
|
176
|
+
if (role === 'prd' && newState === 'handed-off') {
|
|
177
|
+
const { showRequestArtifact: showForType } = await import('../../services/artifacts/request-artifact-service.js');
|
|
178
|
+
const showTypeOptions = {
|
|
179
|
+
projectRoot: options.project,
|
|
180
|
+
role: 'prd',
|
|
181
|
+
requestId
|
|
182
|
+
};
|
|
183
|
+
if (options.sessionId !== undefined) {
|
|
184
|
+
showTypeOptions.sessionId = options.sessionId;
|
|
185
|
+
}
|
|
186
|
+
const existing = await showForType(showTypeOptions);
|
|
187
|
+
if (existing !== null) {
|
|
188
|
+
transitionOptions.typeSanityCheck = { projectRoot: options.project, declaredType: existing.requestType };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
145
191
|
const result = await transitionRequestArtifact(transitionOptions);
|
|
146
192
|
if (result === null) {
|
|
147
193
|
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);
|
|
@@ -163,6 +209,40 @@ export function registerRequestCommands(program, io) {
|
|
|
163
209
|
process.exitCode = 1;
|
|
164
210
|
return;
|
|
165
211
|
}
|
|
212
|
+
if (error instanceof LintGateError) {
|
|
213
|
+
printResult(io, fail('request.transition', error.code, error.message, { role: error.role, newState: error.newState, errorCount: error.errorCount }, [
|
|
214
|
+
'Fix lint errors in the artifact before transitioning.',
|
|
215
|
+
'Run `peaks request lint --role <role> --id <rid> --project <path>` to see details.',
|
|
216
|
+
'Or bypass with: --allow-incomplete --reason "<justification>"'
|
|
217
|
+
]), options.json);
|
|
218
|
+
process.exitCode = 1;
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
if (error instanceof TypeSanityViolationError) {
|
|
222
|
+
printResult(io, fail('request.transition', error.code, error.message, { declaredType: error.declaredType, suggestedTypes: error.suggestedTypes, rationale: error.rationale }, [
|
|
223
|
+
`Re-classify the request — likely correct type: ${error.suggestedTypes.join(' | ')}`,
|
|
224
|
+
'Or, if the declared type is correct, surface the mismatch reason to the user.'
|
|
225
|
+
]), options.json);
|
|
226
|
+
process.exitCode = 1;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
if (error instanceof FileSizeViolationError) {
|
|
230
|
+
printResult(io, fail('request.transition', error.code, error.message, { violations: error.violations, threshold: error.threshold }, [
|
|
231
|
+
...error.violations.map((v) => `Split ${v.file} (${v.lines} lines) into smaller modules (< ${error.threshold} lines)`),
|
|
232
|
+
'Or bypass with: --allow-incomplete --reason "<justification>"'
|
|
233
|
+
]), options.json);
|
|
234
|
+
process.exitCode = 1;
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (error instanceof ConfirmationRequiredError) {
|
|
238
|
+
printResult(io, fail('request.transition', 'CONFIRMATION_REQUIRED', error.message, { role: options.role, requestId }, [
|
|
239
|
+
'Add --confirm to proceed non-interactively.',
|
|
240
|
+
'Or run in an interactive terminal.',
|
|
241
|
+
'In assisted/strict mode, major workflow boundaries require explicit user approval.'
|
|
242
|
+
]), options.json);
|
|
243
|
+
process.exitCode = 1;
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
166
246
|
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);
|
|
167
247
|
process.exitCode = 1;
|
|
168
248
|
}
|
|
@@ -4,6 +4,7 @@ import { scanExistingSystem } from '../../services/scan/existing-system-service.
|
|
|
4
4
|
import { checkTypeSanity } from '../../services/scan/type-sanity-service.js';
|
|
5
5
|
import { getAcceptanceCoverage, isAcceptanceCoverageError } from '../../services/scan/acceptance-coverage-service.js';
|
|
6
6
|
import { getDiffVsScope, isDiffScopeError } from '../../services/scan/diff-scope-service.js';
|
|
7
|
+
import { scanFileSize, DEFAULT_FILE_SIZE_THRESHOLD } from '../../services/scan/file-size-scan.js';
|
|
7
8
|
import { isRequestType, VALID_REQUEST_TYPES } from '../../services/artifacts/artifact-prerequisites.js';
|
|
8
9
|
import { fail, ok } from '../../shared/result.js';
|
|
9
10
|
import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
|
|
@@ -191,4 +192,33 @@ export function registerScanCommands(program, io) {
|
|
|
191
192
|
process.exitCode = 1;
|
|
192
193
|
}
|
|
193
194
|
});
|
|
195
|
+
addJsonOption(scan
|
|
196
|
+
.command('file-size')
|
|
197
|
+
.description('Check git diff for files exceeding a line count threshold (karpathy-skills "Simplicity First")')
|
|
198
|
+
.requiredOption('--project <path>', 'target project root')
|
|
199
|
+
.option('--base-ref <ref>', 'compare working tree against this git ref (default: HEAD)')
|
|
200
|
+
.option('--threshold <n>', `line count threshold (default: ${DEFAULT_FILE_SIZE_THRESHOLD})`)).action((options) => {
|
|
201
|
+
try {
|
|
202
|
+
const threshold = options.threshold !== undefined && /^\d+$/.test(options.threshold)
|
|
203
|
+
? Number(options.threshold)
|
|
204
|
+
: undefined;
|
|
205
|
+
const result = scanFileSize({
|
|
206
|
+
projectRoot: options.project,
|
|
207
|
+
...(options.baseRef !== undefined ? { baseRef: options.baseRef } : {}),
|
|
208
|
+
...(threshold !== undefined ? { threshold } : {})
|
|
209
|
+
});
|
|
210
|
+
const nextActions = [];
|
|
211
|
+
if (!result.ok) {
|
|
212
|
+
nextActions.push(`${result.violations.length} file(s) exceed ${result.threshold} lines. Split into smaller modules.`);
|
|
213
|
+
}
|
|
214
|
+
printResult(io, ok('scan.file-size', result, [], nextActions), options.json);
|
|
215
|
+
if (!result.ok) {
|
|
216
|
+
process.exitCode = 1;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
catch (error) {
|
|
220
|
+
printResult(io, fail('scan.file-size', 'FILE_SIZE_SCAN_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Verify the project path is a git repository']), options.json);
|
|
221
|
+
process.exitCode = 1;
|
|
222
|
+
}
|
|
223
|
+
});
|
|
194
224
|
}
|
|
@@ -5,21 +5,25 @@ import { createAutonomousWorkflowPlan } from '../../services/workflow/workflow-a
|
|
|
5
5
|
import { writeAutonomousResumeArtifacts } from '../../services/workflow/autonomous-resume-writer.js';
|
|
6
6
|
import { createRecommendationPlan } from '../../services/recommendations/recommendation-service.js';
|
|
7
7
|
import { createRefactorDryRun } from '../../services/refactor/refactor-service.js';
|
|
8
|
-
import {
|
|
8
|
+
import { getWorkspaceConfigForPath, readConfig } from '../../services/config/config-service.js';
|
|
9
9
|
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
|
+
import { getSessionId } from '../../services/session/session-manager.js';
|
|
12
13
|
import { fail, ok } from '../../shared/result.js';
|
|
13
14
|
import { addJsonOption, failUnsupportedNonDryRun, getErrorMessage, isRecommendationWorkflow, printResult } from '../cli-helpers.js';
|
|
14
15
|
function getCurrentWorkspaceContext() {
|
|
15
|
-
|
|
16
|
-
|
|
16
|
+
try {
|
|
17
|
+
const sessionId = getSessionId(process.cwd());
|
|
18
|
+
return sessionId ? { sessionId, sessionDir: `.peaks/${sessionId}` } : {};
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
17
21
|
return {};
|
|
18
|
-
|
|
22
|
+
}
|
|
19
23
|
}
|
|
20
24
|
function getWorkflowWorkspaceContext() {
|
|
21
25
|
try {
|
|
22
|
-
const workspace =
|
|
26
|
+
const workspace = getWorkspaceConfigForPath(process.cwd());
|
|
23
27
|
if (!workspace)
|
|
24
28
|
return {};
|
|
25
29
|
return { workspace, artifactWorkspacePath: getLocalArtifactPath(workspace) };
|
|
@@ -14,14 +14,42 @@ export function isRequestType(value) {
|
|
|
14
14
|
return VALID_REQUEST_TYPES.includes(value);
|
|
15
15
|
}
|
|
16
16
|
// Shared prerequisite fragments
|
|
17
|
-
const TECH_DOC = {
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
const TECH_DOC = {
|
|
18
|
+
relativePath: 'rd/tech-doc.md',
|
|
19
|
+
description: 'RD technical design doc (architecture, files changed, data flow)',
|
|
20
|
+
mustContain: ['## Red-line scope', '## Implementation evidence']
|
|
21
|
+
};
|
|
22
|
+
const BUG_ANALYSIS = {
|
|
23
|
+
relativePath: 'rd/bug-analysis.md',
|
|
24
|
+
description: 'Bug root-cause analysis (reproduction, affected paths, fix approach, regression test plan)',
|
|
25
|
+
mustContain: ['## Root cause', '## Fix approach']
|
|
26
|
+
};
|
|
27
|
+
const CODE_REVIEW = {
|
|
28
|
+
relativePath: 'rd/code-review.md',
|
|
29
|
+
description: 'Code review evidence (CRITICAL/HIGH must be fixed before handoff)',
|
|
30
|
+
mustContain: ['## Findings', 'CRITICAL']
|
|
31
|
+
};
|
|
20
32
|
const SECURITY_REVIEW = { relativePath: 'rd/security-review.md', description: 'Security review evidence for the changed surface' };
|
|
21
|
-
const TEST_CASES = {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
33
|
+
const TEST_CASES = {
|
|
34
|
+
relativePath: 'qa/test-cases/<rid>.md',
|
|
35
|
+
description: 'Generated test cases (unit / integration / UI regression)',
|
|
36
|
+
mustContain: ['## Test cases']
|
|
37
|
+
};
|
|
38
|
+
const TEST_REPORT = {
|
|
39
|
+
relativePath: 'qa/test-reports/<rid>.md',
|
|
40
|
+
description: 'Test execution report with actual pass/fail/coverage results',
|
|
41
|
+
mustContain: ['## Test execution']
|
|
42
|
+
};
|
|
43
|
+
const SECURITY_FINDINGS = {
|
|
44
|
+
relativePath: 'qa/security-findings.md',
|
|
45
|
+
description: 'Security test findings (record "no findings" inside if truly clean)',
|
|
46
|
+
mustContain: ['## Findings']
|
|
47
|
+
};
|
|
48
|
+
const PERFORMANCE_FINDINGS = {
|
|
49
|
+
relativePath: 'qa/performance-findings.md',
|
|
50
|
+
description: 'Performance test findings (record baseline/after numbers or explicit "not applicable" rationale)',
|
|
51
|
+
mustContain: ['## Baseline']
|
|
52
|
+
};
|
|
25
53
|
// PRD content prereq: ensures the PRD artifact has actual scope/acceptance content
|
|
26
54
|
// before handoff to RD/UI/QA. The SKILL says "Handoff to RD/UI/QA is blocked while
|
|
27
55
|
// the artifact is missing or in `draft` state" — this gives that claim a CLI gate.
|
|
@@ -30,10 +58,19 @@ const PRD_CONTENT = {
|
|
|
30
58
|
description: 'PRD artifact must contain Goal and Acceptance criteria sections before handoff',
|
|
31
59
|
mustContain: ['## Goals', '## Acceptance']
|
|
32
60
|
};
|
|
61
|
+
const UNIT_TESTS = {
|
|
62
|
+
relativePath: 'qa/test-cases/<rid>.md',
|
|
63
|
+
description: 'Unit test files for the implemented changes (enforces peaks-rd Gate B2)',
|
|
64
|
+
mustContain: ['## Test cases', 'test(']
|
|
65
|
+
};
|
|
66
|
+
const QA_INITIATED = {
|
|
67
|
+
relativePath: 'qa/.initiated',
|
|
68
|
+
description: 'QA skill must be invoked before RD handoff (run peaks request init --role qa)'
|
|
69
|
+
};
|
|
33
70
|
const FEATURE_TABLE = {
|
|
34
71
|
'prd:handed-off': [PRD_CONTENT],
|
|
35
72
|
'rd:implemented': [TECH_DOC],
|
|
36
|
-
'rd:qa-handoff': [TECH_DOC, CODE_REVIEW, SECURITY_REVIEW],
|
|
73
|
+
'rd:qa-handoff': [TECH_DOC, CODE_REVIEW, SECURITY_REVIEW, UNIT_TESTS, QA_INITIATED],
|
|
37
74
|
'qa:running': [TEST_CASES],
|
|
38
75
|
'qa:verdict-issued': [TEST_CASES, TEST_REPORT, SECURITY_FINDINGS, PERFORMANCE_FINDINGS]
|
|
39
76
|
};
|
|
@@ -42,14 +79,17 @@ const FEATURE_TABLE = {
|
|
|
42
79
|
const BUGFIX_TABLE = {
|
|
43
80
|
'prd:handed-off': [PRD_CONTENT],
|
|
44
81
|
'rd:implemented': [BUG_ANALYSIS],
|
|
45
|
-
'rd:qa-handoff': [BUG_ANALYSIS, CODE_REVIEW, SECURITY_REVIEW],
|
|
82
|
+
'rd:qa-handoff': [BUG_ANALYSIS, CODE_REVIEW, SECURITY_REVIEW, UNIT_TESTS, QA_INITIATED],
|
|
46
83
|
'qa:running': [TEST_CASES],
|
|
47
84
|
'qa:verdict-issued': [TEST_CASES, TEST_REPORT, SECURITY_FINDINGS]
|
|
48
85
|
};
|
|
49
86
|
// Refactor: same as feature; refactor hard gates (coverage ≥ 95%) are enforced separately in peaks-rd SKILL.
|
|
50
87
|
const REFACTOR_TABLE = FEATURE_TABLE;
|
|
51
|
-
// Docs / chore:
|
|
52
|
-
|
|
88
|
+
// Docs / chore: minimal gate — require PRD content before proceeding.
|
|
89
|
+
// Prevents jumping to implementation without planning.
|
|
90
|
+
const MINIMAL_TABLE = {
|
|
91
|
+
'prd:handed-off': [PRD_CONTENT]
|
|
92
|
+
};
|
|
53
93
|
// Config: security review is the only mandatory check (config changes can break auth, CORS, CSP, secrets handling).
|
|
54
94
|
const CONFIG_TABLE = {
|
|
55
95
|
'prd:handed-off': [PRD_CONTENT],
|
|
@@ -60,9 +100,9 @@ const PREREQUISITES_BY_TYPE = {
|
|
|
60
100
|
feature: FEATURE_TABLE,
|
|
61
101
|
bugfix: BUGFIX_TABLE,
|
|
62
102
|
refactor: REFACTOR_TABLE,
|
|
63
|
-
docs:
|
|
103
|
+
docs: MINIMAL_TABLE,
|
|
64
104
|
config: CONFIG_TABLE,
|
|
65
|
-
chore:
|
|
105
|
+
chore: MINIMAL_TABLE
|
|
66
106
|
};
|
|
67
107
|
export function getPrerequisitesFor(role, newState, requestType = DEFAULT_REQUEST_TYPE) {
|
|
68
108
|
const table = PREREQUISITES_BY_TYPE[requestType];
|
|
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs';
|
|
|
2
2
|
import { execFileSync } from 'node:child_process';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { resolve } from 'node:path';
|
|
5
|
-
import {
|
|
5
|
+
import { getWorkspaceConfigForPath } from '../config/config-service.js';
|
|
6
6
|
import { getArtifactRemoteRepo, getLocalArtifactPath } from './workspace-service.js';
|
|
7
7
|
function getRemoteUrl(artifactRepo) {
|
|
8
8
|
if (!artifactRepo)
|
|
@@ -45,7 +45,7 @@ export function createArtifactInitPlan(options) {
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
export function createGuidedArtifactSetup() {
|
|
48
|
-
const workspace =
|
|
48
|
+
const workspace = getWorkspaceConfigForPath(process.cwd());
|
|
49
49
|
const artifactRepo = workspace ? getArtifactRemoteRepo(workspace) : null;
|
|
50
50
|
const validationResult = {
|
|
51
51
|
workspaceExists: workspace !== null,
|
|
@@ -54,6 +54,12 @@ export type TransitionRequestArtifactOptions = {
|
|
|
54
54
|
sessionId?: string;
|
|
55
55
|
reason?: string;
|
|
56
56
|
allowIncomplete?: boolean;
|
|
57
|
+
confirmed?: boolean;
|
|
58
|
+
forceConfirm?: boolean;
|
|
59
|
+
typeSanityCheck?: {
|
|
60
|
+
projectRoot: string;
|
|
61
|
+
declaredType: RequestType;
|
|
62
|
+
};
|
|
57
63
|
clock?: () => string;
|
|
58
64
|
};
|
|
59
65
|
export type TransitionRequestArtifactResult = RequestArtifactSummary & {
|
|
@@ -69,4 +75,30 @@ export declare class PrerequisitesNotSatisfiedError extends Error {
|
|
|
69
75
|
readonly missing: PrerequisiteCheckResult['missing'];
|
|
70
76
|
constructor(role: RequestArtifactRole, newState: RequestArtifactState, sessionId: string, missing: PrerequisiteCheckResult['missing']);
|
|
71
77
|
}
|
|
78
|
+
export declare class LintGateError extends Error {
|
|
79
|
+
readonly code = "LINT_GATE_FAILED";
|
|
80
|
+
readonly role: RequestArtifactRole;
|
|
81
|
+
readonly newState: RequestArtifactState;
|
|
82
|
+
readonly errorCount: number;
|
|
83
|
+
constructor(role: RequestArtifactRole, newState: RequestArtifactState, errorCount: number);
|
|
84
|
+
}
|
|
85
|
+
export declare class TypeSanityViolationError extends Error {
|
|
86
|
+
readonly code = "TYPE_SANITY_VIOLATION";
|
|
87
|
+
readonly declaredType: RequestType;
|
|
88
|
+
readonly suggestedTypes: ReadonlyArray<RequestType>;
|
|
89
|
+
readonly rationale: string;
|
|
90
|
+
constructor(declaredType: RequestType, suggestedTypes: ReadonlyArray<RequestType>, rationale: string);
|
|
91
|
+
}
|
|
92
|
+
export declare class FileSizeViolationError extends Error {
|
|
93
|
+
readonly code = "FILE_SIZE_VIOLATION";
|
|
94
|
+
readonly violations: Array<{
|
|
95
|
+
file: string;
|
|
96
|
+
lines: number;
|
|
97
|
+
}>;
|
|
98
|
+
readonly threshold: number;
|
|
99
|
+
constructor(violations: Array<{
|
|
100
|
+
file: string;
|
|
101
|
+
lines: number;
|
|
102
|
+
}>, threshold: number);
|
|
103
|
+
}
|
|
72
104
|
export declare function transitionRequestArtifact(options: TransitionRequestArtifactOptions): Promise<TransitionRequestArtifactResult | null>;
|
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
2
3
|
import { dirname, join } from 'node:path';
|
|
3
|
-
import { isDirectory, listDirectories
|
|
4
|
+
import { isDirectory, listDirectories } from '../../shared/fs.js';
|
|
4
5
|
import { checkPrerequisites, DEFAULT_REQUEST_TYPE, isRequestType, VALID_REQUEST_TYPES } from './artifact-prerequisites.js';
|
|
6
|
+
import { ensureSession } from '../session/session-manager.js';
|
|
7
|
+
import { getNextNumber, buildNumberedFilename } from '../../shared/incrementing-number.js';
|
|
8
|
+
import { lintRequestArtifact } from './artifact-lint-service.js';
|
|
9
|
+
import { checkTypeSanity } from '../scan/type-sanity-service.js';
|
|
10
|
+
import { requireUserConfirmation } from '../mode/mode-enforcement.js';
|
|
11
|
+
import { scanFileSize } from '../scan/file-size-scan.js';
|
|
5
12
|
export { VALID_REQUEST_TYPES, DEFAULT_REQUEST_TYPE, isRequestType };
|
|
6
13
|
const REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
7
14
|
const VALID_ROLES = new Set(['prd', 'ui', 'rd', 'qa', 'sc']);
|
|
@@ -299,17 +306,42 @@ export async function createRequestArtifact(options) {
|
|
|
299
306
|
const requestType = options.requestType ?? DEFAULT_REQUEST_TYPE;
|
|
300
307
|
const clock = options.clock ?? defaultClock;
|
|
301
308
|
const timestamp = clock();
|
|
302
|
-
|
|
303
|
-
const
|
|
309
|
+
// Use provided session ID or get/create current session
|
|
310
|
+
const sessionId = options.sessionId ?? await ensureSession(options.projectRoot);
|
|
311
|
+
// Build numbered path in session directory
|
|
312
|
+
const requestsDir = join(options.projectRoot, '.peaks', sessionId, options.role, 'requests');
|
|
313
|
+
// Check if a file with this requestId already exists (regardless of number prefix)
|
|
314
|
+
if (await isDirectory(requestsDir)) {
|
|
315
|
+
const existingFiles = await listMarkdownFiles(requestsDir);
|
|
316
|
+
const alreadyExists = existingFiles.some((file) => {
|
|
317
|
+
if (file === `${options.requestId}.md`)
|
|
318
|
+
return true;
|
|
319
|
+
if (/^\d+-/.test(file) && file.endsWith(`-${options.requestId}.md`))
|
|
320
|
+
return true;
|
|
321
|
+
return false;
|
|
322
|
+
});
|
|
323
|
+
if (alreadyExists) {
|
|
324
|
+
throw new Error(`A request artifact with id "${options.requestId}" already exists in ${requestsDir}. Remove it before re-running peaks request init.`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
const number = getNextNumber(requestsDir);
|
|
328
|
+
const filename = buildNumberedFilename(number, options.requestId);
|
|
329
|
+
const path = join(requestsDir, filename);
|
|
304
330
|
const content = renderTemplate(options.role, options.requestId, sessionId, timestamp, requestType);
|
|
305
331
|
if (options.apply !== true) {
|
|
306
332
|
return { role: options.role, requestId: options.requestId, sessionId, path, content, applied: false };
|
|
307
333
|
}
|
|
308
|
-
if (await pathExists(path)) {
|
|
309
|
-
throw new Error(`Refusing to write: ${path} already exists. Update it in place or remove it before re-running peaks request init.`);
|
|
310
|
-
}
|
|
311
334
|
await mkdir(dirname(path), { recursive: true });
|
|
312
335
|
await writeFile(path, content, 'utf8');
|
|
336
|
+
// Create QA initiated marker so rd:qa-handoff gate can verify QA was invoked
|
|
337
|
+
if (options.role === 'qa') {
|
|
338
|
+
const qaDir = join(options.projectRoot, '.peaks', sessionId, 'qa');
|
|
339
|
+
const initiatedPath = join(qaDir, '.initiated');
|
|
340
|
+
if (!existsSync(initiatedPath)) {
|
|
341
|
+
await mkdir(qaDir, { recursive: true });
|
|
342
|
+
await writeFile(initiatedPath, '', 'utf8');
|
|
343
|
+
}
|
|
344
|
+
}
|
|
313
345
|
return { role: options.role, requestId: options.requestId, sessionId, path, content, applied: true };
|
|
314
346
|
}
|
|
315
347
|
function extractMetadata(markdown) {
|
|
@@ -346,7 +378,9 @@ async function readSummary(projectRoot, sessionId, role, fileName) {
|
|
|
346
378
|
const path = join(projectRoot, '.peaks', sessionId, role, 'requests', fileName);
|
|
347
379
|
const body = await readFile(path, 'utf8');
|
|
348
380
|
const { state, createdAt, requestType } = extractMetadata(body);
|
|
349
|
-
|
|
381
|
+
// Strip numbered prefix (e.g., "001-requestId.md" -> "requestId")
|
|
382
|
+
// Only strip 3-digit zero-padded prefixes (our incrementing number format)
|
|
383
|
+
const requestId = fileName.replace(/^0\d{2}-/, '').replace(/\.md$/, '');
|
|
350
384
|
const summary = { role, sessionId, requestId, path, state, requestType };
|
|
351
385
|
if (createdAt !== undefined) {
|
|
352
386
|
summary.createdAt = createdAt;
|
|
@@ -389,14 +423,29 @@ export async function showRequestArtifact(options) {
|
|
|
389
423
|
if (!REQUEST_ID_PATTERN.test(options.requestId)) {
|
|
390
424
|
throw new Error(`Invalid request id: ${options.requestId} (expected letters, digits, dots, underscores, or dashes)`);
|
|
391
425
|
}
|
|
392
|
-
|
|
426
|
+
// Search for files matching the requestId (supports both legacy and numbered formats)
|
|
427
|
+
const findFileInDir = async (dir) => {
|
|
428
|
+
const files = await listMarkdownFiles(dir);
|
|
429
|
+
for (const file of files) {
|
|
430
|
+
// Match legacy format: ${requestId}.md
|
|
431
|
+
if (file === `${options.requestId}.md`) {
|
|
432
|
+
return { fileName: file, path: join(dir, file) };
|
|
433
|
+
}
|
|
434
|
+
// Match numbered format: ${number}-${requestId}.md
|
|
435
|
+
if (/^\d+-/.test(file) && file.endsWith(`-${options.requestId}.md`)) {
|
|
436
|
+
return { fileName: file, path: join(dir, file) };
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return null;
|
|
440
|
+
};
|
|
393
441
|
if (options.sessionId !== undefined) {
|
|
394
|
-
const
|
|
395
|
-
|
|
442
|
+
const dir = join(options.projectRoot, '.peaks', options.sessionId, options.role, 'requests');
|
|
443
|
+
const found = await findFileInDir(dir);
|
|
444
|
+
if (found === null) {
|
|
396
445
|
return null;
|
|
397
446
|
}
|
|
398
|
-
const summary = await readSummary(options.projectRoot, options.sessionId, options.role, fileName);
|
|
399
|
-
const content = await readFile(path, 'utf8');
|
|
447
|
+
const summary = await readSummary(options.projectRoot, options.sessionId, options.role, found.fileName);
|
|
448
|
+
const content = await readFile(found.path, 'utf8');
|
|
400
449
|
return { ...summary, content };
|
|
401
450
|
}
|
|
402
451
|
const peaksRoot = join(options.projectRoot, '.peaks');
|
|
@@ -405,10 +454,11 @@ export async function showRequestArtifact(options) {
|
|
|
405
454
|
}
|
|
406
455
|
const sessions = await listDirectories(peaksRoot);
|
|
407
456
|
for (const sessionId of sessions) {
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const
|
|
457
|
+
const dir = join(peaksRoot, sessionId, options.role, 'requests');
|
|
458
|
+
const found = await findFileInDir(dir);
|
|
459
|
+
if (found !== null) {
|
|
460
|
+
const summary = await readSummary(options.projectRoot, sessionId, options.role, found.fileName);
|
|
461
|
+
const content = await readFile(found.path, 'utf8');
|
|
412
462
|
return { ...summary, content };
|
|
413
463
|
}
|
|
414
464
|
}
|
|
@@ -439,6 +489,48 @@ export class PrerequisitesNotSatisfiedError extends Error {
|
|
|
439
489
|
this.missing = missing;
|
|
440
490
|
}
|
|
441
491
|
}
|
|
492
|
+
export class LintGateError extends Error {
|
|
493
|
+
code = 'LINT_GATE_FAILED';
|
|
494
|
+
role;
|
|
495
|
+
newState;
|
|
496
|
+
errorCount;
|
|
497
|
+
constructor(role, newState, errorCount) {
|
|
498
|
+
super(`Cannot transition ${role} to ${newState}: ${errorCount} lint error(s) found in artifact. ` +
|
|
499
|
+
'Fix lint errors or use --allow-incomplete to bypass.');
|
|
500
|
+
this.name = 'LintGateError';
|
|
501
|
+
this.role = role;
|
|
502
|
+
this.newState = newState;
|
|
503
|
+
this.errorCount = errorCount;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
export class TypeSanityViolationError extends Error {
|
|
507
|
+
code = 'TYPE_SANITY_VIOLATION';
|
|
508
|
+
declaredType;
|
|
509
|
+
suggestedTypes;
|
|
510
|
+
rationale;
|
|
511
|
+
constructor(declaredType, suggestedTypes, rationale) {
|
|
512
|
+
super(`Type sanity violation: declared --type=${declaredType} disagrees with changed files. ` +
|
|
513
|
+
`Suggested types: ${suggestedTypes.join(' | ')}. ` +
|
|
514
|
+
`Rationale: ${rationale}`);
|
|
515
|
+
this.name = 'TypeSanityViolationError';
|
|
516
|
+
this.declaredType = declaredType;
|
|
517
|
+
this.suggestedTypes = suggestedTypes;
|
|
518
|
+
this.rationale = rationale;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
export class FileSizeViolationError extends Error {
|
|
522
|
+
code = 'FILE_SIZE_VIOLATION';
|
|
523
|
+
violations;
|
|
524
|
+
threshold;
|
|
525
|
+
constructor(violations, threshold) {
|
|
526
|
+
const summary = violations.map((v) => `${v.file} (${v.lines} lines)`).join(', ');
|
|
527
|
+
super(`File size violation: ${violations.length} file(s) exceed ${threshold} lines: ${summary}. ` +
|
|
528
|
+
'Split into smaller modules or use --allow-incomplete to bypass.');
|
|
529
|
+
this.name = 'FileSizeViolationError';
|
|
530
|
+
this.violations = violations;
|
|
531
|
+
this.threshold = threshold;
|
|
532
|
+
}
|
|
533
|
+
}
|
|
442
534
|
function updateStatusBlock(markdown, newState, timestamp, reason) {
|
|
443
535
|
const lines = markdown.split(/\r?\n/);
|
|
444
536
|
let previousState = 'unknown';
|
|
@@ -499,6 +591,14 @@ export async function transitionRequestArtifact(options) {
|
|
|
499
591
|
if (existing === null) {
|
|
500
592
|
return null;
|
|
501
593
|
}
|
|
594
|
+
// Mode enforcement: require user confirmation in assisted/strict modes
|
|
595
|
+
const transitionKey = `${options.role}:${options.newState}`;
|
|
596
|
+
await requireUserConfirmation({
|
|
597
|
+
projectRoot: options.projectRoot,
|
|
598
|
+
transitionKey,
|
|
599
|
+
confirmed: options.confirmed,
|
|
600
|
+
forceConfirm: options.forceConfirm
|
|
601
|
+
});
|
|
502
602
|
const prerequisiteResult = await checkPrerequisites({
|
|
503
603
|
projectRoot: options.projectRoot,
|
|
504
604
|
sessionId: existing.sessionId,
|
|
@@ -510,6 +610,38 @@ export async function transitionRequestArtifact(options) {
|
|
|
510
610
|
if (!prerequisiteResult.ok && options.allowIncomplete !== true) {
|
|
511
611
|
throw new PrerequisitesNotSatisfiedError(options.role, options.newState, existing.sessionId, prerequisiteResult.missing);
|
|
512
612
|
}
|
|
613
|
+
// Type sanity check for PRD handoff
|
|
614
|
+
if (options.typeSanityCheck !== undefined && options.role === 'prd' && options.newState === 'handed-off') {
|
|
615
|
+
const sanityReport = checkTypeSanity({
|
|
616
|
+
projectRoot: options.typeSanityCheck.projectRoot,
|
|
617
|
+
declaredType: options.typeSanityCheck.declaredType
|
|
618
|
+
});
|
|
619
|
+
if (!sanityReport.consistent) {
|
|
620
|
+
throw new TypeSanityViolationError(options.typeSanityCheck.declaredType, sanityReport.suggestedTypes, sanityReport.rationale);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
// Lint gate: when transitioning OUT of draft, lint must pass (unless --allow-incomplete)
|
|
624
|
+
if (existing.state === 'draft' && options.allowIncomplete !== true) {
|
|
625
|
+
const lintReport = await lintRequestArtifact({
|
|
626
|
+
projectRoot: options.projectRoot,
|
|
627
|
+
role: options.role,
|
|
628
|
+
requestId: options.requestId,
|
|
629
|
+
sessionId: existing.sessionId
|
|
630
|
+
});
|
|
631
|
+
if (lintReport !== null && !lintReport.ok) {
|
|
632
|
+
const errorCount = lintReport.findings.filter((f) => f.severity === 'error').length;
|
|
633
|
+
if (errorCount > 0) {
|
|
634
|
+
throw new LintGateError(options.role, options.newState, errorCount);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
// File size gate: when RD declares implemented, scan for oversized files (karpathy-skills "Simplicity First")
|
|
639
|
+
if (options.role === 'rd' && options.newState === 'implemented' && options.allowIncomplete !== true) {
|
|
640
|
+
const sizeResult = scanFileSize({ projectRoot: options.projectRoot });
|
|
641
|
+
if (!sizeResult.ok) {
|
|
642
|
+
throw new FileSizeViolationError(sizeResult.violations, sizeResult.threshold);
|
|
643
|
+
}
|
|
644
|
+
}
|
|
513
645
|
const clock = options.clock ?? defaultClock;
|
|
514
646
|
const timestamp = clock();
|
|
515
647
|
const bypassNote = !prerequisiteResult.ok && options.allowIncomplete === true
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { Buffer } from 'node:buffer';
|
|
3
|
-
import { homedir } from 'node:os';
|
|
4
3
|
import { resolve } from 'node:path';
|
|
5
4
|
import { isInsidePath, stablePath } from '../../shared/path-utils.js';
|
|
6
|
-
import {
|
|
5
|
+
import { getWorkspaceConfig, getWorkspaceConfigForCurrentPath } from '../config/config-service.js';
|
|
7
6
|
import { pathExists } from '../../shared/fs.js';
|
|
8
7
|
import { execCommand } from '../../shared/process.js';
|
|
9
8
|
function canonicalPath(path) {
|
|
@@ -16,7 +15,7 @@ export function getLocalArtifactPath(workspace) {
|
|
|
16
15
|
if (workspace.artifactStorage?.localPath) {
|
|
17
16
|
return resolve(workspace.artifactStorage.localPath);
|
|
18
17
|
}
|
|
19
|
-
return resolve(
|
|
18
|
+
return resolve(workspace.rootPath, '.peaks', 'artifacts');
|
|
20
19
|
}
|
|
21
20
|
export function isArtifactWorkspaceOutsideTarget(workspace, artifactWorkspacePath = getLocalArtifactPath(workspace)) {
|
|
22
21
|
const targetRoot = canonicalPath(workspace.rootPath);
|
|
@@ -88,8 +87,8 @@ function redactSecrets(message) {
|
|
|
88
87
|
}
|
|
89
88
|
export async function executeArtifactSync(workspaceId) {
|
|
90
89
|
const workspace = workspaceId
|
|
91
|
-
?
|
|
92
|
-
:
|
|
90
|
+
? getWorkspaceConfig(workspaceId)
|
|
91
|
+
: getWorkspaceConfigForCurrentPath();
|
|
93
92
|
if (!workspace) {
|
|
94
93
|
return {
|
|
95
94
|
workspaceId: workspaceId ?? 'unknown',
|
|
@@ -190,8 +189,8 @@ export async function executeArtifactSync(workspaceId) {
|
|
|
190
189
|
}
|
|
191
190
|
export function getArtifactWorkspaceStatus(workspaceId) {
|
|
192
191
|
const workspace = workspaceId
|
|
193
|
-
?
|
|
194
|
-
:
|
|
192
|
+
? getWorkspaceConfig(workspaceId)
|
|
193
|
+
: getWorkspaceConfigForCurrentPath();
|
|
195
194
|
if (!workspace) {
|
|
196
195
|
return {
|
|
197
196
|
workspaceId: workspaceId ?? 'unknown',
|
|
@@ -230,8 +229,8 @@ export function getArtifactWorkspaceStatus(workspaceId) {
|
|
|
230
229
|
}
|
|
231
230
|
export function planArtifactSync(workspaceId, dryRun = true) {
|
|
232
231
|
const workspace = workspaceId
|
|
233
|
-
?
|
|
234
|
-
:
|
|
232
|
+
? getWorkspaceConfig(workspaceId)
|
|
233
|
+
: getWorkspaceConfigForCurrentPath();
|
|
235
234
|
if (!workspace) {
|
|
236
235
|
return {
|
|
237
236
|
workspaceId: workspaceId ?? 'unknown',
|