peaks-cli 1.3.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -46
- package/dist/src/cli/commands/hooks-commands.js +24 -9
- package/dist/src/cli/commands/progress-commands.js +26 -2
- package/dist/src/cli/commands/request-commands.js +5 -0
- package/dist/src/cli/commands/slice-commands.d.ts +3 -0
- package/dist/src/cli/commands/slice-commands.js +42 -0
- package/dist/src/cli/commands/workflow-commands.js +3 -3
- package/dist/src/cli/commands/workspace-commands.d.ts +63 -0
- package/dist/src/cli/commands/workspace-commands.js +288 -4
- package/dist/src/cli/program.js +4 -0
- package/dist/src/services/artifacts/artifact-prerequisites.d.ts +17 -1
- package/dist/src/services/artifacts/artifact-prerequisites.js +38 -5
- package/dist/src/services/artifacts/request-artifact-service.d.ts +22 -0
- package/dist/src/services/artifacts/request-artifact-service.js +172 -54
- package/dist/src/services/doctor/doctor-service.d.ts +7 -0
- package/dist/src/services/doctor/doctor-service.js +20 -2
- package/dist/src/services/progress/progress-service.d.ts +26 -0
- package/dist/src/services/progress/progress-service.js +25 -0
- package/dist/src/services/sc/sc-service.js +71 -13
- package/dist/src/services/scan/acceptance-coverage-service.js +6 -2
- package/dist/src/services/session/session-manager.js +12 -2
- package/dist/src/services/skills/hooks-settings-service.d.ts +25 -3
- package/dist/src/services/skills/hooks-settings-service.js +57 -13
- package/dist/src/services/slice/slice-check-service.d.ts +2 -0
- package/dist/src/services/slice/slice-check-service.js +248 -0
- package/dist/src/services/slice/slice-check-types.d.ts +61 -0
- package/dist/src/services/slice/slice-check-types.js +18 -0
- package/dist/src/services/workflow/pipeline-verify-service.d.ts +5 -2
- package/dist/src/services/workflow/pipeline-verify-service.js +35 -35
- package/dist/src/services/workspace/migrate-service.d.ts +2 -0
- package/dist/src/services/workspace/migrate-service.js +484 -0
- package/dist/src/services/workspace/migrate-types.d.ts +84 -0
- package/dist/src/services/workspace/migrate-types.js +21 -0
- package/dist/src/services/workspace/workspace-service.d.ts +11 -0
- package/dist/src/services/workspace/workspace-service.js +87 -7
- package/dist/src/shared/change-id.d.ts +59 -0
- package/dist/src/shared/change-id.js +194 -16
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +10 -2
- package/skills/peaks-solo/SKILL.md +11 -1
- package/skills/peaks-solo/references/micro-cycle.md +155 -0
|
@@ -1,19 +1,110 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { createInterface } from 'node:readline';
|
|
1
4
|
import { initWorkspace, InvalidSessionIdError, ConflictingSessionError } from '../../services/workspace/workspace-service.js';
|
|
2
5
|
import { reconcileWorkspace } from '../../services/workspace/reconcile-service.js';
|
|
6
|
+
import { migrateWorkspace } from '../../services/workspace/migrate-service.js';
|
|
3
7
|
import { ensureSession } from '../../services/session/session-manager.js';
|
|
4
8
|
import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
|
|
9
|
+
import { applyHookInstall, readHookStatus } from '../../services/skills/hooks-settings-service.js';
|
|
5
10
|
import { fail, ok } from '../../shared/result.js';
|
|
6
11
|
import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
|
|
12
|
+
/** Sticky decision marker for the first-time "install hooks" prompt. */
|
|
13
|
+
const HOOKS_DECISION_REL_PATH = '.peaks/.peaks-init-hooks-decision.json';
|
|
14
|
+
function readDecisionMarker(projectRoot) {
|
|
15
|
+
const path = join(projectRoot, HOOKS_DECISION_REL_PATH);
|
|
16
|
+
if (!existsSync(path))
|
|
17
|
+
return null;
|
|
18
|
+
try {
|
|
19
|
+
const data = JSON.parse(readFileSync(path, 'utf8'));
|
|
20
|
+
if (data.version !== 1)
|
|
21
|
+
return null;
|
|
22
|
+
if (data.decision !== 'installed' && data.decision !== 'skipped')
|
|
23
|
+
return null;
|
|
24
|
+
if (typeof data.decidedAt !== 'string')
|
|
25
|
+
return null;
|
|
26
|
+
if (data.scope !== 'project' && data.scope !== 'global')
|
|
27
|
+
return null;
|
|
28
|
+
return {
|
|
29
|
+
version: 1,
|
|
30
|
+
decision: data.decision,
|
|
31
|
+
decidedAt: data.decidedAt,
|
|
32
|
+
scope: data.scope
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
function writeDecisionMarker(projectRoot, decision) {
|
|
40
|
+
const path = join(projectRoot, HOOKS_DECISION_REL_PATH);
|
|
41
|
+
const dir = join(projectRoot, '.peaks');
|
|
42
|
+
mkdirSync(dir, { recursive: true });
|
|
43
|
+
const marker = {
|
|
44
|
+
version: 1,
|
|
45
|
+
decision,
|
|
46
|
+
decidedAt: new Date().toISOString(),
|
|
47
|
+
scope: 'project'
|
|
48
|
+
};
|
|
49
|
+
writeFileSync(path, JSON.stringify(marker, null, 2) + '\n', 'utf8');
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Read a yes/no answer from stdin. Returns `true` for empty / Y / y,
|
|
53
|
+
* `false` for N / n, or `null` when stdin is not a TTY (the caller falls
|
|
54
|
+
* back to the no-prompt path). Times out after 30s so a piped-but-blocked
|
|
55
|
+
* stdin never hangs the CLI.
|
|
56
|
+
*/
|
|
57
|
+
function promptYesNo(question) {
|
|
58
|
+
return new Promise((resolve) => {
|
|
59
|
+
if (process.stdin.isTTY !== true) {
|
|
60
|
+
resolve(null);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
64
|
+
const timer = setTimeout(() => {
|
|
65
|
+
rl.close();
|
|
66
|
+
resolve(null);
|
|
67
|
+
}, 30_000);
|
|
68
|
+
rl.question(question, (answer) => {
|
|
69
|
+
clearTimeout(timer);
|
|
70
|
+
rl.close();
|
|
71
|
+
const trimmed = answer.trim().toLowerCase();
|
|
72
|
+
if (trimmed === '' || trimmed === 'y' || trimmed === 'yes') {
|
|
73
|
+
resolve(true);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
if (trimmed === 'n' || trimmed === 'no') {
|
|
77
|
+
resolve(false);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
// Treat anything else as "no" — the user can re-run with --install-hooks
|
|
81
|
+
// if they want a different answer. We never throw from this prompt.
|
|
82
|
+
resolve(false);
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
}
|
|
7
86
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
8
87
|
const DEFAULT_RECONCILE_AGE_DAYS = 7;
|
|
9
88
|
export function registerWorkspaceCommands(program, io) {
|
|
10
89
|
const workspace = program.command('workspace').description('Manage the Peaks per-session artifact workspace (.peaks/<session-id>/)');
|
|
11
90
|
addJsonOption(workspace
|
|
12
91
|
.command('init')
|
|
13
|
-
.description('Create the .peaks/<session-id>/ directory structure (prd, ui, rd, qa, sc, txt, system) and bind the session as the project current one. Pass --session-id to use a specific id, or omit it to auto-generate one (and adopt an existing binding if present).')
|
|
92
|
+
.description('Create the .peaks/<session-id>/ directory structure (prd, ui, rd, qa, sc, txt, system) and bind the session as the project current one. Pass --session-id to use a specific id, or omit it to auto-generate one (and adopt an existing binding if present). On the first call for a project, also handles the one-time "install peaks hooks" decision (sticky-marker stored in .peaks/.peaks-init-hooks-decision.json).')
|
|
14
93
|
.requiredOption('--project <path>', 'target project root')
|
|
15
94
|
.option('--session-id <id>', 'optional session id in YYYY-MM-DD-<kebab-slug> format. When omitted, the CLI is the single source of truth: an existing binding is reused, otherwise a fresh id is auto-generated.')
|
|
16
|
-
.option('--allow-session-rebind', 'overwrite an existing session binding when the requested session id differs from the project current one', false)
|
|
95
|
+
.option('--allow-session-rebind', 'overwrite an existing session binding when the requested session id differs from the project current one', false)
|
|
96
|
+
.option('--change-id <id>', 'bind the change-id for reviewable artifacts (writes route to .peaks/<change-id>/<role>/, tracked in git). When omitted, the change-id binding is left unchanged.', (value) => {
|
|
97
|
+
if (value.length === 0) {
|
|
98
|
+
throw new Error('--change-id must not be empty');
|
|
99
|
+
}
|
|
100
|
+
return value;
|
|
101
|
+
})
|
|
102
|
+
.option('--install-hooks <mode>', 'first-time hooks install behaviour: ask (default in TTY, prompt once + sticky-marker), auto (default in --json / non-TTY, install silently + sticky-marker), skip (sticky-marker skipped, do not install)', (value) => {
|
|
103
|
+
if (value !== 'ask' && value !== 'auto' && value !== 'skip') {
|
|
104
|
+
throw new Error(`--install-hooks must be one of: ask, auto, skip (got "${value}")`);
|
|
105
|
+
}
|
|
106
|
+
return value;
|
|
107
|
+
})).action(async (options) => {
|
|
17
108
|
try {
|
|
18
109
|
// Resolve the session id. Two paths:
|
|
19
110
|
// - explicit --session-id: use it as the requested binding target
|
|
@@ -43,7 +134,8 @@ export function registerWorkspaceCommands(program, io) {
|
|
|
43
134
|
const report = await initWorkspace({
|
|
44
135
|
projectRoot,
|
|
45
136
|
sessionId,
|
|
46
|
-
allowSessionRebind: options.allowSessionRebind === true
|
|
137
|
+
allowSessionRebind: options.allowSessionRebind === true,
|
|
138
|
+
...(options.changeId !== undefined ? { changeId: options.changeId } : {})
|
|
47
139
|
});
|
|
48
140
|
const nextActions = [];
|
|
49
141
|
if (report.previousSessionId !== null && report.bound) {
|
|
@@ -55,7 +147,34 @@ export function registerWorkspaceCommands(program, io) {
|
|
|
55
147
|
else {
|
|
56
148
|
nextActions.push('Run `peaks scan archetype --project <path> --json` next to populate rd/project-scan.md.');
|
|
57
149
|
}
|
|
58
|
-
|
|
150
|
+
// First-time hooks install decision. Sticky-marker at
|
|
151
|
+
// .peaks/.peaks-init-hooks-decision.json records the user's answer
|
|
152
|
+
// (or the auto-decision) so subsequent inits for new sessions in the
|
|
153
|
+
// same project do not re-prompt. The marker is the only state that
|
|
154
|
+
// survives across sessions — without it, every new session would
|
|
155
|
+
// re-trigger the question.
|
|
156
|
+
const hooksOutcome = await resolveFirstTimeHooksInstall({
|
|
157
|
+
projectRoot,
|
|
158
|
+
...(options.installHooks !== undefined ? { explicitMode: options.installHooks } : {}),
|
|
159
|
+
jsonMode: options.json === true
|
|
160
|
+
});
|
|
161
|
+
if (hooksOutcome.decision === 'installed') {
|
|
162
|
+
nextActions.push(hooksOutcome.action === 'reinstalled'
|
|
163
|
+
? 'Re-installed the peaks-managed PreToolUse hooks (Bash→gate enforce, Task→progress start) — the marker said installed but the hooks were missing.'
|
|
164
|
+
: 'Installed the peaks-managed PreToolUse hooks (Bash→gate enforce, Task→progress start). Restart Claude Code so the hooks take effect.');
|
|
165
|
+
}
|
|
166
|
+
else if (hooksOutcome.action === 'first-decision' && hooksOutcome.decision === 'skipped') {
|
|
167
|
+
nextActions.push('Skipped peaks-managed hook install for this project. Re-run with --install-hooks=auto (or peaks hooks install) to install later.');
|
|
168
|
+
}
|
|
169
|
+
printResult(io, ok('workspace.init', {
|
|
170
|
+
...report,
|
|
171
|
+
hooksInstall: {
|
|
172
|
+
decision: hooksOutcome.decision,
|
|
173
|
+
action: hooksOutcome.action,
|
|
174
|
+
scope: hooksOutcome.scope,
|
|
175
|
+
...(hooksOutcome.reason !== undefined ? { reason: hooksOutcome.reason } : {})
|
|
176
|
+
}
|
|
177
|
+
}, [], nextActions), options.json);
|
|
59
178
|
}
|
|
60
179
|
catch (error) {
|
|
61
180
|
if (error instanceof InvalidSessionIdError) {
|
|
@@ -133,4 +252,169 @@ export function registerWorkspaceCommands(program, io) {
|
|
|
133
252
|
process.exitCode = 1;
|
|
134
253
|
}
|
|
135
254
|
});
|
|
255
|
+
addJsonOption(workspace
|
|
256
|
+
.command('migrate')
|
|
257
|
+
.description('Migrate legacy `.peaks/<session-id>/<role>/<file>` content into the new layout: ' +
|
|
258
|
+
'`.peaks/retrospective/<change-id>/<role>/<file>`. Each file is routed by a 4-tier ' +
|
|
259
|
+
'change-id resolver (filename regex → content H1 → body frontmatter → per-session fallback ' +
|
|
260
|
+
'to the most recent rd/requests entry). Cross-cutting files (project-scan, perf-baseline) ' +
|
|
261
|
+
'and transient runtime files (session.json, system/) are skipped with reasons in the ' +
|
|
262
|
+
'response. By default the command is a dry-run: it reports the planned moves + conflicts ' +
|
|
263
|
+
'and the session dirs that WOULD be deleted. Pass --apply to actually `git mv` the files ' +
|
|
264
|
+
'and `rm -rf` the emptied session dirs. Idempotent: re-running on an already-migrated tree ' +
|
|
265
|
+
'is a no-op (all files report conflicts with identical content).')
|
|
266
|
+
.requiredOption('--project <path>', 'target project root')
|
|
267
|
+
.option('--apply', 'actually `git mv` the files and delete the emptied session dirs (destructive); without it, dry-run only', false)).action(async (options) => {
|
|
268
|
+
try {
|
|
269
|
+
const projectRoot = resolveCanonicalProjectRoot(options.project);
|
|
270
|
+
const apply = options.apply === true;
|
|
271
|
+
const result = await migrateWorkspace({ projectRoot, apply });
|
|
272
|
+
const warnings = [];
|
|
273
|
+
if (result.sessions.length === 0) {
|
|
274
|
+
warnings.push('No legacy session directories found under .peaks/. Nothing to migrate.');
|
|
275
|
+
}
|
|
276
|
+
else if (result.wouldMove.length === 0) {
|
|
277
|
+
warnings.push('Legacy session dirs found but no reviewable content to migrate (all files were cross-cutting or transient).');
|
|
278
|
+
}
|
|
279
|
+
const nextActions = [];
|
|
280
|
+
if (!apply && result.wouldMove.length > 0) {
|
|
281
|
+
nextActions.push(`Re-run with --apply to perform ${result.wouldMove.length} move(s) and delete ${result.wouldDeleteSessions.length} session dir(s).`);
|
|
282
|
+
}
|
|
283
|
+
if (result.conflicts.length > 0) {
|
|
284
|
+
nextActions.push(`${result.conflicts.length} file(s) already exist at the target path; review before --apply (or re-run after a partial migrate).`);
|
|
285
|
+
}
|
|
286
|
+
if (apply) {
|
|
287
|
+
if (result.moved.length > 0) {
|
|
288
|
+
nextActions.push(`Migrated ${result.moved.length} file(s) into .peaks/retrospective/.`);
|
|
289
|
+
}
|
|
290
|
+
if (result.deletedSessions.length > 0) {
|
|
291
|
+
nextActions.push(`Deleted ${result.deletedSessions.length} emptied session dir(s).`);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
printResult(io, ok('workspace.migrate', result, warnings, nextActions), options.json ?? false);
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
printResult(io, fail('workspace.migrate', 'WORKSPACE_MIGRATE_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Verify the project path exists and is writable']), options.json ?? false);
|
|
298
|
+
process.exitCode = 1;
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
/**
|
|
303
|
+
* Resolve the first-time "install peaks hooks" decision for this project.
|
|
304
|
+
* Decision tree:
|
|
305
|
+
*
|
|
306
|
+
* 1. Read the sticky marker.
|
|
307
|
+
* - Marker present:
|
|
308
|
+
* - marker.decision === 'installed' AND hooks are present → action: marker-honored, no side effects
|
|
309
|
+
* - marker.decision === 'installed' AND hooks are MISSING → re-install, action: reinstalled
|
|
310
|
+
* - marker.decision === 'skipped' → action: marker-honored, no install
|
|
311
|
+
* - Marker absent:
|
|
312
|
+
* - hooks already present → write a fresh 'installed' marker, action: already-installed
|
|
313
|
+
* - otherwise:
|
|
314
|
+
* - explicit --install-hooks=auto → install + marker, action: first-decision
|
|
315
|
+
* - explicit --install-hooks=skip → marker only, action: first-decision
|
|
316
|
+
* - explicit --install-hooks=ask OR default in TTY:
|
|
317
|
+
* - jsonMode → silently auto-install (LLM cannot answer), action: first-decision
|
|
318
|
+
* - TTY → prompt; on yes install + marker, on no marker-only
|
|
319
|
+
* - default in non-TTY → auto-install, action: first-decision
|
|
320
|
+
*
|
|
321
|
+
* Project scope is the only supported scope here; global scope is reserved
|
|
322
|
+
* for explicit `peaks hooks install --global` invocations.
|
|
323
|
+
*/
|
|
324
|
+
export async function resolveFirstTimeHooksInstall(options) {
|
|
325
|
+
const { projectRoot, jsonMode } = options;
|
|
326
|
+
const existingMarker = readDecisionMarker(projectRoot);
|
|
327
|
+
// readHookStatus can throw (e.g. .claude is a symlink → safety check rejects).
|
|
328
|
+
// Treat any throw as "hooks status unknown → treat as not-installed" so the
|
|
329
|
+
// function still reaches the install path; the install will surface the same
|
|
330
|
+
// error in a more specific reason field.
|
|
331
|
+
let hookStatus;
|
|
332
|
+
try {
|
|
333
|
+
hookStatus = readHookStatus('project', projectRoot);
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
hookStatus = { installed: false };
|
|
337
|
+
// Fall through to the install path; the failure will be captured below.
|
|
338
|
+
void error;
|
|
339
|
+
}
|
|
340
|
+
if (existingMarker !== null) {
|
|
341
|
+
if (existingMarker.decision === 'installed' && !hookStatus.installed) {
|
|
342
|
+
try {
|
|
343
|
+
applyHookInstall('project', projectRoot);
|
|
344
|
+
return { decision: 'installed', action: 'reinstalled', scope: 'project', reason: 'marker-said-installed-hooks-missing' };
|
|
345
|
+
}
|
|
346
|
+
catch (error) {
|
|
347
|
+
return { decision: existingMarker.decision, action: 'marker-honored', scope: 'project', reason: `reinstall-failed: ${getErrorMessage(error)}` };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return { decision: existingMarker.decision, action: 'marker-honored', scope: existingMarker.scope };
|
|
351
|
+
}
|
|
352
|
+
// No marker yet — first decision.
|
|
353
|
+
if (hookStatus.installed) {
|
|
354
|
+
writeDecisionMarker(projectRoot, 'installed');
|
|
355
|
+
return { decision: 'installed', action: 'already-installed', scope: 'project' };
|
|
356
|
+
}
|
|
357
|
+
// Determine effective mode (explicit flag wins; default depends on TTY + jsonMode).
|
|
358
|
+
const explicitMode = options.explicitMode;
|
|
359
|
+
const effectiveMode = explicitMode ??
|
|
360
|
+
(jsonMode ? 'auto' : (process.stdin.isTTY === true ? 'ask' : 'auto'));
|
|
361
|
+
if (effectiveMode === 'skip') {
|
|
362
|
+
writeDecisionMarker(projectRoot, 'skipped');
|
|
363
|
+
return { decision: 'skipped', action: 'first-decision', scope: 'project', reason: 'explicit-skip' };
|
|
364
|
+
}
|
|
365
|
+
if (effectiveMode === 'auto' || jsonMode) {
|
|
366
|
+
// The reason code distinguishes the path the user took to reach auto-install:
|
|
367
|
+
// - explicit-auto: user passed --install-hooks=auto
|
|
368
|
+
// - json-mode: no --install-hooks flag, but --json was set
|
|
369
|
+
// - non-tty-default: no flag, no --json, stdin is not a TTY
|
|
370
|
+
let autoReason;
|
|
371
|
+
if (explicitMode === 'auto') {
|
|
372
|
+
autoReason = 'explicit-auto';
|
|
373
|
+
}
|
|
374
|
+
else if (jsonMode) {
|
|
375
|
+
autoReason = 'json-mode';
|
|
376
|
+
}
|
|
377
|
+
else {
|
|
378
|
+
autoReason = 'non-tty-default';
|
|
379
|
+
}
|
|
380
|
+
try {
|
|
381
|
+
applyHookInstall('project', projectRoot);
|
|
382
|
+
writeDecisionMarker(projectRoot, 'installed');
|
|
383
|
+
return { decision: 'installed', action: 'first-decision', scope: 'project', reason: autoReason };
|
|
384
|
+
}
|
|
385
|
+
catch (error) {
|
|
386
|
+
// Auto-install failed: still record the decision so we do not keep retrying
|
|
387
|
+
// every workspace init. The user can fix the underlying problem and run
|
|
388
|
+
// `peaks hooks install` manually.
|
|
389
|
+
writeDecisionMarker(projectRoot, 'installed');
|
|
390
|
+
return { decision: 'installed', action: 'first-decision', scope: 'project', reason: `install-failed: ${getErrorMessage(error)}` };
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
// effectiveMode === 'ask' AND TTY: prompt once.
|
|
394
|
+
process.stderr.write('\nPeaks-Cli: install the PreToolUse hooks for this project now?\n' +
|
|
395
|
+
' → Bash matcher: `peaks gate enforce` (SOP gate enforcement)\n' +
|
|
396
|
+
' → Task matcher: `peaks progress start` (auto-spawn sub-agent progress terminal)\n' +
|
|
397
|
+
'Both run on every Claude Code tool call without further prompting. The decision is sticky\n' +
|
|
398
|
+
'(recorded in .peaks/.peaks-init-hooks-decision.json) and re-runs of `workspace init` will\n' +
|
|
399
|
+
'honour it. Re-run with --install-hooks=skip or --install-hooks=auto to override.\n\n' +
|
|
400
|
+
'Install now? [Y/n]: ');
|
|
401
|
+
const answer = await promptYesNo('');
|
|
402
|
+
if (answer === null) {
|
|
403
|
+
// TTY disappeared mid-prompt (rare): treat as skip + write marker.
|
|
404
|
+
writeDecisionMarker(projectRoot, 'skipped');
|
|
405
|
+
return { decision: 'skipped', action: 'first-decision', scope: 'project', reason: 'tty-prompt-aborted' };
|
|
406
|
+
}
|
|
407
|
+
if (!answer) {
|
|
408
|
+
writeDecisionMarker(projectRoot, 'skipped');
|
|
409
|
+
return { decision: 'skipped', action: 'first-decision', scope: 'project', reason: 'user-answered-no' };
|
|
410
|
+
}
|
|
411
|
+
try {
|
|
412
|
+
applyHookInstall('project', projectRoot);
|
|
413
|
+
writeDecisionMarker(projectRoot, 'installed');
|
|
414
|
+
return { decision: 'installed', action: 'first-decision', scope: 'project', reason: 'user-answered-yes' };
|
|
415
|
+
}
|
|
416
|
+
catch (error) {
|
|
417
|
+
writeDecisionMarker(projectRoot, 'installed');
|
|
418
|
+
return { decision: 'installed', action: 'first-decision', scope: 'project', reason: `install-failed: ${getErrorMessage(error)}` };
|
|
419
|
+
}
|
|
136
420
|
}
|
package/dist/src/cli/program.js
CHANGED
|
@@ -15,6 +15,7 @@ import { registerProjectCommands } from './commands/project-commands.js';
|
|
|
15
15
|
import { registerRequestCommands } from './commands/request-commands.js';
|
|
16
16
|
import { registerScanCommands } from './commands/scan-commands.js';
|
|
17
17
|
import { registerShadcnCommands } from './commands/shadcn-commands.js';
|
|
18
|
+
import { registerSliceCommands } from './commands/slice-commands.js';
|
|
18
19
|
import { registerSopCommands } from './commands/sop-commands.js';
|
|
19
20
|
import { registerGateCommands } from './commands/gate-commands.js';
|
|
20
21
|
import { registerHooksCommands } from './commands/hooks-commands.js';
|
|
@@ -31,6 +32,8 @@ export function createProgram(io = { stdout: (text) => console.log(text), stderr
|
|
|
31
32
|
Run peaks (no arguments) for a quickstart. You likely want one of:
|
|
32
33
|
peaks doctor check your environment
|
|
33
34
|
peaks skill list or manage skills
|
|
35
|
+
peaks slice boundary check (tsc + vitest + 3-way + verify-pipeline)
|
|
36
|
+
peaks workflow plan workflow routing dry-run graphs
|
|
34
37
|
peaks sop author your own workflow gates
|
|
35
38
|
peaks hooks install the un-bypassable gate-enforcement hook
|
|
36
39
|
peaks gate enforce/bypass SOP gates on Bash commands`)
|
|
@@ -87,6 +90,7 @@ Run peaks (no arguments) for a quickstart. You likely want one of:
|
|
|
87
90
|
registerRequestCommands(program, io);
|
|
88
91
|
registerScanCommands(program, io);
|
|
89
92
|
registerShadcnCommands(program, io);
|
|
93
|
+
registerSliceCommands(program, io);
|
|
90
94
|
registerSopCommands(program, io);
|
|
91
95
|
registerGateCommands(program, io);
|
|
92
96
|
registerHooksCommands(program, io);
|
|
@@ -10,6 +10,15 @@ export type ArtifactPrerequisite = {
|
|
|
10
10
|
description: string;
|
|
11
11
|
/** Optional content markers — when set, the file must contain ALL of these (case-insensitive substring). */
|
|
12
12
|
mustContain?: ReadonlyArray<string>;
|
|
13
|
+
/**
|
|
14
|
+
* Optional content markers — when set, the file must contain AT LEAST ONE of
|
|
15
|
+
* these (case-insensitive substring). Use this for escape-hatch patterns
|
|
16
|
+
* (e.g. perf-baseline's "Results table" OR "N/A — no perf surface" stub).
|
|
17
|
+
* `mustContain` and `mustContainAny` are independent: when both are set,
|
|
18
|
+
* `mustContain` markers must all be present AND at least one `mustContainAny`
|
|
19
|
+
* marker must be present.
|
|
20
|
+
*/
|
|
21
|
+
mustContainAny?: ReadonlyArray<string>;
|
|
13
22
|
};
|
|
14
23
|
export type PrerequisiteCheckResult = {
|
|
15
24
|
ok: boolean;
|
|
@@ -20,7 +29,14 @@ export type PrerequisiteCheckResult = {
|
|
|
20
29
|
};
|
|
21
30
|
export type CheckPrerequisitesOptions = {
|
|
22
31
|
projectRoot: string;
|
|
23
|
-
|
|
32
|
+
/**
|
|
33
|
+
* Durable scope of the artifact (the `.peaks/<changeId>/` directory
|
|
34
|
+
* the file lives in). The gate scans under `.peaks/<changeId>/<role>/`
|
|
35
|
+
* for prerequisite artifacts. As of slice 2026-06-05-change-id-as-unit-of-work,
|
|
36
|
+
* this replaces the legacy `sessionId` field — the file body and the
|
|
37
|
+
* on-disk path now agree on the same top-level dir.
|
|
38
|
+
*/
|
|
39
|
+
changeId: string;
|
|
24
40
|
role: RequestArtifactRole;
|
|
25
41
|
newState: RequestArtifactState;
|
|
26
42
|
requestId: string;
|
|
@@ -30,6 +30,19 @@ const CODE_REVIEW = {
|
|
|
30
30
|
mustContain: ['## Findings', 'CRITICAL']
|
|
31
31
|
};
|
|
32
32
|
const SECURITY_REVIEW = { relativePath: 'rd/security-review.md', description: 'Security review evidence for the changed surface' };
|
|
33
|
+
// Gate B9 — RD-side perf baseline (peaks-rd SKILL "Parallel review fan-out").
|
|
34
|
+
// The file must exist; the body must either carry a Results table marker
|
|
35
|
+
// (per peaks-rd SKILL "Mandatory perf-baseline output") or the explicit
|
|
36
|
+
// "N/A — no perf surface" escape hatch. A slice without a perf surface
|
|
37
|
+
// still has to write the stub; an RD that omits perf-baseline entirely is
|
|
38
|
+
// blocked here, matching peaks-rd's BLOCKING Gate B9 claim.
|
|
39
|
+
const PERF_BASELINE = {
|
|
40
|
+
relativePath: 'rd/perf-baseline.md',
|
|
41
|
+
description: 'RD-side perf baseline (peaks-rd Gate B9) — must include a Results table with measurements OR the literal "N/A — no perf surface" escape hatch in the Notes section. QA Gate A4 diffs against this file.',
|
|
42
|
+
// Either a real Results table is present, OR the explicit no-perf-surface
|
|
43
|
+
// stub marker. Both paths satisfy Gate B9; absence of both is BLOCKED.
|
|
44
|
+
mustContainAny: ['## Results', 'N/A — no perf surface']
|
|
45
|
+
};
|
|
33
46
|
const TEST_CASES = {
|
|
34
47
|
relativePath: 'qa/test-cases/<rid>.md',
|
|
35
48
|
description: 'Generated test cases (unit / integration / UI regression)',
|
|
@@ -70,16 +83,19 @@ const QA_INITIATED = {
|
|
|
70
83
|
const FEATURE_TABLE = {
|
|
71
84
|
'prd:handed-off': [PRD_CONTENT],
|
|
72
85
|
'rd:implemented': [TECH_DOC],
|
|
73
|
-
'rd:qa-handoff': [TECH_DOC, CODE_REVIEW, SECURITY_REVIEW, UNIT_TESTS, QA_INITIATED],
|
|
86
|
+
'rd:qa-handoff': [TECH_DOC, CODE_REVIEW, SECURITY_REVIEW, PERF_BASELINE, UNIT_TESTS, QA_INITIATED],
|
|
74
87
|
'qa:running': [TEST_CASES],
|
|
75
88
|
'qa:verdict-issued': [TEST_CASES, TEST_REPORT, SECURITY_FINDINGS, PERFORMANCE_FINDINGS]
|
|
76
89
|
};
|
|
77
90
|
// Bugfix: lighter planning artifact (bug-analysis instead of tech-doc), still requires code review + security review + regression test.
|
|
78
|
-
// Performance
|
|
91
|
+
// Performance baseline: required for perf-shaped bugfixes (where the bug IS a
|
|
92
|
+
// perf regression). For non-perf bugfixes, RD writes the perf-baseline stub
|
|
93
|
+
// with "N/A — no perf surface" — Gate B9 still passes (mustContainAny hit),
|
|
94
|
+
// and the stub tells QA Gate A4 to skip the perf diff.
|
|
79
95
|
const BUGFIX_TABLE = {
|
|
80
96
|
'prd:handed-off': [PRD_CONTENT],
|
|
81
97
|
'rd:implemented': [BUG_ANALYSIS],
|
|
82
|
-
'rd:qa-handoff': [BUG_ANALYSIS, CODE_REVIEW, SECURITY_REVIEW, UNIT_TESTS, QA_INITIATED],
|
|
98
|
+
'rd:qa-handoff': [BUG_ANALYSIS, CODE_REVIEW, SECURITY_REVIEW, PERF_BASELINE, UNIT_TESTS, QA_INITIATED],
|
|
83
99
|
'qa:running': [TEST_CASES],
|
|
84
100
|
'qa:verdict-issued': [TEST_CASES, TEST_REPORT, SECURITY_FINDINGS]
|
|
85
101
|
};
|
|
@@ -146,11 +162,17 @@ export async function checkPrerequisites(options) {
|
|
|
146
162
|
if (requirements.length === 0) {
|
|
147
163
|
return { ok: true, missing: [] };
|
|
148
164
|
}
|
|
149
|
-
|
|
165
|
+
// As of slice 2026-06-05-change-id-as-unit-of-work, the prerequisite
|
|
166
|
+
// gate resolves paths under `.peaks/<changeId>/<role>/...` where the
|
|
167
|
+
// changeId is the file's durable scope (the top-level dir the file
|
|
168
|
+
// lives in), NOT the body's `- session:` line. The body and the path
|
|
169
|
+
// can now disagree (e.g. a request written in one session but read
|
|
170
|
+
// across sessions), and the gate follows the on-disk location.
|
|
171
|
+
const changeRoot = join(options.projectRoot, '.peaks', options.changeId);
|
|
150
172
|
const missing = [];
|
|
151
173
|
for (const prerequisite of requirements) {
|
|
152
174
|
const relative = resolvePrerequisitePath(prerequisite, options.requestId);
|
|
153
|
-
const absolute = await resolvePrerequisiteAbsolutePath(
|
|
175
|
+
const absolute = await resolvePrerequisiteAbsolutePath(changeRoot, prerequisite, options.requestId);
|
|
154
176
|
if (absolute === null) {
|
|
155
177
|
missing.push({ path: relative, description: prerequisite.description });
|
|
156
178
|
continue;
|
|
@@ -166,6 +188,17 @@ export async function checkPrerequisites(options) {
|
|
|
166
188
|
});
|
|
167
189
|
}
|
|
168
190
|
}
|
|
191
|
+
if (prerequisite.mustContainAny && prerequisite.mustContainAny.length > 0) {
|
|
192
|
+
const body = await readFile(absolute, 'utf8');
|
|
193
|
+
const lowered = body.toLowerCase();
|
|
194
|
+
const hitAny = prerequisite.mustContainAny.some((marker) => lowered.includes(marker.toLowerCase()));
|
|
195
|
+
if (!hitAny) {
|
|
196
|
+
missing.push({
|
|
197
|
+
path: relative,
|
|
198
|
+
description: `${prerequisite.description} — none of the escape-hatch markers present: ${prerequisite.mustContainAny.join(', ')}`
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
}
|
|
169
202
|
}
|
|
170
203
|
return { ok: missing.length === 0, missing };
|
|
171
204
|
}
|
|
@@ -6,6 +6,14 @@ export type CreateRequestArtifactOptions = {
|
|
|
6
6
|
requestId: string;
|
|
7
7
|
projectRoot: string;
|
|
8
8
|
sessionId?: string;
|
|
9
|
+
/**
|
|
10
|
+
* Optional explicit change-id. When set, the artifact file lands at
|
|
11
|
+
* `.peaks/<changeId>/<role>/requests/...` regardless of any
|
|
12
|
+
* `current-change` binding. When unset, falls back to the binding, then
|
|
13
|
+
* to the requestId. The CLI's `--session-id <scope>` flag uses this to
|
|
14
|
+
* preserve the legacy "session-id as scope dir name" behavior.
|
|
15
|
+
*/
|
|
16
|
+
changeId?: string;
|
|
9
17
|
apply?: boolean;
|
|
10
18
|
requestType?: RequestType;
|
|
11
19
|
clock?: () => string;
|
|
@@ -21,6 +29,20 @@ export type CreateRequestArtifactResult = {
|
|
|
21
29
|
export declare function createRequestArtifact(options: CreateRequestArtifactOptions): Promise<CreateRequestArtifactResult>;
|
|
22
30
|
export type RequestArtifactSummary = {
|
|
23
31
|
role: RequestArtifactRole;
|
|
32
|
+
/**
|
|
33
|
+
* Durable scope of the artifact: the top-level `.peaks/<changeId>/`
|
|
34
|
+
* directory the file lives in. As of slice 2026-06-05-change-id-as-unit-of-work,
|
|
35
|
+
* the prerequisite gate resolves paths under this dir (not the body
|
|
36
|
+
* `- session:` line), so the file body and the on-disk path agree.
|
|
37
|
+
*/
|
|
38
|
+
changeId: string;
|
|
39
|
+
/**
|
|
40
|
+
* Session binding (which developer's local session wrote the file).
|
|
41
|
+
* Read from the file body's `- session:` line. Falls back to `changeId`
|
|
42
|
+
* when the body is missing the line. For back-compat with legacy
|
|
43
|
+
* session-id dirs, this may equal the dir name; for new change-id
|
|
44
|
+
* dirs, it is the metadata session that produced the file.
|
|
45
|
+
*/
|
|
24
46
|
sessionId: string;
|
|
25
47
|
requestId: string;
|
|
26
48
|
path: string;
|