peaks-loop 4.0.37 → 4.0.39

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 (55) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/code-review-commands.js +43 -1
  5. package/dist/cli/commands/code-runtime-commands.js +16 -4
  6. package/dist/cli/commands/core/skill-command.d.ts +44 -0
  7. package/dist/cli/commands/core/skill-command.js +67 -3
  8. package/dist/cli/commands/dispatch-commands.js +42 -12
  9. package/dist/cli/commands/hooks-commands.js +31 -6
  10. package/dist/services/code/auto-compact-orchestrator.js +2 -2
  11. package/dist/services/context/auto-compact-dispatcher.js +3 -1
  12. package/dist/services/context/build-dispatch-system-prompt.d.ts +35 -1
  13. package/dist/services/context/build-dispatch-system-prompt.js +55 -3
  14. package/dist/services/hooks/auto-compact-hook-install.d.ts +14 -1
  15. package/dist/services/hooks/auto-compact-hook-install.js +39 -15
  16. package/dist/services/lint/detect-ocr-18.d.ts +9 -1
  17. package/dist/services/lint/detect-ocr-18.js +114 -10
  18. package/dist/services/lint/ocr-18-acquire.d.ts +113 -0
  19. package/dist/services/lint/ocr-18-acquire.js +350 -0
  20. package/dist/services/lint/ocr-multilang-adapter.js +12 -2
  21. package/dist/services/skills/hooks-codegate-superpowers.d.ts +41 -0
  22. package/dist/services/skills/hooks-codegate-superpowers.js +95 -0
  23. package/dist/services/skills/hooks-settings-service.d.ts +16 -0
  24. package/dist/services/skills/hooks-settings-service.js +46 -13
  25. package/dist/services/web/playwright-loader.js +5 -24
  26. package/dist/services/workflow/provision-dispatch-node.d.ts +42 -0
  27. package/dist/services/workflow/provision-dispatch-node.js +66 -0
  28. package/dist/services/workspace/claude-settings-template.d.ts +19 -3
  29. package/dist/services/workspace/claude-settings-template.js +25 -5
  30. package/dist/services/workspace/workspace-claude-settings-materializer.js +111 -29
  31. package/dist/shared/npm-cache.d.ts +2 -0
  32. package/dist/shared/npm-cache.js +31 -0
  33. package/package.json +5 -5
  34. package/skills/bee/peaks-perf-audit/SKILL.md +24 -0
  35. package/skills/bee/peaks-prd/SKILL.md +24 -0
  36. package/skills/bee/peaks-qa/SKILL.md +24 -0
  37. package/skills/bee/peaks-rd/SKILL.md +24 -0
  38. package/skills/bee/peaks-reviewer/SKILL.md +24 -0
  39. package/skills/bee/peaks-sc/SKILL.md +24 -0
  40. package/skills/bee/peaks-security-audit/SKILL.md +24 -0
  41. package/skills/bee/peaks-txt/SKILL.md +24 -0
  42. package/skills/bee/peaks-ui/SKILL.md +24 -0
  43. package/skills/peaks-audit/SKILL.md +24 -0
  44. package/skills/peaks-code/SKILL.md +24 -0
  45. package/skills/peaks-content/SKILL.md +24 -0
  46. package/skills/peaks-doctor/SKILL.md +24 -0
  47. package/skills/peaks-final-review/SKILL.md +24 -0
  48. package/skills/peaks-ide/SKILL.md +24 -0
  49. package/skills/peaks-issue-fix-orchestrator/SKILL.md +24 -0
  50. package/skills/peaks-resume/SKILL.md +24 -0
  51. package/skills/peaks-slice-decompose/SKILL.md +24 -0
  52. package/skills/peaks-solo/SKILL.md +24 -0
  53. package/skills/peaks-sop/SKILL.md +24 -0
  54. package/skills/peaks-status/SKILL.md +24 -0
  55. package/skills/peaks-test/SKILL.md +24 -0
