peaks-loop 4.0.37 → 4.0.38

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.38 — 2026-09-11 (适配外部闸门 + 多语言评审复活 + 测试不再弹窗)
4
+
5
+ **Highlights**:
6
+
7
+ 1. **子代理被事先告知"先读再改",闸门不再被误判** — 外部插件(ECC)注册了一个 `PreToolUse` 闸门,会在编辑一个文件时要求 LLM 先建立事实,否则**拒绝**。问题不在闸门本身,而在它的提示词**读起来像工具坏了**——它从不说"你的编辑没有被应用",也从不点名真正的前置动作是**先读文件**。现在每个派发出去的子代理的前缀里都有一段 ~693 B 的说明,**规矩在前、范围点名、失败声明在后**:先说"编辑前先读这个文件,这是这里的正常工作方式",点名作用范围(`.peaks/**` 之外的所有路径),**最后**才说万一跳过会撞上什么、以及那不是失败、编辑未被应用。
8
+
9
+ - **代价如实记录**:这段是 7 个角色各加 694 B,大致**把 4.0.36 那一轮省下的 18% 还了回去**。这是用户明确要求的适配,它值这些字节(否则每次首次编辑都要多一轮往返),但提示词经济性的账今天倒退了,这件事应当被看见。
10
+
11
+ 2. **`.peaks/**` 豁免被自动声明** — peaks-loop **从 `2.0.1-bug3` 起就已经决定了** `.peaks/**` 不该被 fact-gate(那个切片就叫 *fact-forcing bypass*),只是外部闸门从没听说过。现在 `peaks hooks install` / `workspace init` 会把豁免写进**机器本地**的 `settings.local.json`。四条约束都做到了:**第三方变量名全仓库只出现一处**(adapter 单点映射)、**只进机器本地**(绝不进已提交的共享模板——一个第三方变量名不该推给每个消费者)、**合并而非覆盖**(用户已有的豁免取并集,无关 `env` 键保留)、范围用 **`.peaks/**` 而非 `_runtime/`**。15 个用例覆盖,含 uninstall(只剥自己的 glob)与非 Claude Code IDE(不写)。
12
+
13
+ 3. **测试套件不再往桌面弹 PowerShell 窗口** — `write-gate-decision-table.test.ts` 是全仓库唯一 spawn `powershell` 的测试,**没设 `windowsHide`**,且**对决策表每一行都 spawn 一次**。决定性的证据是它的隔壁文件:`gate-enforce-machine-local-shell.test.ts` 的开头就写明了"执行 hook 就是弹窗的原因",因此**刻意不执行**——一个文件知道并回避,另一个照做。修法是**加 `windowsHide` 但保留执行**(真的把命令跑过每个 shell 正是当初抓出 C5 `argv` 反转的原因)。6 处已修,并确认仍在真实执行(`✓ given bash … 1583ms`、`✓ given powershell … 3755ms`)。
14
+
15
+ 4. **多语言评审在 Windows 上复活** — `peaks code-review detect-ocr-18` 在一台 `npx --version` 正常返回 `11.9.0` 的机器上报 `npxAvailable: false`,于是**八种语言**(python/go/java/rust/cpp/csharp/ruby/php,经 `@alibaba-group/open-code-review`)的评审路径**在这个平台上从未可达**。三处仍在 spawn 裸 `npx`(Windows 上是 `.cmd`,无法这样启动)——而**同一个目录里 `detect-eslint.ts` 早就改成正确形式了**。现在三处都走既有的 `resolveNpxInvocation`,并且**"npx 不存在"与"启动不了"分开报**(正是这个混淆让一台正常的机器报成缺工具)。修前 `ocr18-missing / npxAvailable:false` → 修后 **`ready / npxAvailable:true`**。
16
+
17
+ 5. **`peaks hooks install --global` 此前从未成功过** — ESM 包里用了裸 `__dirname`,抛 `__dirname is not defined`,于是**每一次** `--global` 都返回失败——而且是**在 settings 已经写完之后**才失败,把半成品报成失败。同时 `--dry-run --global` **真的会往 `~/.claude/` 拷文件**。两者都已修。
18
+
19
+ **验证**:build clean、`tsc -p tsconfig.build.json` exit 0、宽口径 `tsconfig.json` 维持既有 142;全量 **179 files / 1736 passed(3 skipped)**;版本三处常量(`package.json` / `CLI_VERSION` / `RUNTIME_VERSION`)一致——4.0.37 补上的那条 sync 与那个"能真失败"的守卫当场兑现。
20
+
3
21
  ## 4.0.37 — 2026-09-10 (Windows spawn 根因 + 会话解析 + 诚实性修复)
4
22
 
5
23
  **Highlights**:
package/README-en.md CHANGED
@@ -140,7 +140,7 @@ Every lane opens with **one slash command**.
140
140
 
141
141
  | | |
142
142
  | --- | --- |
