peaks-loop 4.0.35 → 4.0.37

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 (128) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/bin/peaks.js +71 -1
  5. package/dist/cli/cli-helpers.js +7 -0
  6. package/dist/cli/commands/_register.js +2 -0
  7. package/dist/cli/commands/best-practice-scan-command.d.ts +14 -1
  8. package/dist/cli/commands/best-practice-scan-command.js +67 -9
  9. package/dist/cli/commands/code-runtime-commands.d.ts +5 -2
  10. package/dist/cli/commands/code-runtime-commands.js +78 -7
  11. package/dist/cli/commands/core/doctor-command.d.ts +8 -0
  12. package/dist/cli/commands/core/doctor-command.js +44 -2
  13. package/dist/cli/commands/core/memory-command.js +5 -1
  14. package/dist/cli/commands/dispatch-commands.js +15 -3
  15. package/dist/cli/commands/dispatch-from-dag.js +17 -0
  16. package/dist/cli/commands/hooks-commands.js +10 -1
  17. package/dist/cli/commands/job-commands.js +107 -25
  18. package/dist/cli/commands/memory-commands.d.ts +24 -0
  19. package/dist/cli/commands/memory-commands.js +77 -10
  20. package/dist/cli/commands/request-commands.d.ts +8 -0
  21. package/dist/cli/commands/request-commands.js +23 -2
  22. package/dist/cli/commands/scan-commands.js +1 -1
  23. package/dist/cli/commands/sub-agent-commands.js +2 -0
  24. package/dist/cli/commands/wave-plan-commands.d.ts +24 -0
  25. package/dist/cli/commands/wave-plan-commands.js +93 -0
  26. package/dist/cli/commands/web-commands.d.ts +28 -0
  27. package/dist/cli/commands/web-commands.js +327 -0
  28. package/dist/cli/commands/web-lifecycle-commands.d.ts +49 -0
  29. package/dist/cli/commands/web-lifecycle-commands.js +321 -0
  30. package/dist/services/best-practice/scan-orchestrator.d.ts +22 -0
  31. package/dist/services/best-practice/scan-orchestrator.js +14 -5
  32. package/dist/services/code/orchestrator-can-do.js +27 -4
  33. package/dist/services/context/build-dispatch-system-prompt.d.ts +66 -9
  34. package/dist/services/context/build-dispatch-system-prompt.js +132 -17
  35. package/dist/services/context/context-audit-hint.d.ts +79 -0
  36. package/dist/services/context/context-audit-hint.js +150 -0
  37. package/dist/services/context/context-audit.d.ts +100 -0
  38. package/dist/services/context/context-audit.js +322 -0
  39. package/dist/services/context/summary-view.d.ts +54 -0
  40. package/dist/services/context/summary-view.js +114 -0
  41. package/dist/services/dispatch/file-overlap-wave-planner.d.ts +70 -0
  42. package/dist/services/dispatch/file-overlap-wave-planner.js +119 -0
  43. package/dist/services/dispatch/session-capsule.d.ts +23 -0
  44. package/dist/services/dispatch/session-capsule.js +56 -0
  45. package/dist/services/dispatch/slice-dag.d.ts +9 -0
  46. package/dist/services/dispatch/slice-dag.js +9 -1
  47. package/dist/services/dispatch/test-tool-detection.d.ts +12 -1
  48. package/dist/services/dispatch/test-tool-detection.js +14 -13
  49. package/dist/services/hooks/auto-compact-hook-install.js +10 -1
  50. package/dist/services/hooks/write-gate.js +88 -0
  51. package/dist/services/ide/adapters/claude-code-adapter.d.ts +10 -0
  52. package/dist/services/ide/adapters/claude-code-adapter.js +20 -1
  53. package/dist/services/ide/ide-types.d.ts +15 -0
  54. package/dist/services/lint/detect-eslint.d.ts +2 -0
  55. package/dist/services/lint/detect-eslint.js +23 -9
  56. package/dist/services/lint/npx-resolver.d.ts +6 -0
  57. package/dist/services/lint/npx-resolver.js +38 -14
  58. package/dist/services/memory/project-memory-service/parsers/frontmatter.d.ts +5 -0
  59. package/dist/services/memory/project-memory-service/parsers/frontmatter.js +55 -5
  60. package/dist/services/release/version-precheck-service.js +9 -2
  61. package/dist/services/scan/file-size-scan.d.ts +29 -0
  62. package/dist/services/scan/file-size-scan.js +63 -0
  63. package/dist/services/session/caller-binding-service.d.ts +24 -0
  64. package/dist/services/session/caller-binding-service.js +34 -0
  65. package/dist/services/session/getSessionDir.js +15 -10
  66. package/dist/services/skills/hooks-codegate-superpowers.d.ts +33 -0
  67. package/dist/services/skills/hooks-codegate-superpowers.js +34 -3
  68. package/dist/services/skills/hooks-settings-service.d.ts +10 -0
  69. package/dist/services/skills/hooks-settings-service.js +152 -61
  70. package/dist/services/slice/slice-check-service.d.ts +14 -0
  71. package/dist/services/slice/slice-check-service.js +110 -50
  72. package/dist/services/slice/slice-check-types.d.ts +12 -7
  73. package/dist/services/slice/slice-check-types.js +8 -3
  74. package/dist/services/slice/slice-decompose-runners.js +24 -21
  75. package/dist/services/sop/sop-check-service.js +12 -1
  76. package/dist/services/web/bounded-output.d.ts +34 -0
  77. package/dist/services/web/bounded-output.js +68 -0
  78. package/dist/services/web/browser-acquire.d.ts +14 -0
  79. package/dist/services/web/browser-acquire.js +84 -0
  80. package/dist/services/web/browser-session-manager.d.ts +111 -0
  81. package/dist/services/web/browser-session-manager.js +413 -0
  82. package/dist/services/web/daemon-entry.d.ts +1 -0
  83. package/dist/services/web/daemon-entry.js +65 -0
  84. package/dist/services/web/daemon-registry.d.ts +42 -0
  85. package/dist/services/web/daemon-registry.js +164 -0
  86. package/dist/services/web/daemon-supervisor.d.ts +144 -0
  87. package/dist/services/web/daemon-supervisor.js +455 -0
  88. package/dist/services/web/playwright-loader.d.ts +89 -0
  89. package/dist/services/web/playwright-loader.js +253 -0
  90. package/dist/services/web/snapshot-pruner.d.ts +48 -0
  91. package/dist/services/web/snapshot-pruner.js +241 -0
  92. package/dist/services/web/untrusted-envelope.d.ts +27 -0
  93. package/dist/services/web/untrusted-envelope.js +44 -0
  94. package/dist/services/web/web-artifact-paths.d.ts +79 -0
  95. package/dist/services/web/web-artifact-paths.js +163 -0
  96. package/dist/services/web/web-client.d.ts +19 -0
  97. package/dist/services/web/web-client.js +55 -0
  98. package/dist/services/web/web-daemon-service.d.ts +38 -0
  99. package/dist/services/web/web-daemon-service.js +416 -0
  100. package/dist/services/web/web-fallback.d.ts +70 -0
  101. package/dist/services/web/web-fallback.js +121 -0
  102. package/dist/services/web/web-install-service.d.ts +91 -0
  103. package/dist/services/web/web-install-service.js +346 -0
  104. package/dist/services/web/web-login-profile.d.ts +89 -0
  105. package/dist/services/web/web-login-profile.js +612 -0
  106. package/dist/services/web/web-login-staging.d.ts +27 -0
  107. package/dist/services/web/web-login-staging.js +173 -0
  108. package/dist/services/web/web-protocol.d.ts +58 -0
  109. package/dist/services/web/web-protocol.js +58 -0
  110. package/dist/services/web/web-status-report.d.ts +33 -0
  111. package/dist/services/web/web-status-report.js +47 -0
  112. package/dist/services/workspace/claude-settings-template.d.ts +41 -5
  113. package/dist/services/workspace/claude-settings-template.js +116 -64
  114. package/dist/services/workspace/workspace-claude-settings-materializer.js +5 -1
  115. package/dist/services/workspace/workspace-service.js +33 -0
  116. package/package.json +5 -5
  117. package/scripts/copy-templates.mjs +12 -0
  118. package/scripts/sync-version.mjs +20 -0
  119. package/skills/bee/peaks-qa/SKILL.md +2 -0
  120. package/skills/bee/peaks-qa/references/qa-sub-agent-dispatch.md +12 -0
  121. package/skills/bee/peaks-rd/SKILL.md +2 -0
  122. package/skills/bee/peaks-rd/references/rd-sub-agent-dispatch.md +14 -0
  123. package/skills/bee/peaks-txt/SKILL.md +2 -0
  124. package/skills/bee/peaks-ui/SKILL.md +2 -0
  125. package/skills/peaks-code/SKILL.md +18 -0
  126. package/skills/peaks-code/references/browser-workflow.md +10 -1
  127. package/skills/peaks-code/references/context-governance.md +29 -0
  128. package/skills/peaks-doctor/SKILL.md +2 -0
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { homedir } from 'node:os';
3
- import { resolve } from 'node:path';
3
+ import { join, resolve } from 'node:path';
4
4
  import { assertSafeSettingsFile } from '../ide/shared/safe-path.js';