@@ -0,0 +1,350 @@
1
+ /**
2
+ * OCR 1.8.x acquisition — the ONE step that is allowed to install the reviewer.
3
+ *
4
+ * ## Why this exists as its own module
5
+ *
6
+ * `detect-ocr-18` is a *read-only probe*, but it used to produce its answer by
7
+ * running `npx --package <pin> -- ocr version`, which INSTALLS the package when
8
+ * it is not already cached. Dogfooding 4.0.38: a window titled `npm i @…`
9
+ * appeared on the user's desktop, opened by a command whose own help text says
10
+ * "Read-only probe". The probe no longer spawns at all (see
11
+ * `detect-ocr-18.ts`); the install it was performing as a side effect lives
12
+ * HERE, where it is explicit, named, and on the human's channel — the same
13
+ * split `peaks web status` (probe) / `peaks web install` (acquire) already
14
+ * uses, and whose discipline this module mirrors rather than reinvents.
15
+ *
16
+ * ## Visibility is the requirement, not a nicety
17
+ *
18
+ * The user's words: *"最起码可以看到进度"* — at minimum the progress must be
19
+ * visible. So the installer's stdio is INHERITED, never piped-and-dropped and
20
+ * never detached: npm's own progress lands on the terminal the caller is
21
+ * already watching. Two consequences, both deliberate:
22
+ *
23
+ * - **`windowsHide: true` is not the opposite of visibility.** It stops
24
+ * Windows from allocating a NEW console window; inherited stdio keeps
25
+ * writing to the console that already exists. The window in the bug report
26
+ * was *new*; the progress is *inherited*. `detached: true` is deliberately
27
+ * absent — a silent background install is exactly what was rejected.
28
+ * - **In `--json` mode the child's STDOUT is dropped** (`'ignore'`) so npm's
29
+ * output cannot corrupt the JSON envelope `printResult` writes to stdout,
30
+ * while its STDERR stays inherited — npm puts progress and warnings there,
31
+ * so the wait is still explained on screen. Human mode inherits both.
32
+ *
33
+ * ## Shell preference — and why this is NOT `resolveHookShell`
34
+ *
35
+ * Order: **Git Bash, then PowerShell, then a plain spawn with no shell.**
36
+ *
37
+ * Slice `4637baa8` pinned the *hook* shell to PowerShell on Windows, and it is
38
+ * still right: a hook fires on every Bash tool call, and MSYS2's bash
39
+ * force-allocates its own console window, so the hook must avoid bash. An
40
+ * ACQUISITION is the opposite case — it runs once, takes seconds, and the user
41
+ * wants to watch it.
42
+ *
43
+ * **The two preferences are not a contradiction waiting to be "reconciled" —
44
+ * they answer two different questions** ("run constantly, be invisible" vs
45
+ * "run once, be visible"). Changing `resolveHookShell` to this order would put
46
+ * a console window back on every tool call; changing this order to match the
47
+ * hook would take away the shell the user explicitly asked for
48
+ * (*"优先使用git bash没有才是powershell"*). Leave both alone.
49
+ *
50
+ * The Git Bash half is NOT re-implemented: `probeShell`
51
+ * (`src/services/env/shell-probe.ts`) already owns the lookup (the
52
+ * `PEAKS_GIT_BASH` pin, the default install paths, the `where bash` PATH
53
+ * sweep) and already refuses to fall back silently. This module adds the
54
+ * PowerShell step and keeps `probeShell`'s refusal visible: the resolution
55
+ * carries a `note` naming the shell that was used and, when it is not the
56
+ * first choice, why it is not. Silently using a different shell is how this
57
+ * confusion started.
58
+ */
59
+ import { spawnSync } from 'node:child_process';
60
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
61
+ import { homedir } from 'node:os';
62
+ import { dirname, join } from 'node:path';
63
+ import { getErrorMessage } from 'peaks-loop-shared/result';
64
+ import { probeShell } from '../env/shell-probe.js';
65
+ import { isProcessAlive } from '../web/daemon-registry.js';
66
+ import { resolveNpxInvocation } from './npx-resolver.js';
67
+ import { OCR_18_PACKAGE } from './ocr-multilang-adapter.js';
68
+ /**
69
+ * The network wait, named BEFORE it starts and never skipped silently.
70
+ *
71
+ * The size is not quoted as a figure the way `INSTALL_SIZE_WARNING` quotes
72
+ * chromium's 704 MiB: this package's download size is not measured here, and a
73
+ * guessed number in a warning is worse than no number. What the user needs to
74
+ * know before the block is that this touches the network and is one-time.
75
+ */
76
+ export const ACQUIRE_NETWORK_WARNING = `fetching ${OCR_18_PACKAGE} from the npm registry (needs network, one time)`;
77
+ /**
78
+ * A held lock older than this is reclaimed even if its owner is alive.
79
+ * Deliberately longer than `ACQUIRE_TIMEOUT_MS`, so a timed-out install still
80
+ * owns its lock while it winds down.
81
+ */
82
+ const ACQUIRE_LOCK_STALE_MS = 30 * 60_000;
83
+ /**
84
+ * A blocking `spawnSync` needs a ceiling or "never hang" is not true. The
85
+ * package is far smaller than chromium, so this is the web installer's 20 min
86
+ * cut down; the child is killed at this point and a partial fetch is recovered
87
+ * by simply running the verb again (npm exec is idempotent).
88
+ */
89
+ const ACQUIRE_TIMEOUT_MS = 10 * 60_000;
90
+ /** 0600 mirrors the web install lock's reasoning: ours, and nobody else's. */
91
+ const LOCK_FILE_MODE = 0o600;
92
+ /**
93
+ * `<homedir>/.peaks/ocr/install.lock` — the OCR acquisition lock.
94
+ *
95
+ * MACHINE-GLOBAL, like the web install lock and for the same reason: what it
96
+ * guards is npm's per-user exec cache (`~/.npm/_npx`, `%LOCALAPPDATA%
97
+ * \npm-cache\_npx`), which every project and every session of this user
98
+ * shares. A per-project lock would serialize nothing.
99
+ */
100
+ export function ocrAcquireLockPath() {
101
+ return join(homedir(), '.peaks', 'ocr', 'install.lock');
102
+ }
103
+ /**
104
+ * Take the OCR acquire lock (O_EXCL create), reclaiming a STALE one — dead
105
+ * owner, unreadable body, or older than `ACQUIRE_LOCK_STALE_MS`. `false` when
106
+ * another process holds a live lock.
107
+ *
108
+ * Same protocol as `web-install-service.acquireInstallLock`, deliberately
109
+ * including the R15 detail that **the reclaim is a rename, not an unlink**:
110
+ * `O_EXCL` serializes the create only if the removal before it cannot be
111
+ * replayed against a fresh file — `A unlink → A create → B unlink (A's FRESH
112
+ * lock) → B create` leaves both holders. A file can only be moved once, so
113
+ * exactly one reclaimer wins and the create that follows decides.
114
+ *
115
+ * The two copies are not shared because the web module's lock is bound to its
116
+ * own artifact root and this change does not reach into it. If a third
117
+ * acquirer appears, lift the pair into a shared helper with a lock-path
118
+ * parameter rather than writing a third copy.
119
+ */
120
+ export function acquireOcrLock() {
121
+ const target = ocrAcquireLockPath();
122
+ mkdirSync(dirname(target), { recursive: true });
123
+ if (tryCreateLock(target)) {
124
+ return ownsLock(target);
125
+ }
126
+ if (isLiveLock(readLock(target))) {
127
+ return false;
128
+ }
129
+ const claim = `${target}.reclaim-${String(process.pid)}`;
130
+ try {
131
+ renameSync(target, claim);
132
+ }
133
+ catch {
134
+ // Another reclaimer moved it first (ENOENT), or it cannot be moved.
135
+ return false;
136
+ }
137
+ if (isLiveLock(readLock(claim))) {
138
+ // We moved a lock a racer had just legitimately (re)created: put it back
139
+ // rather than steal a lock its owner is already installing under.
140
+ try {
141
+ renameSync(claim, target);
142
+ }
143
+ catch {
144
+ try {
145
+ unlinkSync(claim);
146
+ }
147
+ catch {
148
+ // Nothing left to clean up.
149
+ }
150
+ }
151
+ return false;
152
+ }
153
+ try {
154
+ unlinkSync(claim);
155
+ }
156
+ catch {
157
+ // Already gone; the create below is the decider.
158
+ }
159
+ return tryCreateLock(target) && ownsLock(target);
160
+ }
161
+ /** Release the OCR acquire lock, but only when this process is its owner. */
162
+ export function releaseOcrLock() {
163
+ const target = ocrAcquireLockPath();
164
+ const existing = readLock(target);
165
+ // An UNREADABLE body is not proof of ownership, so it is left alone: the
166
+ // worst case is one `ACQUIRE_LOCK_STALE_MS` wait, where unlinking a racer's
167
+ // half-written `wx` create would hand its lock to whoever asked next.
168
+ if (existing === null || existing.pid !== process.pid) {
169
+ return;
170
+ }
171
+ try {
172
+ unlinkSync(target);
173
+ }
174
+ catch {
175
+ // Already released (or never held).
176
+ }
177
+ }
178
+ function isLiveLock(body) {
179
+ return body !== null && isProcessAlive(body.pid) && Date.now() - Date.parse(body.startedAt) <= ACQUIRE_LOCK_STALE_MS;
180
+ }
181
+ function ownsLock(target) {
182
+ return readLock(target)?.pid === process.pid;
183
+ }
184
+ function tryCreateLock(target) {
185
+ const body = { pid: process.pid, startedAt: new Date().toISOString() };
186
+ try {
187
+ writeFileSync(target, JSON.stringify(body), { flag: 'wx', encoding: 'utf8', mode: LOCK_FILE_MODE });
188
+ return true;
189
+ }
190
+ catch {
191
+ // `EEXIST` (held) and any other write failure both mean "not acquired".
192
+ return false;
193
+ }
194
+ }
195
+ function readLock(target) {
196
+ try {
197
+ const parsed = JSON.parse(readFileSync(target, 'utf8'));
198
+ const { pid, startedAt } = parsed;
199
+ if (typeof pid !== 'number' || typeof startedAt !== 'string') {
200
+ return null;
201
+ }
202
+ return { pid, startedAt };
203
+ }
204
+ catch {
205
+ return null;
206
+ }
207
+ }
208
+ /**
209
+ * Resolve the shell the acquisition runs through: Git Bash → PowerShell →
210
+ * no shell. Never throws, and never returns a shell without a `note`.
211
+ */
212
+ export async function resolveAcquireShell(options = {}) {
213
+ const platform = options.platform ?? process.platform;
214
+ const env = options.env ?? process.env;
215
+ const probeFile = options.probeFile ?? existsSync;
216
+ const probe = await probeShell({
217
+ platform,
218
+ env,
219
+ probeFile,
220
+ ...(options.runner !== undefined ? { runner: options.runner } : {})
221
+ });
222
+ if (probe.available && probe.path !== null) {
223
+ return { kind: 'bash', path: probe.path, note: `bash: ${probe.path} (${probe.reason})` };
224
+ }
225
+ const powershell = await findPowershell(platform, env, probeFile);
226
+ if (powershell !== null) {
227
+ return {
228
+ kind: 'powershell',
229
+ path: powershell,
230
+ note: `PowerShell: ${powershell} — Git Bash is absent on this host (${probe.reason})`
231
+ };
232
+ }
233
+ return {
234
+ kind: 'direct',
235
+ path: null,
236
+ note: 'no shell: this host has neither Git Bash nor PowerShell, so npx is launched directly'
237
+ };
238
+ }
239
+ /**
240
+ * The Windows PowerShell that ships with the OS, or `null`.
241
+ *
242
+ * Its location is not searched for on PATH: `%SystemRoot%\System32\
243
+ * WindowsPowerShell\v1.0\powershell.exe` is where Windows has put it since
244
+ * Windows 7, and a PATH that cannot see it is a PATH problem the `direct`
245
+ * branch below already survives.
246
+ */
247
+ async function findPowershell(platform, env, probeFile) {
248
+ if (platform !== 'win32') {
249
+ return null;
250
+ }
251
+ const root = env['SystemRoot'] ?? env['windir'] ?? 'C:\\Windows';
252
+ const candidate = join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
253
+ return (await probeFile(candidate)) ? candidate : null;
254
+ }
255
+ /**
256
+ * The exact argv of the one-time acquisition. Exported so a unit test can
257
+ * assert the arguments WITHOUT spawning anything — an install is not a test's
258
+ * side effect (same rule as `installCommandLine`).
259
+ *
260
+ * `--yes` is what makes this non-interactive: without it `npx` stops to ask
261
+ * "Ok to proceed?" on a machine that has not cached the package, which is the
262
+ * hang this verb exists to avoid.
263
+ */
264
+ export function acquireCommandArgs() {
265
+ return ['--yes', '--package', OCR_18_PACKAGE, '--', 'ocr', 'version'];
266
+ }
267
+ /**
268
+ * The same argv as ONE shell line, for the bash / PowerShell branches.
269
+ *
270
+ * Every token is double-quoted, and that is load-bearing rather than
271
+ * cosmetic: a bare `@alibaba-group/…` is PowerShell's array syntax. Every
272
+ * token here is a module constant built from `OCR_18_PACKAGE`, so quoting is
273
+ * sufficient — no token carries a `"`, `\`, `$` or backtick that the two
274
+ * shells would escape differently, and none is caller-supplied.
275
+ */
276
+ export function acquireCommandLine() {
277
+ return ['npx', ...acquireCommandArgs()].map((token) => `"${token}"`).join(' ');
278
+ }
279
+ /**
280
+ * Run the acquisition once, under the lock. Returns an outcome on every path —
281
+ * a held lock, a non-zero exit, a timeout, a thrown spawn — so no caller ever
282
+ * sees an exception from here, and never a silent no-op.
283
+ */
284
+ export async function acquireOcr18(options = {}) {
285
+ const startedAt = Date.now();
286
+ const shell = options.shell ?? (await resolveAcquireShell());
287
+ let locked = false;
288
+ try {
289
+ locked = acquireOcrLock();
290
+ if (!locked) {
291
+ return failure('OCR18_ACQUIRE_BUSY', 'another OCR 1.8.x acquisition is already running on this machine; ' +
292
+ `the lock (${ocrAcquireLockPath()}) prevents a second download — retry once it finishes`, shell, startedAt);
293
+ }
294
+ // R2's discipline, mirrored: name the network wait BEFORE the block, on the
295
+ // channel a human reads, so the pause is never unexplained.
296
+ process.stderr.write(`peaks code-review: ${ACQUIRE_NETWORK_WARNING}\n`);
297
+ const result = runOcrAcquire(shell, options.asJson === true);
298
+ if (result.error !== undefined && result.error !== null) {
299
+ return spawnFailure(result.error, shell, startedAt);
300
+ }
301
+ if (result.status !== 0) {
302
+ return failure('OCR18_ACQUIRE_FAILED', `\`npx --package ${OCR_18_PACKAGE} -- ocr version\` exited with status ` +
303
+ `${String(result.status)} (shell: ${shell.kind})`, shell, startedAt);
304
+ }
305
+ return {
306
+ ok: true,
307
+ code: '',
308
+ message: '',
309
+ shell,
310
+ durationMs: Date.now() - startedAt,
311
+ warnings: [ACQUIRE_NETWORK_WARNING]
312
+ };
313
+ }
314
+ catch (error) {
315
+ return failure('OCR18_ACQUIRE_FAILED', getErrorMessage(error), shell, startedAt);
316
+ }
317
+ finally {
318
+ // Every path releases, including a thrown spawn — but only if THIS call
319
+ // took the lock; releasing someone else's would let a second install in.
320
+ if (locked) {
321
+ releaseOcrLock();
322
+ }
323
+ }
324
+ }
325
+ /**
326
+ * One blocking acquisition. See the module docstring for why `stdio` is the
327
+ * visibility switch and why `windowsHide` complements rather than hides it.
328
+ */
329
+ function runOcrAcquire(shell, asJson) {
330
+ const stdio = asJson ? ['ignore', 'ignore', 'inherit'] : 'inherit';
331
+ const options = { stdio, timeout: ACQUIRE_TIMEOUT_MS, windowsHide: true };
332
+ if (shell.kind === 'powershell' && shell.path !== null) {
333
+ return spawnSync(shell.path, ['-NoProfile', '-NonInteractive', '-Command', acquireCommandLine()], options);
334
+ }
335
+ if (shell.kind === 'bash' && shell.path !== null) {
336
+ return spawnSync(shell.path, ['-c', acquireCommandLine()], options);
337
+ }
338
+ // No shell at all: bypass the Windows `npx.cmd` shim the way every other
339
+ // spawn in this repo does (see `resolveNpxInvocation`). `shell: true` is NOT
340
+ // an alternative — it concatenates and splits the `--package` argv.
341
+ const invocation = resolveNpxInvocation(acquireCommandArgs());
342
+ return spawnSync(invocation.command, [...invocation.args], { ...options, env: invocation.baseEnv });
343
+ }
344
+ function spawnFailure(error, shell, startedAt) {
345
+ const code = error.code === 'ETIMEDOUT' ? 'OCR18_ACQUIRE_TIMEOUT' : 'OCR18_ACQUIRE_FAILED';
346
+ return failure(code, `the acquisition did not complete: ${error.message}`, shell, startedAt);
347
+ }
348
+ function failure(code, message, shell, startedAt) {
349
+ return { ok: false, code, message, shell, durationMs: Date.now() - startedAt, warnings: [] };
350
+ }
@@ -3,6 +3,7 @@
3
3
  * 8 supported languages to the corresponding `ocr review` filter.
