peaks-loop 4.0.45 → 4.0.47

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 (137) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/code-runtime-commands.d.ts +22 -0
  5. package/dist/cli/commands/code-runtime-commands.js +128 -34
  6. package/dist/cli/commands/compact-command.js +129 -1
  7. package/dist/cli/commands/container-commands.js +3 -3
  8. package/dist/cli/commands/core/skill-command.js +45 -10
  9. package/dist/cli/commands/cron-commands.js +2 -1
  10. package/dist/cli/commands/e2e-verify.js +3 -3
  11. package/dist/cli/commands/governance-classify-contract-commands.js +1 -0
  12. package/dist/cli/commands/hooks-commands.js +10 -1
  13. package/dist/cli/commands/loop-commands.js +1 -0
  14. package/dist/cli/commands/playwright-commands.js +2 -1
  15. package/dist/cli/commands/reinject-command.d.ts +72 -0
  16. package/dist/cli/commands/reinject-command.js +174 -0
  17. package/dist/cli/commands/request-commands.js +6 -3
  18. package/dist/cli/commands/shadcn-commands.js +1 -0
  19. package/dist/cli/commands/test-commands.js +2 -1
  20. package/dist/cli/commands/vm-commands.js +7 -7
  21. package/dist/cli/commands/workspace/init-command.js +24 -2
  22. package/dist/cli/commands/worktree-lease-commands.js +4 -4
  23. package/dist/cli/index.js +49 -2
  24. package/dist/cli/program.js +5 -0
  25. package/dist/hooks/pre-tool-use-sub-agent.js +1 -1
  26. package/dist/services/adapter/adapter-registry.js +1 -1
  27. package/dist/services/artifacts/artifact-service.js +1 -1
  28. package/dist/services/capability-guard-runner/contracts/J01.js +2 -1
  29. package/dist/services/capability-guard-runner/contracts/J02.js +3 -3
  30. package/dist/services/capability-guard-runner/contracts/J04.js +4 -2
  31. package/dist/services/capability-guard-runner/contracts/J07.js +2 -1
  32. package/dist/services/code/auto-compact-lifecycle.d.ts +50 -4
  33. package/dist/services/code/auto-compact-lifecycle.js +52 -10
  34. package/dist/services/code/auto-compact-orchestrator.d.ts +53 -9
  35. package/dist/services/code/auto-compact-orchestrator.js +173 -43
  36. package/dist/services/code/orchestrator-can-do.d.ts +4 -2
  37. package/dist/services/code/orchestrator-can-do.js +37 -5
  38. package/dist/services/codegraph/codegraph-exclude-reconciler.js +2 -1
  39. package/dist/services/codegraph/codegraph-process-runner.js +3 -2
  40. package/dist/services/compact/request-transition-hook.js +5 -2
  41. package/dist/services/compact-history/compact-history-service.d.ts +61 -0
  42. package/dist/services/compact-history/compact-history-service.js +49 -0
  43. package/dist/services/compact-statusline/compact-lifecycle-store.d.ts +11 -2
  44. package/dist/services/compact-statusline/compact-lifecycle-store.js +19 -1
  45. package/dist/services/compact-statusline/compact-statusline-service.d.ts +1 -1
  46. package/dist/services/compact-statusline/compact-statusline-service.js +22 -0
  47. package/dist/services/config/config-safety.d.ts +52 -0
  48. package/dist/services/config/config-safety.js +75 -1
  49. package/dist/services/context/auto-compact-dispatcher.d.ts +7 -37
  50. package/dist/services/context/auto-compact-dispatcher.js +113 -40
  51. package/dist/services/context/auto-compact-reader.d.ts +68 -28
  52. package/dist/services/context/auto-compact-reader.js +155 -1
  53. package/dist/services/context/auto-compact-types.d.ts +89 -12
  54. package/dist/services/context/auto-compact-types.js +16 -32
  55. package/dist/services/context/harness-window-config.d.ts +412 -0
  56. package/dist/services/context/harness-window-config.js +607 -0
  57. package/dist/services/context/main-session-monitor.d.ts +27 -0
  58. package/dist/services/context/main-session-monitor.js +32 -1
  59. package/dist/services/context/post-compact-reinjection.d.ts +221 -0
  60. package/dist/services/context/post-compact-reinjection.js +491 -0
  61. package/dist/services/dispatch/merge-back-runner.js +5 -5
  62. package/dist/services/dispatch/service-shutdown.js +3 -3
  63. package/dist/services/doc/doc-generator.js +2 -1
  64. package/dist/services/env/shell-probe.js +1 -1
  65. package/dist/services/fuzzy-matching/fzf-pick-service.js +2 -0
  66. package/dist/services/hooks/auto-compact-hook-install.d.ts +10 -2
  67. package/dist/services/hooks/auto-compact-hook-install.js +8 -0
  68. package/dist/services/ide/adapters/claude-code-adapter.d.ts +107 -3
  69. package/dist/services/ide/adapters/claude-code-adapter.js +154 -7
  70. package/dist/services/ide/ide-registry.d.ts +12 -0
  71. package/dist/services/ide/ide-registry.js +14 -0
  72. package/dist/services/ide/ide-types.d.ts +59 -0
  73. package/dist/services/lint/detect-eslint.js +2 -2
  74. package/dist/services/lint/eslint-runner.js +3 -1
  75. package/dist/services/loop/evaluator-dispatcher.js +2 -1
  76. package/dist/services/memory/project-memory-service/index/kind-dispatch.js +1 -1
  77. package/dist/services/memory/project-memory-service/store/paths.d.ts +9 -1
  78. package/dist/services/memory/project-memory-service/store/paths.js +15 -6
  79. package/dist/services/prd/best-practice-auto-trigger.js +1 -0
  80. package/dist/services/release/version-precheck-service.d.ts +2 -1
  81. package/dist/services/release/version-precheck-service.js +82 -12
  82. package/dist/services/runtime/vendor-adapter.d.ts +29 -4
  83. package/dist/services/runtime/vendors/claude-code.js +1 -1
  84. package/dist/services/runtime/vendors/codex.js +1 -1
  85. package/dist/services/runtime/vendors/copilot.js +1 -1
  86. package/dist/services/sc/sc-service.js +1 -1
  87. package/dist/services/scan/diff-scope-service.js +2 -2
  88. package/dist/services/scan/file-size-scan.js +2 -2
  89. package/dist/services/scan/orphan-service.js +2 -1
  90. package/dist/services/scan/type-sanity-service.js +2 -2
  91. package/dist/services/skillhub/tar-runtime.js +1 -0
  92. package/dist/services/skills/hooks-codegate-superpowers.d.ts +8 -0
  93. package/dist/services/skills/hooks-codegate-superpowers.js +40 -3
  94. package/dist/services/skills/hooks-settings-service.d.ts +12 -0
  95. package/dist/services/skills/hooks-settings-service.js +77 -10
  96. package/dist/services/skills/session-start-hook-constants.d.ts +41 -0
  97. package/dist/services/skills/session-start-hook-constants.js +41 -0
  98. package/dist/services/skills/skill-presence-service.js +9 -0
  99. package/dist/services/skills/skill-statusline-renderer.js +30 -5
  100. package/dist/services/skills/statusline-palette.d.ts +6 -0
  101. package/dist/services/skills/statusline-palette.js +4 -1
  102. package/dist/services/slice/slice-check-service.js +2 -1
  103. package/dist/services/slice/slice-decompose-runners.js +2 -1
  104. package/dist/services/upgrade/upgrade-service.js +1 -0
  105. package/dist/services/workflow/workflow-skip-service.js +2 -1
  106. package/dist/services/workspace/migrate-service.js +1 -1
  107. package/dist/services/workspace/workspace-claude-settings-materializer.js +51 -7
  108. package/dist/services/workspace/workspace-service.js +8 -0
  109. package/dist/services/worktree/host-worktree-reconciler.js +1 -0
  110. package/dist/services/worktree/long-path-cleanup.js +3 -2
  111. package/dist/shared/process.js +1 -1
  112. package/package.json +5 -5
  113. package/scripts/install-skills.mjs +1 -0
  114. package/scripts/watch.mjs +3 -1
  115. package/skills/bee/peaks-perf-audit/SKILL.md +1 -1
  116. package/skills/bee/peaks-prd/SKILL.md +1 -1
  117. package/skills/bee/peaks-qa/SKILL.md +2 -2
  118. package/skills/bee/peaks-rd/SKILL.md +2 -2
  119. package/skills/bee/peaks-reviewer/SKILL.md +1 -1
  120. package/skills/bee/peaks-sc/SKILL.md +1 -1
  121. package/skills/bee/peaks-security-audit/SKILL.md +1 -1
  122. package/skills/bee/peaks-txt/SKILL.md +1 -1
  123. package/skills/bee/peaks-ui/SKILL.md +1 -1
  124. package/skills/peaks-audit/SKILL.md +1 -1
  125. package/skills/peaks-code/SKILL.md +4 -4
  126. package/skills/peaks-code/references/sub-agent-dispatch.md +1 -1
  127. package/skills/peaks-content/SKILL.md +1 -1
  128. package/skills/peaks-doctor/SKILL.md +1 -1
  129. package/skills/peaks-final-review/SKILL.md +1 -1
  130. package/skills/peaks-ide/SKILL.md +1 -1
  131. package/skills/peaks-issue-fix-orchestrator/SKILL.md +1 -1
  132. package/skills/peaks-resume/SKILL.md +1 -1
  133. package/skills/peaks-slice-decompose/SKILL.md +1 -1
  134. package/skills/peaks-solo/SKILL.md +1 -1
  135. package/skills/peaks-sop/SKILL.md +1 -1
  136. package/skills/peaks-status/SKILL.md +1 -1
  137. package/skills/peaks-test/SKILL.md +1 -1