5
5
  import { atomicWriteJson, readJsonObjectFile } from '../ide/shared/atomic-json.js';
6
6
  import { getAdapter } from '../ide/ide-registry.js';
@@ -44,6 +44,43 @@ function assertSafeSettingsPathCompat(scope, ide, root, settingsPath) {
44
44
  throw new Error(`settings path drift: ${expected} vs ${settingsPath}`);
45
45
  }
46
46
  }
47
+ /** Machine-local sibling of the adapter's settings file (Claude Code convention). */
48
+ const LOCAL_SETTINGS_FILE_NAME = 'settings.local.json';
49
+ /** IDEs whose hook settings have a machine-local, gitignored sibling file. */
50
+ const IDES_WITH_LOCAL_SETTINGS = new Set(['claude-code']);
51
+ /**
52
+ * Resolve the machine-local settings file an IDE may carry (e.g. Claude
53
+ * Code's `.claude/settings.local.json`), or `undefined` when there is none.
54
+ *
55
+ * Global scope resolves to `undefined`: the global settings file
56
+ * (`~/.claude/settings.json`) is already machine-local, so nothing needs
57
+ * relocating there.
58
+ */
59
+ function resolveLocalSettingsPath(scope, ide, projectRoot) {
60
+ if (scope === 'global' || !IDES_WITH_LOCAL_SETTINGS.has(ide))
61
+ return undefined;
62
+ const root = resolveSettingsRoot(scope, projectRoot);
63
+ const adapter = getAdapter(ide);
64
+ assertSafeSettingsFile(scope, root, adapter.settings.dirName, LOCAL_SETTINGS_FILE_NAME);
65
+ return join(root, adapter.settings.dirName, LOCAL_SETTINGS_FILE_NAME);
66
+ }
67
+ function resolveHookTargets(scope, ide, projectRoot) {
68
+ const sharedPath = resolveSettingsPath(scope, ide, projectRoot);
69
+ const localPath = resolveLocalSettingsPath(scope, ide, projectRoot);
70
+ const shared = { settingsPath: sharedPath, entries: [] };
71
+ if (localPath === undefined || localPath === sharedPath) {
72
+ return [{ settingsPath: sharedPath, entries: [...resolveHookEntries(ide)] }];
73
+ }
74
+ const local = { settingsPath: localPath, entries: [] };
75
+ for (const entry of resolveHookEntries(ide)) {
76
+ (entry.machineLocal === true ? local : shared).entries.push(entry);
77
+ }
78
+ return [shared, local];
79
+ }
80
+ /** Read a settings file as an object, or `{}` when it does not exist yet. */
81
+ function readSettingsFile(settingsPath) {
82
+ return existsSync(settingsPath) ? readJsonObjectFile(settingsPath) : {};
83
+ }
47
84
  /** Read the existing hook array entries for the adapter's hookEvent (tolerant of any prior shape). */
