peaks-loop 4.0.46 → 4.0.48

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 (207) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/agents/karpathy-reviewer.md +11 -10
  5. package/dist/cli/cli-helpers.d.ts +34 -0
  6. package/dist/cli/cli-helpers.js +57 -0
  7. package/dist/cli/commands/code-job-shape-commands.js +8 -0
  8. package/dist/cli/commands/code-runtime-commands.d.ts +22 -0
  9. package/dist/cli/commands/code-runtime-commands.js +139 -16
  10. package/dist/cli/commands/compact-command.js +241 -1
  11. package/dist/cli/commands/config-commands.js +15 -9
  12. package/dist/cli/commands/container-commands.js +3 -3
  13. package/dist/cli/commands/core/skill-command.js +45 -10
  14. package/dist/cli/commands/cron-commands.js +2 -1
  15. package/dist/cli/commands/dashboard-long-run.js +6 -0
  16. package/dist/cli/commands/dispatch-commands.js +11 -1
  17. package/dist/cli/commands/doctor/invoke-from-code.js +6 -0
  18. package/dist/cli/commands/e2e-verify.js +3 -3
  19. package/dist/cli/commands/governance-classify-contract-commands.js +1 -0
  20. package/dist/cli/commands/hooks-commands.js +14 -5
  21. package/dist/cli/commands/job-commands.js +8 -0
  22. package/dist/cli/commands/loop-commands.js +1 -0
  23. package/dist/cli/commands/loop-eval-commands.js +15 -0
  24. package/dist/cli/commands/perf-audit-commands.js +2 -0
  25. package/dist/cli/commands/playwright-commands.js +14 -1
  26. package/dist/cli/commands/prd-commands.js +1 -1
  27. package/dist/cli/commands/qa-commands.js +22 -0
  28. package/dist/cli/commands/reinject-command.d.ts +72 -0
  29. package/dist/cli/commands/reinject-command.js +174 -0
  30. package/dist/cli/commands/request-commands.js +14 -3
  31. package/dist/cli/commands/scan-commands.js +1 -1
  32. package/dist/cli/commands/security-audit-commands.js +2 -0
  33. package/dist/cli/commands/shadcn-commands.js +1 -0
  34. package/dist/cli/commands/slice-integrate-commands.js +5 -0
  35. package/dist/cli/commands/statusline-commands.js +44 -4
  36. package/dist/cli/commands/sub-agent/detached.d.ts +14 -1
  37. package/dist/cli/commands/sub-agent/detached.js +47 -22
  38. package/dist/cli/commands/sub-agent-shutdown-commands.js +11 -0
  39. package/dist/cli/commands/test-commands.js +2 -1
  40. package/dist/cli/commands/verdict-aggregate-command.js +95 -13
  41. package/dist/cli/commands/vm-commands.js +7 -7
  42. package/dist/cli/commands/workflow-commands.js +1 -1
  43. package/dist/cli/commands/workspace/init-command.js +24 -2
  44. package/dist/cli/commands/worktree-lease-commands.js +4 -4
  45. package/dist/cli/index.js +10 -3
  46. package/dist/cli/program.js +5 -0
  47. package/dist/hooks/pre-tool-use-sub-agent.js +1 -1
  48. package/dist/services/adapter/adapter-registry.js +1 -1
  49. package/dist/services/artifacts/artifact-prerequisites.d.ts +38 -7
  50. package/dist/services/artifacts/artifact-prerequisites.js +130 -65
  51. package/dist/services/artifacts/artifact-service.js +1 -1
  52. package/dist/services/artifacts/request-artifact-service.d.ts +8 -0
  53. package/dist/services/artifacts/request-artifact-service.js +18 -8
  54. package/dist/services/artifacts/request-artifact-state-helpers.d.ts +57 -0
  55. package/dist/services/artifacts/request-artifact-state-helpers.js +91 -10
  56. package/dist/services/audit-independent/perf-audit-service.d.ts +9 -0
  57. package/dist/services/audit-independent/perf-audit-service.js +27 -5
  58. package/dist/services/audit-independent/security-audit-service.d.ts +12 -2
  59. package/dist/services/audit-independent/security-audit-service.js +28 -6
  60. package/dist/services/capability-guard-runner/contracts/J01.js +2 -1
  61. package/dist/services/capability-guard-runner/contracts/J02.js +3 -3
  62. package/dist/services/capability-guard-runner/contracts/J04.js +4 -2
  63. package/dist/services/capability-guard-runner/contracts/J07.js +2 -1
  64. package/dist/services/code/auto-compact-lifecycle.d.ts +130 -1
  65. package/dist/services/code/auto-compact-lifecycle.js +180 -4
  66. package/dist/services/code/auto-compact-orchestrator.d.ts +53 -9
  67. package/dist/services/code/auto-compact-orchestrator.js +166 -34
  68. package/dist/services/code/compact-event-settle.d.ts +122 -0
  69. package/dist/services/code/compact-event-settle.js +219 -0
  70. package/dist/services/code/orchestrator-can-do.d.ts +4 -2
  71. package/dist/services/code/orchestrator-can-do.js +37 -5
  72. package/dist/services/codegraph/codegraph-exclude-reconciler.js +2 -1
  73. package/dist/services/codegraph/codegraph-process-runner.js +3 -2
  74. package/dist/services/compact/request-transition-hook.js +5 -2
  75. package/dist/services/compact-history/compact-history-service.d.ts +75 -0
  76. package/dist/services/compact-history/compact-history-service.js +49 -0
  77. package/dist/services/config/config-restore.d.ts +12 -1
  78. package/dist/services/config/config-restore.js +35 -4
  79. package/dist/services/config/config-rollback.js +6 -1
  80. package/dist/services/config/config-safety.d.ts +52 -0
  81. package/dist/services/config/config-safety.js +75 -1
  82. package/dist/services/context/auto-compact-dispatcher.d.ts +7 -37
  83. package/dist/services/context/auto-compact-dispatcher.js +113 -40
  84. package/dist/services/context/auto-compact-reader.d.ts +68 -28
  85. package/dist/services/context/auto-compact-reader.js +155 -1
  86. package/dist/services/context/auto-compact-types.d.ts +89 -12
  87. package/dist/services/context/auto-compact-types.js +16 -32
  88. package/dist/services/context/harness-context-witness.d.ts +310 -0
  89. package/dist/services/context/harness-context-witness.js +606 -0
  90. package/dist/services/context/harness-window-config.d.ts +412 -0
  91. package/dist/services/context/harness-window-config.js +607 -0
  92. package/dist/services/context/main-session-monitor.d.ts +27 -0
  93. package/dist/services/context/main-session-monitor.js +32 -1
  94. package/dist/services/context/post-compact-reinjection.d.ts +221 -0
  95. package/dist/services/context/post-compact-reinjection.js +491 -0
  96. package/dist/services/dispatch/merge-back-runner.js +5 -5
  97. package/dist/services/dispatch/service-shutdown.js +3 -3
  98. package/dist/services/doc/doc-generator.js +2 -1
  99. package/dist/services/env/shell-probe.js +1 -1
  100. package/dist/services/evidence/evidence-generator.js +86 -49
  101. package/dist/services/final-review/final-review-service.d.ts +9 -0
  102. package/dist/services/final-review/final-review-service.js +36 -12
  103. package/dist/services/fuzzy-matching/fzf-pick-service.js +2 -0
  104. package/dist/services/hooks/auto-compact-hook-install.d.ts +10 -2
  105. package/dist/services/hooks/auto-compact-hook-install.js +8 -0
  106. package/dist/services/ide/adapters/claude-code-adapter.d.ts +107 -3
  107. package/dist/services/ide/adapters/claude-code-adapter.js +154 -7
  108. package/dist/services/ide/ide-registry.d.ts +31 -0
  109. package/dist/services/ide/ide-registry.js +35 -0
  110. package/dist/services/ide/ide-types.d.ts +59 -0
  111. package/dist/services/job/job-state-store.js +7 -0
  112. package/dist/services/lint/detect-eslint.js +2 -2
  113. package/dist/services/lint/eslint-runner.js +3 -1
  114. package/dist/services/loop/evaluator-dispatcher.js +2 -1
  115. package/dist/services/memory/project-memory-service/index/kind-dispatch.js +1 -1
  116. package/dist/services/memory/project-memory-service/store/paths.d.ts +9 -1
  117. package/dist/services/memory/project-memory-service/store/paths.js +15 -6
  118. package/dist/services/polyrepo/polyrepo-dispatcher.js +11 -0
  119. package/dist/services/prd/best-practice-auto-trigger.js +1 -0
  120. package/dist/services/prd/handoff-auto-regen.js +31 -27
  121. package/dist/services/prd/handoff-frontmatter.d.ts +44 -0
  122. package/dist/services/prd/handoff-frontmatter.js +75 -0
  123. package/dist/services/prd/handoff-service.d.ts +41 -2
  124. package/dist/services/prd/handoff-service.js +81 -8
  125. package/dist/services/prd/handoff-types.d.ts +3 -2
  126. package/dist/services/prd/handoff-types.js +3 -2
  127. package/dist/services/qa/qa-business-review-state.js +9 -0
  128. package/dist/services/release/version-precheck-service.d.ts +2 -1
  129. package/dist/services/release/version-precheck-service.js +82 -12
  130. package/dist/services/runtime/vendor-adapter.d.ts +29 -4
  131. package/dist/services/runtime/vendors/claude-code.js +1 -1
  132. package/dist/services/runtime/vendors/codex.js +1 -1
  133. package/dist/services/runtime/vendors/copilot.js +1 -1
  134. package/dist/services/sc/sc-service.js +1 -1
  135. package/dist/services/scan/diff-scope-service.js +2 -2
  136. package/dist/services/scan/file-size-scan.js +2 -2
  137. package/dist/services/scan/karpathy-service.js +2 -2
  138. package/dist/services/scan/orphan-service.js +2 -1
  139. package/dist/services/scan/type-sanity-service.js +2 -2
  140. package/dist/services/session/session-checkpoint-service.js +8 -0
  141. package/dist/services/skill/resume-detector.js +29 -11
  142. package/dist/services/skillhub/tar-runtime.js +1 -0
  143. package/dist/services/skills/hooks-codegate-superpowers.d.ts +14 -0
  144. package/dist/services/skills/hooks-codegate-superpowers.js +99 -3
  145. package/dist/services/skills/hooks-settings-service.d.ts +12 -0
  146. package/dist/services/skills/hooks-settings-service.js +91 -14
  147. package/dist/services/skills/session-start-hook-constants.d.ts +86 -0
  148. package/dist/services/skills/session-start-hook-constants.js +86 -0
  149. package/dist/services/skills/skill-presence-service.js +9 -0
  150. package/dist/services/skills/skill-statusline-service.d.ts +14 -0
  151. package/dist/services/slice/slice-check-service.js +31 -12
  152. package/dist/services/slice/slice-decompose-runners.js +2 -1
  153. package/dist/services/slice/slice-review-state.js +8 -0
  154. package/dist/services/upgrade/upgrade-service.js +1 -0
  155. package/dist/services/workflow/pipeline-verify-gate-support.d.ts +47 -10
  156. package/dist/services/workflow/pipeline-verify-gate-support.js +212 -93
  157. package/dist/services/workflow/pipeline-verify-service.js +24 -23
  158. package/dist/services/workflow/pipeline-verify-types.d.ts +10 -3
  159. package/dist/services/workflow/workflow-skip-service.js +2 -1
  160. package/dist/services/workspace/claude-settings-template.d.ts +56 -8
  161. package/dist/services/workspace/claude-settings-template.js +98 -20
  162. package/dist/services/workspace/migrate-service.js +1 -1
  163. package/dist/services/workspace/workspace-claude-settings-materializer.js +124 -9
  164. package/dist/services/workspace/workspace-service.js +8 -0
  165. package/dist/services/worktree/host-worktree-reconciler.js +1 -0
  166. package/dist/services/worktree/long-path-cleanup.js +3 -2
  167. package/dist/shared/process.js +1 -1
  168. package/package.json +6 -6
  169. package/scripts/install-skills.mjs +1 -0
  170. package/scripts/watch.mjs +3 -1
  171. package/skills/bee/peaks-perf-audit/SKILL.md +1 -1
  172. package/skills/bee/peaks-prd/SKILL.md +8 -6
  173. package/skills/bee/peaks-qa/SKILL.md +7 -7
  174. package/skills/bee/peaks-qa/references/qa-runbook.md +2 -2
  175. package/skills/bee/peaks-qa/references/qa-transition-gates.md +7 -7
  176. package/skills/bee/peaks-rd/SKILL.md +10 -8
  177. package/skills/bee/peaks-rd/references/artifact-per-request.md +2 -2
  178. package/skills/bee/peaks-rd/references/parallel-review-fanout.md +7 -5
  179. package/skills/bee/peaks-rd/references/rd-fanout-contracts.md +13 -13
  180. package/skills/bee/peaks-rd/references/rd-runbook.md +9 -5
  181. package/skills/bee/peaks-rd/references/rd-transition-gates.md +9 -7
  182. package/skills/bee/peaks-rd/references/writing-handoff-frontmatter.md +6 -6
  183. package/skills/bee/peaks-reviewer/SKILL.md +1 -1
  184. package/skills/bee/peaks-sc/SKILL.md +1 -1
  185. package/skills/bee/peaks-security-audit/SKILL.md +1 -1
  186. package/skills/bee/peaks-txt/SKILL.md +1 -1
  187. package/skills/bee/peaks-ui/SKILL.md +1 -1
  188. package/skills/peaks-audit/SKILL.md +1 -1
  189. package/skills/peaks-code/SKILL.md +3 -3
  190. package/skills/peaks-code/references/a2a-artifact-mapping.md +3 -3
  191. package/skills/peaks-code/references/local-artifact-workspace.md +1 -1
  192. package/skills/peaks-code/references/resume-detection.md +13 -7
  193. package/skills/peaks-code/references/runbook.md +3 -2
  194. package/skills/peaks-code/references/session-overload-signal-index.md +2 -1
  195. package/skills/peaks-code/references/sub-agent-dispatch.md +1 -1
  196. package/skills/peaks-code/references/workflow-gates-and-types.md +8 -6
  197. package/skills/peaks-content/SKILL.md +1 -1
  198. package/skills/peaks-doctor/SKILL.md +1 -1
  199. package/skills/peaks-final-review/SKILL.md +1 -1
  200. package/skills/peaks-ide/SKILL.md +1 -1
  201. package/skills/peaks-issue-fix-orchestrator/SKILL.md +1 -1
  202. package/skills/peaks-resume/SKILL.md +1 -1
  203. package/skills/peaks-slice-decompose/SKILL.md +1 -1
  204. package/skills/peaks-solo/SKILL.md +1 -1
  205. package/skills/peaks-sop/SKILL.md +1 -1
  206. package/skills/peaks-status/SKILL.md +1 -1
  207. package/skills/peaks-test/SKILL.md +1 -1