4
4
  */
5
5
  import { spawnSync } from 'node:child_process';
6
+ import { resolveNpxInvocation } from './npx-resolver.js';
6
7
  export const OCR_18_PACKAGE = '@alibaba-group/open-code-review@1.8.9';
7
8
  export const OCR_18_LANGUAGES = [
8
9
  'python',
@@ -75,10 +76,18 @@ export function runOcr18(options) {
75
76
  const spawnOptions = {
76
77
  cwd: options.cwd,
77
78
  encoding: 'utf8',
79
+ // Repo convention: without this the child pops a console window on the
80
+ // user's desktop — and this child can run for the length of a full review.
81
+ windowsHide: true,
78
82
  timeout: options.timeoutMs ?? 60_000,
79
83
  maxBuffer: 32 * 1024 * 1024
80
84
  };
81
- const result = spawnSync('npx', args, spawnOptions);
85
+ // 2026-09-10: bare `spawnSync('npx', …)` cannot launch the Windows `npx.cmd`
86
+ // shim (ENOENT, no shell) — same fix and same helper as `detect-ocr-18.ts` /
87
+ // `detect-eslint.ts`. `shell: true` is NOT an option: it would concatenate the
88
+ // argv unescaped and split the `--package` flag.
89
+ const { command, args: npxArgs, baseEnv } = resolveNpxInvocation(args);
90
+ const result = spawnSync(command, npxArgs, { ...spawnOptions, env: baseEnv });
82
91
  const stdout = typeof result.stdout === 'string' ? result.stdout : '';
83
92
  const stderr = typeof result.stderr === 'string' ? result.stderr : '';
84
93
  if (result.error !== undefined && result.error !== null) {
@@ -87,7 +96,8 @@ export function runOcr18(options) {
87
96
  findings: [],
88
97
  summary: null,
89
98
  durationMs: Date.now() - start,
90
- rawOutput: stderr || stdout
99
+ // Never discard WHY the launch failed — stdout/stderr are both empty here.
100
+ rawOutput: stderr || stdout || result.error.message
91
101
  };
92
102
  }
93
103
  if (result.status !== 0) {
@@ -95,4 +95,45 @@ export declare function resolveLegacySentinels(ide: IdeId): ReadonlyArray<string
95
95
  export declare const SUPERPOWERS_DENIED_SKILLS: ReadonlyArray<string>;
96
96
  export declare function formatSuperpowersDenyEntry(skillId: string): string;
97
97
  export declare const SUPERPOWERS_DENY_SENTINELS: ReadonlySet<string>;
98
+ /**
99
+ * Adapter table: a Peaks *concept* → the settings value an EXTERNAL,
100
+ * non-Peaks PreToolUse gate must read to honour it.
101
+ *
102
+ * The concept Peaks holds is "the `.peaks/**` workspace tree is not project
103
+ * source, so 'who imports this / what schema' carries no signal there".
104
+ * Peaks already enforces it for its own hooks: slice 2.0.1-bug3 materializes
105
+ * a `Write|Edit|MultiEdit` bypass in `.claude/settings.local.json` precisely
106
+ * so the first workspace write is never fact-gated. This table is that same
107
+ * intent declared to a gate Peaks does not own.
108
+ *
109
+ * The mapped key is read by the ECC plugin's `gateguard-fact-force` hook: a
110
+ * comma-separated glob list matched against the normalized (forward-slash,
111
+ * lowercased) path, where a match skips first-touch fact-forcing. The row is
112
+ * INERT when that plugin is absent — an env var nothing reads.
113
+ *
114
+ * The key below is the ONLY occurrence of that third-party name in the source
115
+ * tree (a test pins the count). Vendor-specific translation belongs in the
116
+ * adapter layer, next to `HOOK_COMMAND_BY_IDE` and `resolveHookShell` — never
117
+ * scattered through the installer. And it is emitted into a MACHINE-LOCAL
118
+ * settings file only (see `resolveHookTargets`): a third-party variable name in
119
+ * the COMMITTED shared settings would be pushed to every consumer of this repo.
120
+ */
121
+ export declare const EXTERNAL_GATE_EXEMPT_ENV: Readonly<Record<string, string>>;
122
+ /** True when every `EXTERNAL_GATE_EXEMPT_ENV` glob is already declared in `settings.env`. */
123
+ export declare function hasExternalGateExemptions(settings: Record<string, unknown>): boolean;
124
+ /**
125
+ * Union every `EXTERNAL_GATE_EXEMPT_ENV` row into `settings.env`. An existing
126
+ * value is EXTENDED, never replaced, so a user who exempted other trees keeps
127
+ * them; unrelated `env` keys are untouched. Rows already carrying our glob are
128
+ * left byte-identical (so a re-run cannot churn the file), and the input object
129
+ * is returned unchanged when there is nothing to add. Pure.
130
+ */
131
+ export declare function withExternalGateExemptions(settings: Record<string, unknown>): Record<string, unknown>;
132
+ /**
133
+ * Inverse of `withExternalGateExemptions`: remove exactly the globs this repo
134
+ * added, keeping any the user wrote. The key is deleted once it holds nothing
135
+ * of ours, and `env` itself is dropped when it becomes empty — so uninstall
136
+ * leaves no orphan field behind. Pure.
137
+ */
138
+ export declare function withoutExternalGateExemptions(settings: Record<string, unknown>): Record<string, unknown>;
98
139
  export {};
@@ -233,3 +233,98 @@ export function formatSuperpowersDenyEntry(skillId) {
233
233
  return `UseSkill(${skillId})`;
234
234
  }
235
235
  export const SUPERPOWERS_DENY_SENTINELS = new Set(SUPERPOWERS_DENIED_SKILLS.map(formatSuperpowersDenyEntry));
236
+ // --- External (third-party) PreToolUse gate exemptions ---------------------
237
+ /**
238
+ * Adapter table: a Peaks *concept* → the settings value an EXTERNAL,
239
+ * non-Peaks PreToolUse gate must read to honour it.
240
+ *
241
+ * The concept Peaks holds is "the `.peaks/**` workspace tree is not project
242
+ * source, so 'who imports this / what schema' carries no signal there".
243
+ * Peaks already enforces it for its own hooks: slice 2.0.1-bug3 materializes
244
+ * a `Write|Edit|MultiEdit` bypass in `.claude/settings.local.json` precisely
245
+ * so the first workspace write is never fact-gated. This table is that same
246
+ * intent declared to a gate Peaks does not own.
247
+ *
248
+ * The mapped key is read by the ECC plugin's `gateguard-fact-force` hook: a
249
+ * comma-separated glob list matched against the normalized (forward-slash,
250
+ * lowercased) path, where a match skips first-touch fact-forcing. The row is
251
+ * INERT when that plugin is absent — an env var nothing reads.
252
+ *
253
+ * The key below is the ONLY occurrence of that third-party name in the source
254
+ * tree (a test pins the count). Vendor-specific translation belongs in the
255
+ * adapter layer, next to `HOOK_COMMAND_BY_IDE` and `resolveHookShell` — never
256
+ * scattered through the installer. And it is emitted into a MACHINE-LOCAL
257
+ * settings file only (see `resolveHookTargets`): a third-party variable name in
258
+ * the COMMITTED shared settings would be pushed to every consumer of this repo.
259
+ */
260
+ export const EXTERNAL_GATE_EXEMPT_ENV = Object.freeze({
261
+ // peaks' `.peaks/**` workspace tree is not project source → skip fact-forcing
262
+ GATEGUARD_EXEMPT_GLOBS: '.peaks/**'
263
+ });
264
+ function isPlainObject(value) {
265
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
266
+ }
267
+ /** Split a comma-separated glob list, dropping blanks and surrounding space. */
268
+ function splitGlobList(value) {
269
+ return value.split(',').map((glob) => glob.trim()).filter((glob) => glob.length > 0);
270
+ }
271
+ /** True when every `EXTERNAL_GATE_EXEMPT_ENV` glob is already declared in `settings.env`. */
272
+ export function hasExternalGateExemptions(settings) {
273
+ const env = isPlainObject(settings.env) ? settings.env : {};
274
+ return Object.entries(EXTERNAL_GATE_EXEMPT_ENV).every(([key, glob]) => {
275
+ const current = env[key];
276
+ return typeof current === 'string' && splitGlobList(current).includes(glob);
277
+ });
278
+ }
279
+ /**
280
+ * Union every `EXTERNAL_GATE_EXEMPT_ENV` row into `settings.env`. An existing
281
+ * value is EXTENDED, never replaced, so a user who exempted other trees keeps
282
+ * them; unrelated `env` keys are untouched. Rows already carrying our glob are
283
+ * left byte-identical (so a re-run cannot churn the file), and the input object
284
+ * is returned unchanged when there is nothing to add. Pure.
285
+ */
286
+ export function withExternalGateExemptions(settings) {
287
+ const env = isPlainObject(settings.env) ? { ...settings.env } : {};
288
+ let changed = false;
289
+ for (const [key, glob] of Object.entries(EXTERNAL_GATE_EXEMPT_ENV)) {
290
+ const current = typeof env[key] === 'string' ? env[key] : '';
291
+ const existing = splitGlobList(current);
292
+ if (existing.includes(glob))
293
+ continue;
294
+ env[key] = [...existing, glob].join(',');
295
+ changed = true;
296
+ }
297
+ return changed ? { ...settings, env } : settings;
298
+ }
299
+ /**
300
+ * Inverse of `withExternalGateExemptions`: remove exactly the globs this repo
301
+ * added, keeping any the user wrote. The key is deleted once it holds nothing
302
+ * of ours, and `env` itself is dropped when it becomes empty — so uninstall
303
+ * leaves no orphan field behind. Pure.
304
+ */
305
+ export function withoutExternalGateExemptions(settings) {
306
+ if (!isPlainObject(settings.env))
307
+ return settings;
308
+ const env = { ...settings.env };
309
+ let changed = false;
310
+ for (const [key, glob] of Object.entries(EXTERNAL_GATE_EXEMPT_ENV)) {
311
+ const current = env[key];
312
+ if (typeof current !== 'string')
313
+ continue;
314
+ const kept = splitGlobList(current).filter((entry) => entry !== glob);
315
+ changed = true;
316
+ if (kept.length > 0) {
317
+ env[key] = kept.join(',');
318
+ }
319
+ else {
320
+ delete env[key];
321
+ }
322
+ }
323
+ if (!changed)
324
+ return settings;
325
+ if (Object.keys(env).length === 0) {
326
+ const { env: _omit, ...rest } = settings;
327
+ return rest;
328
+ }
329
+ return { ...settings, env };
330
+ }
@@ -68,6 +68,20 @@ export type HookInstallOptions = {
68
68
  };
69
69
  /** Default (claude-code) hook command — kept as a stable export for tests. */
70
70
  export declare const HOOK_ENFORCE_COMMAND = "peaks gate enforce --project \"${CLAUDE_PROJECT_DIR}\" --json";
71
+ /**
72
+ * One peaks-managed entry paired with the settings file it is (or will be)
73
+ * written to.
74
+ *
75
+ * `entries` is a flat list of the same matcher/sentinel pairs, which reads as
76
+ * "written to `settingsPath`" even when the entry is routed to the other file
77
+ * (see `resolveHookTargets`). This carries the routing explicitly so the
78
+ * dry-run can name the real target without a real run.
79
+ */
80
+ export type HookEntryTarget = {
81
+ matcher: string;
82
+ sentinel: string;
83
+ settingsPath: string;
84
+ };
71
85
  export type HookInstallPlan = {
72
86
  scope: HookScope;
73
87
  settingsPath: string;
@@ -83,6 +97,8 @@ export type HookInstallPlan = {
83
97
  * machine-local). See `resolveHookTargets`.
84
98
  */
85
99
  localSettingsPath?: string;
100
+ /** Every entry the install writes, paired with its target file. */
101
+ entryTargets: ReadonlyArray<HookEntryTarget>;
86
102
  };
87
103
  export type HookInstallResult = HookInstallPlan & {
88
104
  applied: boolean;