peaks-cli 1.3.0 → 1.3.2

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/core-artifact-commands.js +49 -11
  4. package/dist/src/cli/commands/hooks-commands.js +24 -9
  5. package/dist/src/cli/commands/progress-commands.js +26 -2
  6. package/dist/src/cli/commands/request-commands.js +5 -0
  7. package/dist/src/cli/commands/slice-commands.d.ts +3 -0
  8. package/dist/src/cli/commands/slice-commands.js +44 -0
  9. package/dist/src/cli/commands/workflow-commands.js +3 -3
  10. package/dist/src/cli/commands/workspace-commands.d.ts +63 -0
  11. package/dist/src/cli/commands/workspace-commands.js +349 -12
  12. package/dist/src/cli/program.js +4 -0
  13. package/dist/src/services/artifacts/artifact-prerequisites.d.ts +29 -1
  14. package/dist/src/services/artifacts/artifact-prerequisites.js +69 -5
  15. package/dist/src/services/artifacts/request-artifact-service.d.ts +22 -0
  16. package/dist/src/services/artifacts/request-artifact-service.js +214 -56
  17. package/dist/src/services/doctor/doctor-service.d.ts +69 -0
  18. package/dist/src/services/doctor/doctor-service.js +296 -3
  19. package/dist/src/services/progress/progress-service.d.ts +26 -0
  20. package/dist/src/services/progress/progress-service.js +25 -0
  21. package/dist/src/services/sc/sc-service.js +71 -13
  22. package/dist/src/services/scan/acceptance-coverage-service.js +6 -2
  23. package/dist/src/services/session/session-manager.d.ts +22 -1
  24. package/dist/src/services/session/session-manager.js +149 -30
  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/slice/slice-check-service.d.ts +2 -0
  28. package/dist/src/services/slice/slice-check-service.js +267 -0
  29. package/dist/src/services/slice/slice-check-types.d.ts +70 -0
  30. package/dist/src/services/slice/slice-check-types.js +18 -0
  31. package/dist/src/services/workflow/pipeline-verify-service.d.ts +5 -2
  32. package/dist/src/services/workflow/pipeline-verify-service.js +35 -35
  33. package/dist/src/services/workspace/migrate-service.d.ts +2 -0
  34. package/dist/src/services/workspace/migrate-service.js +606 -0
  35. package/dist/src/services/workspace/migrate-types.d.ts +127 -0
  36. package/dist/src/services/workspace/migrate-types.js +21 -0
  37. package/dist/src/services/workspace/reconcile-service.d.ts +33 -0
  38. package/dist/src/services/workspace/reconcile-service.js +160 -42
  39. package/dist/src/services/workspace/reconcile-types.d.ts +25 -0
  40. package/dist/src/services/workspace/workspace-service.d.ts +11 -0
  41. package/dist/src/services/workspace/workspace-service.js +71 -24
  42. package/dist/src/shared/change-id.d.ts +59 -0
  43. package/dist/src/shared/change-id.js +194 -16
  44. package/dist/src/shared/version.d.ts +1 -1
  45. package/dist/src/shared/version.js +1 -1
  46. package/package.json +10 -2
  47. package/schemas/doctor-report.schema.json +2 -2
  48. package/skills/peaks-qa/SKILL.md +1 -0
  49. package/skills/peaks-rd/SKILL.md +2 -1
  50. package/skills/peaks-solo/SKILL.md +17 -1
  51. package/skills/peaks-solo/references/micro-cycle.md +155 -0
  52. package/skills/peaks-txt/SKILL.md +2 -0
  53. package/skills/peaks-ui/SKILL.md +1 -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/_runtime/<session-id>/ directory with ONLY the session.json metadata file (slice 006: role subdirs prd/ui/rd/qa/sc/txt and the system/ subdir are created lazily by writers, not pre-created at init). When --change-id is given, also creates the .peaks/<change-id>/ dir. 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)).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) => {
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
- 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);
59
178
  }
