peaks-cli 1.2.9 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/README.md +62 -46
  2. package/bin/peaks.js +0 -0
  3. package/dist/src/cli/commands/hooks-commands.js +24 -9
  4. package/dist/src/cli/commands/progress-commands.js +26 -2
  5. package/dist/src/cli/commands/request-commands.js +5 -0
  6. package/dist/src/cli/commands/slice-commands.d.ts +3 -0
  7. package/dist/src/cli/commands/slice-commands.js +42 -0
  8. package/dist/src/cli/commands/workflow-commands.js +3 -3
  9. package/dist/src/cli/commands/workspace-commands.d.ts +63 -0
  10. package/dist/src/cli/commands/workspace-commands.js +347 -5
  11. package/dist/src/cli/program.js +4 -0
  12. package/dist/src/services/artifacts/artifact-prerequisites.d.ts +17 -1
  13. package/dist/src/services/artifacts/artifact-prerequisites.js +38 -5
  14. package/dist/src/services/artifacts/request-artifact-service.d.ts +22 -0
  15. package/dist/src/services/artifacts/request-artifact-service.js +172 -54
  16. package/dist/src/services/doctor/doctor-service.d.ts +7 -0
  17. package/dist/src/services/doctor/doctor-service.js +20 -2
  18. package/dist/src/services/progress/progress-service.d.ts +26 -0
  19. package/dist/src/services/progress/progress-service.js +25 -0
  20. package/dist/src/services/sc/sc-service.d.ts +52 -1
  21. package/dist/src/services/sc/sc-service.js +324 -17
  22. package/dist/src/services/scan/acceptance-coverage-service.js +6 -2
  23. package/dist/src/services/session/session-manager.d.ts +7 -5
  24. package/dist/src/services/session/session-manager.js +60 -16
  25. package/dist/src/services/skills/hooks-settings-service.d.ts +25 -3
  26. package/dist/src/services/skills/hooks-settings-service.js +57 -13
  27. package/dist/src/services/skills/skill-presence-service.js +102 -68
  28. package/dist/src/services/skills/skill-runbook-service.js +2 -1
  29. package/dist/src/services/skills/skill-statusline-service.js +13 -7
  30. package/dist/src/services/slice/slice-check-service.d.ts +2 -0
  31. package/dist/src/services/slice/slice-check-service.js +248 -0
  32. package/dist/src/services/slice/slice-check-types.d.ts +61 -0
  33. package/dist/src/services/slice/slice-check-types.js +18 -0
  34. package/dist/src/services/workflow/pipeline-verify-service.d.ts +5 -2
  35. package/dist/src/services/workflow/pipeline-verify-service.js +35 -35
  36. package/dist/src/services/workspace/migrate-service.d.ts +2 -0
  37. package/dist/src/services/workspace/migrate-service.js +484 -0
  38. package/dist/src/services/workspace/migrate-types.d.ts +84 -0
  39. package/dist/src/services/workspace/migrate-types.js +21 -0
  40. package/dist/src/services/workspace/reconcile-service.d.ts +119 -0
  41. package/dist/src/services/workspace/reconcile-service.js +464 -0
  42. package/dist/src/services/workspace/reconcile-types.d.ts +93 -0
  43. package/dist/src/services/workspace/reconcile-types.js +13 -0
  44. package/dist/src/services/workspace/workspace-service.d.ts +11 -0
  45. package/dist/src/services/workspace/workspace-service.js +87 -7
  46. package/dist/src/shared/change-id.d.ts +59 -0
  47. package/dist/src/shared/change-id.js +194 -16
  48. package/dist/src/shared/version.d.ts +1 -1
  49. package/dist/src/shared/version.js +1 -1
  50. package/package.json +13 -2
  51. package/skills/peaks-solo/SKILL.md +28 -4
  52. package/skills/peaks-solo/references/micro-cycle.md +155 -0
  53. package/skills/peaks-solo/references/runbook.md +2 -0
@@ -1,16 +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';
5
+ import { reconcileWorkspace } from '../../services/workspace/reconcile-service.js';
6
+ import { migrateWorkspace } from '../../services/workspace/migrate-service.js';
2
7
  import { ensureSession } from '../../services/session/session-manager.js';
3
8
  import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
9
+ import { applyHookInstall, readHookStatus } from '../../services/skills/hooks-settings-service.js';
4
10
  import { fail, ok } from '../../shared/result.js';
