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 +18 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/commands/hooks-commands.js +31 -6
- package/dist/services/context/build-dispatch-system-prompt.d.ts +35 -1
- package/dist/services/context/build-dispatch-system-prompt.js +55 -3
- package/dist/services/lint/detect-ocr-18.d.ts +2 -0
- package/dist/services/lint/detect-ocr-18.js +36 -5
- package/dist/services/lint/ocr-multilang-adapter.js +9 -2
- package/dist/services/skills/hooks-codegate-superpowers.d.ts +41 -0
- package/dist/services/skills/hooks-codegate-superpowers.js +95 -0
- package/dist/services/skills/hooks-settings-service.d.ts +16 -0
- package/dist/services/skills/hooks-settings-service.js +46 -13
- package/dist/services/workspace/claude-settings-template.d.ts +19 -3
- package/dist/services/workspace/claude-settings-template.js +25 -5
- package/dist/services/workspace/workspace-claude-settings-materializer.js +46 -23
- package/package.json +5 -5
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** | [](https://www.npmjs.com/package/peaks-loop) — 4.0.
|
|
143
|
+
| **Latest** | [](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
|
-
| **最新版本** | [](https://www.npmjs.com/package/peaks-loop) — 4.0.
|
|
143
|
+
| **最新版本** | [](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(
|
|
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(
|
|
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
|
|
8
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
70
|
+
const wantsEnvExemptions = ide === 'claude-code';
|
|
71
71
|
if (localPath === undefined || localPath === sharedPath) {
|
|
72
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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.
|
|
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-
|
|
105
|
-
"peaks-loop-
|
|
106
|
-
"peaks-loop-
|
|
107
|
-
"peaks-loop-shared": "0.0.
|
|
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",
|