@@ -37,12 +37,45 @@ function upgrade(result, strict) {
37
37
  }
38
38
  return result;
39
39
  }
40
+ /** Read + parse a JSON manifest, mapping both failure modes onto a blocker description. */
41
+ function readPackageJson(path, label) {
42
+ let raw;
43
+ try {
44
+ raw = readFileSync(path, 'utf8');
45
+ }
46
+ catch {
47
+ return {
48
+ ok: false,
49
+ message: `${label} is missing or unreadable at ${path}`,
50
+ remediation: `run \`peaks release precheck\` from a project root that has a readable ${label}`
51
+ };
52
+ }
53
+ try {
54
+ return { ok: true, value: JSON.parse(raw) };
55
+ }
56
+ catch {
57
+ return {
58
+ ok: false,
59
+ message: `${label} at ${path} is not valid JSON`,
60
+ remediation: `fix the ${label} syntax; \`peaks release precheck\` cannot gate a version it cannot parse`
61
+ };
62
+ }
63
+ }
40
64
  function readRootVersion(projectRoot) {
41
- const pkg = JSON.parse(readFileSync(join(projectRoot, 'package.json'), 'utf8'));
65
+ const path = join(projectRoot, 'package.json');
66
+ const read = readPackageJson(path, 'root package.json');
67
+ if (!read.ok) {
68
+ return read;
69
+ }
70
+ const pkg = read.value;
42
71
  if (typeof pkg.version !== 'string' || !SEMVER_RE.test(pkg.version)) {
43
- throw new Error(`invalid root package.json#version: ${JSON.stringify(pkg.version)}`);
72
+ return {
73
+ ok: false,
74
+ message: `invalid root package.json#version: ${JSON.stringify(pkg.version)}`,
75
+ remediation: 'set root package.json#version to a clean semver (e.g. 4.0.0) before running `peaks release precheck`'
76
+ };
44
77
  }
45
- return pkg.version;
78
+ return { ok: true, version: pkg.version };
46
79
  }
