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.
- package/README.md +62 -46
- package/bin/peaks.js +0 -0
- package/dist/src/cli/commands/core-artifact-commands.js +49 -11
- package/dist/src/cli/commands/hooks-commands.js +24 -9
- package/dist/src/cli/commands/progress-commands.js +26 -2
- package/dist/src/cli/commands/request-commands.js +5 -0
- package/dist/src/cli/commands/slice-commands.d.ts +3 -0
- package/dist/src/cli/commands/slice-commands.js +44 -0
- package/dist/src/cli/commands/workflow-commands.js +3 -3
- package/dist/src/cli/commands/workspace-commands.d.ts +63 -0
- package/dist/src/cli/commands/workspace-commands.js +349 -12
- package/dist/src/cli/program.js +4 -0
- package/dist/src/services/artifacts/artifact-prerequisites.d.ts +29 -1
- package/dist/src/services/artifacts/artifact-prerequisites.js +69 -5
- package/dist/src/services/artifacts/request-artifact-service.d.ts +22 -0
- package/dist/src/services/artifacts/request-artifact-service.js +214 -56
- package/dist/src/services/doctor/doctor-service.d.ts +69 -0
- package/dist/src/services/doctor/doctor-service.js +296 -3
- package/dist/src/services/progress/progress-service.d.ts +26 -0
- package/dist/src/services/progress/progress-service.js +25 -0
- package/dist/src/services/sc/sc-service.js +71 -13
- package/dist/src/services/scan/acceptance-coverage-service.js +6 -2
- package/dist/src/services/session/session-manager.d.ts +22 -1
- package/dist/src/services/session/session-manager.js +149 -30
- package/dist/src/services/skills/hooks-settings-service.d.ts +25 -3
- package/dist/src/services/skills/hooks-settings-service.js +57 -13
- package/dist/src/services/slice/slice-check-service.d.ts +2 -0
- package/dist/src/services/slice/slice-check-service.js +267 -0
- package/dist/src/services/slice/slice-check-types.d.ts +70 -0
- package/dist/src/services/slice/slice-check-types.js +18 -0
- package/dist/src/services/workflow/pipeline-verify-service.d.ts +5 -2
- package/dist/src/services/workflow/pipeline-verify-service.js +35 -35
- package/dist/src/services/workspace/migrate-service.d.ts +2 -0
- package/dist/src/services/workspace/migrate-service.js +606 -0
- package/dist/src/services/workspace/migrate-types.d.ts +127 -0
- package/dist/src/services/workspace/migrate-types.js +21 -0
- package/dist/src/services/workspace/reconcile-service.d.ts +33 -0
- package/dist/src/services/workspace/reconcile-service.js +160 -42
- package/dist/src/services/workspace/reconcile-types.d.ts +25 -0
- package/dist/src/services/workspace/workspace-service.d.ts +11 -0
- package/dist/src/services/workspace/workspace-service.js +71 -24
- package/dist/src/shared/change-id.d.ts +59 -0
- package/dist/src/shared/change-id.js +194 -16
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +10 -2
- package/schemas/doctor-report.schema.json +2 -2
- package/skills/peaks-qa/SKILL.md +1 -0
- package/skills/peaks-rd/SKILL.md +2 -1
- package/skills/peaks-solo/SKILL.md +17 -1
- package/skills/peaks-solo/references/micro-cycle.md +155 -0
- package/skills/peaks-txt/SKILL.md +2 -0
- 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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
288
|
-
|
|
289
|
-
const
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
const
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
20
|
-
|
|
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
|
-
/**
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
106
|
-
function
|
|
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
|
-
|
|
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: [...
|
|
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,
|
|
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);
|