60
179
  catch (error) {
61
180
  if (error instanceof InvalidSessionIdError) {
@@ -80,14 +199,24 @@ export function registerWorkspaceCommands(program, io) {
80
199
  });
81
200
  addJsonOption(workspace
82
201
  .command('reconcile')
83
- .description('Scan .peaks/2026-MM-DD-session-*/ directories and re-point .peaks/_runtime/session.json ' +
84
- 'to the canonical session (4-tier heuristic: active-skill binding -> latest session.json mtime -> ' +
85
- 'latest any-file mtime -> dir-name sort). Also migrates any legacy .peaks/.session.json / ' +
86
- '.peaks/.active-skill.json / .peaks/sop-state/ into .peaks/_runtime/ (idempotent; no-op on a ' +
87
- 'tree that is already on the new layout). By default the command is a dry-run: it reports empty / abandoned ' +
88
- `session dirs older than ${DEFAULT_RECONCILE_AGE_DAYS} days as deletion candidates but does not delete them. ` +
89
- 'Pass --apply to actually remove the listed candidate dirs (destructive). ' +
90
- 'Override the age threshold with --older-than <days>.')
202
+ .description('Scan .peaks/2026-MM-DD-session-*/ directories and consolidate the runtime state. ' +
203
+ 'By default (no --apply) the command performs four actions:\n' +
204
+ ' 1. Migrates legacy runtime files into .peaks/_runtime/: ' +
205
+ '.peaks/.session.json -> .peaks/_runtime/session.json, ' +
206
+ '.peaks/.active-skill.json -> .peaks/_runtime/active-skill.json, ' +
207
+ '.peaks/sop-state/ -> .peaks/_runtime/sop-state/ ' +
208
+ '(idempotent; no-op if already on the new layout).\n' +
209
+ ' 2. Re-points .peaks/_runtime/session.json to the canonical session ' +
210
+ 'using a 4-tier heuristic: active-skill binding -> latest session.json mtime -> ' +
211
+ 'latest any-file mtime -> dir-name sort.\n' +
212
+ ' 3. (slice 006) Syncs the single change/<sid>/ live marker under ' +
213
+ '.peaks/_runtime/change/. The marker is an empty directory; every other ' +
214
+ 'entry under change/ is removed. Also cleans up the F3-introduced ' +
215
+ '.peaks/_runtime/<sid>/system/ subdir (no-op if already absent).\n' +
216
+ ' 4. REPORTS (but does not delete) session dirs older than --older-than <days> ' +
217
+ `(default ${DEFAULT_RECONCILE_AGE_DAYS}) as deletion candidates; this is the only step that is dry-run by default.\n` +
218
+ 'Pass --apply to additionally REMOVE the listed candidate dirs (destructive). ' +
219
+ 'Migration (1), repoint (2), and marker sync (3) always run regardless of --apply.')
91
220
  .requiredOption('--project <path>', 'target project root')
92
221
  .option('--apply', 'actually delete the deletion candidates (destructive); without it, dry-run only', false)
93
222
  .option('--older-than <days>', `age threshold in days for deletion candidates (default: ${DEFAULT_RECONCILE_AGE_DAYS})`, (value) => Number.parseFloat(value))).action((options) => {
@@ -123,6 +252,18 @@ export function registerWorkspaceCommands(program, io) {
123
252
  if (!apply && result.wouldDelete.length > 0) {
124
253
  nextActions.push(`Re-run with --apply to delete ${result.wouldDelete.length} candidate dir(s).`);
125
254
  }
255
+ if (result.changeMarker.created !== null) {
256
+ nextActions.push(`Synced change/<${result.changeMarker.created}>/ live marker.`);
257
+ }
258
+ else if (result.canonicalSessionId !== null) {
259
+ nextActions.push(`change/<${result.canonicalSessionId}>/ live marker already in place.`);
260
+ }
261
+ if (result.changeMarker.removed.length > 0) {
262
+ nextActions.push(`Removed ${result.changeMarker.removed.length} stale change/<oldSid>/ marker(s).`);
263
+ }
264
+ if (result.systemCleaned.length > 0) {
265
+ nextActions.push(`Removed ${result.systemCleaned.length} F3 system/ subdir(s).`);
266
+ }
126
267
  printResult(io, ok('workspace.reconcile', result, warnings, nextActions), options.json);
127
268
  if (result.errors.length > 0) {
128
269
  process.exitCode = 1;
@@ -133,4 +274,200 @@ export function registerWorkspaceCommands(program, io) {
133
274
  process.exitCode = 1;
134
275
  }
135
276
  });
277
+ addJsonOption(workspace
278
+ .command('migrate')
279
+ .description('Migrate legacy `.peaks/<session-id>/<role>/<file>` content into the new layout: ' +
280
+ '`.peaks/retrospective/<change-id>/<role>/<file>`. Each file is routed by a 4-tier ' +
281
+ 'change-id resolver (filename regex → content H1 → body frontmatter → per-session fallback ' +
282
+ 'to the most recent rd/requests entry). Cross-cutting files (project-scan, perf-baseline) ' +
283
+ 'and transient runtime files (session.json, system/) are skipped with reasons in the ' +
284
+ 'response. By default the command is a dry-run: it reports the planned moves + conflicts ' +
285
+ 'and the session dirs that WOULD be deleted. Pass --apply to actually `git mv` the files ' +
286
+ 'and `rm -rf` the emptied session dirs. Idempotent: re-running on an already-migrated tree ' +
287
+ 'is a no-op (all files report conflicts with identical content).' +
288
+ '\n\nSlice 003 (--to-runtime): moves every top-level `.peaks/<sid>/` to `.peaks/_runtime/<sid>/` ' +
289
+ 'for projects still on the pre-runtime-layer layout. Idempotent: re-running on a tree ' +
290
+ 'that is already canonical is a no-op. F15 carve-out: top-level `rd/project-scan.md` is ' +
291
+ 'never overwritten when the runtime copy already exists with different content.')
292
+ .requiredOption('--project <path>', 'target project root')
293
+ .option('--apply', 'actually `git mv` the files and delete the emptied session dirs (destructive); without it, dry-run only', false)
294
+ .option('--to-runtime', 'slice 003: also consolidate every top-level .peaks/<sid>/ dir into .peaks/_runtime/<sid>/. Idempotent; conflicts are logged but never overwrite.', false)).action(async (options) => {
295
+ try {
296
+ const projectRoot = resolveCanonicalProjectRoot(options.project);
297
+ const apply = options.apply === true;
298
+ const toRuntime = options.toRuntime === true;
299
+ const result = await migrateWorkspace({ projectRoot, apply, toRuntime });
300
+ const warnings = [];
301
+ if (result.sessions.length === 0 && (result.toRuntimePlans?.length ?? 0) === 0) {
302
+ warnings.push('No legacy session directories found under .peaks/. Nothing to migrate.');
303
+ }
304
+ else if (result.wouldMove.length === 0 && (result.toRuntimePlans?.length ?? 0) === 0) {
305
+ warnings.push('Legacy session dirs found but no reviewable content to migrate (all files were cross-cutting or transient).');
306
+ }
307
+ const nextActions = [];
308
+ if (!apply && result.wouldMove.length > 0) {
309
+ nextActions.push(`Re-run with --apply to perform ${result.wouldMove.length} move(s) and delete ${result.wouldDeleteSessions.length} session dir(s).`);
310
+ }
311
+ if (result.conflicts.length > 0) {
312
+ nextActions.push(`${result.conflicts.length} file(s) already exist at the target path; review before --apply (or re-run after a partial migrate).`);
313
+ }
314
+ if (toRuntime) {
315
+ const plans = result.toRuntimePlans ?? [];
316
+ if (apply) {
317
+ if ((result.toRuntimeMoved?.length ?? 0) > 0) {
318
+ nextActions.push(`Moved ${result.toRuntimeMoved?.length} top-level session dir(s) to .peaks/_runtime/ (slice 003 --to-runtime).`);
319
+ }
320
+ if ((result.toRuntimeConflicts?.length ?? 0) > 0) {
321
+ nextActions.push(`${result.toRuntimeConflicts?.length} --to-runtime conflict(s) — see response. ${plans.filter((p) => p.action === 'f15-conflict-project-scan').length} are F15 carve-outs (deferred to a separate slice).`);
322
+ }
323
+ }
324
+ else {
325
+ const wouldMoveCount = plans.filter((p) => p.action === 'moved').length;
326
+ const wouldSkipCount = plans.filter((p) => p.action === 'skipped-already-canonical').length;
327
+ if (wouldMoveCount > 0) {
328
+ nextActions.push(`Re-run with --apply to move ${wouldMoveCount} top-level session dir(s) to .peaks/_runtime/; ${wouldSkipCount} already canonical.`);
329
+ }
330
+ else if (wouldSkipCount > 0) {
331
+ nextActions.push(`All ${wouldSkipCount} top-level session dir(s) are already canonical — no moves needed.`);
332
+ }
333
+ const f15Count = plans.filter((p) => p.action === 'f15-conflict-project-scan').length;
334
+ if (f15Count > 0) {
335
+ nextActions.push(`${f15Count} F15 carve-out conflict(s) (rd/project-scan.md differs from runtime copy) — see response.`);
336
+ }
337
+ }
338
+ }
339
+ if (apply) {
340
+ if (result.moved.length > 0) {
341
+ nextActions.push(`Migrated ${result.moved.length} file(s) into .peaks/retrospective/.`);
342
+ }
343
+ if (result.deletedSessions.length > 0) {
344
+ nextActions.push(`Deleted ${result.deletedSessions.length} emptied session dir(s).`);
345
+ }
346
+ }
347
+ printResult(io, ok('workspace.migrate', result, warnings, nextActions), options.json ?? false);
348
+ }
349
+ catch (error) {
350
+ printResult(io, fail('workspace.migrate', 'WORKSPACE_MIGRATE_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Verify the project path exists and is writable']), options.json ?? false);
351
+ process.exitCode = 1;
352
+ }
353
+ });
354
+ }
355
+ /**
356
+ * Resolve the first-time "install peaks hooks" decision for this project.
357
+ * Decision tree:
358
+ *
359
+ * 1. Read the sticky marker.
360
+ * - Marker present:
361
+ * - marker.decision === 'installed' AND hooks are present → action: marker-honored, no side effects
362
+ * - marker.decision === 'installed' AND hooks are MISSING → re-install, action: reinstalled
363
+ * - marker.decision === 'skipped' → action: marker-honored, no install
364
+ * - Marker absent:
365
+ * - hooks already present → write a fresh 'installed' marker, action: already-installed
366
+ * - otherwise:
367
+ * - explicit --install-hooks=auto → install + marker, action: first-decision
368
+ * - explicit --install-hooks=skip → marker only, action: first-decision
369
+ * - explicit --install-hooks=ask OR default in TTY:
370
+ * - jsonMode → silently auto-install (LLM cannot answer), action: first-decision
371
+ * - TTY → prompt; on yes install + marker, on no marker-only
372
+ * - default in non-TTY → auto-install, action: first-decision
373
+ *
374
+ * Project scope is the only supported scope here; global scope is reserved
375
+ * for explicit `peaks hooks install --global` invocations.
376
+ */
377
+ export async function resolveFirstTimeHooksInstall(options) {
378
+ const { projectRoot, jsonMode } = options;
379
+ const existingMarker = readDecisionMarker(projectRoot);
380
+ // readHookStatus can throw (e.g. .claude is a symlink → safety check rejects).
381
+ // Treat any throw as "hooks status unknown → treat as not-installed" so the
382
+ // function still reaches the install path; the install will surface the same
383
+ // error in a more specific reason field.
384
+ let hookStatus;
385
+ try {
386
+ hookStatus = readHookStatus('project', projectRoot);
387
+ }
388
+ catch (error) {
389
+ hookStatus = { installed: false };
390
+ // Fall through to the install path; the failure will be captured below.
391
+ void error;
392
+ }
393
+ if (existingMarker !== null) {
394
+ if (existingMarker.decision === 'installed' && !hookStatus.installed) {
395
+ try {
396
+ applyHookInstall('project', projectRoot);
397
+ return { decision: 'installed', action: 'reinstalled', scope: 'project', reason: 'marker-said-installed-hooks-missing' };
398
+ }
399
+ catch (error) {
400
+ return { decision: existingMarker.decision, action: 'marker-honored', scope: 'project', reason: `reinstall-failed: ${getErrorMessage(error)}` };
401
+ }
402
+ }
403
+ return { decision: existingMarker.decision, action: 'marker-honored', scope: existingMarker.scope };
404
+ }
405
+ // No marker yet — first decision.
406
+ if (hookStatus.installed) {
407
+ writeDecisionMarker(projectRoot, 'installed');
408
+ return { decision: 'installed', action: 'already-installed', scope: 'project' };
409
+ }
410
+ // Determine effective mode (explicit flag wins; default depends on TTY + jsonMode).
411
+ const explicitMode = options.explicitMode;
412
+ const effectiveMode = explicitMode ??
413
+ (jsonMode ? 'auto' : (process.stdin.isTTY === true ? 'ask' : 'auto'));
414
+ if (effectiveMode === 'skip') {
415
+ writeDecisionMarker(projectRoot, 'skipped');
416
+ return { decision: 'skipped', action: 'first-decision', scope: 'project', reason: 'explicit-skip' };
417
+ }
418
+ if (effectiveMode === 'auto' || jsonMode) {
419
+ // The reason code distinguishes the path the user took to reach auto-install:
420
+ // - explicit-auto: user passed --install-hooks=auto
421
+ // - json-mode: no --install-hooks flag, but --json was set
422
+ // - non-tty-default: no flag, no --json, stdin is not a TTY
423
+ let autoReason;
424
+ if (explicitMode === 'auto') {
425
+ autoReason = 'explicit-auto';
426
+ }
427
+ else if (jsonMode) {
428
+ autoReason = 'json-mode';
429
+ }
430
+ else {
431
+ autoReason = 'non-tty-default';
432
+ }
433
+ try {
434
+ applyHookInstall('project', projectRoot);
435
+ writeDecisionMarker(projectRoot, 'installed');
436
+ return { decision: 'installed', action: 'first-decision', scope: 'project', reason: autoReason };
437
+ }
438
+ catch (error) {
439
+ // Auto-install failed: still record the decision so we do not keep retrying
440
+ // every workspace init. The user can fix the underlying problem and run
441
+ // `peaks hooks install` manually.
442
+ writeDecisionMarker(projectRoot, 'installed');
443
+ return { decision: 'installed', action: 'first-decision', scope: 'project', reason: `install-failed: ${getErrorMessage(error)}` };
444
+ }
445
+ }
446
+ // effectiveMode === 'ask' AND TTY: prompt once.
447
+ process.stderr.write('\nPeaks-Cli: install the PreToolUse hooks for this project now?\n' +
448
+ ' → Bash matcher: `peaks gate enforce` (SOP gate enforcement)\n' +
449
+ ' → Task matcher: `peaks progress start` (auto-spawn sub-agent progress terminal)\n' +
450
+ 'Both run on every Claude Code tool call without further prompting. The decision is sticky\n' +
451
+ '(recorded in .peaks/.peaks-init-hooks-decision.json) and re-runs of `workspace init` will\n' +
452
+ 'honour it. Re-run with --install-hooks=skip or --install-hooks=auto to override.\n\n' +
453
+ 'Install now? [Y/n]: ');
454
+ const answer = await promptYesNo('');
455
+ if (answer === null) {
456
+ // TTY disappeared mid-prompt (rare): treat as skip + write marker.
457
+ writeDecisionMarker(projectRoot, 'skipped');
458
+ return { decision: 'skipped', action: 'first-decision', scope: 'project', reason: 'tty-prompt-aborted' };
459
+ }
460
+ if (!answer) {
461
+ writeDecisionMarker(projectRoot, 'skipped');
462
+ return { decision: 'skipped', action: 'first-decision', scope: 'project', reason: 'user-answered-no' };
463
+ }
464
+ try {
465
+ applyHookInstall('project', projectRoot);
466
+ writeDecisionMarker(projectRoot, 'installed');
467
+ return { decision: 'installed', action: 'first-decision', scope: 'project', reason: 'user-answered-yes' };
468
+ }
469
+ catch (error) {
470
+ writeDecisionMarker(projectRoot, 'installed');
471
+ return { decision: 'installed', action: 'first-decision', scope: 'project', reason: `install-failed: ${getErrorMessage(error)}` };
472
+ }
136
473
  }
@@ -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,26 @@ 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;
40
+ /**
41
+ * Session binding (the developer's local session that wrote the
42
+ * request artifact). Read from the file body's `- session:` line.
43
+ * Optional, but when present the gate falls back to
44
+ * `.peaks/_runtime/<sid>/<role>/` and then `.peaks/<sid>/<role>/`
45
+ * for prerequisite artifacts that don't exist at the per-change-id
46
+ * path. This mirrors the F1/F2 back-compat pattern (read new path
47
+ * first, then legacy) and keeps the gate working for users whose
48
+ * QA / tech-doc / initiated artifacts still live under the session
49
+ * dir rather than under the change-id dir.
50
+ */
51
+ sessionId?: string;
24
52
  role: RequestArtifactRole;
25
53
  newState: RequestArtifactState;
26
54
  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,25 @@ 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
+ // Slice 006 simplifies the resolution to a 2-tier fallback. The
166
+ // per-change-id scope (`.peaks/<changeId>/<role>/`) is gone — new
167
+ // artifacts go to the session dir directly. The 2 tiers are:
168
+ // 1. `.peaks/_runtime/<sid>/<role>/...` (post-F3 canonical
169
+ // session home; primary).
170
+ // 2. `.peaks/<sid>/<role>/...` (pre-F3 legacy session home;
171
+ // back-compat).
172
+ // The changeId is preserved in the artifact body's frontmatter for
173
+ // human navigation; it is no longer a filesystem path key.
174
+ const canonicalSessionRoot = options.sessionId !== undefined
175
+ ? join(options.projectRoot, '.peaks', '_runtime', options.sessionId)
176
+ : null;
177
+ const legacySessionRoot = options.sessionId !== undefined
178
+ ? join(options.projectRoot, '.peaks', options.sessionId)
179
+ : null;
150
180
  const missing = [];
151
181
  for (const prerequisite of requirements) {
152
182
  const relative = resolvePrerequisitePath(prerequisite, options.requestId);
153
- const absolute = await resolvePrerequisiteAbsolutePath(sessionRoot, prerequisite, options.requestId);
183
+ const absolute = await resolvePrerequisiteAbsolutePathWithFallback(canonicalSessionRoot, legacySessionRoot, prerequisite, options.requestId);
154
184
  if (absolute === null) {
155
185
  missing.push({ path: relative, description: prerequisite.description });
156
186
  continue;
@@ -166,6 +196,40 @@ export async function checkPrerequisites(options) {
166
196
  });
167
197
  }
168
198
  }