5
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
+ }
86
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
87
+ const DEFAULT_RECONCILE_AGE_DAYS = 7;
6
88
  export function registerWorkspaceCommands(program, io) {
7
89
  const workspace = program.command('workspace').description('Manage the Peaks per-session artifact workspace (.peaks/<session-id>/)');
8
90
  addJsonOption(workspace
9
91
  .command('init')
10
- .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).')
11
93
  .requiredOption('--project <path>', 'target project root')
12
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.')
13
- .option('--allow-session-rebind', 'overwrite an existing session binding when the requested session id differs from the project current one', false)).action(async (options) => {
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) => {
14
108
  try {
15
109
  // Resolve the session id. Two paths:
16
110
  // - explicit --session-id: use it as the requested binding target
@@ -18,7 +112,7 @@ export function registerWorkspaceCommands(program, io) {
18
112
  // session, unless --allow-session-rebind is set)
19
113
  // - omitted: defer to ensureSession(), which reuses an existing
20
114
  // binding or auto-generates a fresh one. The init then writes
21
- // .session.json so the binding sticks.
115
+ // .peaks/_runtime/session.json so the binding sticks.
22
116
  //
23
117
  // Before that: canonicalise the project root. If the user (or the
24
118
  // LLM via "$(pwd)") passed a sub-directory of a real git repo
@@ -40,7 +134,8 @@ export function registerWorkspaceCommands(program, io) {
40
134
  const report = await initWorkspace({
41
135
  projectRoot,
42
136
  sessionId,
43
- allowSessionRebind: options.allowSessionRebind === true
137
+ allowSessionRebind: options.allowSessionRebind === true,
138
+ ...(options.changeId !== undefined ? { changeId: options.changeId } : {})
44
139
  });
45
140
  const nextActions = [];
46
141
  if (report.previousSessionId !== null && report.bound) {
@@ -52,7 +147,34 @@ export function registerWorkspaceCommands(program, io) {
52
147
  else {
53
148
  nextActions.push('Run `peaks scan archetype --project <path> --json` next to populate rd/project-scan.md.');
54
149
  }
55
- printResult(io, ok('workspace.init', report, [], nextActions), options.json);
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);
56
178
  }
57
179
  catch (error) {
58
180
  if (error instanceof InvalidSessionIdError) {
@@ -75,4 +197,224 @@ export function registerWorkspaceCommands(program, io) {
75
197
  process.exitCode = 1;
76
198
  }
77
199
  });
200
+ addJsonOption(workspace
201
+ .command('reconcile')
202
+ .description('Scan .peaks/2026-MM-DD-session-*/ directories and re-point .peaks/_runtime/session.json ' +
203
+ 'to the canonical session (4-tier heuristic: active-skill binding -> latest session.json mtime -> ' +
204
+ 'latest any-file mtime -> dir-name sort). Also migrates any legacy .peaks/.session.json / ' +
205
+ '.peaks/.active-skill.json / .peaks/sop-state/ into .peaks/_runtime/ (idempotent; no-op on a ' +
206
+ 'tree that is already on the new layout). By default the command is a dry-run: it reports empty / abandoned ' +
207
+ `session dirs older than ${DEFAULT_RECONCILE_AGE_DAYS} days as deletion candidates but does not delete them. ` +
208
+ 'Pass --apply to actually remove the listed candidate dirs (destructive). ' +
209
+ 'Override the age threshold with --older-than <days>.')
210
+ .requiredOption('--project <path>', 'target project root')
211
+ .option('--apply', 'actually delete the deletion candidates (destructive); without it, dry-run only', false)
212
+ .option('--older-than <days>', `age threshold in days for deletion candidates (default: ${DEFAULT_RECONCILE_AGE_DAYS})`, (value) => Number.parseFloat(value))).action((options) => {
213
+ try {
214
+ const projectRoot = resolveCanonicalProjectRoot(options.project);
215
+ const olderThanDays = options.olderThan ?? DEFAULT_RECONCILE_AGE_DAYS;
216
+ if (typeof olderThanDays !== 'number' || !Number.isFinite(olderThanDays) || olderThanDays <= 0) {
217
+ printResult(io, fail('workspace.reconcile', 'INVALID_AGE_THRESHOLD', `--older-than must be a positive number of days`, { provided: options.olderThan }, ['Use --older-than 7 (or omit it to accept the 7-day default)']), options.json);
218
+ process.exitCode = 1;
219
+ return;
220
+ }
221
+ const olderThanMs = olderThanDays * MS_PER_DAY;
222
+ const apply = options.apply === true;
223
+ const result = reconcileWorkspace({
224
+ projectRoot,
225
+ apply,
226
+ olderThanMs
227
+ });
228
+ const warnings = [];
229
+ if (result.sessions.length === 0) {
230
+ warnings.push('No session directories found under .peaks/. Run peaks workspace init first.');
231
+ }
232
+ if (apply && result.deleted.length > 0) {
233
+ warnings.push(`Deleted ${result.deleted.length} session dir(s) older than ${olderThanDays} day(s).`);
234
+ }
235
+ const nextActions = [];
236
+ if (result.migratedFiles.length > 0) {
237
+ nextActions.push(`Migrated ${result.migratedFiles.length} legacy runtime file(s) into .peaks/_runtime/: ${result.migratedFiles.join(', ')}.`);
238
+ }
239
+ if (result.repointed) {
240
+ nextActions.push(`Re-pointed .peaks/_runtime/session.json from ${result.repointedFrom ?? '<unbound>'} to ${result.repointedTo}.`);
241
+ }
242
+ if (!apply && result.wouldDelete.length > 0) {
243
+ nextActions.push(`Re-run with --apply to delete ${result.wouldDelete.length} candidate dir(s).`);
244
+ }
245
+ printResult(io, ok('workspace.reconcile', result, warnings, nextActions), options.json);
246
+ if (result.errors.length > 0) {
247
+ process.exitCode = 1;
248
+ }
249
+ }
250
+ catch (error) {
251
+ printResult(io, fail('workspace.reconcile', 'WORKSPACE_RECONCILE_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Verify the project path exists and is writable']), options.json);
252
+ process.exitCode = 1;
253
+ }
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
+ }
78
420
  }
@@ -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
- sessionId: string;
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 findings not mandatory for non-perf bugs (use --allow-incomplete --reason if a perf bug requires it).
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
- const sessionRoot = join(options.projectRoot, '.peaks', options.sessionId);
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(sessionRoot, prerequisite, options.requestId);
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;