48
85
  function readHookEventEntries(settings, eventKey) {
49
86
  const hooks = settings.hooks;
@@ -209,8 +246,7 @@ export const PEAKS_HOOK_ENTRIES = (() => {
209
246
  { sentinel: spec.hookEnforceSentinel, matcher: spec.hookEnforceMatcher, command: spec.hookEnforceCommand, event: spec.hookEnforceEvent }
210
247
  ];
211
248
  })();
212
- function isInstalledForIde(settings, ide) {
213
- const entries = resolveHookEntries(ide);
249
+ function isInstalledForEntries(settings, entries) {
214
250
  const sentinels = entries.map((e) => e.sentinel);
215
251
  // Check every distinct event key our entries could be on.
216
252
  const eventKeys = new Set(entries.map((e) => e.event));
@@ -230,12 +266,14 @@ function isInstalledForIde(settings, ide) {
230
266
  * fully reflected on disk: gate-enforce present AND no legacy
231
267
  * progress-start present.
232
268
  */
233
- function shapeMatchesDesired(settings, ide) {
234
- const desiredEntries = resolveHookEntries(ide);
235
- const desiredSentinels = new Set(desiredEntries.map((e) => e.sentinel));
236
- const allPeaksSentinels = resolveLegacySentinels(ide);
237
- const eventKeys = new Set(resolveHookEntries(ide).map((e) => e.event));
269
+ function shapeMatchesDesired(settings, entries, allPeaksSentinels) {
270
+ const eventKeys = new Set(entries.map((e) => e.event));
238
271
  for (const eventKey of eventKeys) {
272
+ // Only the entries that belong to THIS event key can be expected on it —
273
+ // the desired set must be per-event, or a target whose entries span two
274
+ // event keys (claude-code: SessionStart + PreToolUse) would never look
275
+ // installed and every `peaks hooks install` would rewrite the files.
276
+ const desiredSentinels = new Set(entries.filter((e) => e.event === eventKey).map((e) => e.sentinel));
239
277
  const present = readHookEventEntries(settings, eventKey);
240
278
  const peaksPresent = present.filter((e) => entryIsPeaksManaged(e, allPeaksSentinels));
241
279
  // (a) every peaks-managed entry currently on disk must match the
@@ -261,29 +299,36 @@ export function planHookInstall(scope, projectRoot, options) {
261
299
  const root = resolveSettingsRoot(scope, projectRoot);
262
300
  const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
263
301
  assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
302
+ const targets = resolveHookTargets(scope, ide, projectRoot);
264
303
  const exists = existsSync(settingsPath);
265
- const settings = exists ? readJsonObjectFile(settingsPath) : {};
266
304
  const spec = resolveHookSpec(ide);
305
+ const localTarget = targets.find((t) => t.settingsPath !== settingsPath);
267
306
  return {
268
307
  scope,
269
308
  settingsPath,
270
309
  exists,
271
- alreadyInstalled: isInstalledForIde(settings, ide),
310
+ alreadyInstalled: targets.every((t) => isInstalledForEntries(readSettingsFile(t.settingsPath), t.entries)),
272
311
  desiredCommand: spec.hookEnforceCommand,
273
312
  sentinel: spec.hookEnforceSentinel,
274
- matcher: spec.hookEnforceMatcher
313
+ matcher: spec.hookEnforceMatcher,
314
+ ...(localTarget !== undefined ? { localSettingsPath: localTarget.settingsPath } : {})
275
315
  };
276
316
  }
277
- /** Merge all peaks-managed hook entries into settings, preserving all other keys and hooks. */
278
- function withHooksInstalledForIde(settings, ide, _skipProgress = false) {
317
+ /**
318
+ * Merge a target's peaks-managed hook entries into `settings`, preserving
319
+ * all other keys and hooks. Entries routed to a DIFFERENT settings file are
320
+ * not part of `entries`, so the legacy-sentinel filter below strips them
321
+ * from this file (that is what migrates a gate-enforce entry that an older
322
+ * peaks release wrote into the shared file).
323
+ */
324
+ function withHooksInstalled(settings, entries, allSentinels) {
279
325
  const existingHooks = (settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks))
280
326
  ? settings.hooks
281
327
  : {};
282
- // Per-IDE entries may map to different events (Trae: gate-enforce on
283
- // beforeToolCall; Claude: gate-enforce on PreToolUse). Group by event
284
- // so each event array is independently merged.
328
+ // Our entries may sit on more than one event key. Group by event so each
329
+ // event array is independently merged.
285
330
  const ourByEvent = new Map();
286
- for (const spec of resolveHookEntries(ide)) {
331
+ for (const spec of entries) {
287
332
  const list = ourByEvent.get(spec.event) ?? [];
288
333
  list.push(spec);
289
334
  ourByEvent.set(spec.event, list);
@@ -293,14 +338,16 @@ function withHooksInstalledForIde(settings, ide, _skipProgress = false) {
293
338
  // stripped by the filter (the file converges on the new
294
339
  // gate-enforce-only shape, idempotently). The desired set (passed
295
340
  // in below) only contains the gate-enforce sentinel.
296
- const allSentinels = resolveLegacySentinels(ide);
297
341
  const nextHooks = { ...existingHooks };
298
- for (const [eventKey, ourEntries] of ourByEvent) {
342
+ // An event key this file used to carry but whose entries all moved to the
343
+ // other file still needs its stale peaks entries stripped.
344
+ const eventKeys = new Set([...ourByEvent.keys(), ...Object.keys(existingHooks)]);
345
+ for (const eventKey of eventKeys) {
299
346
  const existing = readHookEntriesFromHooks(nextHooks, eventKey);
300
347
  const nonPeaks = existing.filter((entry) => !entryIsPeaksManaged(entry, allSentinels));
301
- const ourFormatted = ourEntries.map((spec) => ({
348
+ const ourFormatted = (ourByEvent.get(eventKey) ?? []).map((spec) => ({
302
349
  matcher: spec.matcher,
303
- hooks: [{ type: 'command', command: spec.command }]
350
+ hooks: [{ type: 'command', command: spec.command, ...(spec.shell !== undefined ? { shell: spec.shell } : {}) }]
304
351
  }));
305
352
  const merged = [...nonPeaks, ...ourFormatted];
306
353
  if (merged.length > 0) {
@@ -322,15 +369,17 @@ export function applyHookInstall(scope, projectRoot, options) {
322
369
  const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
323
370
  assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
324
371
  const exists = existsSync(settingsPath);
325
- const settings = exists ? readJsonObjectFile(settingsPath) : {};
326
372
  const spec = resolveHookSpec(ide);
373
+ const targets = resolveHookTargets(scope, ide, projectRoot);
374
+ const allSentinels = resolveLegacySentinels(ide);
375
+ const localTarget = targets.find((t) => t.settingsPath !== settingsPath);
327
376
  // Slice #014: `alreadyInstalled` reflects the FULL desired shape
328
377
  // (gate-enforce-only + no stale progress-start entry). Pre-#014
329
378
  // installs that left a progress-start entry behind will be treated
330
379
  // as not-yet-installed, so the merge strips the stale entry on the
331
380
  // next install call. This is the only path that converges the file
332
381
  // on the new shape; pure presence-checks are insufficient.
333
- const alreadyInstalled = shapeMatchesDesired(settings, ide);
382
+ const alreadyInstalled = targets.every((t) => shapeMatchesDesired(readSettingsFile(t.settingsPath), t.entries, allSentinels));
334
383
  const baseResult = {
335
384
  scope,
336
385
  settingsPath,
@@ -338,7 +387,8 @@ export function applyHookInstall(scope, projectRoot, options) {
338
387
  alreadyInstalled,
339
388
  desiredCommand: spec.hookEnforceCommand,
340
389
  sentinel: spec.hookEnforceSentinel,
341
- matcher: spec.hookEnforceMatcher
390
+ matcher: spec.hookEnforceMatcher,
391
+ ...(localTarget !== undefined ? { localSettingsPath: localTarget.settingsPath } : {})
342
392
  };
343
393
  if (baseResult.alreadyInstalled) {
344
394
  return { ...baseResult, applied: false };
@@ -356,8 +406,17 @@ export function applyHookInstall(scope, projectRoot, options) {
356
406
  // entry. The chain cannot run raw `git worktree add` (or
357
407
  // `podman run`) without an explicit `peaks hooks uninstall`
358
408
  // first. Both helpers are pure + idempotent.
359
- const merged = withTriggeredDenyList(withSuperpowersSkillDenylist(withHooksInstalledForIde(settings, ide)));
360
- atomicWriteJson(settingsPath, merged);
409
+ //
410
+ // The `permissions.deny` block lives in the adapter's settings file
411
+ // only (as it always has) — it is committed, so the deny list is
412
+ // shared; the machine-local file carries hook entries only.
413
+ for (const target of targets) {
414
+ const next = withHooksInstalled(readSettingsFile(target.settingsPath), target.entries, allSentinels);
415
+ const merged = target.settingsPath === settingsPath
416
+ ? withTriggeredDenyList(withSuperpowersSkillDenylist(next))
417
+ : next;
418
+ atomicWriteJson(target.settingsPath, merged);
419
+ }
361
420
  return { ...baseResult, alreadyInstalled: false, applied: true };
362
421
  }
363
422
  export function removeHookInstall(scope, projectRoot, options) {
@@ -365,60 +424,92 @@ export function removeHookInstall(scope, projectRoot, options) {
365
424
  const root = resolveSettingsRoot(scope, projectRoot);
366
425
  const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
367
426
  assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
368
- if (!existsSync(settingsPath)) {
369
- return { scope, settingsPath, removed: false };
427
+ const targets = resolveHookTargets(scope, ide, projectRoot);
428
+ const localTarget = targets.find((t) => t.settingsPath !== settingsPath);
429
+ const present = targets.filter((t) => existsSync(t.settingsPath));
430
+ if (present.length === 0) {
431
+ return {
432
+ scope,
433
+ settingsPath,
434
+ removed: false,
435
+ ...(localTarget !== undefined ? { localSettingsPath: localTarget.settingsPath } : {})
436
+ };
370
437
  }
371
- const settings = readJsonObjectFile(settingsPath);
372
- const existingHooks = settings.hooks ?? {};
373
438
  // Slice #014: uninstall must remove the gate-enforce entry AND any
374
439
  // legacy progress-start entry that a pre-#014 install left behind.
375
440
  // The legacy sentinel set covers both shapes so the uninstall
376
441
  // converges the file on "no peaks-managed entries", regardless of
377
442
  // what shape the file was in when the user ran uninstall.
378
443
  const sentinels = resolveLegacySentinels(ide);
379
- const eventKeys = new Set(resolveHookEntries(ide).map((e) => e.event));
380
444
  let removedAny = false;
381
- const nextHooks = { ...existingHooks };
382
- for (const eventKey of eventKeys) {
383
- const entries = readHookEntriesFromHooks(nextHooks, eventKey);
384
- const kept = entries.filter((entry) => !entryIsPeaksManaged(entry, sentinels));
385
- if (kept.length !== entries.length)
386
- removedAny = true;
387
- if (kept.length > 0) {
388
- nextHooks[eventKey] = kept;
445
+ for (const target of present) {
446
+ const settings = readJsonObjectFile(target.settingsPath);
447
+ const existingHooks = settings.hooks ?? {};
448
+ // Scan every event key on disk as well as the ones we install on:
449
+ // entries may have been routed to the other settings file by a
450
+ // previous install.
451
+ const eventKeys = new Set([...target.entries.map((e) => e.event), ...Object.keys(existingHooks)]);
452
+ const nextHooks = { ...existingHooks };
453
+ for (const eventKey of eventKeys) {
454
+ const entries = readHookEntriesFromHooks(nextHooks, eventKey);
455
+ const kept = entries.filter((entry) => !entryIsPeaksManaged(entry, sentinels));
456
+ if (kept.length !== entries.length)
457
+ removedAny = true;
458
+ if (kept.length > 0) {
459
+ nextHooks[eventKey] = kept;
460
+ }
461
+ else {
462
+ delete nextHooks[eventKey];
463
+ }
464
+ }
465
+ const nextSettings = { ...settings };
466
+ if (Object.keys(nextHooks).length > 0) {
467
+ nextSettings.hooks = nextHooks;
389
468
  }
390
469
  else {
391
- delete nextHooks[eventKey];
470
+ delete nextSettings.hooks;
392
471
  }
472
+ // Slice 2026-07-29-worktree-layer3-deny: Layer 3 — symmetric uninstall.
473
+ // Strips the peaks-managed `UseSkill(...)` deny entries alongside the
474
+ // hook entries; user-written entries are untouched. The helper self-
475
+ // decomposes an empty `permissions` object.
476
+ //
477
+ // Slice 2026-07-29-worktree-l2-extended Part 29: also strip the
478
+ // trigger-style deny entries via withoutTriggeredDenyList. The
479
+ // chain of helpers is order-independent (each is idempotent and
480
+ // additive over the same set of peaks-managed entries).
481
+ const finalSettings = target.settingsPath === settingsPath
482
+ ? withoutTriggeredDenyList(withoutSuperpowersSkillDenylist(nextSettings))
483
+ : nextSettings;
484
+ atomicWriteJson(target.settingsPath, finalSettings);
393
485
  }
394
- const nextSettings = { ...settings };
395
- if (Object.keys(nextHooks).length > 0) {
396
- nextSettings.hooks = nextHooks;
397
- }
398
- else {
399
- delete nextSettings.hooks;
400
- }
401
- // Slice 2026-07-29-worktree-layer3-deny: Layer 3 — symmetric uninstall.
402
- // Strips the peaks-managed `UseSkill(...)` deny entries alongside the
403
- // hook entries; user-written entries are untouched. The helper self-
404
- // decomposes an empty `permissions` object.
405
- //
406
- // Slice 2026-07-29-worktree-l2-extended Part 29: also strip the
407
- // trigger-style deny entries via withoutTriggeredDenyList. The
408
- // chain of helpers is order-independent (each is idempotent and
409
- // additive over the same set of peaks-managed entries).
410
- const finalSettings = withoutTriggeredDenyList(withoutSuperpowersSkillDenylist(nextSettings));
411
- atomicWriteJson(settingsPath, finalSettings);
412
- return { scope, settingsPath, removed: removedAny };
486
+ return {
487
+ scope,
488
+ settingsPath,
489
+ removed: removedAny,
490
+ ...(localTarget !== undefined ? { localSettingsPath: localTarget.settingsPath } : {})
491
+ };
413
492
  }
414
493
  export function readHookStatus(scope, projectRoot, options) {
415
494
  const ide = resolveIde(options);
416
495
  const root = resolveSettingsRoot(scope, projectRoot);
417
496
  const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
418
497
  assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
498
+ const targets = resolveHookTargets(scope, ide, projectRoot);
419
499
  const exists = existsSync(settingsPath);
420
- const settings = exists ? readJsonObjectFile(settingsPath) : {};
421
- return { scope, settingsPath, exists, installed: isInstalledForIde(settings, ide) };
500
+ const localTarget = targets.find((t) => t.settingsPath !== settingsPath);
501
+ return {
502
+ scope,
503
+ settingsPath,
504
+ exists,
505
+ ...(localTarget !== undefined
506
+ ? { localSettingsPath: localTarget.settingsPath, localExists: existsSync(localTarget.settingsPath) }
507
+ : {}),
508
+ installed: targets.some((t) => {
509
+ const settings = readSettingsFile(t.settingsPath);
510
+ return Object.keys(settings).length > 0 && isInstalledForEntries(settings, t.entries);
511
+ })
512
+ };
422
513
  }
423
514
  /**
424
515
  * Slice 2026-07-29-worktree-l2-extended Part 27 — trigger-style deny.
@@ -1,2 +1,16 @@
1
1
  import type { SliceCheckOptions, SliceCheckResult } from './slice-check-types.js';
2
+ /**
3
+ * 2026-09-10 D1-follow-up (G5): `tsconfig.build.json` is the type contract the
4
+ * package actually ships (`src/**` only) and it is clean, so the typecheck
5
+ * stage GATES on it. The wider `tsconfig.json` adds `tests/**`, which still
6
+ * carries errors this slice did not cause — 142 as measured on 2026-09-10
7
+ * (`pnpm exec tsc -p tsconfig.json --noEmit 2>&1 | grep -c "error TS"`), all
8
+ * of them under `tests/`, none under `src/`. That count is therefore a
9
+ * baseline, not a gate: the stage fails only when it GROWS, which is the
10
+ * question "did this slice add a type error anywhere?". Re-measure and update
11
+ * this constant when the residue is paid down.
12
+ */
13
+ export declare const TYPECHECK_PREEXISTING_BASELINE = 142;
14
+ /** Named code for "the CLI this stage needs is not installed". */
15
+ export declare const BINARY_UNRESOLVED_CODE = "SLICE_CHECK_BINARY_UNRESOLVED";
2
16
  export declare function sliceCheck(options: SliceCheckOptions): Promise<SliceCheckResult>;
@@ -28,49 +28,75 @@ const HEAD_LINES_IN_TAIL = 3;
28
28
  const AUDIT_MIN_RED_LINES = 60;
29
29
  const REVIEW_FILE_MIN_BYTES = 20;
30
30
  /**
31
- * Resolve a CLI binary to a project-local path, falling back to
32
- * the system `npx`. pnpm (and npm/yarn) all create
33
- * `node_modules/.bin/<name>`:
34
- *
35
- * - On Unix, this is a symlink to the package's executable.
36
- * - On Windows, this is a `.cmd` shim; `execFileSync` only
37
- * resolves `.cmd` through the shell (PATHEXT), so we pass
38
- * `shell: true` when invoking one. Without this, the
39
- * Windows `npx ENOENT` false-positive from
40
- * observations 2317 + 2792 reproduces for every local
41
- * binary.
31
+ * 2026-09-10 D1-follow-up (G5): `tsconfig.build.json` is the type contract the
32
+ * package actually ships (`src/**` only) and it is clean, so the typecheck
33
+ * stage GATES on it. The wider `tsconfig.json` adds `tests/**`, which still
34
+ * carries errors this slice did not cause — 142 as measured on 2026-09-10
35
+ * (`pnpm exec tsc -p tsconfig.json --noEmit 2>&1 | grep -c "error TS"`), all
36
+ * of them under `tests/`, none under `src/`. That count is therefore a
37
+ * baseline, not a gate: the stage fails only when it GROWS, which is the
38
+ * question "did this slice add a type error anywhere?". Re-measure and update
39
+ * this constant when the residue is paid down.
40
+ */
41
+ export const TYPECHECK_PREEXISTING_BASELINE = 142;
42
+ /**
43
+ * 2026-09-10 D1-follow-up (C6): each name resolves to the JS entry its package
44
+ * declares, as a path relative to `node_modules/` — the same files
45
+ * `node_modules/.bin/<name>` shims point at.
42
46
  *
43
- * Returns the command + args + a `shell` flag that the
44
- * `runCommand` helper threads into `execFileSync`.
47
+ * The `.bin` shim these used to spawn is a `.cmd` on Windows, and a `.cmd`
48
+ * cannot be spawned without `shell: true` (EINVAL, Node >= 20). `shell: true`
49
+ * re-parses the command line, so a project path containing a space was split
50
+ * at the space and the stage reported a phantom typecheck/test failure.
51
+ * Running the JS entry through `process.execPath` needs no shell — a spaced
52
+ * path is just an argument — and is the shape the D1 fix uses for `peaks`
53
+ * itself (`orchestrator-can-do.ts`).
54
+ */
55
+ const LOCAL_CLI_ENTRIES = {
56
+ tsc: ['typescript', 'bin', 'tsc'],
57
+ vitest: ['vitest', 'vitest.mjs']
58
+ };
59
+ /** Named code for "the CLI this stage needs is not installed". */
60
+ export const BINARY_UNRESOLVED_CODE = 'SLICE_CHECK_BINARY_UNRESOLVED';
61
+ function localCliPath(projectRoot, name) {
62
+ return join(projectRoot, 'node_modules', ...LOCAL_CLI_ENTRIES[name]);
63
+ }
64
+ /**
65
+ * Resolve a CLI to the interpreter + JS entry `runCommand` needs, or `null`
66
+ * when that entry is not on disk. Never returns a shell: the returned command
67
+ * is always `process.execPath`.
45
68
  */
46
69
  function resolveLocalBinary(projectRoot, name) {
47
- // pnpm creates `node_modules/.bin/<name>` (symlink on Unix,
48
- // `.cmd` shim on Windows). We probe both shapes; the
49
- // `process.platform === 'win32'` extension probe is the most
50
- // portable approach.
51
- const isWin = process.platform === 'win32';
52
- const candidateNames = isWin ? [`${name}.cmd`, `${name}.ps1`, `${name}`] : [name];
53
- for (const candidate of candidateNames) {
54
- const cmdPath = join(projectRoot, 'node_modules', '.bin', candidate);
55
- if (existsSync(cmdPath)) {
56
- return { command: cmdPath, args: [], shell: isWin };
57
- }
58
- }
59
- // Fallback: system npx. On Windows this still has the ENOENT
60
- // issue, but the fallback is at least informative when it
61
- // fires (the user can see "npx not found" instead of a
62
- // silent exit 1).
63
- return { command: 'npx', args: [name], shell: false };
70
+ const entry = localCliPath(projectRoot, name);
71
+ if (!existsSync(entry))
72
+ return null;
73
+ return { command: process.execPath, args: [entry] };
64
74
  }
65
- function runCommand(command, args, cwd, timeoutMs, shell = false) {
75
+ /**
76
+ * Failed stage for an unresolvable CLI. The previous fallback spawned `npx`
77
+ * and reported whatever came back — an ENOENT-shaped "exited 1" that reads
78
+ * exactly like a genuine typecheck/test failure. This names the code and the
79
+ * path that was searched instead.
80
+ */
81
+ function unresolvedBinaryStage(stage, cli, projectRoot, durationMs) {
82
+ const expected = localCliPath(projectRoot, cli);
83
+ return {
84
+ name: stage,
85
+ description: `${cli}: binary unresolved (${BINARY_UNRESOLVED_CODE})`,
86
+ status: 'fail',
87
+ durationMs,
88
+ detail: `${BINARY_UNRESOLVED_CODE}: ${cli} not found at ${expected}. Install dependencies (pnpm install) so that entry exists.`,
89
+ data: { code: BINARY_UNRESOLVED_CODE, cli, expected }
90
+ };
91
+ }
92
+ function runCommand(command, args, cwd, timeoutMs) {
66
93
  const start = Date.now();
67
94
  try {
68
95
  const stdout = execFileSync(command, args, {
69
96
  cwd,
70
97
  stdio: ['ignore', 'pipe', 'pipe'],
71
98
  timeout: timeoutMs,
72
- maxBuffer: EXEC_MAX_BUFFER_BYTES,
73
- shell
99
+ maxBuffer: EXEC_MAX_BUFFER_BYTES
74
100
  }).toString('utf8');
75
101
  return {
76
102
  status: 'pass',
@@ -105,22 +131,52 @@ function tailLines(text, max) {
105
131
  async function runTypecheck(projectRoot) {
106
132
  const start = Date.now();
107
133
  // Per Windows npx ENOENT (observations 2317+2792 from
108
- // 2026-06-09), prefer the project-local `node_modules/.bin/tsc`
109
- // (symlink on Unix, .cmd on Windows). The local binary is
110
- // installed by pnpm at workspace-install time and avoids the
111
- // npx PATH-lookup issue.
134
+ // 2026-06-09), prefer the project-local tsc; it is installed by
135
+ // pnpm at workspace-install time and avoids the npx PATH-lookup
136
+ // issue. 2026-09-10 C6: that local binary is now the package's
137
+ // JS entry run through `process.execPath`, not the `.bin` shim.
112
138
  const tsc = resolveLocalBinary(projectRoot, 'tsc');
113
- const result = runCommand(tsc.command, [...tsc.args, '--noEmit'], projectRoot, TYPECHECK_TIMEOUT_MS, tsc.shell);
114
- const testFiles = result.stdout.match(/(tests?\/.*\.test\.ts)/g) ?? [];
139
+ if (tsc === null) {
140
+ return unresolvedBinaryStage('typecheck', 'tsc', projectRoot, Date.now() - start);
141
+ }
142
+ // 2026-09-10 G5: this stage used to run a bare `tsc --noEmit`, which picked
143
+ // up the DEFAULT tsconfig.json (src/** + tests/**) and its 142 pre-existing
144
+ // test-side errors — so the stage was permanently red and its one real
145
+ // signal was buried. The gate is now the build tsconfig, which covers
146
+ // exactly what ships.
147
+ const buildConfig = 'tsconfig.build.json';
148
+ const hasBuildConfig = existsSync(join(projectRoot, buildConfig));
149
+ const gate = runCommand(tsc.command, [...tsc.args, '-p', hasBuildConfig ? buildConfig : 'tsconfig.json', '--noEmit'], projectRoot, TYPECHECK_TIMEOUT_MS);
150
+ // Measure the wider count so the residue is surfaced rather than hidden.
151
+ // The baseline comparison only applies when a clean build tsconfig gives it
152
+ // a meaning; without one the gate above already IS this run.
153
+ const wide = hasBuildConfig
154
+ ? runCommand(tsc.command, [...tsc.args, '-p', 'tsconfig.json', '--noEmit'], projectRoot, TYPECHECK_TIMEOUT_MS)
155
+ : gate;
156
+ const wideErrors = (wide.stdout + wide.stderr).match(/error TS\d+/g)?.length ?? 0;
157
+ const wideLabel = hasBuildConfig ? 'tsconfig.json (src/** + tests/**)' : 'tsconfig.json';
158
+ const regressed = hasBuildConfig && gate.status === 'pass' && wideErrors > TYPECHECK_PREEXISTING_BASELINE;
159
+ const reason = gate.status !== 'pass'
160
+ ? `tsc -p ${hasBuildConfig ? buildConfig : 'tsconfig.json'} --noEmit exited ${gate.exitCode}`
161
+ : regressed
162
+ ? `typecheck errors grew to ${wideErrors} (pre-existing baseline ${TYPECHECK_PREEXISTING_BASELINE})`
163
+ : '';
115
164
  return {
116
165
  name: 'typecheck',
117
- description: `${tsc.command} --noEmit (no JS emit, type-only check)`,
118
- status: result.status,
119
- durationMs: result.durationMs,
120
- detail: result.status === 'pass'
121
- ? `Typecheck passed in ${result.durationMs}ms.`
122
- : tailLines(result.stdout + result.stderr, TYPECHECK_TAIL_LINES) || `tsc exited with code ${result.exitCode}.`,
123
- data: { exitCode: result.exitCode }
166
+ description: hasBuildConfig
167
+ ? `tsc -p ${buildConfig} --noEmit (gate) + -p tsconfig.json (baseline report)`
168
+ : `tsc --noEmit (no JS emit, type-only check)`,
169
+ status: reason.length === 0 ? 'pass' : 'fail',
170
+ durationMs: Date.now() - start,
171
+ detail: reason.length === 0
172
+ ? `${hasBuildConfig ? `${buildConfig} (src/**) clean; ` : ''}${wideLabel}: ${wideErrors} pre-existing error(s), baseline ${TYPECHECK_PREEXISTING_BASELINE} — reported, not hidden.`
173
+ : `${reason}. ${tailLines(gate.status !== 'pass' ? gate.stdout + gate.stderr : wide.stdout + wide.stderr, TYPECHECK_TAIL_LINES)}`,
174
+ data: {
175
+ exitCode: gate.exitCode,
176
+ wideTsconfigErrors: wideErrors,
177
+ preExistingBaseline: hasBuildConfig ? TYPECHECK_PREEXISTING_BASELINE : null,
178
+ ...(regressed ? { regression: wideErrors - TYPECHECK_PREEXISTING_BASELINE } : {})
179
+ }
124
180
  };
125
181
  }
126
182
  function parseVitestSummary(stdout, fallbackDuration) {
@@ -147,15 +203,19 @@ async function runUnitTests(projectRoot, runTests) {
147
203
  // `tests/unit/slice-check-service.test.ts` for the regression net.
148
204
  // Per Windows npx ENOENT (observations 2317+2792), resolve
149
205
  // the project-local vitest binary instead of shelling out
150
- // through npx.
206
+ // through npx. 2026-09-10 C6: that binary is the package's JS
207
+ // entry run through `process.execPath`, not the `.bin` shim.
151
208
  const vitest = resolveLocalBinary(projectRoot, 'vitest');
209
+ if (vitest === null) {
210
+ return unresolvedBinaryStage('unit-tests', 'vitest', projectRoot, Date.now() - start);
211
+ }
152
212
  const vitestArgs = runTests
153
213
  ? ['run', '--reporter=default', '--coverage=false']
154
214
  : ['run', '--changed', '--reporter=default', '--coverage=false'];
155
215
  const description = runTests
156
- ? `${vitest.command} run (full test suite, coverage off)`
157
- : `${vitest.command} run --changed (tests for git-changed files only, coverage off)`;
158
- const result = runCommand(vitest.command, [...vitest.args, ...vitestArgs], projectRoot, UNIT_TESTS_TIMEOUT_MS, vitest.shell);
216
+ ? `vitest run (full test suite, coverage off)`
217
+ : `vitest run --changed (tests for git-changed files only, coverage off)`;
218
+ const result = runCommand(vitest.command, [...vitest.args, ...vitestArgs], projectRoot, UNIT_TESTS_TIMEOUT_MS);
159
219
  const summary = parseVitestSummary(result.stdout, result.durationMs);
160
220
  // Vitest doesn't always print the per-bucket counts cleanly; infer "passed"
161
221
  // as total - failed - skipped when failed/skipped buckets are present.
@@ -6,10 +6,15 @@
6
6
  * 4 self-checks that must pass at slice end before the slice is handed
7
7
  * off to peaks-qa:
8
8
  *
9
- * 1. typecheck (`npx tsc --noEmit`)
9
+ * 1. typecheck — the project-local TypeScript entry
10
+ * (`node_modules/typescript/bin/tsc`) run as `node <entry>`: no
11
+ * `npx`, no shell. Gates on `-p tsconfig.build.json --noEmit`; the
12
+ * wider `-p tsconfig.json --noEmit` count is reported against
13
+ * `TYPECHECK_PREEXISTING_BASELINE` and gates only if it grows.
10
14
  * 2. unit tests — by default the **changed-only** suite
11
- * (`npx vitest run --changed`). Pass `--run-tests` to opt in to the
12
- * full suite (`npx vitest run`); pass `--skip-tests` to skip
15
+ * (`vitest run --changed`, same `node <entry>` shape via
16
+ * `node_modules/vitest/vitest.mjs`). Pass `--run-tests` to opt in to
17
+ * the full suite (`vitest run`); pass `--skip-tests` to skip
13
18
  * entirely (e.g. docs-only or config-only slices).
14
19
  * 3. 3-way review fan-out (code-review + security-review + perf-baseline)
15
20
  * 4. gate machinery (`peaks workflow verify-pipeline --rid <rid>`)
@@ -48,8 +53,8 @@ export type SliceCheckResult = {
48
53
  stages: SliceCheckStage[];
49
54
  /**
50
55
  * Which unit-test mode actually ran. One of:
51
- * - `"changed"` — default: `npx vitest run --changed` (tests for git-changed files only)
52
- * - `"full"` — opt-in via `--run-tests`: `npx vitest run` (full suite)
56
+ * - `"changed"` — default: `vitest run --changed` (tests for git-changed files only)
57
+ * - `"full"` — opt-in via `--run-tests`: `vitest run` (full suite)
53
58
  * - `"skipped"` — opt-in via `--skip-tests` (stage not executed)
54
59
  * - `"overridden"` — full mode + `--allow-pre-existing-failures` and the run failed;
55
60
  * stage downgraded to `skipped` with the pre-existing-failure reason
@@ -73,9 +78,9 @@ export type SliceCheckOptions = {
73
78
  */
74
79
  refreshFanout: boolean;
75
80
  /**
76
- * When true, run the **full** `npx vitest run` suite at the boundary.
81
+ * When true, run the **full** `vitest run` suite at the boundary.
77
82
  * When false (the default), run the **changed-only** suite
78
- * (`npx vitest run --changed`) which only exercises tests related to
83
+ * (`vitest run --changed`) which only exercises tests related to
79
84
  * git-changed files. The changed-only mode is the new default as of
80
85
  * run 017 — full suite costs 30s+ on this repo; the changed-only
81
86
  * mode costs ~1-3s in steady state and is what catches the
@@ -6,10 +6,15 @@
6
6
  * 4 self-checks that must pass at slice end before the slice is handed
7
7
  * off to peaks-qa:
8
8
  *
9
- * 1. typecheck (`npx tsc --noEmit`)
9
+ * 1. typecheck — the project-local TypeScript entry
10
+ * (`node_modules/typescript/bin/tsc`) run as `node <entry>`: no
11
+ * `npx`, no shell. Gates on `-p tsconfig.build.json --noEmit`; the
12
+ * wider `-p tsconfig.json --noEmit` count is reported against
13
+ * `TYPECHECK_PREEXISTING_BASELINE` and gates only if it grows.
10
14
  * 2. unit tests — by default the **changed-only** suite
11
- * (`npx vitest run --changed`). Pass `--run-tests` to opt in to the
12
- * full suite (`npx vitest run`); pass `--skip-tests` to skip
15
+ * (`vitest run --changed`, same `node <entry>` shape via
16
+ * `node_modules/vitest/vitest.mjs`). Pass `--run-tests` to opt in to
17
+ * the full suite (`vitest run`); pass `--skip-tests` to skip
13
18
  * entirely (e.g. docs-only or config-only slices).
14
19
  * 3. 3-way review fan-out (code-review + security-review + perf-baseline)
15
20
  * 4. gate machinery (`peaks workflow verify-pipeline --rid <rid>`)