199
+ if (prerequisite.mustContainAny && prerequisite.mustContainAny.length > 0) {
200
+ const body = await readFile(absolute, 'utf8');
201
+ const lowered = body.toLowerCase();
202
+ const hitAny = prerequisite.mustContainAny.some((marker) => lowered.includes(marker.toLowerCase()));
203
+ if (!hitAny) {
204
+ missing.push({
205
+ path: relative,
206
+ description: `${prerequisite.description} — none of the escape-hatch markers present: ${prerequisite.mustContainAny.join(', ')}`
207
+ });
208
+ }
209
+ }
169
210
  }
170
211
  return { ok: missing.length === 0, missing };
171
212
  }
213
+ /**
214
+ * Resolve a prerequisite to an on-disk path, with a 2-tier fallback
215
+ * (slice 006 — the per-change-id tier was dropped because per-change-id
216
+ * dirs are no longer created):
217
+ * 1. `<canonicalSessionRoot>/<relative>` (post-F3 canonical session
218
+ * home, when `canonicalSessionRoot` is provided).
219
+ * 2. `<legacySessionRoot>/<relative>` (pre-F3 legacy session home,
220
+ * when `legacySessionRoot` is provided).
221
+ * Tolerates the numbered filename prefix that `request init` writes
222
+ * (e.g. `001-<rid>.md`) at every tier. Returns the matched absolute
223
+ * path, or null when nothing matches.
224
+ */
225
+ async function resolvePrerequisiteAbsolutePathWithFallback(canonicalSessionRoot, legacySessionRoot, prerequisite, requestId) {
226
+ const roots = [canonicalSessionRoot, legacySessionRoot];
227
+ for (const root of roots) {
228
+ if (root === null)
229
+ continue;
230
+ const found = await resolvePrerequisiteAbsolutePath(root, prerequisite, requestId);
231
+ if (found !== null)
232
+ return found;
233
+ }
234
+ return null;
235
+ }
@@ -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;