47
80
  function rollup(layers, strict) {
48
81
  const statuses = Object.values(layers).map((l) => l.status);
@@ -58,7 +91,16 @@ function rollup(layers, strict) {
58
91
  // Layer A — rootVsShared (mirrors publish.yml §(A) on-disk gate only)
59
92
  // ---------------------------------------------------------------------------
60
93
  export function runRootVsShared(opts) {
61
- const rootVersion = readRootVersion(opts.projectRoot);
94
+ const rootRead = readRootVersion(opts.projectRoot);
95
+ if (!rootRead.ok) {
96
+ return {
97
+ status: 'blocker',
98
+ message: rootRead.message,
99
+ remediation: rootRead.remediation,
100
+ observed: { rootVersion: null, sharedVersion: null }
101
+ };
102
+ }
103
+ const rootVersion = rootRead.version;
62
104
  const sharedDist = join(opts.projectRoot, 'packages', 'peaks-loop-shared', 'dist', 'version.js');
63
105
  let sharedVersion = null;
64
106
  let distExists = false;
@@ -112,7 +154,18 @@ export function runRootVsShared(opts) {
112
154
  // Layer B — tagCollision (CLI-only; not in publish.yml today)
113
155
  // ---------------------------------------------------------------------------
114
156
  export function runTagCollision(opts) {
115
- const rootVersion = readRootVersion(opts.projectRoot);
157
+ const rootRead = readRootVersion(opts.projectRoot);
158
+ if (!rootRead.ok) {
159
+ // Fail closed: without a version there is no tag name to check, so this
160
+ // layer cannot claim "safe to publish".
161
+ return {
162
+ status: 'blocker',
163
+ message: rootRead.message,
164
+ remediation: rootRead.remediation,
165
+ observed: { tagName: null }
166
+ };
167
+ }
168
+ const rootVersion = rootRead.version;
116
169
  const tagName = `v${rootVersion}`;
117
170
  // 2026-09-10: no shell. `git` is `git.exe` on Windows, so the wrapper bought
118
171
  // nothing — and it actively broke this layer, because `projectRoot` is an
@@ -125,7 +178,8 @@ export function runTagCollision(opts) {
125
178
  // blocker). It also emitted DEP0190 on every run, on every layer.
126
179
  const res = spawnSync('git', ['-C', opts.projectRoot, 'tag', '--list', tagName], {
127
180
  encoding: 'utf8',
128
- timeout: 5_000
181
+ timeout: 5_000,
182
+ windowsHide: true
129
183
  });
130
184
  if (res.error !== null && res.error !== undefined) {
131
185
  return {
@@ -198,8 +252,16 @@ export function runChangesetStaged(opts) {
198
252
  // Layer D — workspaceLockstep (CLI-only; not in publish.yml today)
199
253
  // ---------------------------------------------------------------------------
200
254
  export function runWorkspaceLockstep(opts) {
201
- const rootPkgRaw = readFileSync(join(opts.projectRoot, 'package.json'), 'utf8');
202
- const rootPkg = JSON.parse(rootPkgRaw);
255
+ const rootRead = readPackageJson(join(opts.projectRoot, 'package.json'), 'root package.json');
256
+ if (!rootRead.ok) {
257
+ return {
258
+ status: 'blocker',
259
+ message: rootRead.message,
260
+ remediation: rootRead.remediation,
261
+ observed: { sharedDep: null }
262
+ };
263
+ }
264
+ const rootPkg = rootRead.value;
203
265
  const allDeps = {
204
266
  ...(rootPkg.dependencies ?? {}),
205
267
  ...(rootPkg.devDependencies ?? {})
@@ -222,9 +284,16 @@ export function runWorkspaceLockstep(opts) {
222
284
  observed: { sharedDep }
223
285
  };
224
286
  }
225
- const sharedPkgRaw = readFileSync(join(opts.projectRoot, 'packages', 'peaks-loop-shared', 'package.json'), 'utf8');
226
- const sharedPkg = JSON.parse(sharedPkgRaw);
227
- const sharedVersion = sharedPkg.version ?? '';
287
+ const sharedRead = readPackageJson(join(opts.projectRoot, 'packages', 'peaks-loop-shared', 'package.json'), 'packages/peaks-loop-shared/package.json');
288
+ if (!sharedRead.ok) {
289
+ return {
290
+ status: 'blocker',
291
+ message: sharedRead.message,
292
+ remediation: sharedRead.remediation,
293
+ observed: { sharedDep, sharedVersion: null }
294
+ };
295
+ }
296
+ const sharedVersion = sharedRead.value.version ?? '';
228
297
  if (!SEMVER_RE.test(sharedVersion)) {
229
298
  return {
230
299
  status: 'blocker',
@@ -244,7 +313,8 @@ export function runWorkspaceLockstep(opts) {
244
313
  // runAllLayers — orchestrator
245
314
  // ---------------------------------------------------------------------------
246
315
  export function runAllLayers(opts) {
247
- const rootVersion = readRootVersion(opts.projectRoot);
316
+ const rootRead = readRootVersion(opts.projectRoot);
317
+ const rootVersion = rootRead.ok ? rootRead.version : null;
248
318
  const strict = opts.strict === true;
249
319
  const layers = {
250
320
  rootVsShared: upgrade(runRootVsShared(opts), strict),
@@ -20,11 +20,36 @@
20
20
  *
21
21
  * Vendor-specific verb strings (`claude --compact`, `codex --compact`,
22
22
  * `copilot compact`) MUST live ONLY in adapter implementations under
23
- * `src/services/runtime/vendors/<vendor>.ts`. The runtime service that
23
+ * `src/services/runtime/vendors/<vendor>.ts` and
24
+ * `src/services/ide/adapters/<vendor>-adapter.ts`. The runtime service that
24
25
  * orchestrates these adapters (runtime-service.ts) and the CLI commands
25
- * (`peaks runtime compact --via <id>`) MUST NOT contain vendor verbs
26
- * — verified by AC-1: `rg -n "claude --compact|codex --compact|copilot
27
- * compact" src/services/code/` must return 0 matches.
26
+ * (`peaks runtime compact --via <id>`) MUST NOT contain vendor verbs.
27
+ *
28
+ * Slice 2026-09-12-auto-compact-vendor-neutrality widened AC-1's scope from
29
+ * `src/services/code/` to ALL of `src/`, and had to change how it is
30
+ * enforced. Both changes came from measurement:
31
+ *
32
+ * - The old scope excluded the files that carried the defect AC-1
33
+ * describes — the compact dispatcher (`src/services/context/`) and the
34
+ * hook installer (`src/services/hooks/`) — so it returned 0 by
35
+ * construction. A grep over a directory that does not contain the code
36
+ * being checked is not evidence of anything.
37
+ * - Widened as a raw grep over `src/`, the check returns 13, not 0. Twelve
38
+ * hits are the phrase quoted in COMMENTS and doc strings (this block
39
+ * among them); the thirteenth is `compactCommand: 'claude --compact'`
40
+ * in `src/services/ide/adapters/claude-code-adapter.ts` — the one place
41
+ * the string is supposed to be. A text grep measures prose, not code.
42
+ *
43
+ * AC-1 is therefore enforced by the AST-based check in
44
+ * `tests/unit/runtime/vendor-neutral-identity-guard.test.ts`: no vendor
45
+ * verb in any STRING LITERAL of `src/` outside the adapter implementations.
46
+ * Parsed, the count is 1 and the single hit is that adapter — which is the
47
+ * property AC-1 was always about.
48
+ *
49
+ * AC-1 covers VERB STRINGS only. The compact path's defects were IDENTITY
50
+ * COMPARISONS (`ideId !== 'claude-code'`), a second shape the same guard
51
+ * file covers and AC-1 never could. Read that guard's header for what
52
+ * neither check catches before treating vendor neutrality as proven.
28
53
  *
29
54
  * See `.peaks/_runtime/2026-07-08-session-17918f/prd/002-adapter-runtime-and-polyrepo.md`
30
55
  * for the source PRD.
@@ -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
  }
@@ -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 };
@@ -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
@@ -41,6 +41,14 @@ interface ResolvedHookSpec {
41
41
  * everywhere else.
42
42
  */
43
43
  export declare function resolveHookShell(platform?: NodeJS.Platform): string | undefined;
44
+ /**
45
+ * True when the IDE is tabled in `HOOK_COMMAND_BY_IDE`, i.e. peaks can write a
46
+ * hook entry for it. `qoder` / `tongyi-lingma` / `zcode` are reserved adapter
47
+ * ids the registry knows but this table does not, so no hook can exist for
48
+ * them — which makes "uninstall" a no-op (nothing was ever installed) while
49
+ * "install" remains a hard failure.
50
+ */
51
+ export declare function hasHookSpec(ide: IdeId): boolean;
44
52
  export declare function resolveHookSpec(ide: IdeId): ResolvedHookSpec;
45
53
  /** A typed descriptor for a single peaks-managed hook entry. */
46
54
  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_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
  /**
@@ -45,6 +45,16 @@ const HOOK_COMMAND_BY_IDE = {
45
45
  // registered. When a slice adds them, add a HOOK_COMMAND_BY_IDE entry
46
46
  // here — the function below fail-closes on missing entries.
47
47
  };
48
+ /**
49
+ * True when the IDE is tabled in `HOOK_COMMAND_BY_IDE`, i.e. peaks can write a
50
+ * hook entry for it. `qoder` / `tongyi-lingma` / `zcode` are reserved adapter
51
+ * ids the registry knows but this table does not, so no hook can exist for
52
+ * them — which makes "uninstall" a no-op (nothing was ever installed) while
53
+ * "install" remains a hard failure.
54
+ */
55
+ export function hasHookSpec(ide) {
56
+ return HOOK_COMMAND_BY_IDE[ide] !== undefined;
57
+ }
48
58
  export function resolveHookSpec(ide) {
49
59
  // getAdapter throws on unregistered IDEs — the registry is the source of truth.
50
60
  const adapter = getAdapter(ide);
@@ -53,7 +63,12 @@ export function resolveHookSpec(ide) {
53
63
  // Defensive fallback: if an adapter is added to the registry without a
54
64
  // HOOK_COMMAND_BY_IDE entry, fail-closed with a clear error instead of
55
65
  // 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)`);
66
+ //
67
+ // Caller-neutral on purpose: this one resolver backs install, status,
68
+ // uninstall and the dry-run plan, so the message names neither a verb
69
+ // (naming the install verb made an uninstall failure read as an install
70
+ // failure) nor a file that is not the table.
71
+ throw new Error(`unsupported IDE '${ide}': no HOOK_COMMAND_BY_IDE entry (add one in hooks-codegate-superpowers.ts)`);
57
72
  }
58
73
  const isClaudeCode = ide === 'claude-code';
59
74
  // Claude Code's gate hook must emit its structured decision as JSON:
@@ -129,6 +144,23 @@ export function resolveHookEntries(ide, _skipProgress = false) {
129
144
  command: HOOK_CODE_GATE_COMMAND,
130
145
  event: HOOK_CODE_GATE_EVENT
131
146
  });
147
+ // rid 2026-09-13-a2-post-compact-reinject: the post-compaction
148
+ // engineering-state re-injection. A THIRD SessionStart entry, on a
149
+ // DIFFERENT matcher (`compact`) than the outer-cache and primer entries
150
+ // (matcher `''`, i.e. every source). The matcher is what scopes it: the
151
+ // card belongs in the context after a compaction emptied it, not on a
152
+ // fresh `startup` where the dispatch context is still present.
153
+ //
154
+ // It rides this table rather than a bespoke installer so install,
155
+ // status, uninstall, the dry-run plan and the workspace-init drift
156
+ // comparator all see it as one more peaks-managed entry — the same
157
+ // contract the other two SessionStart entries have.
158
+ entries.push({
159
+ sentinel: HOOK_POST_COMPACT_REINJECT_SENTINEL,
160
+ matcher: HOOK_POST_COMPACT_REINJECT_MATCHER,
161
+ command: HOOK_POST_COMPACT_REINJECT_COMMAND,
162
+ event: HOOK_POST_COMPACT_REINJECT_EVENT
163
+ });
132
164
  }
133
165
  return entries;
134
166
  }
@@ -154,7 +186,12 @@ export function resolveLegacySentinels(ide) {
154
186
  // the gate-enforce entry, and so hand-added entries matching this
155
187
  // sentinel are recognized as peaks-managed (not stripped as
156
188
  // non-Peaks).
157
- return [...base, HOOK_OUTER_CACHE_SENTINEL, HOOK_WORKSPACE_INIT_SENTINEL];
189
+ // rid 2026-09-13-a2-post-compact-reinject: the post-compact re-injection
190
+ // sentinel joins the same set, so uninstall strips it (and status counts
191
+ // it) exactly like the other two SessionStart entries. Without this the
192
+ // entry would be unremovable by `peaks hooks uninstall` — the rollback
193
+ // path T2 requires.
194
+ return [...base, HOOK_OUTER_CACHE_SENTINEL, HOOK_WORKSPACE_INIT_SENTINEL, HOOK_POST_COMPACT_REINJECT_SENTINEL];
158
195
  }
159
196
  return base;
160
197
  }
@@ -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);
@@ -453,6 +477,24 @@ export function removeHookInstall(scope, projectRoot, options) {
453
477
  const root = resolveSettingsRoot(scope, projectRoot);
454
478
  const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
455
479
  assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
480
+ // An IDE with no HOOK_COMMAND_BY_IDE entry can never carry a peaks hook, so
481
+ // "remove it" is already true. That is a no-op success, not a failure: the
482
+ // post-condition the caller asked for holds. This stays asymmetric with
483
+ // `applyHookInstall`, which still fails closed for the same IDEs — reporting
484
+ // a successful install there would claim an enforcement that cannot exist.
485
+ //
486
+ // `resolveHookTargets` below is what throws for these IDEs, so the check has
487
+ // to come first. Note `resolveSettingsPath` above still runs: an IDE the
488
+ // adapter registry does not know at all remains a hard error.
489
+ if (!hasHookSpec(ide)) {
490
+ const localSettingsPath = resolveLocalSettingsPath(scope, ide, projectRoot);
491
+ return {
492
+ scope,
493
+ settingsPath,
494
+ removed: false,
495
+ ...(localSettingsPath !== undefined && localSettingsPath !== settingsPath ? { localSettingsPath } : {})
496
+ };
497
+ }
456
498
  const targets = resolveHookTargets(scope, ide, projectRoot);
457
499
  const localTarget = targets.find((t) => t.settingsPath !== settingsPath);
458
500
  const present = targets.filter((t) => existsSync(t.settingsPath));
@@ -528,6 +570,30 @@ export function readHookStatus(scope, projectRoot, options) {
528
570
  const root = resolveSettingsRoot(scope, projectRoot);
529
571
  const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
530
572
  assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
573
+ // An IDE with no HOOK_COMMAND_BY_IDE entry has no hook on disk and can never
574
+ // have one, so "what is installed?" has a definite answer: nothing. That is a
575
+ // no-op success, exactly like `removeHookInstall` — the query ran, the disk
576
+ // was read, and the honest reply is `supportsHooks: false, installed: false`.
577
+ // This stays asymmetric with `applyHookInstall`, which still fails closed for
578
+ // the same IDEs: reporting a successful INSTALL would claim an enforcement
579
+ // that cannot exist, whereas reporting a successful STATUS claims nothing.
580
+ //
581
+ // `resolveHookTargets` below is what throws for these IDEs, so the check has
582
+ // to come first. `resolveSettingsPath` above still runs: an IDE the adapter
583
+ // registry does not know at all remains a hard error.
584
+ if (!hasHookSpec(ide)) {
585
+ const localSettingsPath = resolveLocalSettingsPath(scope, ide, projectRoot);
586
+ return {
587
+ scope,
588
+ settingsPath,
589
+ exists: existsSync(settingsPath),
590
+ ...(localSettingsPath !== undefined && localSettingsPath !== settingsPath
591
+ ? { localSettingsPath, localExists: existsSync(localSettingsPath) }
592
+ : {}),
593
+ installed: false,
594
+ supportsHooks: false
595
+ };
596
+ }
531
597
  const targets = resolveHookTargets(scope, ide, projectRoot);
532
598
  const exists = existsSync(settingsPath);
533
599
  const localTarget = targets.find((t) => t.settingsPath !== settingsPath);
@@ -541,7 +607,8 @@ export function readHookStatus(scope, projectRoot, options) {
541
607
  installed: targets.some((t) => {
542
608
  const settings = readSettingsFile(t.settingsPath);
543
609
  return Object.keys(settings).length > 0 && isInstalledForEntries(settings, t.entries);
544
- })
610
+ }),
611
+ supportsHooks: true
545
612
  };
546
613
  }
547
614
  /**
@@ -25,6 +25,47 @@
25
25
  */
26
26
  /** Sentinel substring identifying a SessionStart outer-cache hook entry. */
27
27
  export declare const HOOK_OUTER_CACHE_SENTINEL = "peaks outer-cache write";
28
+ /**
29
+ * rid `2026-09-13-a2-post-compact-reinject` — SessionStart entry scoped to
30
+ * `matcher: 'compact'` that puts the engineering state back into the context
31
+ * a compaction just emptied.
32
+ *
33
+ * This is the official mechanism, and the `matcher` is the load-bearing part
34
+ * of it: Claude Code adds a `SessionStart` hook's plain-text stdout to the
35
+ * context, and scoping to `compact` means the card is re-injected AFTER a
36
+ * compaction rather than on every fresh session. Compaction itself cannot be
37
+ * initiated from here — the harness performs it, and a hook can only observe
38
+ * or veto. Re-injection is the half that IS available to us.
39
+ *
40
+ * Why a separate entry instead of folding this into the `peaks session
41
+ * primer` entry that already runs on every SessionStart: the primer's own
42
+ * contract is "rotation + presence cleanup", it fires on `startup` too (where
43
+ * the dispatch context is still in the history and the card would be noise),
44
+ * and its output is a rotation envelope rather than an engineering-state
45
+ * card. Two entries, two intents — each one's output means exactly one thing.
46
+ */
47
+ export declare const HOOK_POST_COMPACT_REINJECT_SENTINEL = "peaks session reinject";
48
+ /**
49
+ * The reinject hook command. `--project "${CLAUDE_PROJECT_DIR}"` matches the
50
+ * other two SessionStart entries: `${CLAUDE_PROJECT_DIR}` is Claude Code's
51
+ * standard project-root convention, and the CLI resolves it strictly (a
52
+ * SessionStart payload is env-driven and must not be trusted as a path).
53
+ *
54
+ * `--quiet-failure` is NOT a flag and deliberately so: printing nothing on
55
+ * failure is this command's unconditional behaviour, not something a caller
56
+ * opts into. See `reinject-command.ts` — stdout IS context here, so an error
57
+ * message on stdout would be injected into the model's context as though
58
+ * peaks-loop meant it.
59
+ */
60
+ export declare const HOOK_POST_COMPACT_REINJECT_COMMAND = "peaks session reinject --project \"${CLAUDE_PROJECT_DIR}\"";
61
+ /** SessionStart hook event key (same as outer-cache + primer). */
62
+ export declare const HOOK_POST_COMPACT_REINJECT_EVENT = "SessionStart";
63
+ /**
64
+ * The SessionStart `source` value the re-injection is scoped to. Claude Code
65
+ * reports `compact` as the SessionStart source when the session is resuming
66
+ * from a compaction; the hook entry's `matcher` is matched against it.
67
+ */
68
+ export declare const HOOK_POST_COMPACT_REINJECT_MATCHER = "compact";
28
69
  /** Default (claude-code) SessionStart hook command. */
29
70
  export declare const HOOK_OUTER_CACHE_COMMAND = "peaks outer-cache write --project \"${CLAUDE_PROJECT_DIR}\"";
30
71
  /** SessionStart hook event key for Claude Code. */
@@ -25,6 +25,47 @@
25
25
  */
26
26
  /** Sentinel substring identifying a SessionStart outer-cache hook entry. */
27
27
  export const HOOK_OUTER_CACHE_SENTINEL = 'peaks outer-cache write';
28
+ /**
29
+ * rid `2026-09-13-a2-post-compact-reinject` — SessionStart entry scoped to
30
+ * `matcher: 'compact'` that puts the engineering state back into the context
31
+ * a compaction just emptied.
32
+ *
33
+ * This is the official mechanism, and the `matcher` is the load-bearing part
34
+ * of it: Claude Code adds a `SessionStart` hook's plain-text stdout to the
35
+ * context, and scoping to `compact` means the card is re-injected AFTER a
36
+ * compaction rather than on every fresh session. Compaction itself cannot be
37
+ * initiated from here — the harness performs it, and a hook can only observe
38
+ * or veto. Re-injection is the half that IS available to us.
39
+ *
40
+ * Why a separate entry instead of folding this into the `peaks session
41
+ * primer` entry that already runs on every SessionStart: the primer's own
42
+ * contract is "rotation + presence cleanup", it fires on `startup` too (where
43
+ * the dispatch context is still in the history and the card would be noise),
44
+ * and its output is a rotation envelope rather than an engineering-state
45
+ * card. Two entries, two intents — each one's output means exactly one thing.
46
+ */
47
+ export const HOOK_POST_COMPACT_REINJECT_SENTINEL = 'peaks session reinject';
48
+ /**
49
+ * The reinject hook command. `--project "${CLAUDE_PROJECT_DIR}"` matches the
50
+ * other two SessionStart entries: `${CLAUDE_PROJECT_DIR}` is Claude Code's
51
+ * standard project-root convention, and the CLI resolves it strictly (a
52
+ * SessionStart payload is env-driven and must not be trusted as a path).
53
+ *
54
+ * `--quiet-failure` is NOT a flag and deliberately so: printing nothing on
55
+ * failure is this command's unconditional behaviour, not something a caller
56
+ * opts into. See `reinject-command.ts` — stdout IS context here, so an error
57
+ * message on stdout would be injected into the model's context as though
58
+ * peaks-loop meant it.
59
+ */
60
+ export const HOOK_POST_COMPACT_REINJECT_COMMAND = `peaks session reinject --project "\${CLAUDE_PROJECT_DIR}"`;
61
+ /** SessionStart hook event key (same as outer-cache + primer). */
62
+ export const HOOK_POST_COMPACT_REINJECT_EVENT = 'SessionStart';
63
+ /**
64
+ * The SessionStart `source` value the re-injection is scoped to. Claude Code
65
+ * reports `compact` as the SessionStart source when the session is resuming
66
+ * from a compaction; the hook entry's `matcher` is matched against it.
67
+ */
68
+ export const HOOK_POST_COMPACT_REINJECT_MATCHER = 'compact';
28
69
  /** Default (claude-code) SessionStart hook command. */
29
70
  export const HOOK_OUTER_CACHE_COMMAND = `peaks outer-cache write --project "\${CLAUDE_PROJECT_DIR}"`;
30
71
  /** SessionStart hook event key for Claude Code. */