@@ -23,7 +23,7 @@ export class ClaudeCodeAdapter {
23
23
  if (args.force === true)
24
24
  argv.push('--force');
25
25
  return new Promise((resolveRun) => {
26
- const proc = spawn('claude', argv, { stdio: ['ignore', 'pipe', 'pipe'] });
26
+ const proc = spawn('claude', argv, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
27
27
  let stdout = '';
28
28
  let stderr = '';
29
29
  proc.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
@@ -27,7 +27,7 @@ export class CodexAdapter {
27
27
  if (args.force === true)
28
28
  argv.push('--force');
29
29
  return new Promise((resolveRun) => {
30
- const proc = spawn('codex', argv, { stdio: ['ignore', 'pipe', 'pipe'] });
30
+ const proc = spawn('codex', argv, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
31
31
  let stdout = '';
32
32
  let stderr = '';
33
33
  proc.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
@@ -24,7 +24,7 @@ export class CopilotAdapter {
24
24
  if (args.force === true)
25
25
  argv.push('--force');
26
26
  return new Promise((resolveRun) => {
27
- const proc = spawn('copilot', argv, { stdio: ['ignore', 'pipe', 'pipe'] });
27
+ const proc = spawn('copilot', argv, { stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true });
28
28
  let stdout = '';
29
29
  let stderr = '';
30
30
  proc.stdout.on('data', (chunk) => { stdout += chunk.toString('utf8'); });
@@ -60,7 +60,7 @@ function getCurrentCommitHash(workspaceRoot) {
60
60
  if (!workspaceRoot)
61
61
  return null;
62
62
  try {
63
- return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: workspaceRoot, encoding: 'utf-8' }).trim();
63
+ return execFileSync('git', ['rev-parse', 'HEAD'], { cwd: workspaceRoot, encoding: 'utf-8', windowsHide: true }).trim();
64
64
  }
65
65
  catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
66
66
  return null;
@@ -119,9 +119,9 @@ function parseRedLineScope(rdBody) {
119
119
  }
120
120
  function tryGitChangedFiles(projectRoot, baseRef) {
121
121
  try {
122
- const trackedRaw = execFileSync('git', ['-C', projectRoot, 'diff', '--name-only', baseRef], { encoding: 'utf8' });
122
+ const trackedRaw = execFileSync('git', ['-C', projectRoot, 'diff', '--name-only', baseRef], { encoding: 'utf8', windowsHide: true });
123
123
  const tracked = trackedRaw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
124
- const untrackedRaw = execFileSync('git', ['-C', projectRoot, 'ls-files', '--others', '--exclude-standard'], { encoding: 'utf8' });
124
+ const untrackedRaw = execFileSync('git', ['-C', projectRoot, 'ls-files', '--others', '--exclude-standard'], { encoding: 'utf8', windowsHide: true });
125
125
  const untracked = untrackedRaw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
126
126
  return { ok: true, files: Array.from(new Set([...tracked, ...untracked])) };
127
127
  }
@@ -65,9 +65,9 @@ function getChangedFiles(projectRoot, baseRef) {
65
65
  // (--diff-filter=D) are intentionally excluded: they have no on-disk
66
66
  // body to count, and a refactor that deletes large files is exactly
67
67
  // when the gate should NOT block. Slice #015 fix.
68
- const trackedRaw = execFileSync('git', ['-C', projectRoot, 'diff', '--name-only', '--diff-filter=AM', baseRef], { encoding: 'utf8' });
68
+ const trackedRaw = execFileSync('git', ['-C', projectRoot, 'diff', '--name-only', '--diff-filter=AM', baseRef], { encoding: 'utf8', windowsHide: true });
69
69
  const tracked = trackedRaw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
70
- const untrackedRaw = execFileSync('git', ['-C', projectRoot, 'ls-files', '--others', '--exclude-standard'], { encoding: 'utf8' });
70
+ const untrackedRaw = execFileSync('git', ['-C', projectRoot, 'ls-files', '--others', '--exclude-standard'], { encoding: 'utf8', windowsHide: true });
71
71
  const untracked = untrackedRaw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
72
72
  return Array.from(new Set([...tracked, ...untracked]));
73
73
  }
@@ -68,7 +68,7 @@ export async function scanKarpathy(options) {
68
68
  warnings: [
69
69
  `Karpathy review file missing: ${reviewRel}`,
70
70
  'Per karpathy §1 Think Before Coding: state your assumptions. Without a review file, no 5-way fanout evidence is available.',
71
- 'Per karpathy §3 Surgical Changes: touch only what the request requires. Create a minimal rd/karpathy-review.md stub before requesting qa-handoff.'
71
+ 'Per karpathy §3 Surgical Changes: touch only what the request requires. The rd:qa-handoff gate is satisfied by rd/karpathy-review-<rid>.md; this scanner has no rid and reads only the back-compat name rd/karpathy-review.md, so a rid-scoped-only slice still reads as missing here. `peaks request transition --state qa-handoff` is the authoritative gate.'
72
72
  ]
73
73
  };
74
74
  }
@@ -223,6 +223,6 @@ export function formatKarpathyMarkdown(report, opts = {}) {
223
223
  }
224
224
  lines.push('### Karpathy-Gate');
225
225
  lines.push('');
226
- lines.push('Per `andrej-karpathy-skills:karpathy-guidelines` §1 Think Before Coding / §3 Surgical Changes, the hard Karpathy-Gate requires `rd/karpathy-review.md` to be present with all 4 guideline sections before `peaks request transition --state qa-handoff`.');
226
+ lines.push('Per `andrej-karpathy-skills:karpathy-guidelines` §1 Think Before Coding / §3 Surgical Changes, the hard Karpathy-Gate requires `rd/karpathy-review-<rid>.md` to be present with all 4 guideline sections before `peaks request transition --state qa-handoff`. (`rd/karpathy-review.md` is the accepted back-compat tier, and the only name this scanner probes.)');
227
227
  return lines.join('\n');
228
228
  }
@@ -233,7 +233,8 @@ function diffVsHead(projectRoot, baseRef) {
233
233
  const res = spawnSync('git', args, {
234
234
  cwd: projectRoot,
235
235
  encoding: 'utf8',
236
- timeout: 5000
236
+ timeout: 5000,
237
+ windowsHide: true
237
238
  });
238
239
  if (res.status !== 0 || !res.stdout) {
239
240
  return { added: [], removed: [], modified: [] };
@@ -39,9 +39,9 @@ function isArtifactWorkspaceFile(filePath) {
39
39
  function tryGitDiffFiles(projectRoot, baseRef) {
40
40
  try {
41
41
  // Combine: tracked changes vs baseRef + untracked files. Use porcelain status for untracked too.
42
- const trackedRaw = execFileSync('git', ['-C', projectRoot, 'diff', '--name-only', baseRef], { encoding: 'utf8' });
42
+ const trackedRaw = execFileSync('git', ['-C', projectRoot, 'diff', '--name-only', baseRef], { encoding: 'utf8', windowsHide: true });
43
43
  const tracked = trackedRaw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
44
- const untrackedRaw = execFileSync('git', ['-C', projectRoot, 'ls-files', '--others', '--exclude-standard'], { encoding: 'utf8' });
44
+ const untrackedRaw = execFileSync('git', ['-C', projectRoot, 'ls-files', '--others', '--exclude-standard'], { encoding: 'utf8', windowsHide: true });
45
45
  const untracked = untrackedRaw.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
46
46
  const merged = Array.from(new Set([...tracked, ...untracked])).filter((file) => !isArtifactWorkspaceFile(file));
47
47
  return { ok: true, files: merged };
@@ -12,6 +12,7 @@
12
12
  import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from 'node:fs';
13
13
  import { join, sep } from 'node:path';
14
14
  import { emitObservabilityEvent } from '../observability/observability-service.js';
15
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
15
16
  const CHECKPOINTS_DIR = 'checkpoints';
16
17
  const CHECKPOINT_FILENAME_EXT = '.json';
17
18
  const MAX_CHECKPOINTS = 10;
@@ -76,6 +77,13 @@ function pruneOldest(dir) {
76
77
  return removed;
77
78
  }
78
79
  export function writeCheckpoint(projectRoot, options) {
80
+ // Sid axis, placed before the first read and the first `mkdir`. Both
81
+ // `peaks session checkpoint --session-id` and `peaks compact force
82
+ // --session-id` reach this same function, so one guard closes both measured
83
+ // surfaces — they were never two joins.
84
+ if (isUnsafePathInput(options.sessionId)) {
85
+ throw new Error(`Invalid session id: ${options.sessionId} (must be a single path segment)`);
86
+ }
79
87
  const now = (options.now ?? (() => new Date()))();
80
88
  const createdAt = now.toISOString();
81
89
  const lastActivity = readSessionLastActivity(projectRoot, options.sessionId) || createdAt;
@@ -32,6 +32,7 @@
32
32
  */
33
33
  import { existsSync, readdirSync, readFileSync } from 'node:fs';
34
34
  import { join } from 'node:path';
35
+ import { readArtifactState } from '../artifacts/request-artifact-state-helpers.js';
35
36
  const MID_IMPL_RD_STATES = new Set([
36
37
  'spec-locked',
37
38
  'implemented',
@@ -192,13 +193,21 @@ function classifyTerminalGates(sessionDir, ctx) {
192
193
  // RD qa-handoff → deepest gate is C. If the review artifacts are
193
194
  // missing the state is inconsistent; fall back to rd-review-fanout.
194
195
  if (ctx.primaryRd !== null && ctx.primaryRd.state === 'qa-handoff') {
195
- const codeReviewPath = join(sessionDir, 'rd', 'code-review.md');
196
- const securityReviewPath = join(sessionDir, 'rd', 'security-review.md');
196
+ // Slice `2026-09-14-audit-artifact-rid-scoping`: the fan-out evidence
197
+ // filenames carry the rid. Probe the canonical rid-scoped name first and
198
+ // the pre-rid names behind it — the same order the transition gate
199
+ // resolves in, so an inconsistent-looking slice here means the fan-out
200
+ // never ran, not that it wrote to a name this reader does not know.
201
+ const rid = ridOf(ctx.primaryRd.filename);
197
202
  const missing = [];
198
- if (!existsSync(codeReviewPath))
199
- missing.push('rd/code-review.md');
200
- if (!existsSync(securityReviewPath))
201
- missing.push('rd/security-review.md');
203
+ const codeReviewCandidates = [`rd/code-review-${rid}.md`, 'rd/code-review.md'];
204
+ const securityCandidates = [`audit/security-${rid}.md`, 'audit/security.md', 'rd/security-review.md'];
205
+ if (!codeReviewCandidates.some((rel) => existsSync(join(sessionDir, rel)))) {
206
+ missing.push(codeReviewCandidates[0]);
207
+ }
208
+ if (!securityCandidates.some((rel) => existsSync(join(sessionDir, rel)))) {
209
+ missing.push(securityCandidates[0]);
210
+ }
202
211
  if (missing.length > 0) {
203
212
  return {
204
213
  kind: 'resume',
@@ -317,12 +326,21 @@ function readRequestStates(sessionDir, role) {
317
326
  };
318
327
  });
319
328
  }
329
+ /** Empty string (not 'unknown') when the artifact has no state line — `primaryPrd.state.length > 0` below depends on that. */
320
330
  function extractState(content) {
321
- const match = /^-\s*state:\s*(\S+)|^state:\s*(\S+)/m.exec(content);
322
- if (match === null)
323
- return '';
324
- const captured = match[1] ?? match[2] ?? '';
325
- return captured.trim();
331
+ return readArtifactState(content) ?? '';
332
+ }
333
+ /**
334
+ * The `<rid>` embedded in a request filename: drop the `.md` suffix and the
335
+ * zero-padded `NNN-` prefix `request init` writes.
336
+ *
337
+ * Uses the same `^0\d{2}-` rule as the request loader
338
+ * (`request-artifact-service.ts`: "Only strip 3-digit zero-padded prefixes").
339
+ * A looser `^\d+-` would eat the leading year of every real rid, which is
340
+ * itself date-shaped (`2026-09-14-<slug>`).
341
+ */
342
+ function ridOf(filename) {
343
+ return filename.replace(/^0\d{2}-/, '').replace(/\.md$/, '');
326
344
  }
327
345
  function hasAbandonedTransitionNote(content) {
328
346
  return /user-requested-abandon/.test(content);
@@ -62,6 +62,7 @@ export function runTar(args) {
62
62
  try {
63
63
  execFileSync(cmd, args, {
64
64
  stdio: ["ignore", "ignore", "pipe"],
65
+ windowsHide: true,
65
66
  // Surface stderr text rather than dumping a Buffer at the caller.
66
67
  // execFileSync with stdio: ["ignore","ignore","pipe"] captures the
67
68
  // stderr stream into the Error's `.stderr` Buffer when the child
@@ -39,8 +39,22 @@ interface ResolvedHookSpec {
39
39
  * cannot help; pinning the hook's `shell` is the only lever the hook schema
40
40
  * offers. The platform-neutral default (`undefined` → omit the key) is kept
41
41
  * everywhere else.
42
+ *
43
+ * Scope note: this is applied to the `Bash`-matcher handlers only. The three
44
+ * `SessionStart` entries are deliberately NOT pinned, and not because the
45
+ * mechanism is believed absent there — see the block comment in
46
+ * `resolveHookEntries` for what is and is not established, and for why adding
47
+ * the pin in place would break every non-Windows teammate.
42
48
  */
43
49
  export declare function resolveHookShell(platform?: NodeJS.Platform): string | undefined;
50
+ /**
51
+ * True when the IDE is tabled in `HOOK_COMMAND_BY_IDE`, i.e. peaks can write a
52
+ * hook entry for it. `qoder` / `tongyi-lingma` / `zcode` are reserved adapter
53
+ * ids the registry knows but this table does not, so no hook can exist for
54
+ * them — which makes "uninstall" a no-op (nothing was ever installed) while
55
+ * "install" remains a hard failure.
56
+ */
57
+ export declare function hasHookSpec(ide: IdeId): boolean;
44
58
  export declare function resolveHookSpec(ide: IdeId): ResolvedHookSpec;
45
59
  /** A typed descriptor for a single peaks-managed hook entry. */
46
60
  export type PeaksHookEntry = {
@@ -8,7 +8,7 @@
8
8
  * type contract re-exported by the caller.
9
9
  */
10
10
  import { getAdapter } from '../ide/ide-registry.js';
11
- import { HOOK_OUTER_CACHE_COMMAND, HOOK_OUTER_CACHE_EVENT, HOOK_OUTER_CACHE_SENTINEL, HOOK_WORKSPACE_INIT_COMMAND, HOOK_WORKSPACE_INIT_EVENT, HOOK_WORKSPACE_INIT_SENTINEL } from './session-start-hook-constants.js';
11
+ import { HOOK_COMPACT_SETTLE_COMMAND, HOOK_COMPACT_SETTLE_EVENT, HOOK_COMPACT_SETTLE_MATCHER, HOOK_COMPACT_SETTLE_SENTINEL, HOOK_OUTER_CACHE_COMMAND, HOOK_OUTER_CACHE_EVENT, HOOK_OUTER_CACHE_SENTINEL, HOOK_POST_COMPACT_REINJECT_COMMAND, HOOK_POST_COMPACT_REINJECT_EVENT, HOOK_POST_COMPACT_REINJECT_MATCHER, HOOK_POST_COMPACT_REINJECT_SENTINEL, HOOK_WORKSPACE_INIT_COMMAND, HOOK_WORKSPACE_INIT_EVENT, HOOK_WORKSPACE_INIT_SENTINEL } from './session-start-hook-constants.js';
12
12
  /** Sentinel substring identifying a Claude-Code gate-enforce hook entry. */
13
13
  export const HOOK_ENFORCE_SENTINEL = 'peaks gate enforce';
14
14
  /**
@@ -20,6 +20,12 @@ export const HOOK_ENFORCE_SENTINEL = 'peaks gate enforce';
20
20
  * cannot help; pinning the hook's `shell` is the only lever the hook schema
21
21
  * offers. The platform-neutral default (`undefined` → omit the key) is kept
22
22
  * everywhere else.
23
+ *
24
+ * Scope note: this is applied to the `Bash`-matcher handlers only. The three
25
+ * `SessionStart` entries are deliberately NOT pinned, and not because the
26
+ * mechanism is believed absent there — see the block comment in
27
+ * `resolveHookEntries` for what is and is not established, and for why adding
28
+ * the pin in place would break every non-Windows teammate.
23
29
  */
24
30
  export function resolveHookShell(platform = process.platform) {
25
31
  return platform === 'win32' ? 'powershell' : undefined;
@@ -45,6 +51,16 @@ const HOOK_COMMAND_BY_IDE = {
45
51
  // registered. When a slice adds them, add a HOOK_COMMAND_BY_IDE entry
46
52
  // here — the function below fail-closes on missing entries.
47
53
  };
54
+ /**
55
+ * True when the IDE is tabled in `HOOK_COMMAND_BY_IDE`, i.e. peaks can write a
56
+ * hook entry for it. `qoder` / `tongyi-lingma` / `zcode` are reserved adapter
57
+ * ids the registry knows but this table does not, so no hook can exist for
58
+ * them — which makes "uninstall" a no-op (nothing was ever installed) while
59
+ * "install" remains a hard failure.
60
+ */
61
+ export function hasHookSpec(ide) {
62
+ return HOOK_COMMAND_BY_IDE[ide] !== undefined;
63
+ }
48
64
  export function resolveHookSpec(ide) {
49
65
  // getAdapter throws on unregistered IDEs — the registry is the source of truth.
50
66
  const adapter = getAdapter(ide);
@@ -53,7 +69,12 @@ export function resolveHookSpec(ide) {
53
69
  // Defensive fallback: if an adapter is added to the registry without a
54
70
  // HOOK_COMMAND_BY_IDE entry, fail-closed with a clear error instead of
55
71
  // silently writing a Claude-shaped entry to a non-Claude settings.json.
56
- throw new Error(`peaks hooks install: unsupported IDE '${ide}' (no HOOK_COMMAND_BY_IDE entry; add one to hooks-settings-service.ts)`);
72
+ //
73
+ // Caller-neutral on purpose: this one resolver backs install, status,
74
+ // uninstall and the dry-run plan, so the message names neither a verb
75
+ // (naming the install verb made an uninstall failure read as an install
76
+ // failure) nor a file that is not the table.
77
+ throw new Error(`unsupported IDE '${ide}': no HOOK_COMMAND_BY_IDE entry (add one in hooks-codegate-superpowers.ts)`);
57
78
  }
58
79
  const isClaudeCode = ide === 'claude-code';
59
80
  // Claude Code's gate hook must emit its structured decision as JSON:
@@ -97,6 +118,37 @@ export function resolveHookEntries(ide, _skipProgress = false) {
97
118
  ...(spec.hookEnforceShell !== undefined ? { shell: spec.hookEnforceShell } : {})
98
119
  }
99
120
  ];
121
+ // ── Why the three SessionStart entries below carry NO `shell` pin ─────────
122
+ //
123
+ // The gate-enforce entry above is shell-pinned on Windows (see
124
+ // `resolveHookShell`) because Claude Code runs a shell-form hook command
125
+ // through a shell that defaults to bash — Git Bash / MSYS2 on Windows — and
126
+ // MSYS2 bash force-allocates its own console window. That reason is a
127
+ // property of the hook RUNNER's shell resolution, not of the `PreToolUse`
128
+ // event: nothing in it exempts `SessionStart`, so the same command-form
129
+ // entry on this event goes through the same shell. The pin was applied only
130
+ // to the `Bash`-matcher handlers because those were the ones the reporter
131
+ // could see (they run on EVERY Bash tool call); these three run once per
132
+ // session, so a window here — if there is one — is a single flash rather
133
+ // than a per-tool-call nuisance. That is a difference in frequency, not
134
+ // evidence that the window does not appear, and no A/B measurement on this
135
+ // event exists.
136
+ //
137
+ // The pin is nonetheless NOT applied here, and adding it would be a defect
138
+ // rather than a fix: `shell: "powershell"` resolves to `pwsh`, these entries
139
+ // are written to the COMMITTED `.claude/settings.json`, and a macOS / Linux
140
+ // teammate reading that file has no `pwsh` — the exact cross-platform damage
141
+ // the machine-local split exists to prevent (see the gate-enforce entry's
142
+ // `machineLocal` flag, and commit 4637baa8's rationale).
143
+ //
144
+ // A correct fix therefore has to move these three entries to the
145
+ // machine-local file as well, which changes where a fresh clone gets its
146
+ // SessionStart hooks: today they arrive with the repository, after such a
147
+ // change they would require `peaks hooks install` / `peaks workspace init`.
148
+ // That is a product-shape decision, not a leftover; it is recorded here so
149
+ // that whoever makes it starts from the reason the pin is absent instead of
150
+ // re-deriving it — or, worse, adding the pin in place and breaking every
151
+ // non-Windows teammate.
100
152
  if (ide === 'claude-code') {
101
153
  entries.push({
102
154
  sentinel: HOOK_OUTER_CACHE_SENTINEL,
@@ -129,6 +181,41 @@ export function resolveHookEntries(ide, _skipProgress = false) {
129
181
  command: HOOK_CODE_GATE_COMMAND,
130
182
  event: HOOK_CODE_GATE_EVENT
131
183
  });
184
+ // rid 2026-09-13-a2-post-compact-reinject: the post-compaction
185
+ // engineering-state re-injection. A THIRD SessionStart entry, on a
186
+ // DIFFERENT matcher (`compact`) than the outer-cache and primer entries
187
+ // (matcher `''`, i.e. every source). The matcher is what scopes it: the
188
+ // card belongs in the context after a compaction emptied it, not on a
189
+ // fresh `startup` where the dispatch context is still present.
190
+ //
191
+ // It rides this table rather than a bespoke installer so install,
192
+ // status, uninstall, the dry-run plan and the workspace-init drift
193
+ // comparator all see it as one more peaks-managed entry — the same
194
+ // contract the other two SessionStart entries have.
195
+ entries.push({
196
+ sentinel: HOOK_POST_COMPACT_REINJECT_SENTINEL,
197
+ matcher: HOOK_POST_COMPACT_REINJECT_MATCHER,
198
+ command: HOOK_POST_COMPACT_REINJECT_COMMAND,
199
+ event: HOOK_POST_COMPACT_REINJECT_EVENT
200
+ });
201
+ // rid 2026-09-13-compact-event-settle: the harness's OWN "a compaction
202
+ // completed" event, which is the only signal that settles a compact as a
203
+ // FACT rather than as an inference from a ratio that fell.
204
+ //
205
+ // No `machineLocal` flag, so this lands in the shared, committed
206
+ // `.claude/settings.json` — the same file as the three SessionStart entries
207
+ // above, and NOT the machine-local file the workspace-init materializer
208
+ // owns. That routing IS the answer to "who owns the `hooks` key for this
209
+ // entry": the only writer that could delete it is the one that installed
210
+ // it. See `session-start-hook-constants.ts` for why it also carries no
211
+ // `shell` pin, and `mergeHooksTree` for the second line of defence if it
212
+ // ever moves.
213
+ entries.push({
214
+ sentinel: HOOK_COMPACT_SETTLE_SENTINEL,
215
+ matcher: HOOK_COMPACT_SETTLE_MATCHER,
216
+ command: HOOK_COMPACT_SETTLE_COMMAND,
217
+ event: HOOK_COMPACT_SETTLE_EVENT
218
+ });
132
219
  }
133
220
  return entries;
134
221
  }
@@ -154,7 +241,16 @@ export function resolveLegacySentinels(ide) {
154
241
  // the gate-enforce entry, and so hand-added entries matching this
155
242
  // sentinel are recognized as peaks-managed (not stripped as
156
243
  // non-Peaks).
157
- return [...base, HOOK_OUTER_CACHE_SENTINEL, HOOK_WORKSPACE_INIT_SENTINEL];
244
+ // rid 2026-09-13-a2-post-compact-reinject: the post-compact re-injection
245
+ // sentinel joins the same set, so uninstall strips it (and status counts
246
+ // it) exactly like the other two SessionStart entries. Without this the
247
+ // entry would be unremovable by `peaks hooks uninstall` — the rollback
248
+ // path T2 requires.
249
+ // rid 2026-09-13-compact-event-settle: the PostCompact settle sentinel joins
250
+ // too — same rollback argument as the reinject entry above. A hook that
251
+ // `peaks hooks uninstall` cannot remove is a hook the user cannot get rid
252
+ // of, and this one fires on every compaction.
253
+ return [...base, HOOK_OUTER_CACHE_SENTINEL, HOOK_WORKSPACE_INIT_SENTINEL, HOOK_POST_COMPACT_REINJECT_SENTINEL, HOOK_COMPACT_SETTLE_SENTINEL];
158
254
  }
159
255
  return base;
160
256
  }
@@ -116,6 +116,18 @@ export type HookStatus = {
116
116
  localSettingsPath?: string;
117
117
  localExists?: boolean;
118
118
  installed: boolean;
119
+ /**
120
+ * Whether this IDE can host a peaks hook AT ALL (`hasHookSpec`).
121
+ *
122
+ * `status` answers "what is on disk", and for an IDE with no
123
+ * `HOOK_COMMAND_BY_IDE` entry the answer is "nothing, and nothing ever will
124
+ * be" — which is a successful query returning a negative fact, not a failed
125
+ * one. The exit code says the query succeeded; this field says what it found.
126
+ * Without it `installed: false` would be indistinguishable from a supported
127
+ * IDE that simply has no hook installed yet, which is a state the user can
128
+ * act on.
129
+ */
130
+ supportsHooks: boolean;
119
131
  };
120
132
  /**
121
133
  * Slice #014: read the *actually-installed* peaks-managed hook entries
@@ -4,7 +4,7 @@ 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';
7
- import { resolveHookSpec, resolveHookEntries, resolveLegacySentinels, SUPERPOWERS_DENIED_SKILLS, formatSuperpowersDenyEntry, SUPERPOWERS_DENY_SENTINELS, hasExternalGateExemptions, withExternalGateExemptions, withoutExternalGateExemptions } from './hooks-codegate-superpowers.js';
7
+ import { resolveHookSpec, resolveHookEntries, hasHookSpec, resolveLegacySentinels, SUPERPOWERS_DENIED_SKILLS, formatSuperpowersDenyEntry, SUPERPOWERS_DENY_SENTINELS, hasExternalGateExemptions, withExternalGateExemptions, withoutExternalGateExemptions } from './hooks-codegate-superpowers.js';
8
8
  export { HOOK_ENFORCE_SENTINEL, HOOK_CODE_GATE_SENTINEL, HOOK_CODE_GATE_MATCHER, HOOK_CODE_GATE_EVENT, HOOK_CODE_GATE_COMMAND, SUPERPOWERS_DENIED_SKILLS } from './hooks-codegate-superpowers.js';
9
9
  // --- Module-level defaults (claude-code) -----------------------------------
10
10
  // These exports remain for backward compat — tests and downstream callers
@@ -44,25 +44,49 @@ 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
47
  /**
52
48
  * Resolve the machine-local settings file an IDE may carry (e.g. Claude
53
49
  * Code's `.claude/settings.local.json`), or `undefined` when there is none.
54
50
  *
51
+ * B3(b) 2026-09-13: the machine-local layer is an adapter-DECLARED capability,
52
+ * not a name list. It used to be two hardcoded facts here — a `Set(['claude-code'])`
53
+ * membership test and the filename literal `'settings.local.json'` — i.e. a
54
+ * module that routes per-IDE was itself naming one IDE and one IDE's file.
55
+ * Both now read off the adapter that owns them
56
+ * (`IdeSettingsLocation.localSettingsFileName`), exactly as
57
+ * `auto-compact-dispatcher.ts` already did for the same field.
58
+ *
59
+ * Behaviour is UNCHANGED, and that was verified rather than assumed: over all
60
+ * nine registered `IdeId`s, `IDES_WITH_LOCAL_SETTINGS.has(id)` was `true` for
61
+ * exactly the ids whose adapter declares `localSettingsFileName` (claude-code
62
+ * alone, `'settings.local.json'`). So the set was an exact duplicate of a
63
+ * declaration the adapters already carried, not an independent policy — and
64
+ * a new adapter that declares the field now gets this path for free instead
65
+ * of being silently denied it.
66
+ *
67
+ * This ALSO removes a live instance of the vendor guard's limit 1 (indirect
68
+ * identity: `SET.has(ide)`), which no AST check over comparisons or literals
69
+ * can see. The guard's header named this exact line as the example.
70
+ *
55
71
  * Global scope resolves to `undefined`: the global settings file
56
72
  * (`~/.claude/settings.json`) is already machine-local, so nothing needs
57
73
  * relocating there.
58
74
  */
59
75
  function resolveLocalSettingsPath(scope, ide, projectRoot) {
60
- if (scope === 'global' || !IDES_WITH_LOCAL_SETTINGS.has(ide))
76
+ if (scope === 'global')
61
77
  return undefined;
62
- const root = resolveSettingsRoot(scope, projectRoot);
63
78
  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);
79
+ const localFileName = adapter.settings.localSettingsFileName;
80
+ // `undefined` = this IDE has no machine-local layer. Note the order: a
81
+ // caller reaching here with an id the registry does not know still throws
82
+ // from `getAdapter` above, exactly as it did before — every path into this
83
+ // function already called `getAdapter(ide)` via `resolveSettingsPath`, so
84
+ // no new throw path is introduced.
85
+ if (localFileName === undefined)
86
+ return undefined;
87
+ const root = resolveSettingsRoot(scope, projectRoot);
88
+ assertSafeSettingsFile(scope, root, adapter.settings.dirName, localFileName);
89
+ return join(root, adapter.settings.dirName, localFileName);
66
90
  }
67
91
  function resolveHookTargets(scope, ide, projectRoot) {
68
92
  const sharedPath = resolveSettingsPath(scope, ide, projectRoot);
@@ -298,10 +322,20 @@ function shapeMatchesDesired(settings, entries, allPeaksSentinels) {
298
322
  return false;
299
323
  }
300
324
  }
301
- // (b) every desired entry must be on disk.
302
- for (const sentinel of desiredSentinels) {
303
- const has = peaksPresent.some((entry) => (entry.hooks ?? []).some((h) => String(h.command ?? '').includes(sentinel)));
304
- if (!has)
325
+ // (b) every desired entry must be on disk AND carry the matcher it declares.
326
+ // Presence alone cannot see a WRONG matcher, and a wrong matcher is not
327
+ // cosmetic: `''` and `Bash|Task` route the same command to different
328
+ // tool sets, so the entry is present while the hook never fires on the
329
+ // tools it was installed for. Measured (rid `2026-09-13-compact-event-settle`,
330
+ // residual R2): a `PostCompact` entry hand-corrupted to matcher
331
+ // `auto|manual` survived `peaks hooks install` unchanged, and the
332
+ // installer was structurally unable to repair it.
333
+ // An absent matcher reads as `''` — the form Claude Code takes as "every
334
+ // source" — so a file written before matchers were explicit converges
335
+ // once instead of churning on every install.
336
+ for (const desired of entries.filter((e) => e.event === eventKey)) {
337
+ const onDisk = peaksPresent.find((entry) => (entry.hooks ?? []).some((h) => String(h.command ?? '').includes(desired.sentinel)));
338
+ if (onDisk === undefined || (onDisk.matcher ?? '') !== desired.matcher)
305
339
  return false;
306
340
  }
307
341
  }
@@ -453,6 +487,24 @@ export function removeHookInstall(scope, projectRoot, options) {
453
487
  const root = resolveSettingsRoot(scope, projectRoot);
454
488
  const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
455
489
  assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
490
+ // An IDE with no HOOK_COMMAND_BY_IDE entry can never carry a peaks hook, so
491
+ // "remove it" is already true. That is a no-op success, not a failure: the
492
+ // post-condition the caller asked for holds. This stays asymmetric with
493
+ // `applyHookInstall`, which still fails closed for the same IDEs — reporting
494
+ // a successful install there would claim an enforcement that cannot exist.
495
+ //
496
+ // `resolveHookTargets` below is what throws for these IDEs, so the check has
497
+ // to come first. Note `resolveSettingsPath` above still runs: an IDE the
498
+ // adapter registry does not know at all remains a hard error.
499
+ if (!hasHookSpec(ide)) {
500
+ const localSettingsPath = resolveLocalSettingsPath(scope, ide, projectRoot);
501
+ return {
502
+ scope,
503
+ settingsPath,
504
+ removed: false,
505
+ ...(localSettingsPath !== undefined && localSettingsPath !== settingsPath ? { localSettingsPath } : {})
506
+ };
507
+ }
456
508
  const targets = resolveHookTargets(scope, ide, projectRoot);
457
509
  const localTarget = targets.find((t) => t.settingsPath !== settingsPath);
458
510
  const present = targets.filter((t) => existsSync(t.settingsPath));
@@ -528,6 +580,30 @@ export function readHookStatus(scope, projectRoot, options) {
528
580
  const root = resolveSettingsRoot(scope, projectRoot);
529
581
  const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
530
582
  assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
583
+ // An IDE with no HOOK_COMMAND_BY_IDE entry has no hook on disk and can never
584
+ // have one, so "what is installed?" has a definite answer: nothing. That is a
585
+ // no-op success, exactly like `removeHookInstall` — the query ran, the disk
586
+ // was read, and the honest reply is `supportsHooks: false, installed: false`.
587
+ // This stays asymmetric with `applyHookInstall`, which still fails closed for
588
+ // the same IDEs: reporting a successful INSTALL would claim an enforcement
589
+ // that cannot exist, whereas reporting a successful STATUS claims nothing.
590
+ //
591
+ // `resolveHookTargets` below is what throws for these IDEs, so the check has
592
+ // to come first. `resolveSettingsPath` above still runs: an IDE the adapter
593
+ // registry does not know at all remains a hard error.
594
+ if (!hasHookSpec(ide)) {
595
+ const localSettingsPath = resolveLocalSettingsPath(scope, ide, projectRoot);
596
+ return {
597
+ scope,
598
+ settingsPath,
599
+ exists: existsSync(settingsPath),
600
+ ...(localSettingsPath !== undefined && localSettingsPath !== settingsPath
601
+ ? { localSettingsPath, localExists: existsSync(localSettingsPath) }
602
+ : {}),
603
+ installed: false,
604
+ supportsHooks: false
605
+ };
606
+ }
531
607
  const targets = resolveHookTargets(scope, ide, projectRoot);
532
608
  const exists = existsSync(settingsPath);
533
609
  const localTarget = targets.find((t) => t.settingsPath !== settingsPath);
@@ -541,7 +617,8 @@ export function readHookStatus(scope, projectRoot, options) {
541
617
  installed: targets.some((t) => {
542
618
  const settings = readSettingsFile(t.settingsPath);
543
619
  return Object.keys(settings).length > 0 && isInstalledForEntries(settings, t.entries);
544
- })
620
+ }),
621
+ supportsHooks: true
545
622
  };
546
623
  }
547
624
  /**