peaks-loop 4.0.38 → 4.0.40
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 +47 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/commands/code-review-commands.js +43 -1
- package/dist/cli/commands/code-runtime-commands.js +16 -4
- package/dist/cli/commands/core/skill-command.d.ts +44 -0
- package/dist/cli/commands/core/skill-command.js +67 -3
- package/dist/cli/commands/dispatch-commands.js +42 -12
- package/dist/services/code/auto-compact-orchestrator.js +2 -2
- package/dist/services/context/auto-compact-dispatcher.js +3 -1
- package/dist/services/hooks/auto-compact-hook-install.d.ts +14 -1
- package/dist/services/hooks/auto-compact-hook-install.js +39 -15
- package/dist/services/lint/detect-ocr-18.d.ts +7 -1
- package/dist/services/lint/detect-ocr-18.js +81 -8
- package/dist/services/lint/ocr-18-acquire.d.ts +113 -0
- package/dist/services/lint/ocr-18-acquire.js +350 -0
- package/dist/services/lint/ocr-multilang-adapter.js +3 -0
- package/dist/services/web/playwright-loader.js +5 -24
- package/dist/services/workflow/provision-dispatch-node.d.ts +42 -0
- package/dist/services/workflow/provision-dispatch-node.js +66 -0
- package/dist/services/workspace/workspace-claude-settings-materializer.js +65 -6
- package/dist/shared/npm-cache.d.ts +2 -0
- package/dist/shared/npm-cache.js +31 -0
- package/package.json +5 -5
- package/skills/bee/peaks-perf-audit/SKILL.md +31 -3
- package/skills/bee/peaks-prd/SKILL.md +28 -0
- package/skills/bee/peaks-qa/SKILL.md +28 -0
- package/skills/bee/peaks-rd/SKILL.md +28 -0
- package/skills/bee/peaks-reviewer/SKILL.md +28 -0
- package/skills/bee/peaks-sc/SKILL.md +28 -0
- package/skills/bee/peaks-security-audit/SKILL.md +31 -3
- package/skills/bee/peaks-txt/SKILL.md +28 -0
- package/skills/bee/peaks-ui/SKILL.md +28 -0
- package/skills/peaks-audit/SKILL.md +28 -0
- package/skills/peaks-code/SKILL.md +28 -0
- package/skills/peaks-content/SKILL.md +28 -0
- package/skills/peaks-doctor/SKILL.md +28 -0
- package/skills/peaks-final-review/SKILL.md +28 -0
- package/skills/peaks-ide/SKILL.md +28 -0
- package/skills/peaks-issue-fix-orchestrator/SKILL.md +29 -1
- package/skills/peaks-resume/SKILL.md +28 -0
- package/skills/peaks-slice-decompose/SKILL.md +28 -0
- package/skills/peaks-solo/SKILL.md +28 -0
- package/skills/peaks-sop/SKILL.md +29 -1
- package/skills/peaks-status/SKILL.md +28 -0
- package/skills/peaks-test/SKILL.md +28 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,52 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.0.40 — 2026-09-11 (两条被 4.0.39 暴露出来的既有缺陷)
|
|
4
|
+
|
|
5
|
+
**Highlights**:
|
|
6
|
+
|
|
7
|
+
1. **4.0.39 的闸门指导写了一个错误的因果 —— 已更正。** 那段文字告诉每个技能:"编辑前先读这个文件,**跳过这一步会撞上闸门**"。**这是假的。** 用户在 Mac 上装完 4.0.39 后拿实际日志打回来,促使我们去读 ECC 闸门的源码(`gateguard-fact-force.js`):
|
|
8
|
+
|
|
9
|
+
```js
|
|
10
|
+
if (!isChecked(filePath)) {
|
|
11
|
+
markCheckedAndCountDenial(filePath); // 同一个调用里:先标记,再拒绝
|
|
12
|
+
return denyResult(...);
|
|
13
|
+
}
|
|
14
|
+
return rawInput; // 第二次直接放行
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**它是"每个路径首次接触拒绝一次"的减速带,不是"你有没有读过"的检查。** 先读**并不能**避免拒绝;拒绝由"编辑"触发,与是否读过无关。而给一个错的因果**比不给更糟** —— 模型照做了、仍被拦时,会得出"我哪里做错了",这正是这段指导本来要防的误读。现在它陈述真实机制与真实对策:每个文件预期被拦一次,**那不是失败、编辑未被应用、重试即放行**。
|
|
18
|
+
|
|
19
|
+
- 顺带记录边界:子代理豁免(父会话已过闸);`.peaks/**` 经 `GATEGUARD_EXEMPT_GLOBS` 豁免;**重试是否放行与"有没有列出那四条事实"无关** —— 闸门不校验事实。
|
|
20
|
+
- **闸门本身以后仍会拦**,那是它被设计成这样的;peaks 不该也无法替非 `.peaks/**` 路径压制它。能改的只是让模型别误读。
|
|
21
|
+
|
|
22
|
+
2. **四个 `SKILL.md` 的 frontmatter 是无效 YAML,其中两个的 `description` 从未被加载过。** `peaks-perf-audit` 与 `peaks-security-audit` 的 `sources:` 里有未加引号的 `schemaVersion: 2`,YAML 把其中的冒号+空格读成嵌套映射的开头,整个 frontmatter 解析失败 —— 而 Claude Code 在解析失败时会**回退到闭合 `---` 之后的第一行**,于是它们的 description 从未生效,**两个技能都无法按自己的描述被发现**。
|
|
23
|
+
|
|
24
|
+
- **这个缺陷为何一直看不见:** 回退渲染出来的是紧跟其后的标题(`# peaks-perf-audit`),**读起来像标题而不像损坏**。只有当 4.0.39 把 loop-hygiene 块放进那个位置后,技能列表开始显示块里的 HTML 注释,它才显形。
|
|
25
|
+
- `peaks-issue-fix-orchestrator` 与 `peaks-sop` 带同样的病(值里未加引号的 `: `)。Claude Code 自己的解析器足够宽松能读它们,所以看着正常,但它们不是合法 YAML,任何更严格的读取方都会拒绝。
|
|
26
|
+
- 四处均已加引号,**逐字验证无损**(重写的两条描述 662 / 654 字符前后完全一致)。
|
|
27
|
+
- 新增守卫:每个 `SKILL.md` 的 frontmatter 必须能解析为 YAML 且产出可用的 `description`。
|
|
28
|
+
|
|
29
|
+
**验证**:三个版本常量一致(4.0.40);`tsc -p tsconfig.build.json` exit 0;宽 `tsconfig.json` 保持 **142** 基线;`tests/unit` **188 files / 1794 passed / 3 skipped / 0 failed**。
|
|
30
|
+
|
|
31
|
+
## 4.0.39 — 2026-09-11 (四条"死机制" + 可见的 OCR 获取)
|
|
32
|
+
|
|
33
|
+
**Highlights**:
|
|
34
|
+
|
|
35
|
+
1. **本轮查出的东西有一个共同形状:机制写好了、但从没执行过。** 四条各自独立,每一条都能通过全部现有测试,因为**测试断言的是"声明存在",不是"它真的跑"**:
|
|
36
|
+
|
|
37
|
+
- **`24h` 模式从没选过 `partial`。** `--mode` 声明里带了 commander 默认值 `'standard'`,于是 `opts.mode` **永远有值**,orchestrator 里 `input.mode ?? resolveAutoCompactMode(projectRoot)` 的右边**永远不执行**。每个 24h 会话都在按 standard 的 0.80/0.85 跑,而 help 文本写着 0.65/0.70。症状看起来是"两个命令报的 mode 不一致",根因是**默认值顶掉了一条分支**。修的时候要动两处:只删默认值不够,handler 仍会把 `'standard'` 显式传下去。
|
|
38
|
+
- **auto-compact 的 hook 一次都没触发过。** 安装进 `settings.local.json` 的命令是 `peaks code auto-compact`,而 `--project` 是 `.requiredOption` —— 于是它挂在 `Bash|Task` 上,**每次工具调用都以"缺 --project"失败**。非阻塞错误,桌面上什么都看不到。更要紧的是:光改常量**到不了任何已有安装**,因为安装器只比对 matcher、从不比对命令,一看到 `Bash|Task` 条目就返回"已安装"。现在它是一起迁移。
|
|
39
|
+
- **那条"忽略离线模板"的 managed 规则,在任何项目里都没生效过。** 模式是按**项目根**写的,却被写进 `.peaks/.gitignore` —— 带 `/` 的 gitignore 模式锚定到所在目录,于是解析成 `.peaks/.peaks/...`,匹配不到任何东西;而根级的 `.claude/settings.local.json` 更是**根本无法**从 `.peaks/` 内部表达(gitignore 没有 `..`)。后果在本仓库可见:模板文件因为这条规则never生效而一直被跟踪,每次发版都显示 modified,被当"机器路径泄漏"**回滚了三次**——它从来不是泄漏。snippet 已改写到根,并加了**行为式**守卫(逐条模式用 `git check-ignore --no-index` 验证生效;已实测对旧行为失败)。顺带:搬家会留下僵尸块,`workspace init` 现在把它剥掉(保留用户写的每一行,畸形块不动)。
|
|
40
|
+
- **`--graph-node` 的强制只由 `.requiredOption` 支撑。** 传一个**根本不存在的**节点,dispatch 照样成功 —— 下游从不校验,`PEAKS_GRAPH_NODE_NOT_PREPARED` 只被 import、从未抛出;而且 `graphNodeId`/`workflowId`/`graphRef` **从未传给记录写入器**,记录上恒为 null,那条 transition 永远不触发。**这个特性整条链是死的,唯一真实的是它的前置条件** —— 而那个前置条件正好把每个没有图基础设施的项目(即:除了 peaks-loop 自己以外的所有项目)全打断。那句"友好提示"指向的路径本身也走不通:`workflow node prepare` **从不落盘**(打印一个节点就丢),而它要求的图没有任何命令会创建。现在可选 + 按需 provision,三步压成一步,并把三个字段真正写进记录。
|
|
41
|
+
|
|
42
|
+
2. **契约与指导,现在能到达它们该到的地方。** 零暂停契约(「compaction 是技能自己的动作,不是用户的」)早就写在 `peaks-code/SKILL.md` 里,`:180` 甚至点名禁止"prompt the user to run `/compact`" —— **而它依然被违反了,因为 SKILL.md 正文只加载一次、随后会被 compact 掉,规则恰好在上下文压力大到需要它的那一刻失效**;而且 22 个技能里只有 1 个有这段。现在它挂在**每轮必调的 tool 输出**上(`peaks skill presence --json` 的返回值里带 `context: { ratioPct, action, mode }`),compact 不掉,且技能无关。同样地,read-first 指导此前只注入了**子代理**的 system prompt,真正被闸门拒的**主会话**从没收到 —— 现在两者都在 22 个技能共享的、有**漂移守卫**的块里。
|
|
43
|
+
|
|
44
|
+
3. **OCR 获取变成显式、可见的一步。** dogfood 时桌面上弹出了一个标题为 `npm i @alibaba-group/open-code-review@1.11.9` 的窗口,而它来自一个自称 **"Read-only probe"** 的命令 —— `npx --package …` 在包未缓存时会**安装**。因果值得记下:**是今天的 unblock 让它暴露的** —— 修好之前,探针在启动步就死了,根本走不到安装。现在探针**只看不取**,获取成为独立动词,带安装锁与下载前告警,输出走 `inherit` 让 npm 自己的进度到达用户终端。Shell 偏好 **Git Bash → PowerShell**,且**每次都上报用了哪个** —— 静默换壳正是这场混乱的起点。
|
|
45
|
+
|
|
46
|
+
4. **本轮的一条自查:我写的守卫,自己犯了它要防的病。** 漂移守卫用 `execSync('find skills -name SKILL.md')` 找文件。`execSync` 走平台 shell,CI 的 Windows 上是 `cmd.exe`,那里的 `find` 是 `System32\find.exe`(另一个程序,不认 `-name`)。**本地能过,只因为 Git Bash 的 `find` 在 PATH 上胜出** —— 典型的"测试断言的是这台笔记本"。CI 第一次推就抓到。已改成 `fs` 遍历,无 shell 参与。
|
|
47
|
+
|
|
48
|
+
**验证**:三个版本常量一致(4.0.39);`tsc -p tsconfig.build.json` exit 0;宽 `tsconfig.json` 保持 **142** 基线;`tests/unit` **188 files / 1793 passed / 3 skipped / 0 failed**;CI 在 ubuntu + windows(Node 22)双平台绿。本轮新增的守卫**每一条都实测过"对旧行为会失败"**。
|
|
49
|
+
|
|
3
50
|
## 4.0.38 — 2026-09-11 (适配外部闸门 + 多语言评审复活 + 测试不再弹窗)
|
|
4
51
|
|
|
5
52
|
**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.40 (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.40(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,8 +1,11 @@
|
|
|
1
1
|
import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
|
|
2
2
|
import { fail, ok } from 'peaks-loop-shared/result';
|
|
3
3
|
import { detectOcr18 } from '../../services/lint/detect-ocr-18.js';
|
|
4
|
-
import {
|
|
4
|
+
import { acquireOcr18 } from '../../services/lint/ocr-18-acquire.js';
|
|
5
|
+
import { OCR_18_LANGUAGES, OCR_18_PACKAGE, runOcr18 } from '../../services/lint/ocr-multilang-adapter.js';
|
|
5
6
|
const SUPPORTED_LANGUAGES_SET = new Set(OCR_18_LANGUAGES);
|
|
7
|
+
/** What a caller can do when the acquisition did not land the package. */
|
|
8
|
+
const ACQUIRE_RETRY_ACTION = 'Re-run `peaks code-review acquire-ocr-18` once the network is available.';
|
|
6
9
|
export function registerCodeReviewCommands(program, io) {
|
|
7
10
|
const codeReview = program
|
|
8
11
|
.command('code-review', { hidden: true })
|
|
@@ -23,6 +26,45 @@ export function registerCodeReviewCommands(program, io) {
|
|
|
23
26
|
printResult(io, fail('code-review.detect-ocr-18', 'DETECT_FAILED', getErrorMessage(error), { state: 'detection-failed' }, ['Re-run with a valid npx on PATH.']), options.json);
|
|
24
27
|
}
|
|
25
28
|
});
|
|
29
|
+
addJsonOption(codeReview
|
|
30
|
+
.command('acquire-ocr-18')
|
|
31
|
+
.description('Acquire the OCR 1.8.x reviewer: runs the pinned npx package VISIBLY (npm progress on the ' +
|
|
32
|
+
'terminal) and installs it once when it is not already cached. The companion to the read-only ' +
|
|
33
|
+
'`detect-ocr-18` probe, which never installs.')).action(async (options) => {
|
|
34
|
+
const command = 'code-review.acquire-ocr-18';
|
|
35
|
+
try {
|
|
36
|
+
// R6's other half, mirrored from `peaks web install`: "nothing to fetch"
|
|
37
|
+
// is the only reason not to fetch, so an acquisition with nothing to do
|
|
38
|
+
// says so without spawning anything.
|
|
39
|
+
const before = detectOcr18();
|
|
40
|
+
if (before.state === 'ready') {
|
|
41
|
+
printResult(io, ok(command, { acquired: false, state: before.state, package: before.package, shell: null, shellNote: null }, [], []), options.json);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
// `asJson` is threaded in so the installer cannot write its stdout into
|
|
45
|
+
// the envelope this verb prints to the same stream.
|
|
46
|
+
const outcome = await acquireOcr18({ asJson: options.json === true });
|
|
47
|
+
if (!outcome.ok) {
|
|
48
|
+
printResult(io, fail(command, outcome.code, outcome.message, { state: 'acquire-failed', shell: outcome.shell.kind, shellNote: outcome.shell.note }, [ACQUIRE_RETRY_ACTION]), options.json);
|
|
49
|
+
process.exitCode = 1;
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
// R7, mirrored: an installer that exits 0 without landing the package is
|
|
53
|
+
// CONSULTED, not assumed — a proxy filtering the registry, or `npx`
|
|
54
|
+
// resolving into a different cache root, both exit 0.
|
|
55
|
+
const after = detectOcr18();
|
|
56
|
+
if (after.state !== 'ready') {
|
|
57
|
+
printResult(io, fail(command, 'OCR18_ACQUIRE_INCOMPLETE', `the acquisition exited 0 but ${OCR_18_PACKAGE} still does not resolve (${after.state})`, { state: after.state, shell: outcome.shell.kind, shellNote: outcome.shell.note }, [...after.nextActions]), options.json);
|
|
58
|
+
process.exitCode = 1;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
printResult(io, ok(command, { acquired: true, state: after.state, package: after.package, shell: outcome.shell.kind, shellNote: outcome.shell.note, durationMs: outcome.durationMs }, [...outcome.warnings]), options.json);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
printResult(io, fail(command, 'OCR18_ACQUIRE_FAILED', getErrorMessage(error), { state: 'acquire-failed' }, [ACQUIRE_RETRY_ACTION]), options.json);
|
|
65
|
+
process.exitCode = 1;
|
|
66
|
+
}
|
|
67
|
+
});
|
|
26
68
|
addJsonOption(codeReview
|
|
27
69
|
.command('run-ocr-18')
|
|
28
70
|
.description('Invoke `ocr review --filter-language <lang>` (or `ocr delegate preview` when --delegate) for one of 8 supported languages.')
|
|
@@ -60,8 +60,11 @@ export function registerCodeRuntimeCommands(code, io) {
|
|
|
60
60
|
'context-fill % via the active IDE adapter; ≥ 0.85 writes a pre-compact ' +
|
|
61
61
|
'checkpoint + convergence plan + auto-decisions log; ≥ 0.95 forces ' +
|
|
62
62
|
'synchronous IDE-side compact. The LLM / runner keeps working with ' +
|
|
63
|
-
'context < 95% without human intervention.
|
|
64
|
-
'now` (AC-1)
|
|
63
|
+
'context < 95% without human intervention. Pair with `peaks code ' +
|
|
64
|
+
'context-now` (AC-1), the read-only probe that reports the ratio this ' +
|
|
65
|
+
'command acts on. This command is also fired by the installed ' +
|
|
66
|
+
'PreToolUse hook, which passes `--project .` — without that argument ' +
|
|
67
|
+
'the hook could never run at all. rid-027 ' +
|
|
65
68
|
'adds `--mode <mode>`: `standard` (0.85/0.95) or `partial` (0.70/0.85 ' +
|
|
66
69
|
'for 24h long-run mode).')
|
|
67
70
|
.requiredOption('--project <path>', 'target project root')
|
|
@@ -69,7 +72,12 @@ export function registerCodeRuntimeCommands(code, io) {
|
|
|
69
72
|
.option('--in-flight-batch', 'defer if a sub-agent batch is in flight (D6.e)')
|
|
70
73
|
.option('--force', 'force compact at any ratio (test seam)')
|
|
71
74
|
.option('--bypass-red-line', 'skip the 95% red-line gate (test seam; never true in production)')
|
|
72
|
-
|
|
75
|
+
// No commander default here, deliberately. A declared default makes
|
|
76
|
+
// `opts.mode` permanently defined, which defeats the orchestrator's
|
|
77
|
+
// `input.mode ?? resolveAutoCompactMode(projectRoot)` fallback and
|
|
78
|
+
// silently disables "24h mode auto-selects partial" — the help text
|
|
79
|
+
// below promises it. Absence must stay absent.
|
|
80
|
+
.option('--mode <mode>', 'auto-compact mode (standard | partial). Default: standard. 24h mode auto-selects partial.')).action(async (opts) => {
|
|
73
81
|
try {
|
|
74
82
|
const { isValidMode } = await import('../../services/code/auto-compact-modes.js');
|
|
75
83
|
const modeName = opts.mode ?? 'standard';
|
|
@@ -112,7 +120,11 @@ export function registerCodeRuntimeCommands(code, io) {
|
|
|
112
120
|
: {}),
|
|
113
121
|
force: opts.force === true,
|
|
114
122
|
bypassRedLine: opts.bypassRedLine === true,
|
|
115
|
-
|
|
123
|
+
// Forward the FLAG verbatim — `undefined` when the user named no
|
|
124
|
+
// mode — so the orchestrator's fallback to the presence-derived
|
|
125
|
+
// mode can actually run. Forwarding the validated `modeName` is
|
|
126
|
+
// what disabled 24h → partial.
|
|
127
|
+
mode: opts.mode === undefined ? undefined : modeName
|
|
116
128
|
});
|
|
117
129
|
const code = result.code;
|
|
118
130
|
const exitOk = result.ok || code === 'AUTO_COMPACT_SKIP' || code === 'AUTO_COMPACT_WAIT';
|
|
@@ -1,3 +1,47 @@
|
|
|
1
1
|
import type { Command } from 'commander';
|
|
2
|
+
import type { AutoCompactMode } from '../../../services/code/auto-compact-modes.js';
|
|
3
|
+
/**
|
|
4
|
+
* Canonicalize a user-supplied `--project <path>` value.
|
|
5
|
+
*
|
|
6
|
+
* Git Bash on Windows hands us forward-slash paths
|
|
7
|
+
* (`C:/Users/.../peaks-loop`) while `peaks workspace init` writes the
|
|
8
|
+
* backslash form, and either side may carry a trailing separator or
|
|
9
|
+
* differing case. Resolving to the real path here means every
|
|
10
|
+
* downstream consumer (`getSessionId`, `setSessionMeta`,
|
|
11
|
+
* `setSkillPresence`) sees one stable form.
|
|
12
|
+
*
|
|
13
|
+
* Returns the input unchanged when it cannot be resolved (path does
|
|
14
|
+
* not exist yet, or is not readable) so a bad `--project` still
|
|
15
|
+
* reaches the existing error handling rather than throwing here.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* The loop-hygiene verdict attached to every ACTIVE `skill.presence` read.
|
|
19
|
+
*
|
|
20
|
+
* Why it lives here instead of only in a SKILL.md body: the zero-pause
|
|
21
|
+
* contract ("auto-compact is the skill's own action, never the user's")
|
|
22
|
+
* was already written into `peaks-code/SKILL.md`, including an explicit
|
|
23
|
+
* ban on the exact phrasing "prompt the user to run `/compact`" — and it
|
|
24
|
+
* was still violated. A SKILL.md body is loaded once and is then
|
|
25
|
+
* compacted away, so the rule stops governing at precisely the moment
|
|
26
|
+
* context pressure makes it matter. `skill.presence` is the one call
|
|
27
|
+
* EVERY skill makes on EVERY turn, in EVERY consumer project and EVERY
|
|
28
|
+
* mode, so a value carried here cannot be forgotten, and no skill has to
|
|
29
|
+
* be edited for the obligation to reach the model.
|
|
30
|
+
*
|
|
31
|
+
* READ-ONLY by contract. It probes and reports; it never compacts.
|
|
32
|
+
* Executing `peaks code auto-compact` stays the skill's own action — a
|
|
33
|
+
* side effect here would fire on every single turn.
|
|
34
|
+
*
|
|
35
|
+
* Failure swallows to `null`: the hygiene verdict is advisory and must
|
|
36
|
+
* never be able to break the presence read itself.
|
|
37
|
+
*/
|
|
38
|
+
export declare function buildContextVerdict(ratio: number, mode: AutoCompactMode): {
|
|
39
|
+
context: {
|
|
40
|
+
ratioPct: string;
|
|
41
|
+
action: string;
|
|
42
|
+
mode: string;
|
|
43
|
+
};
|
|
44
|
+
nextActions: string[];
|
|
45
|
+
};
|
|
2
46
|
import { type ProgramIO } from '../../cli-helpers.js';
|
|
3
47
|
export declare function registerSkillCommand(program: Command, io: ProgramIO): void;
|
|
@@ -8,6 +8,10 @@ import { findProjectRoot } from '../../../services/config/config-safety.js';
|
|
|
8
8
|
import { generateProjectContext } from '../../../services/memory/project-context-service.js';
|
|
9
9
|
import { getSessionId, setSessionMeta } from '../../../services/session/session-manager.js';
|
|
10
10
|
import { resolveCallerProjection } from '../../../services/session/resolve-caller-id.js';
|
|
11
|
+
import { readContextPercent } from '../../../services/context/auto-compact-reader.js';
|
|
12
|
+
import { resolveOuterSessionId } from '../../../services/session/binding-status-service.js';
|
|
13
|
+
import { evaluateCompactTrigger } from '../../../services/code/auto-compact-orchestrator.js';
|
|
14
|
+
import { resolveAutoCompactProfile } from '../../../services/mode/mode-status-service.js';
|
|
11
15
|
import { gcStalePresenceLeases } from '../../../services/skills/presence-lease-service.js';
|
|
12
16
|
import { fail, ok } from 'peaks-loop-shared/result';
|
|
13
17
|
import { stableRealPath } from '../../../shared/path-utils.js';
|
|
@@ -25,6 +29,61 @@ import { stableRealPath } from '../../../shared/path-utils.js';
|
|
|
25
29
|
* not exist yet, or is not readable) so a bad `--project` still
|
|
26
30
|
* reaches the existing error handling rather than throwing here.
|
|
27
31
|
*/
|
|
32
|
+
/**
|
|
33
|
+
* The loop-hygiene verdict attached to every ACTIVE `skill.presence` read.
|
|
34
|
+
*
|
|
35
|
+
* Why it lives here instead of only in a SKILL.md body: the zero-pause
|
|
36
|
+
* contract ("auto-compact is the skill's own action, never the user's")
|
|
37
|
+
* was already written into `peaks-code/SKILL.md`, including an explicit
|
|
38
|
+
* ban on the exact phrasing "prompt the user to run `/compact`" — and it
|
|
39
|
+
* was still violated. A SKILL.md body is loaded once and is then
|
|
40
|
+
* compacted away, so the rule stops governing at precisely the moment
|
|
41
|
+
* context pressure makes it matter. `skill.presence` is the one call
|
|
42
|
+
* EVERY skill makes on EVERY turn, in EVERY consumer project and EVERY
|
|
43
|
+
* mode, so a value carried here cannot be forgotten, and no skill has to
|
|
44
|
+
* be edited for the obligation to reach the model.
|
|
45
|
+
*
|
|
46
|
+
* READ-ONLY by contract. It probes and reports; it never compacts.
|
|
47
|
+
* Executing `peaks code auto-compact` stays the skill's own action — a
|
|
48
|
+
* side effect here would fire on every single turn.
|
|
49
|
+
*
|
|
50
|
+
* Failure swallows to `null`: the hygiene verdict is advisory and must
|
|
51
|
+
* never be able to break the presence read itself.
|
|
52
|
+
*/
|
|
53
|
+
export function buildContextVerdict(ratio, mode) {
|
|
54
|
+
const trigger = evaluateCompactTrigger(ratio, mode);
|
|
55
|
+
const ratioPct = `${(ratio * 100).toFixed(1)}%`;
|
|
56
|
+
// `auto-fire` belongs in the in-zone set: it is the tier where
|
|
57
|
+
// peaks-loop preempts rather than asking the LLM to decide.
|
|
58
|
+
const inZone = trigger.kind === 'auto-fire' || trigger.kind === 'pre-compact' || trigger.kind === 'red-line';
|
|
59
|
+
return {
|
|
60
|
+
context: { ratioPct, action: trigger.kind, mode },
|
|
61
|
+
nextActions: inZone
|
|
62
|
+
? [
|
|
63
|
+
`Context at ${ratioPct} is in the '${trigger.kind}' zone (mode=${mode}).`,
|
|
64
|
+
'Run `peaks code auto-compact --project .` YOURSELF now, then continue. Do NOT ask the user to run /compact — that is the regression the zero-pause contract forbids.'
|
|
65
|
+
]
|
|
66
|
+
: []
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function contextVerdict(projectRoot) {
|
|
70
|
+
try {
|
|
71
|
+
const sessionId = getSessionId(projectRoot);
|
|
72
|
+
if (sessionId === null)
|
|
73
|
+
return { context: null, nextActions: [] };
|
|
74
|
+
const mode = resolveAutoCompactProfile(projectRoot);
|
|
75
|
+
// `outerSessionId` is NOT optional in practice: without it the
|
|
76
|
+
// adapter cannot find the harness transcript and falls back to
|
|
77
|
+
// conservative-zero, reporting a serene "0.0%" that never crosses
|
|
78
|
+
// any threshold. Same resolution order the orchestrator uses.
|
|
79
|
+
const outerSessionId = resolveOuterSessionId(projectRoot, sessionId, process.env);
|
|
80
|
+
const probe = readContextPercent({ projectRoot, sessionId, outerSessionId, env: process.env });
|
|
81
|
+
return buildContextVerdict(probe.ratio, mode);
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return { context: null, nextActions: [] };
|
|
85
|
+
}
|
|
86
|
+
}
|
|
28
87
|
function canonicalizeProjectOption(project) {
|
|
29
88
|
if (project === undefined)
|
|
30
89
|
return undefined;
|
|
@@ -174,6 +233,10 @@ export function registerSkillCommand(program, io) {
|
|
|
174
233
|
printResult(io, ok('skill.presence', { active: false }), options.json);
|
|
175
234
|
return;
|
|
176
235
|
}
|
|
236
|
+
// Loop-hygiene verdict: attached to every ACTIVE read, so the
|
|
237
|
+
// zero-pause contract travels with the one call every skill already
|
|
238
|
+
// makes — in every mode and every consumer project.
|
|
239
|
+
const verdict = contextVerdict(projectOption ?? process.cwd());
|
|
177
240
|
if (options.checkStale === true) {
|
|
178
241
|
// Slice 002 (v2.15.0) AC-1: pair the read with a staleness
|
|
179
242
|
// check so callers (peaks-code Step 1, statusline) get both
|
|
@@ -187,11 +250,12 @@ export function registerSkillCommand(program, io) {
|
|
|
187
250
|
stale: staleness.stale,
|
|
188
251
|
staleReason: staleness.reason,
|
|
189
252
|
currentOuterSessionId: staleness.currentOuterSessionId,
|
|
190
|
-
recordedOuterSessionId: staleness.recordedOuterSessionId
|
|
191
|
-
|
|
253
|
+
recordedOuterSessionId: staleness.recordedOuterSessionId,
|
|
254
|
+
...(verdict.context !== null ? { context: verdict.context } : {})
|
|
255
|
+
}, [], verdict.nextActions), options.json);
|
|
192
256
|
return;
|
|
193
257
|
}
|
|
194
|
-
printResult(io, ok('skill.presence', { active: true, ...presence }), options.json);
|
|
258
|
+
printResult(io, ok('skill.presence', { active: true, ...presence, ...(verdict.context !== null ? { context: verdict.context } : {}) }, [], verdict.nextActions), options.json);
|
|
195
259
|
});
|
|
196
260
|
addJsonOption(skill
|
|
197
261
|
.command('presence:set <name>')
|
|
@@ -24,6 +24,7 @@ import { SubAgentNotSupportedError } from '../../services/dispatch/sub-agent-dis
|
|
|
24
24
|
import { emitObservabilityEvent, OBSERVABILITY_SUBAGENT_ROLES } from '../../services/observability/observability-service.js';
|
|
25
25
|
import { noteDispatched, BATCH_LIMIT } from '../../services/dispatch/batch-counter.js';
|
|
26
26
|
import { writeInitialDispatchRecord } from '../../services/dispatch/dispatch-record-writer.js';
|
|
27
|
+
import { provisionDispatchNode } from '../../services/workflow/provision-dispatch-node.js';
|
|
27
28
|
import { evaluatePromptSize } from '../../services/context/context-guard.js';
|
|
28
29
|
import { getCurrentSessionId } from '../../services/skills/skill-presence-service.js';
|
|
29
30
|
import { resolveOuterSessionId } from '../../services/session/binding-status-service.js';
|
|
@@ -69,9 +70,13 @@ export function registerDispatchCommand(parent, io) {
|
|
|
69
70
|
.option('--force', 'G9: override the 80% hard reject threshold at CLI (NOT allowed at hook layer per RL-30 strict)')
|
|
70
71
|
.option('--from-dag <file>', '2.7.0 slice-dag-dispatcher MVP: read a SliceDag JSON file, dispatch one sub-agent per node in topological order; --batch-id overrides the auto-generated batch id (mutually exclusive with <role>)')
|
|
71
72
|
.option('--isolation <mode>', 'slice 2026-07-29-worktree-l2-extended Part 2.C: isolation mode for the sub-agent. Accepts "worktree" (Part 2.C + Part 12 L2 surface), "container" (Part 8 contract + Part 12 L4 docker runtime), or "vm" (Part 25 contract; the VM runtime is a follow-up rid and fail-fasts with ISOLATION_VM_NOT_YET_IMPLEMENTED). Auto-spawns a lease + injects PEAKS_<MODE>_LEASE_ID into the dispatch envelope so the sub-agent can write to the isolated surface without a separate auth grant.')
|
|
72
|
-
// Slice 4.0.8 RD §4
|
|
73
|
-
//
|
|
74
|
-
|
|
73
|
+
// Slice 4.0.8 RD §4 made this a `.requiredOption`. It is optional now:
|
|
74
|
+
// the requirement was enforced but never validated — nothing downstream
|
|
75
|
+
// reads the node, and the record writer's graph transition is
|
|
76
|
+
// best-effort — so its only observable effect was to block dispatch in
|
|
77
|
+
// every project without graph infrastructure. See
|
|
78
|
+
// `provisionDispatchNode`.
|
|
79
|
+
.option('--graph-node <id>', 'graph node id this dispatch binds to (default: a node is provisioned on demand)')
|
|
75
80
|
.option('--workflow-id <id>', 'workflow id the graph node belongs to (defaults to derived from session)')
|
|
76
81
|
.option('--graph-ref <ref>', 'graphRef (defaults to graphs/<workflow-id>.json)')
|
|
77
82
|
// rid-001 detached sub-agent dispatch: 4 new options. Default
|
|
@@ -175,15 +180,9 @@ export function registerDispatchCommand(parent, io) {
|
|
|
175
180
|
process.exitCode = 1;
|
|
176
181
|
return;
|
|
177
182
|
}
|
|
178
|
-
// Slice 4.0.8 RD §4 D4c
|
|
179
|
-
//
|
|
180
|
-
// the
|
|
181
|
-
// enforce it so tests / service callers can't bypass it.
|
|
182
|
-
if (typeof options.graphNode !== 'string' || options.graphNode.length === 0) {
|
|
183
|
-
printResult(io, fail('sub-agent.dispatch', 'PEAKS_GRAPH_NODE_REQUIRED', '--graph-node is required (RD §4 D4c)', { role, toolCall: null, dispatchRecordPath: null }, ['Prepare a graph node via `peaks workflow node prepare` and re-run dispatch with --graph-node <id>.']), asJson);
|
|
184
|
-
process.exitCode = 1;
|
|
185
|
-
return;
|
|
186
|
-
}
|
|
183
|
+
// Slice 4.0.8 RD §4 D4c required `--graph-node` here, before the session
|
|
184
|
+
// id existed, so the only recovery it could offer was prose. The node is
|
|
185
|
+
// now provisioned inside the try block below, once `sid` is known.
|
|
187
186
|
// DOGFOOD ONLY: --prompt-length overrides the actual prompt content with
|
|
188
187
|
// a synthetic prompt of the given size in bytes. The original --prompt
|
|
189
188
|
// is still required (commander needs it). This avoids ARG_MAX limits
|
|
@@ -623,12 +622,43 @@ export function registerDispatchCommand(parent, io) {
|
|
|
623
622
|
warnings.push(`ARTIFACT_PATH_INVALID: ${getErrorMessage(err)}`);
|
|
624
623
|
}
|
|
625
624
|
}
|
|
625
|
+
// Slice 4.0.8 RD §4 D4c: bind this dispatch to a graph node. When the
|
|
626
|
+
// caller names none, provision one — that is what collapses the
|
|
627
|
+
// documented three-step ritual (create graph -> `peaks workflow node
|
|
628
|
+
// prepare` -> dispatch) into a single call. Those three fields were
|
|
629
|
+
// also never passed to the writer, so the record's graph binding was
|
|
630
|
+
// always null and the writer's transition never fired: the feature was
|
|
631
|
+
// inert, and only its precondition (the required flag) was real.
|
|
632
|
+
const graphBinding = (() => {
|
|
633
|
+
if (typeof options.graphNode === 'string' && options.graphNode.length > 0) {
|
|
634
|
+
return {
|
|
635
|
+
nodeId: options.graphNode,
|
|
636
|
+
workflowId: options.workflowId ?? null,
|
|
637
|
+
graphRef: options.graphRef ?? null
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
const provisioned = provisionDispatchNode({
|
|
641
|
+
projectRoot,
|
|
642
|
+
sessionId: sid,
|
|
643
|
+
role,
|
|
644
|
+
workflowId: options.workflowId,
|
|
645
|
+
graphRef: options.graphRef
|
|
646
|
+
});
|
|
647
|
+
return {
|
|
648
|
+
nodeId: provisioned.nodeId,
|
|
649
|
+
workflowId: provisioned.workflowId,
|
|
650
|
+
graphRef: provisioned.graphRef
|
|
651
|
+
};
|
|
652
|
+
})();
|
|
626
653
|
const { path: dispatchRecordPath } = writeInitialDispatchRecord({
|
|
627
654
|
projectRoot,
|
|
628
655
|
sessionId: sid,
|
|
629
656
|
requestId: rid,
|
|
630
657
|
role,
|
|
631
658
|
prompt: effectivePrompt,
|
|
659
|
+
workflowId: graphBinding.workflowId,
|
|
660
|
+
graphNodeId: graphBinding.nodeId,
|
|
661
|
+
graphRef: graphBinding.graphRef,
|
|
632
662
|
toolCall,
|
|
633
663
|
batchId,
|
|
634
664
|
// Slice 2026-07-29-worktree-l2-extended Part 3.A: persist the
|
|
@@ -32,7 +32,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } fr
|
|
|
32
32
|
import { dirname, join } from 'node:path';
|
|
33
33
|
import { getSessionIdCanonical } from '../session/session-manager.js';
|
|
34
34
|
import { resolveOuterSessionId } from '../session/binding-status-service.js';
|
|
35
|
-
import { AUTO_COMPACT_PRE_COMPACT_RATIO
|
|
35
|
+
import { AUTO_COMPACT_PRE_COMPACT_RATIO } from '../context/auto-compact-types.js';
|
|
36
36
|
import { describeMode, thresholdFor } from './auto-compact-modes.js';
|
|
37
37
|
import { resolveAutoCompactProfile } from '../mode/mode-status-service.js';
|
|
38
38
|
import { CompactLifecyclePublisher, newCompactRunId, settleOpenLifecycleRun, summarizeLifecycleError } from './auto-compact-lifecycle.js';
|
|
@@ -352,7 +352,7 @@ export async function runAutoCompact(input) {
|
|
|
352
352
|
? decision.trigger.message
|
|
353
353
|
: decision.reason === 'in-flight-batch'
|
|
354
354
|
? `In-flight batch detected; deferring pre-compact (ratio=${(probe.ratio * 100).toFixed(1)}%); next probe will re-evaluate.`
|
|
355
|
-
: `Context at ${(probe.ratio * 100).toFixed(1)}%; below ${(
|
|
355
|
+
: `Context at ${(probe.ratio * 100).toFixed(1)}%; below the ${(thresholdFor(mode, 'autoFire') * 100).toFixed(0)}% auto-fire threshold (mode=${mode}).`,
|
|
356
356
|
data: {
|
|
357
357
|
sessionId,
|
|
358
358
|
ratio: probe.ratio,
|
|
@@ -165,6 +165,8 @@ async function dispatchIdeNativeHook(input) {
|
|
|
165
165
|
pathway: 'ide-native',
|
|
166
166
|
message: result.action === 'installed'
|
|
167
167
|
? `Auto-compact PreToolUse hook installed at ${result.settingsPath}. Next Bash/Task tool call will read CLAUDE_CONTEXT_USAGE_PERCENT and compact in-band at ratio ≥ 95%.`
|
|
168
|
-
:
|
|
168
|
+
: result.action === 'updated'
|
|
169
|
+
? `Auto-compact PreToolUse hook REPAIRED at ${result.settingsPath} — the installed entry carried a stale command and was rewritten.`
|
|
170
|
+
: `Auto-compact PreToolUse hook already installed at ${result.settingsPath}; next Bash/Task tool call will trigger compact in-band at ratio ≥ 95%.`
|
|
169
171
|
};
|
|
170
172
|
}
|
|
@@ -41,12 +41,22 @@ export declare const AUTO_COMPACT_HOOK_MATCHER = "Bash|Task";
|
|
|
41
41
|
* heavy lifting (ratio probe + in-band `claude --compact` spawn)
|
|
42
42
|
* lives in the CLI surface, not inlined into the hook.
|
|
43
43
|
*
|
|
44
|
+
* `--project .` is LOAD-BEARING, not decoration. `--project` is a
|
|
45
|
+
* `.requiredOption` on `peaks code auto-compact`, and a hook has no way
|
|
46
|
+
* to prompt for a missing required option. Without this argument the
|
|
47
|
+
* hook exited with `error: required option '--project <path>' not
|
|
48
|
+
* specified` on EVERY matching tool call — a non-blocking failure, so
|
|
49
|
+
* the hook looked installed while the zero-human-intervention
|
|
50
|
+
* auto-compact contract never once fired. `.` is promoted to the git
|
|
51
|
+
* root by `resolveCanonicalProjectRoot`, the same convention the
|
|
52
|
+
* `post-compact-detect` hook already documents in its own option help.
|
|
53
|
+
*
|
|
44
54
|
* `npx peaks` is deliberately omitted: the project-local install
|
|
45
55
|
* puts `peaks` on PATH inside the consumer repo (postinstall step),
|
|
46
56
|
* so the unqualified command works in the same shell the runner
|
|
47
57
|
* is using.
|
|
48
58
|
*/
|
|
49
|
-
export declare const AUTO_COMPACT_HOOK_COMMAND = "peaks code auto-compact";
|
|
59
|
+
export declare const AUTO_COMPACT_HOOK_COMMAND = "peaks code auto-compact --project .";
|
|
50
60
|
/**
|
|
51
61
|
* Magic comment marker that fences the auto-compatchook block.
|
|
52
62
|
* Both install (writes the marker) and remove (matches on it) use
|
|
@@ -68,6 +78,9 @@ export type AutoCompactHookInstallResult = {
|
|
|
68
78
|
} | {
|
|
69
79
|
readonly action: 'already-installed';
|
|
70
80
|
readonly settingsPath: string;
|
|
81
|
+
} | {
|
|
82
|
+
readonly action: 'updated';
|
|
83
|
+
readonly settingsPath: string;
|
|
71
84
|
};
|
|
72
85
|
/**
|
|
73
86
|
* Result envelope for `removeAutoCompactHook`.
|
|
@@ -44,12 +44,22 @@ export const AUTO_COMPACT_HOOK_MATCHER = 'Bash|Task';
|
|
|
44
44
|
* heavy lifting (ratio probe + in-band `claude --compact` spawn)
|
|
45
45
|
* lives in the CLI surface, not inlined into the hook.
|
|
46
46
|
*
|
|
47
|
+
* `--project .` is LOAD-BEARING, not decoration. `--project` is a
|
|
48
|
+
* `.requiredOption` on `peaks code auto-compact`, and a hook has no way
|
|
49
|
+
* to prompt for a missing required option. Without this argument the
|
|
50
|
+
* hook exited with `error: required option '--project <path>' not
|
|
51
|
+
* specified` on EVERY matching tool call — a non-blocking failure, so
|
|
52
|
+
* the hook looked installed while the zero-human-intervention
|
|
53
|
+
* auto-compact contract never once fired. `.` is promoted to the git
|
|
54
|
+
* root by `resolveCanonicalProjectRoot`, the same convention the
|
|
55
|
+
* `post-compact-detect` hook already documents in its own option help.
|
|
56
|
+
*
|
|
47
57
|
* `npx peaks` is deliberately omitted: the project-local install
|
|
48
58
|
* puts `peaks` on PATH inside the consumer repo (postinstall step),
|
|
49
59
|
* so the unqualified command works in the same shell the runner
|
|
50
60
|
* is using.
|
|
51
61
|
*/
|
|
52
|
-
export const AUTO_COMPACT_HOOK_COMMAND = 'peaks code auto-compact';
|
|
62
|
+
export const AUTO_COMPACT_HOOK_COMMAND = 'peaks code auto-compact --project .';
|
|
53
63
|
/**
|
|
54
64
|
* Magic comment marker that fences the auto-compatchook block.
|
|
55
65
|
* Both install (writes the marker) and remove (matches on it) use
|
|
@@ -102,10 +112,6 @@ export function installAutoCompactHook(input) {
|
|
|
102
112
|
const settings = readSettings(settingsPath);
|
|
103
113
|
const hooks = settings.hooks ?? {};
|
|
104
114
|
const preToolUse = hooks.PreToolUse ?? [];
|
|
105
|
-
const alreadyInstalled = preToolUse.some(isAutoCompactEntry);
|
|
106
|
-
if (alreadyInstalled) {
|
|
107
|
-
return { action: 'already-installed', settingsPath };
|
|
108
|
-
}
|
|
109
115
|
// The matcher is `Bash|Task`, so on Windows this runs on the same
|
|
110
116
|
// Git-Bash / MSYS2 shell-form path as the other peaks Bash hooks, which
|
|
111
117
|
// force-allocates a console window on every matching tool call. The
|
|
@@ -113,18 +119,36 @@ export function installAutoCompactHook(input) {
|
|
|
113
119
|
// `.claude/settings.local.json`, so a machine-specific `shell` cannot
|
|
114
120
|
// leak into a shared file. `undefined` on POSIX omits the key entirely.
|
|
115
121
|
const shell = resolveHookShell();
|
|
122
|
+
const desiredHook = {
|
|
123
|
+
type: 'command',
|
|
124
|
+
command: AUTO_COMPACT_HOOK_COMMAND,
|
|
125
|
+
...(shell !== undefined ? { shell } : {})
|
|
126
|
+
};
|
|
127
|
+
const existing = preToolUse.find(isAutoCompactEntry);
|
|
128
|
+
// "Already installed" must mean installed CORRECTLY.
|
|
129
|
+
//
|
|
130
|
+
// Keying only off the matcher is how a corrected command never reaches
|
|
131
|
+
// an existing install: the entry is present, so the installer returns
|
|
132
|
+
// early and the stale string stays in the settings file forever. That
|
|
133
|
+
// is not hypothetical — it is exactly how the missing `--project`
|
|
134
|
+
// survived on every machine that had already installed the hook, and
|
|
135
|
+
// re-running the installer could not repair any of them.
|
|
136
|
+
if (existing !== undefined) {
|
|
137
|
+
if (existing.hooks?.[0]?.command === AUTO_COMPACT_HOOK_COMMAND) {
|
|
138
|
+
return { action: 'already-installed', settingsPath };
|
|
139
|
+
}
|
|
140
|
+
const migrated = preToolUse.map((entry) => isAutoCompactEntry(entry)
|
|
141
|
+
? { ...entry, matcher: AUTO_COMPACT_HOOK_MATCHER, hooks: [desiredHook] }
|
|
142
|
+
: entry);
|
|
143
|
+
writeSettings(settingsPath, {
|
|
144
|
+
...settings,
|
|
145
|
+
hooks: { ...hooks, PreToolUse: migrated }
|
|
146
|
+
});
|
|
147
|
+
return { action: 'updated', settingsPath };
|
|
148
|
+
}
|
|
116
149
|
const nextPreToolUse = [
|
|
117
150
|
...preToolUse,
|
|
118
|
-
{
|
|
119
|
-
matcher: AUTO_COMPACT_HOOK_MATCHER,
|
|
120
|
-
hooks: [
|
|
121
|
-
{
|
|
122
|
-
type: 'command',
|
|
123
|
-
command: AUTO_COMPACT_HOOK_COMMAND,
|
|
124
|
-
...(shell !== undefined ? { shell } : {})
|
|
125
|
-
}
|
|
126
|
-
]
|
|
127
|
-
}
|
|
151
|
+
{ matcher: AUTO_COMPACT_HOOK_MATCHER, hooks: [desiredHook] }
|
|
128
152
|
];
|
|
129
153
|
writeSettings(settingsPath, {
|
|
130
154
|
...settings,
|
|
@@ -9,4 +9,10 @@ export type Ocr18DetectResult = {
|
|
|
9
9
|
};
|
|
10
10
|
/** Named code for "npx itself could not be launched" — distinct from "npx is absent". */
|
|
11
11
|
export declare const NPX_PROBE_UNRESOLVED_CODE = "NPX_PROBE_UNRESOLVED";
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* `cwd` is where the local npm tree is looked for (defaults to the process
|
|
14
|
+
* cwd, like `runOcr18`'s own `cwd`); the npx cache roots are the machine's.
|
|
15
|
+
*/
|
|
16
|
+
export declare function detectOcr18(options?: {
|
|
17
|
+
readonly cwd?: string;
|
|
18
|
+
}): Ocr18DetectResult;
|