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
@@ -6,6 +6,7 @@
6
6
  * Each session gets a unique directory under .peaks/ with incrementing numbered files.
7
7
  */
8
8
  import { existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, unlinkSync, writeFileSync } from 'node:fs';
9
+ import { mkdir as mkdirAsync } from 'node:fs/promises';
9
10
  import { dirname, join, resolve } from 'node:path';
10
11
  import { randomBytes } from 'node:crypto';
11
12
  import { initWorkspace } from '../workspace/workspace-service.js';
@@ -179,9 +180,16 @@ function writeSessionFile(projectRoot, info) {
179
180
  * no binding was present. The caller is expected to do something
180
181
  * with that — at minimum surface it in the CLI response so the
181
182
  * user can find the directory again if they need to.
183
+ *
184
+ * Slice 008 (F22 fix): the read uses the canonicalize-on-read
185
+ * variant so a binding written with `projectRoot: "."` (relative
186
+ * form, anchored from inside the project dir) is still found when
187
+ * the caller passes the absolute realpath. Pre-F22 the
188
+ * strict-equality read returned null in that case, and rotate
189
+ * silently no-op'd (the CLI reported "no prior binding").
182
190
  */
183
191
  export function rotateSessionBinding(projectRoot) {
184
- const previous = readSessionFile(projectRoot);
192
+ const previous = readSessionFileCanonical(projectRoot);
185
193
  if (previous === null) {
186
194
  return null;
187
195
  }
@@ -220,7 +228,14 @@ export function setCurrentSessionBinding(projectRoot, sessionId) {
220
228
  return info;
221
229
  }
222
230
  function getMetaFilePath(projectRoot, sessionId) {
223
- return join(projectRoot, '.peaks', sessionId, META_FILE);
231
+ // As of slice 2026-06-06-session-layout-canonicalize, the per-session
232
+ // `session.json` (the file written by `setSessionMeta`) lives at the
233
+ // canonical runtime home `.peaks/_runtime/<sid>/session.json`, NOT
234
+ // at the top-level `.peaks/<sid>/session.json` (which would imply
235
+ // the legacy session-scoped layout and conflict with the workspace
236
+ // service's `_runtime/<sid>/` invariant). The migration in slice
237
+ // 003 moved any top-level meta files into the runtime home.
238
+ return join(projectRoot, '.peaks', '_runtime', sessionId, META_FILE);
224
239
  }
225
240
  function readSessionMeta(projectRoot, sessionId) {
226
241
  const metaPath = getMetaFilePath(projectRoot, sessionId);
@@ -240,7 +255,9 @@ function readSessionMeta(projectRoot, sessionId) {
240
255
  }
241
256
  function writeSessionMeta(projectRoot, sessionId, meta) {
242
257
  const metaPath = getMetaFilePath(projectRoot, sessionId);
243
- const metaDir = join(projectRoot, '.peaks', sessionId);
258
+ // As of slice 003, the meta file lives at `.peaks/_runtime/<sid>/session.json`.
259
+ // The parent dir of that file is the canonical runtime session dir.
260
+ const metaDir = dirname(metaPath);
244
261
  if (!existsSync(metaDir)) {
245
262
  mkdirSync(metaDir, { recursive: true });
246
263
  }
@@ -281,23 +298,67 @@ export function setSessionTitle(projectRoot, sessionId, title) {
281
298
  /**
282
299
  * List all session directories under .peaks with their metadata.
283
300
  * Returns sessions sorted by sessionId descending (most recent first).
301
+ *
302
+ * As of slice 2026-06-06-session-layout-canonicalize the session
303
+ * dirs live at the canonical runtime home `.peaks/_runtime/<sid>/`.
304
+ * The legacy top-level layout is read for back-compat (one minor
305
+ * release) but is not authoritative.
284
306
  */
285
307
  export function listSessionMetas(projectRoot) {
308
+ const runtimeRoot = join(projectRoot, '.peaks', '_runtime');
286
309
  const peaksRoot = join(projectRoot, '.peaks');
287
- if (!existsSync(peaksRoot))
288
- return [];
289
- const entries = readdirSync(peaksRoot, { withFileTypes: true });
290
- return entries
291
- .filter((entry) => entry.isDirectory() && /^\d{4}-\d{2}-\d{2}-session-[a-f0-9]+$/.test(entry.name))
292
- .map((entry) => {
293
- const meta = readSessionMeta(projectRoot, entry.name);
294
- return meta ?? {
295
- sessionId: entry.name,
296
- projectRoot,
297
- createdAt: ''
298
- };
299
- })
300
- .sort((a, b) => b.sessionId.localeCompare(a.sessionId));
310
+ const seen = new Set();
311
+ const result = [];
312
+ const collect = (root) => {
313
+ if (!existsSync(root))
314
+ return;
315
+ const names = [];
316
+ try {
317
+ const out = readdirSync(root, { withFileTypes: true });
318
+ for (const e of out) {
319
+ if (e.isDirectory())
320
+ names.push(e.name);
321
+ }
322
+ }
323
+ catch {
324
+ return;
325
+ }
326
+ for (const name of names) {
327
+ if (!/^\d{4}-\d{2}-\d{2}-session-[a-f0-9]+$/.test(name))
328
+ continue;
329
+ if (seen.has(name))
330
+ continue;
331
+ seen.add(name);
332
+ const meta = readSessionMeta(projectRoot, name);
333
+ result.push(meta ?? { sessionId: name, projectRoot, createdAt: '' });
334
+ }
335
+ };
336
+ // Canonical home first, then the legacy top-level (back-compat).
337
+ collect(runtimeRoot);
338
+ collect(peaksRoot);
339
+ result.sort((a, b) => b.sessionId.localeCompare(a.sessionId));
340
+ return result;
341
+ }
342
+ /**
343
+ * Back-compat read for the legacy top-level meta file (the one that
344
+ * pre-slice 003 trees still have at `.peaks/<sid>/session.json`).
345
+ * Kept as a separate helper so the canonical reader is the default.
346
+ */
347
+ function readSessionMetaCompat(peaksRoot, sessionId) {
348
+ const metaPath = join(peaksRoot, sessionId, META_FILE);
349
+ if (!existsSync(metaPath))
350
+ return null;
351
+ try {
352
+ const raw = readFileSync(metaPath, 'utf8');
353
+ const parsed = JSON.parse(raw);
354
+ if (typeof parsed?.sessionId !== 'string' || parsed.sessionId.length === 0) {
355
+ return null;
356
+ }
357
+ return parsed;
358
+ }
359
+ catch {
360
+ return null;
361
+ }
301
362
  }
302
363
  /**
303
364
  * Get or create the current session for a project.
@@ -321,6 +382,26 @@ export async function ensureSession(projectRoot) {
321
382
  if (existing) {
322
383
  return existing.sessionId;
323
384
  }
385
+ // Slice 007 — sub-agent session sharing. When the strict-equality
386
+ // read returns null (e.g. the binding was written with the relative
387
+ // form "." from inside the project dir, but the caller passes the
388
+ // absolute realpath), fall through to the canonical-fallback read.
389
+ // `ensureSession` is a session-creating primitive — its caller
390
+ // wants the existing binding if one exists, even if the projectRoot
391
+ // forms differ. Without this fallback, a sub-agent that anchors via
392
+ // `cd <repo> && peaks skill presence:set` and then runs
393
+ // `peaks request init --project <abs-path>` would auto-generate a
394
+ // new session and create an orphan dir.
395
+ //
396
+ // The strict-equality read is preserved for other modules
397
+ // (notably `shared/change-id.ts` via `buildArtifactRelativePath`)
398
+ // that depend on the "no session bound" code path — switching the
399
+ // default would cascade into ~30 test failures in those modules.
400
+ // The canonical-fallback is opt-in for `ensureSession` only.
401
+ const canonical = getSessionIdCanonical(projectRoot);
402
+ if (canonical !== null) {
403
+ return canonical;
404
+ }
324
405
  const sessionId = generateSessionId();
325
406
  const now = new Date().toISOString();
326
407
  const info = {
@@ -386,31 +467,60 @@ export function getSessionIdCanonical(projectRoot) {
386
467
  * Get the absolute path to the current session directory.
387
468
  * Creates the session if it doesn't exist.
388
469
  *
470
+ * As of slice 2026-06-06-session-layout-canonicalize the canonical
471
+ * home is `.peaks/_runtime/<sid>/`. The legacy top-level layout
472
+ * `.peaks/<sid>/` is the back-compat read fallback only.
473
+ *
389
474
  * @param projectRoot - Root directory of the project
390
- * @returns Absolute path to session directory (e.g., "/path/to/project/.peaks/2026-05-26-session-a3f8b1")
475
+ * @returns Absolute path to session directory (e.g., "/path/to/project/.peaks/_runtime/2026-05-26-session-a3f8b1")
391
476
  */
392
477
  export async function getCurrentSessionDir(projectRoot) {
393
478
  const sessionId = await ensureSession(projectRoot);
394
- return join(projectRoot, '.peaks', sessionId);
479
+ return join(projectRoot, '.peaks', '_runtime', sessionId);
395
480
  }
396
481
  /**
397
482
  * List all session directories in the .peaks folder.
398
483
  * Returns session IDs (directory names) sorted by date.
399
484
  *
485
+ * As of slice 2026-06-06-session-layout-canonicalize the canonical
486
+ * home is `.peaks/_runtime/<sid>/`. The legacy top-level layout
487
+ * `.peaks/<sid>/` is read for back-compat (one minor release) so
488
+ * pre-migration trees keep working.
489
+ *
400
490
  * @param projectRoot - Root directory of the project
401
491
  * @returns Array of session IDs
402
492
  */
403
493
  export function listSessions(projectRoot) {
494
+ const runtimeRoot = join(projectRoot, '.peaks', '_runtime');
404
495
  const peaksRoot = join(projectRoot, '.peaks');
405
- if (!existsSync(peaksRoot))
406
- return [];
407
- const { readdirSync } = require('node:fs');
408
- const entries = readdirSync(peaksRoot, { withFileTypes: true });
409
- return entries
410
- .filter((entry) => entry.isDirectory() && /^\d{4}-\d{2}-\d{2}-session-[a-f0-9]+$/.test(entry.name))
411
- .map((entry) => entry.name)
412
- .sort()
413
- .reverse(); // Most recent first
496
+ const seen = new Set();
497
+ const result = [];
498
+ const collect = (root) => {
499
+ if (!existsSync(root))
500
+ return;
501
+ const names = [];
502
+ try {
503
+ const out = readdirSync(root, { withFileTypes: true });
504
+ for (const e of out) {
505
+ if (e.isDirectory())
506
+ names.push(e.name);
507
+ }
508
+ }
509
+ catch {
510
+ return;
511
+ }
512
+ for (const name of names) {
513
+ if (!/^\d{4}-\d{2}-\d{2}-session-[a-f0-9]+$/.test(name))
514
+ continue;
515
+ if (seen.has(name))
516
+ continue;
517
+ seen.add(name);
518
+ result.push(name);
519
+ }
520
+ };
521
+ collect(runtimeRoot);
522
+ collect(peaksRoot);
523
+ return result.sort().reverse();
414
524
  }
415
525
  /**
416
526
  * Get the path to project-scan.md for the current session.
@@ -421,7 +531,15 @@ export function listSessions(projectRoot) {
421
531
  */
422
532
  export async function getProjectScanPath(projectRoot) {
423
533
  const sessionId = await ensureSession(projectRoot);
424
- return join(projectRoot, '.peaks', sessionId, 'rd', 'project-scan.md');
534
+ // As of slice 2026-06-05-change-id-as-unit-of-work the session dir
535
+ // is at the canonical runtime location (gitignored). The scan is a
536
+ // session-local artifact; it lives alongside the rest of the
537
+ // ephemeral state under `_runtime/`. The parent `rd/` subdir is
538
+ // created on demand so the first scanner call has a place to land
539
+ // (consistent with the legacy behavior pre-1.3.1).
540
+ const scanPath = join(projectRoot, '.peaks', '_runtime', sessionId, 'rd', 'project-scan.md');
541
+ await mkdirAsync(dirname(scanPath), { recursive: true });
542
+ return scanPath;
425
543
  }
426
544
  /**
427
545
  * Check if project-scan.md exists for the current session.
@@ -433,6 +551,7 @@ export function hasProjectScan(projectRoot) {
433
551
  const info = readSessionFile(projectRoot);
434
552
  if (!info)
435
553
  return false;
436
- const scanPath = join(projectRoot, '.peaks', info.sessionId, 'rd', 'project-scan.md');
554
+ // Canonical runtime location of the session dir (slice 2026-06-05).
555
+ const scanPath = join(projectRoot, '.peaks', '_runtime', info.sessionId, 'rd', 'project-scan.md');
437
556
  return existsSync(scanPath);
438
557
  }
@@ -14,16 +14,31 @@
14
14
  * removes only our own entry.
15
15
  */
16
16
  export type HookScope = 'project' | 'global';
17
- /** The hook command written into settings. `${CLAUDE_PROJECT_DIR}` is injected by Claude Code. */
17
+ /** The hook command written into settings for the gate-enforce PreToolUse hook. `${CLAUDE_PROJECT_DIR}` is injected by Claude Code. */
18
18
  export declare const HOOK_ENFORCE_COMMAND = "peaks gate enforce --project \"${CLAUDE_PROJECT_DIR}\"";
19
- /** Substring that identifies a Peaks-managed PreToolUse hook entry. */
20
- export declare const HOOK_SENTINEL = "peaks gate enforce";
19
+ /**
20
+ * Hook command for the sub-agent progress auto-spawn. Fires on every Task
21
+ * tool call (the harness-enforced mechanism for "sub-agent dispatch"). The
22
+ * command itself is non-blocking: `peaks progress start` is idempotent
23
+ * (5-minute TTL on the spawn record) so the LLM does not see a fresh
24
+ * terminal per Task. The `--quiet` flag keeps the LLM context clean — the
25
+ * hook output otherwise adds ~500 tokens per Task call.
26
+ */
27
+ export declare const HOOK_PROGRESS_COMMAND = "peaks progress start --project \"${CLAUDE_PROJECT_DIR}\" --reason \"auto-spawn for sub-agent Task\" --quiet";
28
+ /** Substring that identifies a Peaks-managed PreToolUse gate-enforce hook entry. */
29
+ export declare const HOOK_ENFORCE_SENTINEL = "peaks gate enforce";
30
+ /** Substring that identifies a Peaks-managed PreToolUse sub-agent-progress hook entry. */
31
+ export declare const HOOK_PROGRESS_SENTINEL = "peaks progress start";
21
32
  export type HookInstallPlan = {
22
33
  scope: HookScope;
23
34
  settingsPath: string;
24
35
  exists: boolean;
25
36
  alreadyInstalled: boolean;
26
37
  desiredCommand: string;
38
+ /** Substring sentinel used to detect the entry. */
39
+ sentinel: string;
40
+ /** Tool name (Bash | Task) the PreToolUse hook is keyed on. */
41
+ matcher: string;
27
42
  };
28
43
  export type HookInstallResult = HookInstallPlan & {
29
44
  applied: boolean;
@@ -39,6 +54,13 @@ export type HookStatus = {
39
54
  exists: boolean;
40
55
  installed: boolean;
41
56
  };
57
+ /** A typed descriptor for a single peaks-managed hook entry. */
58
+ export type PeaksHookEntry = {
59
+ sentinel: string;
60
+ matcher: string;
61
+ command: string;
62
+ };
63
+ export declare const PEAKS_HOOK_ENTRIES: ReadonlyArray<PeaksHookEntry>;
42
64
  export declare function planHookInstall(scope: HookScope, projectRoot?: string): HookInstallPlan;
43
65
  export declare function applyHookInstall(scope: HookScope, projectRoot?: string): HookInstallResult;
44
66
  export declare function removeHookInstall(scope: HookScope, projectRoot?: string): HookRemoveResult;
@@ -2,11 +2,33 @@ import { closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readF
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
- /** The hook command written into settings. `${CLAUDE_PROJECT_DIR}` is injected by Claude Code. */
5
+ /** The hook command written into settings for the gate-enforce PreToolUse hook. `${CLAUDE_PROJECT_DIR}` is injected by Claude Code. */
6
6
  export const HOOK_ENFORCE_COMMAND = 'peaks gate enforce --project "${CLAUDE_PROJECT_DIR}"';
7
- /** Substring that identifies a Peaks-managed PreToolUse hook entry. */
8
- export const HOOK_SENTINEL = 'peaks gate enforce';
9
- const HOOK_MATCHER = 'Bash';
7
+ /**
8
+ * Hook command for the sub-agent progress auto-spawn. Fires on every Task
9
+ * tool call (the harness-enforced mechanism for "sub-agent dispatch"). The
10
+ * command itself is non-blocking: `peaks progress start` is idempotent
11
+ * (5-minute TTL on the spawn record) so the LLM does not see a fresh
12
+ * terminal per Task. The `--quiet` flag keeps the LLM context clean — the
13
+ * hook output otherwise adds ~500 tokens per Task call.
14
+ */
15
+ export const HOOK_PROGRESS_COMMAND = 'peaks progress start --project "${CLAUDE_PROJECT_DIR}" --reason "auto-spawn for sub-agent Task" --quiet';
16
+ /** Substring that identifies a Peaks-managed PreToolUse gate-enforce hook entry. */
17
+ export const HOOK_ENFORCE_SENTINEL = 'peaks gate enforce';
18
+ /** Substring that identifies a Peaks-managed PreToolUse sub-agent-progress hook entry. */
19
+ export const HOOK_PROGRESS_SENTINEL = 'peaks progress start';
20
+ const HOOK_GATE_MATCHER = 'Bash';
21
+ const HOOK_PROGRESS_MATCHER = 'Task';
22
+ /**
23
+ * Substring sentinels that identify a Peaks-managed PreToolUse hook entry.
24
+ * Used to keep `uninstall` and `isInstalled` checks tight: we only touch
25
+ * entries we wrote, never third-party hooks.
26
+ */
27
+ const PEAKS_HOOK_SENTINELS = [HOOK_ENFORCE_SENTINEL, HOOK_PROGRESS_SENTINEL];
28
+ export const PEAKS_HOOK_ENTRIES = [
29
+ { sentinel: HOOK_ENFORCE_SENTINEL, matcher: HOOK_GATE_MATCHER, command: HOOK_ENFORCE_COMMAND },
30
+ { sentinel: HOOK_PROGRESS_SENTINEL, matcher: HOOK_PROGRESS_MATCHER, command: HOOK_PROGRESS_COMMAND }
31
+ ];
10
32
  function isInsidePath(childPath, parentPath) {
11
33
  const rel = relative(parentPath, childPath);
12
34
  return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
@@ -87,9 +109,17 @@ function readPreToolUse(settings) {
87
109
  const pre = hooks.PreToolUse;
88
110
  return Array.isArray(pre) ? pre : [];
89
111
  }
112
+ /** True when every command handler in the entry matches a known peaks sentinel. */
90
113
  function entryIsPeaksManaged(entry) {
91
114
  const handlers = Array.isArray(entry?.hooks) ? entry.hooks : [];
92
- return handlers.length > 0 && handlers.every((h) => typeof h?.command === 'string' && h.command.includes(HOOK_SENTINEL));
115
+ if (handlers.length === 0)
116
+ return false;
117
+ return handlers.every((h) => {
118
+ if (typeof h?.command !== 'string')
119
+ return false;
120
+ const cmd = h.command;
121
+ return PEAKS_HOOK_SENTINELS.some((sentinel) => cmd.includes(sentinel));
122
+ });
93
123
  }
94
124
  function isInstalled(settings) {
95
125
  return readPreToolUse(settings).some(entryIsPeaksManaged);
@@ -100,18 +130,32 @@ export function planHookInstall(scope, projectRoot) {
100
130
  assertSafeSettingsPath(scope, root, settingsPath);
101
131
  const exists = existsSync(settingsPath);
102
132
  const settings = readSettings(settingsPath);
103
- return { scope, settingsPath, exists, alreadyInstalled: isInstalled(settings), desiredCommand: HOOK_ENFORCE_COMMAND };
133
+ return {
134
+ scope,
135
+ settingsPath,
136
+ exists,
137
+ alreadyInstalled: isInstalled(settings),
138
+ desiredCommand: HOOK_ENFORCE_COMMAND,
139
+ sentinel: HOOK_ENFORCE_SENTINEL,
140
+ matcher: HOOK_GATE_MATCHER
141
+ };
104
142
  }
105
- /** Merge our PreToolUse entry into settings, preserving all other keys and hooks. */
106
- function withHookInstalled(settings) {
143
+ /** Merge all peaks-managed PreToolUse entries into settings, preserving all other keys and hooks. */
144
+ function withHooksInstalled(settings) {
107
145
  const existingHooks = (settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks))
108
146
  ? settings.hooks
109
147
  : {};
110
148
  const preToolUse = readPreToolUse(settings);
111
- const ourEntry = { matcher: HOOK_MATCHER, hooks: [{ type: 'command', command: HOOK_ENFORCE_COMMAND }] };
149
+ // Drop any existing peaks-managed entries first so re-runs are idempotent
150
+ // even if the command string changed (e.g. a bug fix in the command).
151
+ const nonPeaks = preToolUse.filter((entry) => !entryIsPeaksManaged(entry));
152
+ const ourEntries = PEAKS_HOOK_ENTRIES.map((spec) => ({
153
+ matcher: spec.matcher,
154
+ hooks: [{ type: 'command', command: spec.command }]
155
+ }));
112
156
  return {
113
157
  ...settings,
114
- hooks: { ...existingHooks, PreToolUse: [...preToolUse, ourEntry] }
158
+ hooks: { ...existingHooks, PreToolUse: [...nonPeaks, ...ourEntries] }
115
159
  };
116
160
  }
117
161
  export function applyHookInstall(scope, projectRoot) {
@@ -121,10 +165,10 @@ export function applyHookInstall(scope, projectRoot) {
121
165
  const exists = existsSync(settingsPath);
122
166
  const settings = readSettings(settingsPath);
123
167
  if (isInstalled(settings)) {
124
- return { scope, settingsPath, exists, alreadyInstalled: true, desiredCommand: HOOK_ENFORCE_COMMAND, applied: false };
168
+ return { scope, settingsPath, exists, alreadyInstalled: true, desiredCommand: HOOK_ENFORCE_COMMAND, applied: false, sentinel: HOOK_ENFORCE_SENTINEL, matcher: HOOK_GATE_MATCHER };
125
169
  }
126
- atomicWriteJson(settingsPath, withHookInstalled(settings));
127
- return { scope, settingsPath, exists, alreadyInstalled: false, desiredCommand: HOOK_ENFORCE_COMMAND, applied: true };
170
+ atomicWriteJson(settingsPath, withHooksInstalled(settings));
171
+ return { scope, settingsPath, exists, alreadyInstalled: false, desiredCommand: HOOK_ENFORCE_COMMAND, applied: true, sentinel: HOOK_ENFORCE_SENTINEL, matcher: HOOK_GATE_MATCHER };
128
172
  }
129
173
  export function removeHookInstall(scope, projectRoot) {
130
174
  const root = resolveSettingsRoot(scope, projectRoot);
@@ -0,0 +1,2 @@
1
+ import type { SliceCheckOptions, SliceCheckResult } from './slice-check-types.js';
2
+ export declare function sliceCheck(options: SliceCheckOptions): Promise<SliceCheckResult>;