143
- | **Latest** | [![npm](https://img.shields.io/npm/v/peaks-loop?style=for-the-badge&logo=npm&logoColor=white&color=cb3837)](https://www.npmjs.com/package/peaks-loop) — 4.0.37 (2026-09-10) |
143
+ | **Latest** | [![npm](https://img.shields.io/npm/v/peaks-loop?style=for-the-badge&logo=npm&logoColor=white&color=cb3837)](https://www.npmjs.com/package/peaks-loop) — 4.0.38 (2026-09-11) |
144
144
  | **Domains** | Code (`peaks-code`) · Content (`peaks-content`) · Project health (`peaks-doctor`) · Issue sweep (`peaks-issue-fix-orchestrator`) · Custom SOP (`peaks-sop`) · Cross-domain primitives (`peaks-solo` dispatcher · `peaks-resume` · `peaks-status` · `peaks-test` · `peaks-slice-decompose`) |
145
145
  | **Sediment pool** | `~/.peaks/` local pool · twice-clean runs auto-promote to a bee · broken runs come back for you to redefine · the bee grows with your taste |
146
146
  | **Test suite** | 285+ cases · 4 packages (peaks-loop / peaks-loop-mut / peaks-loop-shared-channel / peaks-loop-shared) · **0 timeouts** · 14 BDD caller-binding edge cases |
package/README.md CHANGED
@@ -140,7 +140,7 @@ npm i -g peaks-loop
140
140
 
141
141
  | | |
142
142
  | --- | --- |
143
- | **最新版本** | [![npm](https://img.shields.io/npm/v/peaks-loop?style=for-the-badge&logo=npm&logoColor=white&color=cb3837)](https://www.npmjs.com/package/peaks-loop) — 4.0.37(2026-09-10) |
143
+ | **最新版本** | [![npm](https://img.shields.io/npm/v/peaks-loop?style=for-the-badge&logo=npm&logoColor=white&color=cb3837)](https://www.npmjs.com/package/peaks-loop) — 4.0.38(2026-09-11) |
144
144
  | **覆盖域** | 代码(`peaks-code`) · 内容(`peaks-content`) · 项目健康(`peaks-doctor`) · 批量修 issue(`peaks-issue-fix-orchestrator`) · 自定义 SOP(`peaks-sop`) · 通用原语(`peaks-solo` 分诊 / `peaks-resume` 续 / `peaks-status` 看 / `peaks-test` 测 / `peaks-slice-decompose` 切片) |
145
145
  | **沉淀池** | `~/.peaks/` 本地池 · 跑两次自动晋升成 bee · 跑翻车让你重定义 · bee 跟着你的口味长 |
146
146
  | **测试套件** | 1096 cases · 4 packages (peaks-loop 1015 / runtime 39 / mut 22 / shared-channel 20) · **CI 首次全绿**(ubuntu + windows) · 14 BDD caller-binding coverage |
@@ -1,5 +1,6 @@
1
1
  import { existsSync, copyFileSync, mkdirSync } from 'node:fs';
2
2
  import { dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
3
4
  import { fail, ok } from 'peaks-loop-shared/result';
4
5
  import { addJsonOption, printResult, getErrorMessage } from '../cli-helpers.js';
5
6
  import { findProjectRoot } from '../../services/config/config-safety.js';
@@ -7,6 +8,16 @@ import { applyHookInstall, planHookInstall, readHookStatus, readInstalledEntries
7
8
  import { readJsonObjectFile } from '../../services/ide/shared/atomic-json.js';
8
9
  import { detectIdeFromContext } from '../../services/ide/hook-translator.js';
9
10
  import { getAdapter } from '../../services/ide/ide-registry.js';
11
+ /**
12
+ * This module's own directory — `<root>/src/cli/commands` in the source tree,
13
+ * `<root>/dist/cli/commands` in a build. Same reason as
14
+ * `claude-settings-template.ts`: `package.json#type` is `module`, so the CJS
15
+ * module-directory global does not exist here (it is a ReferenceError under
16
+ * both tsx and the shipped `dist` build — see the guard in
17
+ * `tests/unit/hooks/gate-enforce-machine-local-shell.test.ts`), and
18
+ * `process.argv[1]` names a different file per entry point.
19
+ */
20
+ const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
10
21
  function resolveScope(options) {
11
22
  return options.global ? 'global' : 'project';
12
23
  }
@@ -97,12 +108,15 @@ function readOnDiskDenyEntries(settings) {
97
108
  return [];
98
109
  return deny.filter((d) => typeof d === 'string');
99
110
  }
100
- function copyBridgeHookIfPresent(userHome) {
101
- const source = resolve(__dirname, '..', '..', 'services', 'hooks', 'pre-tool-superpowers-bridge.sh');
111
+ function copyBridgeHookIfPresent(userHome, dryRun = false) {
112
+ const source = resolve(MODULE_DIR, '..', '..', 'services', 'hooks', 'pre-tool-superpowers-bridge.sh');
102
113
  const target = resolve(userHome, '.claude', 'skills', 'peaks-code', 'hooks', 'pre-tool-superpowers-bridge.sh');
103
114
  if (!existsSync(source)) {
104
115
  return { copied: false, source, target };
105
116
  }
117
+ if (dryRun) {
118
+ return { copied: true, source, target };
119
+ }
106
120
  mkdirSync(dirname(target), { recursive: true });
107
121
  copyFileSync(source, target);
108
122
  return { copied: true, source, target };
@@ -117,12 +131,15 @@ function copyBridgeHookIfPresent(userHome) {
117
131
  * runtime entry; the shell script is the canonical artifact distributed
118
132
  * alongside the bridge hook.
119
133
  */
120
- function copyCodeGateHookIfPresent(userHome) {
121
- const source = resolve(__dirname, '..', '..', 'services', 'hooks', 'pre-tool-code-gate.sh');
134
+ function copyCodeGateHookIfPresent(userHome, dryRun = false) {
135
+ const source = resolve(MODULE_DIR, '..', '..', 'services', 'hooks', 'pre-tool-code-gate.sh');
122
136
  const target = resolve(userHome, '.claude', 'skills', 'peaks-code', 'hooks', 'pre-tool-code-gate.sh');
123
137
  if (!existsSync(source)) {
124
138
  return { copied: false, source, target };
125
139
  }
140
+ if (dryRun) {
141
+ return { copied: true, source, target };
142
+ }
126
143
  mkdirSync(dirname(target), { recursive: true });
127
144
  copyFileSync(source, target);
128
145
  return { copied: true, source, target };
@@ -147,8 +164,10 @@ export function registerHooksCommands(program, io) {
147
164
  if (options.dryRun === true) {
148
165
  const plan = planHookInstall(scope, projectRoot, { ide, skipProgress });
149
166
  const dryRunEntries = listExpectedEntriesForIde(ide, skipProgress);
167
+ // `dryRun: true` — a --dry-run must not write anything, and these
168
+ // helpers' target is the user's home directory.
150
169
  const bridgeCopy = scope === 'global'
151
- ? copyBridgeHookIfPresent(process.env.USERPROFILE ?? process.env.HOME ?? '')
170
+ ? copyBridgeHookIfPresent(process.env.USERPROFILE ?? process.env.HOME ?? '', true)
152
171
  : { copied: false, source: '', target: '' };
153
172
  // Slice 2026-08-06-codegate-vendor-neutral: also copy the
154
173
  // code-gate hook script. The runtime gate lives at
@@ -156,7 +175,7 @@ export function registerHooksCommands(program, io) {
156
175
  // in the install output); the script is the build artifact
157
176
  // distributed alongside the bridge hook.
158
177
  const codeGateCopy = scope === 'global'
159
- ? copyCodeGateHookIfPresent(process.env.USERPROFILE ?? process.env.HOME ?? '')
178
+ ? copyCodeGateHookIfPresent(process.env.USERPROFILE ?? process.env.HOME ?? '', true)
160
179
  : { copied: false, source: '', target: '' };
161
180
  printResult(io, ok('hooks.install', {
162
181
  ...plan,
@@ -176,6 +195,12 @@ export function registerHooksCommands(program, io) {
176
195
  permissionsDenyEntries: listSuperpowersDenyEntries()
177
196
  }, [], [
178
197
  `would install ${dryRunEntries.length} peaks-managed hook entries`,
198
+ // Name the target file of every entry: the gate-enforce entry is
199
+ // routed to the machine-local settings file (see
200
+ // `resolveHookTargets`), so a summary that only listed
201
+ // `settingsPath` + the entry names read as if it landed in the
202
+ // committed, shared file.
203
+ ...plan.entryTargets.map((entry) => `would write ${entry.matcher || '(no matcher)'} → ${entry.sentinel} to ${entry.settingsPath}`),
179
204
  `would write ${listSuperpowersDenyEntries().length} permissions.deny entries (Layer 3 worktree governance)`,
180
205
  bridgeCopy.copied
181
206
  ? `would copy bridge hook from ${bridgeCopy.source} to ${bridgeCopy.target}`
@@ -150,6 +150,40 @@ export declare const LIFECYCLE_RULES = "## Sub-agent lifecycle rules (locked 202
150
150
  * the ones the orchestrator must have to decide the next gate.
151
151
  */
152
152
  export declare const REPORT_CAP_BLOCK = "## Final report cap (mandatory)\n\nYour FINAL report to the parent MUST be \u2264 40 lines and \u2264 2 KB. Write any longer detail into the artifact file you already own \u2014 the parent can `Read` that file for the full detail, so nothing is lost. The report itself MUST still carry: changed files (one line each), the exact commands you ran, pass/fail counts, tsc status, and any blocker. Do NOT paste file contents, full tool output, or logs into the report.\n";
153
+ /**
154
+ * Slice 2026-09-10-fact-force-gate-adaptation: stand IN FRONT of an external
155
+ * `PreToolUse` gate instead of explaining its denial after the fact.
156
+ *
157
+ * ECC (a third-party plugin under `~/.claude/plugins/`) registers
158
+ * `gateguard-fact-force.js` on `Edit|Write|MultiEdit`. It denies the first
159
+ * edit of a file whose facts the agent has not established, and its four-item
160
+ * message never says two things the agent needs: that the file must be READ
161
+ * first, and that the edit was NOT applied. Measured failure mode
162
+ * (session 2026-09-10-session-528a63): the sub-agent reads the denial as
163
+ * "the tool is broken" and abandons the edit.
164
+ *
165
+ * So the block below leads with the STANDING RULE (read before you edit) and
166
+ * names the paths it covers, and mentions the denial only as the consequence
167
+ * of skipping that rule. A sub-agent that has never seen the gate can read
168
+ * this once and never trip it.
169
+ *
170
+ * The gate itself is untouched: peaks-loop adapts to it, and does NOT disable,
171
+ * bypass, or re-implement it. Nor is `ECC_GATEGUARD=off` part of this.
172
+ *
173
+ * Always rendered — no opt-out flag and no role split. Every role edits files
174
+ * outside `.peaks/**`, and only sub-agent #1 of a session sees a denial (the
175
+ * gate fires once per file), so a role-scoped or opt-in block would leave the
176
+ * rest of the fleet untold. It joins the stable boilerplate prefix: constant
177
+ * bytes for every dispatch, prompt-cache friendly.
178
+ *
179
+ * The `.peaks/**` exemption is real and pre-dates this slice — peaks-loop
180
+ * materialises `.claude/settings.local.json` so the gate skips `.peaks/**`
181
+ * (slice 2.0.1-bug3-fact-forcing-bypass; see
182
+ * `src/cli/commands/workspace/init-command.ts`).
183
+ */
184
+ export declare const FACT_FORCE_GATE_BLOCK = "## Read before you edit (Fact-Forcing Gate)\n\nRead a file BEFORE your first `Edit` / `Write` / `MultiEdit` on it \u2014 the normal way to work here, not an optional step. It applies to every path OUTSIDE `.peaks/**` (source, tests, docs, config); `.peaks/**` writes are exempt.\n\nSkipping that read trips a `PreToolUse` plugin gate (ECC's \"Fact-Forcing Gate\"), which denies the edit. A denial is NOT a failure and the tool is NOT broken \u2014 your edit was NOT applied. Read the file, state the four facts the gate asks for (importers, affected API, data schemas if any, the user's verbatim instruction), then retry the same operation. Do not switch tools, do not give up, do not re-attempt blindly.\n";
185
+ /** Always-on renderer for {@link FACT_FORCE_GATE_BLOCK}. */
186
+ export declare function renderFactForceGateBlock(): string;
153
187
  /**
154
188
  * Compose the system-prompt body for a sub-agent dispatch.
155
189
  *
@@ -161,7 +195,7 @@ export declare const REPORT_CAP_BLOCK = "## Final report cap (mandatory)\n\nYour
161
195
  * Byte-identical degradation contract (slice 2026-07-22-orchestrator-memory-preflight
162
196
  * controller brief): when the memory block is unavailable, the composed body is
163
197
  * exactly `formatTestToolDetection() + "\n\n" + L1 + "\n" + LIFECYCLE +
164
- * "\n" + REPORT_CAP + "\n" + contextBlock + taskBody`, so the unavailable
198
+ * "\n" + REPORT_CAP + "\n" + FACT_FORCE_GATE + "\n" + contextBlock + taskBody`, so the unavailable
165
199
  * branch MUST return `taskBody` unwrapped (NOT a `# title\n\n` wrap).
166
200
  * (REPORT_CAP joined the stable prefix in slice
167
201
  * 2026-09-10-context-audit-and-discipline, Slice C.)
@@ -93,6 +93,47 @@ export const REPORT_CAP_BLOCK = `## Final report cap (mandatory)
93
93
 
94
94
  Your FINAL report to the parent MUST be ≤ 40 lines and ≤ 2 KB. Write any longer detail into the artifact file you already own — the parent can \`Read\` that file for the full detail, so nothing is lost. The report itself MUST still carry: changed files (one line each), the exact commands you ran, pass/fail counts, tsc status, and any blocker. Do NOT paste file contents, full tool output, or logs into the report.
95
95
  `;
96
+ /**
97
+ * Slice 2026-09-10-fact-force-gate-adaptation: stand IN FRONT of an external
98
+ * `PreToolUse` gate instead of explaining its denial after the fact.
99
+ *
100
+ * ECC (a third-party plugin under `~/.claude/plugins/`) registers
101
+ * `gateguard-fact-force.js` on `Edit|Write|MultiEdit`. It denies the first
102
+ * edit of a file whose facts the agent has not established, and its four-item
103
+ * message never says two things the agent needs: that the file must be READ
104
+ * first, and that the edit was NOT applied. Measured failure mode
105
+ * (session 2026-09-10-session-528a63): the sub-agent reads the denial as
106
+ * "the tool is broken" and abandons the edit.
107
+ *
108
+ * So the block below leads with the STANDING RULE (read before you edit) and
109
+ * names the paths it covers, and mentions the denial only as the consequence
110
+ * of skipping that rule. A sub-agent that has never seen the gate can read
111
+ * this once and never trip it.
112
+ *
113
+ * The gate itself is untouched: peaks-loop adapts to it, and does NOT disable,
114
+ * bypass, or re-implement it. Nor is `ECC_GATEGUARD=off` part of this.
115
+ *
116
+ * Always rendered — no opt-out flag and no role split. Every role edits files
117
+ * outside `.peaks/**`, and only sub-agent #1 of a session sees a denial (the
118
+ * gate fires once per file), so a role-scoped or opt-in block would leave the
119
+ * rest of the fleet untold. It joins the stable boilerplate prefix: constant
120
+ * bytes for every dispatch, prompt-cache friendly.
121
+ *
122
+ * The `.peaks/**` exemption is real and pre-dates this slice — peaks-loop
123
+ * materialises `.claude/settings.local.json` so the gate skips `.peaks/**`
124
+ * (slice 2.0.1-bug3-fact-forcing-bypass; see
125
+ * `src/cli/commands/workspace/init-command.ts`).
126
+ */
127
+ export const FACT_FORCE_GATE_BLOCK = `## Read before you edit (Fact-Forcing Gate)
128
+
129
+ Read a file BEFORE your first \`Edit\` / \`Write\` / \`MultiEdit\` on it — the normal way to work here, not an optional step. It applies to every path OUTSIDE \`.peaks/**\` (source, tests, docs, config); \`.peaks/**\` writes are exempt.
130
+
131
+ Skipping that read trips a \`PreToolUse\` plugin gate (ECC's "Fact-Forcing Gate"), which denies the edit. A denial is NOT a failure and the tool is NOT broken — your edit was NOT applied. Read the file, state the four facts the gate asks for (importers, affected API, data schemas if any, the user's verbatim instruction), then retry the same operation. Do not switch tools, do not give up, do not re-attempt blindly.
132
+ `;
133
+ /** Always-on renderer for {@link FACT_FORCE_GATE_BLOCK}. */
134
+ export function renderFactForceGateBlock() {
135
+ return `${FACT_FORCE_GATE_BLOCK}\n`;
136
+ }
96
137
  /**
97
138
  * Compose the system-prompt body for a sub-agent dispatch.
98
139
  *
@@ -104,7 +145,7 @@ Your FINAL report to the parent MUST be ≤ 40 lines and ≤ 2 KB. Write any lon
104
145
  * Byte-identical degradation contract (slice 2026-07-22-orchestrator-memory-preflight
105
146
  * controller brief): when the memory block is unavailable, the composed body is
106
147
  * exactly `formatTestToolDetection() + "\n\n" + L1 + "\n" + LIFECYCLE +
107
- * "\n" + REPORT_CAP + "\n" + contextBlock + taskBody`, so the unavailable
148
+ * "\n" + REPORT_CAP + "\n" + FACT_FORCE_GATE + "\n" + contextBlock + taskBody`, so the unavailable
108
149
  * branch MUST return `taskBody` unwrapped (NOT a `# title\n\n` wrap).
109
150
  * (REPORT_CAP joined the stable prefix in slice
110
151
  * 2026-09-10-context-audit-and-discipline, Slice C.)
@@ -128,15 +169,17 @@ export function buildDispatchSystemPrompt(input) {
128
169
  // for every role — the composer owns the injection so callers MUST NOT
129
170
  // prepend `formatTestToolDetection()` themselves (double injection).
130
171
  const testToolText = `${formatTestToolDetection()}\n\n`;
172
+ // 2026-09-10-fact-force-gate-adaptation: always-on, both branches.
173
+ const factForceGateText = renderFactForceGateBlock();
131
174
  const contextBlock = renderContextBlock(contextProbe ?? null);
132
175
  const codegraphText = renderCodegraphBlock(codegraphBlock);
133
176
  const projectStackText = renderProjectStackBlock(projectStackBlock);
134
177
  const freshContextText = renderFreshContextBlock(freshContextBlock);
135
178
  const capsuleText = renderCapsulePointer(capsule);
136
179
  if (memoryBlock.available === true && typeof memoryBlock.block === 'string') {
137
- return `${testToolText}${L1_WORKTREE_GOVERNANCE_BLOCK}\n${LIFECYCLE_RULES}\n${REPORT_CAP_BLOCK}\n${contextBlock}${codegraphText}${projectStackText}${freshContextText}${capsuleText}${memoryBlock.block}\n## Task\n${taskBody}`;
180
+ return `${testToolText}${L1_WORKTREE_GOVERNANCE_BLOCK}\n${LIFECYCLE_RULES}\n${REPORT_CAP_BLOCK}\n${factForceGateText}${contextBlock}${codegraphText}${projectStackText}${freshContextText}${capsuleText}${memoryBlock.block}\n## Task\n${taskBody}`;
138
181
  }
139
- return `${testToolText}${L1_WORKTREE_GOVERNANCE_BLOCK}\n${LIFECYCLE_RULES}\n${REPORT_CAP_BLOCK}\n${contextBlock}${codegraphText}${projectStackText}${freshContextText}${capsuleText}${taskBody}`;
182
+ return `${testToolText}${L1_WORKTREE_GOVERNANCE_BLOCK}\n${LIFECYCLE_RULES}\n${REPORT_CAP_BLOCK}\n${factForceGateText}${contextBlock}${codegraphText}${projectStackText}${freshContextText}${capsuleText}${taskBody}`;
140
183
  }
141
184
  /**
142
185
  * Slice 2026-09-10-dispatch-token-and-swarm §4 — session capsule pointer.
@@ -294,6 +337,15 @@ export const BINDING_RULE_TOKENS = [
294
337
  'changed files (one line each)',
295
338
  'pass/fail counts',
296
339
  'tsc status',
340
+ // fact-forcing gate (slice 2026-09-10-fact-force-gate-adaptation)
341
+ '## Read before you edit (Fact-Forcing Gate)',
342
+ 'Read a file BEFORE your first `Edit` / `Write` / `MultiEdit` on it',
343
+ 'every path OUTSIDE `.peaks/**`',
344
+ '`PreToolUse` plugin gate',
345
+ 'A denial is NOT a failure and the tool is NOT broken',
346
+ 'your edit was NOT applied',
347
+ 'retry the same operation',
348
+ 'do not re-attempt blindly',
297
349
  // test scope — ONE unified block, byte-identical for EVERY role
298
350
  '## Test Tool Detection (mandatory)',
299
351
  '`package.json#scripts.test`',
@@ -7,4 +7,6 @@ export type Ocr18DetectResult = {
7
7
  readonly warnings: readonly string[];
8
8
  readonly nextActions: readonly string[];
9
9
  };
10
+ /** Named code for "npx itself could not be launched" — distinct from "npx is absent". */
11
+ export declare const NPX_PROBE_UNRESOLVED_CODE = "NPX_PROBE_UNRESOLVED";
10
12
  export declare function detectOcr18(): Ocr18DetectResult;
@@ -2,22 +2,53 @@
2
2
  * 5-state OCR 1.8.x detect. Mirrors the ECC detect shape.
3
3
  */
4
4
  import { spawnSync } from 'node:child_process';
5
+ import { resolveNpxInvocation } from './npx-resolver.js';
5
6
  import { OCR_18_PACKAGE } from './ocr-multilang-adapter.js';
7
+ /** Named code for "npx itself could not be launched" — distinct from "npx is absent". */
8
+ export const NPX_PROBE_UNRESOLVED_CODE = 'NPX_PROBE_UNRESOLVED';
9
+ // 2026-09-10: `npx` on Windows is an `npx.cmd` shim, which Node refuses to spawn
10
+ // without `shell: true` — a bare `spawnSync('npx', …)` failed with ENOENT and was
11
+ // then reported as "npx is not on PATH" on machines where `npx --version` exits 0.
12
+ // The shim is bypassed through `resolveNpxInvocation` (same helper as
13
+ // `detect-eslint.ts`), and a launch failure is reported as its OWN reason rather
14
+ // than being collapsed into "absent".
6
15
  function probeNpx() {
7
- const probe = spawnSync('npx', ['--version'], { encoding: 'utf8' });
8
- return probe.status === 0;
16
+ const { command, args, baseEnv } = resolveNpxInvocation(['--version']);
17
+ const probe = spawnSync(command, args, { encoding: 'utf8', env: baseEnv });
18
+ if (probe.status === 0)
19
+ return { available: true };
20
+ const error = probe.error;
21
+ if (error !== undefined && error !== null) {
22
+ // `command !== 'npx'` ⇒ the resolver located a real npx CLI entry and it STILL
23
+ // could not be launched — a different failure from "npx is not on PATH".
24
+ return command !== 'npx'
25
+ ? { available: false, reason: 'not-launchable', detail: error.message }
26
+ : { available: false, reason: 'not-on-path', detail: error.message };
27
+ }
28
+ return { available: false, reason: 'probe-failed', detail: `npx --version exited ${probe.status ?? 'null'}` };
9
29
  }
10
30
  function probeOcr18() {
11
- const result = spawnSync('npx', ['--package', OCR_18_PACKAGE, '--', 'ocr', 'version'], { encoding: 'utf8' });
31
+ const { command, args, baseEnv } = resolveNpxInvocation(['--package', OCR_18_PACKAGE, '--', 'ocr', 'version']);
32
+ const result = spawnSync(command, args, { encoding: 'utf8', env: baseEnv });
12
33
  return result.status === 0;
13
34
  }
14
35
  export function detectOcr18() {
15
- if (!probeNpx()) {
36
+ const probe = probeNpx();
37
+ if (!probe.available) {
38
+ if (probe.reason === 'not-launchable') {
39
+ return {
40
+ state: 'detection-failed',
41
+ npxAvailable: false,
42
+ package: OCR_18_PACKAGE,
43
+ warnings: [`${NPX_PROBE_UNRESOLVED_CODE}: could not launch npx to probe (${probe.detail}).`],
44
+ nextActions: ['Ensure Node.js >= 20 with its bundled npm is installed; `npx --version` must succeed.']
45
+ };
46
+ }
16
47
  return {
17
48
  state: 'ocr18-missing',
18
49
  npxAvailable: false,
19
50
  package: OCR_18_PACKAGE,
20
- warnings: ['npx is not on PATH'],
51
+ warnings: [probe.reason === 'not-on-path' ? 'npx is not on PATH' : probe.detail],
21
52
  nextActions: ['Install Node.js ≥ 20 with npm to enable `npx --package`.']
22
53
  };
23
54
  }
@@ -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',
@@ -78,7 +79,12 @@ export function runOcr18(options) {
78
79
  timeout: options.timeoutMs ?? 60_000,
79
80
  maxBuffer: 32 * 1024 * 1024
80
81
  };
81
- const result = spawnSync('npx', args, spawnOptions);
82
+ // 2026-09-10: bare `spawnSync('npx', …)` cannot launch the Windows `npx.cmd`
83
+ // shim (ENOENT, no shell) — same fix and same helper as `detect-ocr-18.ts` /
84
+ // `detect-eslint.ts`. `shell: true` is NOT an option: it would concatenate the
85
+ // argv unescaped and split the `--package` flag.
86
+ const { command, args: npxArgs, baseEnv } = resolveNpxInvocation(args);
87
+ const result = spawnSync(command, npxArgs, { ...spawnOptions, env: baseEnv });
82
88
  const stdout = typeof result.stdout === 'string' ? result.stdout : '';
83
89
  const stderr = typeof result.stderr === 'string' ? result.stderr : '';
84
90
  if (result.error !== undefined && result.error !== null) {
@@ -87,7 +93,8 @@ export function runOcr18(options) {
87
93
  findings: [],
88
94
  summary: null,
89
95
  durationMs: Date.now() - start,
90
- rawOutput: stderr || stdout
96
+ // Never discard WHY the launch failed — stdout/stderr are both empty here.
97
+ rawOutput: stderr || stdout || result.error.message
91
98
  };
92
99
  }
93
100
  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;
@@ -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 } from './hooks-codegate-superpowers.js';
7
+ import { resolveHookSpec, resolveHookEntries, 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
@@ -67,16 +67,30 @@ function resolveLocalSettingsPath(scope, ide, projectRoot) {
67
67
  function resolveHookTargets(scope, ide, projectRoot) {
68
68
  const sharedPath = resolveSettingsPath(scope, ide, projectRoot);
69
69
  const localPath = resolveLocalSettingsPath(scope, ide, projectRoot);
70
- const shared = { settingsPath: sharedPath, entries: [] };
70
+ const wantsEnvExemptions = ide === 'claude-code';
71
71
  if (localPath === undefined || localPath === sharedPath) {
72
- return [{ settingsPath: sharedPath, entries: [...resolveHookEntries(ide)] }];
72
+ // Global scope lands here: the user-level file is already machine-local,
73
+ // so it is the safe target (there is no sibling to prefer).
74
+ return [{ settingsPath: sharedPath, entries: [...resolveHookEntries(ide)], envExemptions: wantsEnvExemptions }];
73
75
  }
74
- const local = { settingsPath: localPath, entries: [] };
76
+ const shared = { settingsPath: sharedPath, entries: [] };
77
+ const local = { settingsPath: localPath, entries: [], envExemptions: wantsEnvExemptions };
75
78
  for (const entry of resolveHookEntries(ide)) {
76
79
  (entry.machineLocal === true ? local : shared).entries.push(entry);
77
80
  }
78
81
  return [shared, local];
79
82
  }
83
+ /** True when `target` already holds everything the install would write to it. */
84
+ function targetIsSatisfied(target, allSentinels) {
85
+ const settings = readSettingsFile(target.settingsPath);
86
+ if (!shapeMatchesDesired(settings, target.entries, allSentinels))
87
+ return false;
88
+ return target.envExemptions !== true || hasExternalGateExemptions(settings);
89
+ }
90
+ /** Flatten the resolved targets into one `{ matcher, sentinel, settingsPath }` row per entry. */
91
+ function describeEntryTargets(targets) {
92
+ return targets.flatMap((target) => target.entries.map((entry) => ({ matcher: entry.matcher, sentinel: entry.sentinel, settingsPath: target.settingsPath })));
93
+ }
80
94
  /** Read a settings file as an object, or `{}` when it does not exist yet. */
81
95
  function readSettingsFile(settingsPath) {
82
96
  return existsSync(settingsPath) ? readJsonObjectFile(settingsPath) : {};
@@ -307,11 +321,17 @@ export function planHookInstall(scope, projectRoot, options) {
307
321
  scope,
308
322
  settingsPath,
309
323
  exists,
310
- alreadyInstalled: targets.every((t) => isInstalledForEntries(readSettingsFile(t.settingsPath), t.entries)),
324
+ // The env clause mirrors `applyHookInstall`'s: without it the dry-run would
325
+ // claim "nothing to do" about a file the install is going to write.
326
+ alreadyInstalled: targets.every((t) => {
327
+ const settings = readSettingsFile(t.settingsPath);
328
+ return isInstalledForEntries(settings, t.entries) && (t.envExemptions !== true || hasExternalGateExemptions(settings));
329
+ }),
311
330
  desiredCommand: spec.hookEnforceCommand,
312
331
  sentinel: spec.hookEnforceSentinel,
313
332
  matcher: spec.hookEnforceMatcher,
314
- ...(localTarget !== undefined ? { localSettingsPath: localTarget.settingsPath } : {})
333
+ ...(localTarget !== undefined ? { localSettingsPath: localTarget.settingsPath } : {}),
334
+ entryTargets: describeEntryTargets(targets)
315
335
  };
316
336
  }
317
337
  /**
@@ -379,7 +399,10 @@ export function applyHookInstall(scope, projectRoot, options) {
379
399
  // as not-yet-installed, so the merge strips the stale entry on the
380
400
  // next install call. This is the only path that converges the file
381
401
  // on the new shape; pure presence-checks are insufficient.
382
- const alreadyInstalled = targets.every((t) => shapeMatchesDesired(readSettingsFile(t.settingsPath), t.entries, allSentinels));
402
+ //
403
+ // The external-gate exemption is part of the desired shape too, so a project
404
+ // installed by a release that predates it still converges on upgrade.
405
+ const alreadyInstalled = targets.every((t) => targetIsSatisfied(t, allSentinels));
383
406
  const baseResult = {
384
407
  scope,
385
408
  settingsPath,
@@ -388,7 +411,8 @@ export function applyHookInstall(scope, projectRoot, options) {
388
411
  desiredCommand: spec.hookEnforceCommand,
389
412
  sentinel: spec.hookEnforceSentinel,
390
413
  matcher: spec.hookEnforceMatcher,
391
- ...(localTarget !== undefined ? { localSettingsPath: localTarget.settingsPath } : {})
414
+ ...(localTarget !== undefined ? { localSettingsPath: localTarget.settingsPath } : {}),
415
+ entryTargets: describeEntryTargets(targets)
392
416
  };
393
417
  if (baseResult.alreadyInstalled) {
394
418
  return { ...baseResult, applied: false };
@@ -409,9 +433,14 @@ export function applyHookInstall(scope, projectRoot, options) {
409
433
  //
410
434
  // The `permissions.deny` block lives in the adapter's settings file
411
435
  // only (as it always has) — it is committed, so the deny list is
412
- // shared; the machine-local file carries hook entries only.
436
+ // shared; the machine-local file carries hook entries plus, for Claude
437
+ // Code, the third-party gate exemptions (machine-local by the same
438
+ // argument as the hook `shell` pin).
413
439
  for (const target of targets) {
414
- const next = withHooksInstalled(readSettingsFile(target.settingsPath), target.entries, allSentinels);
440
+ let next = withHooksInstalled(readSettingsFile(target.settingsPath), target.entries, allSentinels);
441
+ if (target.envExemptions === true) {
442
+ next = withExternalGateExemptions(next);
443
+ }
415
444
  const merged = target.settingsPath === settingsPath
416
445
  ? withTriggeredDenyList(withSuperpowersSkillDenylist(next))
417
446
  : next;
@@ -478,9 +507,13 @@ export function removeHookInstall(scope, projectRoot, options) {
478
507
  // trigger-style deny entries via withoutTriggeredDenyList. The
479
508
  // chain of helpers is order-independent (each is idempotent and
480
509
  // additive over the same set of peaks-managed entries).
481
- const finalSettings = target.settingsPath === settingsPath
482
- ? withoutTriggeredDenyList(withoutSuperpowersSkillDenylist(nextSettings))
483
- : nextSettings;
510
+ let finalSettings = nextSettings;
511
+ if (target.envExemptions === true) {
512
+ finalSettings = withoutExternalGateExemptions(finalSettings);
513
+ }
514
+ if (target.settingsPath === settingsPath) {
515
+ finalSettings = withoutTriggeredDenyList(withoutSuperpowersSkillDenylist(finalSettings));
516
+ }
484
517
  atomicWriteJson(target.settingsPath, finalSettings);
485
518
  }
486
519
  return {
@@ -75,14 +75,23 @@ export declare const CLAUDE_SETTINGS_LOCAL_FILENAME = ".claude/settings.local.js
75
75
  * string carries no shell-escaped payload at all and the handler
76
76
  * can take the same platform `shell` pin as its siblings. The
77
77
  * decision itself is a verbatim relocation — see that file.
78
+ * 1.7.0 — added the `env` block declaring Peaks' workspace tree exempt
79
+ * from a THIRD-PARTY PreToolUse fact-forcing gate
80
+ * (`EXTERNAL_GATE_EXEMPT_ENV`). The comparator now requires the
81
+ * on-disk file to declare those exemptions too, so a project
82
+ * installed by an earlier release refreshes once and converges.
78
83
  */
79
- export declare const TEMPLATE_VERSION = "1.6.0";
84
+ export declare const TEMPLATE_VERSION = "1.7.0";
80
85
  /**
81
- * Compare two serialized template strings for semantic equivalence.
86
+ * Compare two serialized template strings for semantic equivalence: does the
87
+ * on-disk file already declare everything the generated template declares?
82
88
  *
83
89
  * Returns `true` iff both strings parse to objects whose
84
90
  * `hooks.PreToolUse` arrays are structurally identical (same length;
85
- * each entry's `matcher`, `hooks[].type`, `hooks[].command` match).
91
+ * each entry's `matcher`, `hooks[].type`, `hooks[].command` match) AND the
92
+ * on-disk `env` already carries every exemption the template declares (extra
93
+ * on-disk keys and extra globs are allowed — a user may exempt other trees,
94
+ * and a requirement the file already exceeds must not re-trigger a write).
86
95
  *
87
96
  * Returns `false` on any `JSON.parse` error, shape mismatch, or
88
97
  * missing `hooks.PreToolUse`. Whitespace and key order do NOT affect
@@ -120,6 +129,13 @@ type ClaudeSettingsLocal = {
120
129
  hooks: {
121
130
  PreToolUse: ClaudePreToolUseEntry[];
122
131
  };
132
+ /**
133
+ * Exemptions declared to the third-party PreToolUse gate peaks does not own
134
+ * (`EXTERNAL_GATE_EXEMPT_ENV`). They belong in THIS file because it is
135
+ * machine-local and gitignored: a third-party variable name in the committed
136
+ * shared `settings.json` would be pushed to every consumer of the project.
137
+ */
138
+ env: Record<string, string>;
123
139
  };
124
140
  /**
125
141
  * Build the full template object. The shape is the subset of Claude
@@ -35,7 +35,7 @@
35
35
  */
36
36
  import { dirname, resolve } from 'node:path';
37
37
  import { fileURLToPath } from 'node:url';
38
- import { resolveHookShell, resolveHookSpec } from '../skills/hooks-codegate-superpowers.js';
38
+ import { EXTERNAL_GATE_EXEMPT_ENV, hasExternalGateExemptions, resolveHookShell, resolveHookSpec } from '../skills/hooks-codegate-superpowers.js';
39
39
  export const CLAUDE_SETTINGS_LOCAL_FILENAME = '.claude/settings.local.json';
40
40
  /**
41
41
  * Informational version of the offline template shape. Bumped when the
@@ -78,14 +78,23 @@ export const CLAUDE_SETTINGS_LOCAL_FILENAME = '.claude/settings.local.json';
78
78
  * string carries no shell-escaped payload at all and the handler
79
79
  * can take the same platform `shell` pin as its siblings. The
80
80
  * decision itself is a verbatim relocation — see that file.
81
+ * 1.7.0 — added the `env` block declaring Peaks' workspace tree exempt
82
+ * from a THIRD-PARTY PreToolUse fact-forcing gate
83
+ * (`EXTERNAL_GATE_EXEMPT_ENV`). The comparator now requires the
84
+ * on-disk file to declare those exemptions too, so a project
85
+ * installed by an earlier release refreshes once and converges.
81
86
  */
82
- export const TEMPLATE_VERSION = '1.6.0';
87
+ export const TEMPLATE_VERSION = '1.7.0';
83
88
  /**
84
- * Compare two serialized template strings for semantic equivalence.
89
+ * Compare two serialized template strings for semantic equivalence: does the
90
+ * on-disk file already declare everything the generated template declares?
85
91
  *
86
92
  * Returns `true` iff both strings parse to objects whose
87
93
  * `hooks.PreToolUse` arrays are structurally identical (same length;
88
- * each entry's `matcher`, `hooks[].type`, `hooks[].command` match).
94
+ * each entry's `matcher`, `hooks[].type`, `hooks[].command` match) AND the
95
+ * on-disk `env` already carries every exemption the template declares (extra
96
+ * on-disk keys and extra globs are allowed — a user may exempt other trees,
97
+ * and a requirement the file already exceeds must not re-trigger a write).
89
98
  *
90
99
  * Returns `false` on any `JSON.parse` error, shape mismatch, or
91
100
  * missing `hooks.PreToolUse`. Whitespace and key order do NOT affect
@@ -127,7 +136,12 @@ export function templateContentMatches(generated, onDisk) {
127
136
  return false;
128
137
  }
129
138
  }
130
- return true;
139
+ // A project installed by a release that predates a template-declared
140
+ // exemption still needs the refresh this comparator gates — otherwise the
141
+ // entry would only ever appear on a machine that re-ran `peaks hooks
142
+ // install`. `hasExternalGateExemptions` is the same predicate the installer
143
+ // uses, so the two writers cannot drift apart.
144
+ return hasExternalGateExemptions({ env: parsedOnDisk.env });
131
145
  }
132
146
  function isTemplateShape(value) {
133
147
  if (typeof value !== 'object' || value === null) {
@@ -234,6 +248,12 @@ export function buildClaudeSettingsLocalJson() {
234
248
  // the key entirely.
235
249
  const writeShell = resolveHookShell();
236
250
  return {
251
+ // Slice emit-gateguard-exemption — the third-party gate exemption. Peaks
252
+ // already bypasses its OWN fact-forcing gate for `.peaks/**` (the
253
+ // Write|Edit|MultiEdit handler below); this is the same intent declared in
254
+ // the currency an external PreToolUse gate reads. `peaks hooks install`
255
+ // merges the same row into this file, so the two writers agree.
256
+ env: { ...EXTERNAL_GATE_EXEMPT_ENV },
237
257
  hooks: {
238
258
  PreToolUse: [
239
259
  {
@@ -9,10 +9,37 @@
9
9
  * the parent module and calls into this sibling. Function signatures
10
10
  * and behaviour are unchanged (verbatim move).
11
11
  */
12
- import { existsSync } from 'node:fs';
12
+ import { existsSync, readFileSync } from 'node:fs';
13
13
  import { mkdir, writeFile } from 'node:fs/promises';
14
14
  import { join } from 'node:path';
15
+ import { withExternalGateExemptions } from '../skills/hooks-codegate-superpowers.js';
15
16
  import { buildClaudeSettingsLocalJson, CLAUDE_SETTINGS_LOCAL_FILENAME, templateContentMatches } from './claude-settings-template.js';
17
+ /** Read a file as text, or `undefined` when it cannot be read. */
18
+ function readTextIfPresent(filePath) {
19
+ try {
20
+ return readFileSync(filePath, 'utf8');
21
+ }
22
+ catch {
23
+ return undefined;
24
+ }
25
+ }
26
+ /**
27
+ * The `env` object of a serialized settings file, or `undefined` when the file
28
+ * is malformed or has no `env` object. Tolerant on purpose: a bad on-disk file
29
+ * must not stop the materialization.
30
+ */
31
+ function readEnvObject(serialized) {
32
+ try {
33
+ const parsed = JSON.parse(serialized);
34
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
35
+ return undefined;
36
+ const env = parsed.env;
37
+ return typeof env === 'object' && env !== null && !Array.isArray(env) ? env : undefined;
38
+ }
39
+ catch {
40
+ return undefined;
41
+ }
42
+ }
16
43
  /**
17
44
  * The peaks-managed snippet appended to the consumer project's
18
45
  * `.peaks/.gitignore` so the local-only settings file never lands
@@ -66,7 +93,15 @@ export async function materializeClaudeSettingsLocal(projectRoot, noClaudeHooks)
66
93
  const settingsRel = CLAUDE_SETTINGS_LOCAL_FILENAME;
67
94
  const settingsPath = join(projectRoot, settingsRel);
68
95
  const template = buildClaudeSettingsLocalJson();
69
- const serialized = JSON.stringify(template, null, 2) + '\n';
96
+ const fileExists = existsSync(settingsPath);
97
+ const existing = fileExists ? readTextIfPresent(settingsPath) : undefined;
98
+ // `.claude/settings.local.json` has a second writer: `peaks hooks install`
99
+ // unions the user's own exemption globs into `env`. Carry that value across
100
+ // the rewrite this function is about to do, or a refresh would silently
101
+ // drop someone else's exemptions. The template's own row is added on top, so
102
+ // the result is a union either way.
103
+ const onDiskEnv = existing === undefined ? undefined : readEnvObject(existing);
104
+ const serialized = JSON.stringify(withExternalGateExemptions(onDiskEnv === undefined ? template : { ...template, env: onDiskEnv }), null, 2) + '\n';
70
105
  // Always drop (or self-heal) a copy of the template under .peaks/
71
106
  // so the --no-claude-hooks recovery flow has a known source-of-truth
72
107
  // on disk. The file is gitignored by the snippet below.
@@ -84,27 +119,15 @@ export async function materializeClaudeSettingsLocal(projectRoot, noClaudeHooks)
84
119
  // hooks-settings-service applies the safety check for the Bash
85
120
  // gate-enforce path).
86
121
  await mkdir(join(projectRoot, '.claude'), { recursive: true });
87
- let action = 'written';
88
- if (existsSync(settingsPath)) {
89
- try {
90
- const { readFile } = await import('node:fs/promises');
91
- const existing = await readFile(settingsPath, 'utf8');
92
- // Structural comparison (not a byte comparison): `peaks hooks
93
- // install` also writes this file, through a different serializer, so
94
- // an equal hooks tree must be recognized as current or every init
95
- // would rewrite the file and drop the installer's entries.
96
- if (templateContentMatches(serialized, existing)) {
97
- action = 'already-current';
98
- }
99
- else {
100
- action = 'refreshed';
101
- }
102
- }
103
- catch {
104
- // Treat any read failure as "needs refresh" so the consumer
105
- // always ends up with a valid template on disk.
106
- action = 'refreshed';
107
- }
122
+ // An existing-but-unreadable file is treated as drifted, so the consumer
123
+ // always ends up with a valid template on disk.
124
+ let action = fileExists ? 'refreshed' : 'written';
125
+ // Structural comparison (not a byte comparison): `peaks hooks install` also
126
+ // writes this file, through a different serializer, so an equal hooks tree
127
+ // must be recognized as current or every init would rewrite the file and
128
+ // drop the installer's entries.
129
+ if (existing !== undefined && templateContentMatches(serialized, existing)) {
130
+ action = 'already-current';
108
131
  }
109
132
  if (action !== 'already-current') {
110
133
  await writeFile(settingsPath, serialized, 'utf8');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-loop",
3
- "version": "4.0.37",
3
+ "version": "4.0.38",
4
4
  "description": "Loop Engineering CLI — workflow primitive / loop guards / evaluators / slice orchestration",
5
5
  "author": "SquabbyZ",
6
6
  "keywords": [
@@ -101,10 +101,10 @@
101
101
  "fzf": "^0.5.2",
102
102
  "yaml": "^2.9.0",
103
103
  "zod": "^4.4.3",
104
- "peaks-loop-mut": "0.1.35",
105
- "peaks-loop-internal-runtime": "0.0.22",
106
- "peaks-loop-shared-channel": "0.0.39",
107
- "peaks-loop-shared": "0.0.71"
104
+ "peaks-loop-internal-runtime": "0.0.23",
105
+ "peaks-loop-shared": "0.0.72",
106
+ "peaks-loop-mut": "0.1.36",
107
+ "peaks-loop-shared-channel": "0.0.40"
108
108
  },
109
109
  "devDependencies": {
110
110
  "@changesets/cli": "2.31.1",