peaks-loop 4.0.37 → 4.0.39

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/code-review-commands.js +43 -1
  5. package/dist/cli/commands/code-runtime-commands.js +16 -4
  6. package/dist/cli/commands/core/skill-command.d.ts +44 -0
  7. package/dist/cli/commands/core/skill-command.js +67 -3
  8. package/dist/cli/commands/dispatch-commands.js +42 -12
  9. package/dist/cli/commands/hooks-commands.js +31 -6
  10. package/dist/services/code/auto-compact-orchestrator.js +2 -2
  11. package/dist/services/context/auto-compact-dispatcher.js +3 -1
  12. package/dist/services/context/build-dispatch-system-prompt.d.ts +35 -1
  13. package/dist/services/context/build-dispatch-system-prompt.js +55 -3
  14. package/dist/services/hooks/auto-compact-hook-install.d.ts +14 -1
  15. package/dist/services/hooks/auto-compact-hook-install.js +39 -15
  16. package/dist/services/lint/detect-ocr-18.d.ts +9 -1
  17. package/dist/services/lint/detect-ocr-18.js +114 -10
  18. package/dist/services/lint/ocr-18-acquire.d.ts +113 -0
  19. package/dist/services/lint/ocr-18-acquire.js +350 -0
  20. package/dist/services/lint/ocr-multilang-adapter.js +12 -2
  21. package/dist/services/skills/hooks-codegate-superpowers.d.ts +41 -0
  22. package/dist/services/skills/hooks-codegate-superpowers.js +95 -0
  23. package/dist/services/skills/hooks-settings-service.d.ts +16 -0
  24. package/dist/services/skills/hooks-settings-service.js +46 -13
  25. package/dist/services/web/playwright-loader.js +5 -24
  26. package/dist/services/workflow/provision-dispatch-node.d.ts +42 -0
  27. package/dist/services/workflow/provision-dispatch-node.js +66 -0
  28. package/dist/services/workspace/claude-settings-template.d.ts +19 -3
  29. package/dist/services/workspace/claude-settings-template.js +25 -5
  30. package/dist/services/workspace/workspace-claude-settings-materializer.js +111 -29
  31. package/dist/shared/npm-cache.d.ts +2 -0
  32. package/dist/shared/npm-cache.js +31 -0
  33. package/package.json +5 -5
  34. package/skills/bee/peaks-perf-audit/SKILL.md +24 -0
  35. package/skills/bee/peaks-prd/SKILL.md +24 -0
  36. package/skills/bee/peaks-qa/SKILL.md +24 -0
  37. package/skills/bee/peaks-rd/SKILL.md +24 -0
  38. package/skills/bee/peaks-reviewer/SKILL.md +24 -0
  39. package/skills/bee/peaks-sc/SKILL.md +24 -0
  40. package/skills/bee/peaks-security-audit/SKILL.md +24 -0
  41. package/skills/bee/peaks-txt/SKILL.md +24 -0
  42. package/skills/bee/peaks-ui/SKILL.md +24 -0
  43. package/skills/peaks-audit/SKILL.md +24 -0
  44. package/skills/peaks-code/SKILL.md +24 -0
  45. package/skills/peaks-content/SKILL.md +24 -0
  46. package/skills/peaks-doctor/SKILL.md +24 -0
  47. package/skills/peaks-final-review/SKILL.md +24 -0
  48. package/skills/peaks-ide/SKILL.md +24 -0
  49. package/skills/peaks-issue-fix-orchestrator/SKILL.md +24 -0
  50. package/skills/peaks-resume/SKILL.md +24 -0
  51. package/skills/peaks-slice-decompose/SKILL.md +24 -0
  52. package/skills/peaks-solo/SKILL.md +24 -0
  53. package/skills/peaks-sop/SKILL.md +24 -0
  54. package/skills/peaks-status/SKILL.md +24 -0
  55. package/skills/peaks-test/SKILL.md +24 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,42 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.39 — 2026-09-11 (四条"死机制" + 可见的 OCR 获取)
4
+
5
+ **Highlights**:
6
+
7
+ 1. **本轮查出的东西有一个共同形状:机制写好了、但从没执行过。** 四条各自独立,每一条都能通过全部现有测试,因为**测试断言的是"声明存在",不是"它真的跑"**:
8
+
9
+ - **`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'` 显式传下去。
10
+ - **auto-compact 的 hook 一次都没触发过。** 安装进 `settings.local.json` 的命令是 `peaks code auto-compact`,而 `--project` 是 `.requiredOption` —— 于是它挂在 `Bash|Task` 上,**每次工具调用都以"缺 --project"失败**。非阻塞错误,桌面上什么都看不到。更要紧的是:光改常量**到不了任何已有安装**,因为安装器只比对 matcher、从不比对命令,一看到 `Bash|Task` 条目就返回"已安装"。现在它是一起迁移。
11
+ - **那条"忽略离线模板"的 managed 规则,在任何项目里都没生效过。** 模式是按**项目根**写的,却被写进 `.peaks/.gitignore` —— 带 `/` 的 gitignore 模式锚定到所在目录,于是解析成 `.peaks/.peaks/...`,匹配不到任何东西;而根级的 `.claude/settings.local.json` 更是**根本无法**从 `.peaks/` 内部表达(gitignore 没有 `..`)。后果在本仓库可见:模板文件因为这条规则never生效而一直被跟踪,每次发版都显示 modified,被当"机器路径泄漏"**回滚了三次**——它从来不是泄漏。snippet 已改写到根,并加了**行为式**守卫(逐条模式用 `git check-ignore --no-index` 验证生效;已实测对旧行为失败)。顺带:搬家会留下僵尸块,`workspace init` 现在把它剥掉(保留用户写的每一行,畸形块不动)。
12
+ - **`--graph-node` 的强制只由 `.requiredOption` 支撑。** 传一个**根本不存在的**节点,dispatch 照样成功 —— 下游从不校验,`PEAKS_GRAPH_NODE_NOT_PREPARED` 只被 import、从未抛出;而且 `graphNodeId`/`workflowId`/`graphRef` **从未传给记录写入器**,记录上恒为 null,那条 transition 永远不触发。**这个特性整条链是死的,唯一真实的是它的前置条件** —— 而那个前置条件正好把每个没有图基础设施的项目(即:除了 peaks-loop 自己以外的所有项目)全打断。那句"友好提示"指向的路径本身也走不通:`workflow node prepare` **从不落盘**(打印一个节点就丢),而它要求的图没有任何命令会创建。现在可选 + 按需 provision,三步压成一步,并把三个字段真正写进记录。
13
+
14
+ 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 个技能共享的、有**漂移守卫**的块里。
15
+
16
+ 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**,且**每次都上报用了哪个** —— 静默换壳正是这场混乱的起点。
17
+
18
+ 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 参与。
19
+
20
+ **验证**:三个版本常量一致(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)双平台绿。本轮新增的守卫**每一条都实测过"对旧行为会失败"**。
21
+
22
+ ## 4.0.38 — 2026-09-11 (适配外部闸门 + 多语言评审复活 + 测试不再弹窗)
23
+
24
+ **Highlights**:
25
+
26
+ 1. **子代理被事先告知"先读再改",闸门不再被误判** — 外部插件(ECC)注册了一个 `PreToolUse` 闸门,会在编辑一个文件时要求 LLM 先建立事实,否则**拒绝**。问题不在闸门本身,而在它的提示词**读起来像工具坏了**——它从不说"你的编辑没有被应用",也从不点名真正的前置动作是**先读文件**。现在每个派发出去的子代理的前缀里都有一段 ~693 B 的说明,**规矩在前、范围点名、失败声明在后**:先说"编辑前先读这个文件,这是这里的正常工作方式",点名作用范围(`.peaks/**` 之外的所有路径),**最后**才说万一跳过会撞上什么、以及那不是失败、编辑未被应用。
27
+
28
+ - **代价如实记录**:这段是 7 个角色各加 694 B,大致**把 4.0.36 那一轮省下的 18% 还了回去**。这是用户明确要求的适配,它值这些字节(否则每次首次编辑都要多一轮往返),但提示词经济性的账今天倒退了,这件事应当被看见。
29
+
30
+ 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(不写)。
31
+
32
+ 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`)。
33
+
34
+ 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`**。
35
+
36
+ 5. **`peaks hooks install --global` 此前从未成功过** — ESM 包里用了裸 `__dirname`,抛 `__dirname is not defined`,于是**每一次** `--global` 都返回失败——而且是**在 settings 已经写完之后**才失败,把半成品报成失败。同时 `--dry-run --global` **真的会往 `~/.claude/` 拷文件**。两者都已修。
37
+
38
+ **验证**: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 与那个"能真失败"的守卫当场兑现。
39
+
3
40
  ## 4.0.37 — 2026-09-10 (Windows spawn 根因 + 会话解析 + 诚实性修复)
4
41
 
5
42
  **Highlights**:
package/README-en.md CHANGED
@@ -140,7 +140,7 @@ Every lane opens with **one slash command**.
140
140
 
141
141
  | | |
142
142
  | --- | --- |
143
- | **Latest** | [![npm](https://img.shields.io/npm/v/peaks-loop?style=for-the-badge&logo=npm&logoColor=white&color=cb3837)](https://www.npmjs.com/package/peaks-loop) — 4.0.37 (2026-09-10) |
143
+ | **Latest** | [![npm](https://img.shields.io/npm/v/peaks-loop?style=for-the-badge&logo=npm&logoColor=white&color=cb3837)](https://www.npmjs.com/package/peaks-loop) — 4.0.39 (2026-09-11) |
144
144
  | **Domains** | Code (`peaks-code`) · Content (`peaks-content`) · Project health (`peaks-doctor`) · Issue sweep (`peaks-issue-fix-orchestrator`) · Custom SOP (`peaks-sop`) · Cross-domain primitives (`peaks-solo` dispatcher · `peaks-resume` · `peaks-status` · `peaks-test` · `peaks-slice-decompose`) |
145
145
  | **Sediment pool** | `~/.peaks/` local pool · twice-clean runs auto-promote to a bee · broken runs come back for you to redefine · the bee grows with your taste |
146
146
  | **Test suite** | 285+ cases · 4 packages (peaks-loop / peaks-loop-mut / peaks-loop-shared-channel / peaks-loop-shared) · **0 timeouts** · 14 BDD caller-binding edge cases |
package/README.md CHANGED
@@ -140,7 +140,7 @@ npm i -g peaks-loop
140
140
 
141
141
  | | |
142
142
  | --- | --- |
143
- | **最新版本** | [![npm](https://img.shields.io/npm/v/peaks-loop?style=for-the-badge&logo=npm&logoColor=white&color=cb3837)](https://www.npmjs.com/package/peaks-loop) — 4.0.37(2026-09-10) |
143
+ | **最新版本** | [![npm](https://img.shields.io/npm/v/peaks-loop?style=for-the-badge&logo=npm&logoColor=white&color=cb3837)](https://www.npmjs.com/package/peaks-loop) — 4.0.39(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 { OCR_18_LANGUAGES, runOcr18 } from '../../services/lint/ocr-multilang-adapter.js';
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. pair with `peaks context ' +
64
- 'now` (AC-1) which feeds the ratio into this command. rid-027 ' +
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
- .option('--mode <mode>', 'auto-compact mode (standard | partial). Default: standard. 24h mode auto-selects partial.', 'standard')).action(async (opts) => {
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
- mode: modeName
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
- }), options.json);
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: required --graph-node binding. Absent/wrong-kind
73
- // rejects with PEAKS_GRAPH_NODE_REQUIRED / PEAKS_GRAPH_NODE_NOT_PREPARED.
74
- .requiredOption('--graph-node <id>', 'graph node id this dispatch binds to (RD §4 D4c)')
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: --graph-node is REQUIRED for single dispatch.
179
- // commander.js `.requiredOption` already enforces this at the CLI layer;
180
- // the programmatic dispatcher (`dispatchSubAgent`) below must also
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
@@ -1,5 +1,6 @@
1
1
  import { existsSync, copyFileSync, mkdirSync } from 'node:fs';
2
2
  import { dirname, resolve } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
3
4
  import { fail, ok } from 'peaks-loop-shared/result';
4
5
  import { addJsonOption, printResult, getErrorMessage } from '../cli-helpers.js';
5
6
  import { findProjectRoot } from '../../services/config/config-safety.js';
@@ -7,6 +8,16 @@ import { applyHookInstall, planHookInstall, readHookStatus, readInstalledEntries
7
8
  import { readJsonObjectFile } from '../../services/ide/shared/atomic-json.js';
8
9
  import { detectIdeFromContext } from '../../services/ide/hook-translator.js';
9
10
  import { getAdapter } from '../../services/ide/ide-registry.js';
11
+ /**
12
+ * This module's own directory — `<root>/src/cli/commands` in the source tree,
13
+ * `<root>/dist/cli/commands` in a build. Same reason as
14
+ * `claude-settings-template.ts`: `package.json#type` is `module`, so the CJS
15
+ * module-directory global does not exist here (it is a ReferenceError under
16
+ * both tsx and the shipped `dist` build — see the guard in
17
+ * `tests/unit/hooks/gate-enforce-machine-local-shell.test.ts`), and
18
+ * `process.argv[1]` names a different file per entry point.
19
+ */
20
+ const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
10
21
  function resolveScope(options) {
11
22
  return options.global ? 'global' : 'project';
12
23
  }
@@ -97,12 +108,15 @@ function readOnDiskDenyEntries(settings) {
97
108
  return [];
98
109
  return deny.filter((d) => typeof d === 'string');
99
110
  }
100
- function copyBridgeHookIfPresent(userHome) {
101
- const source = resolve(__dirname, '..', '..', 'services', 'hooks', 'pre-tool-superpowers-bridge.sh');
111
+ function copyBridgeHookIfPresent(userHome, dryRun = false) {
112
+ const source = resolve(MODULE_DIR, '..', '..', 'services', 'hooks', 'pre-tool-superpowers-bridge.sh');
102
113
  const target = resolve(userHome, '.claude', 'skills', 'peaks-code', 'hooks', 'pre-tool-superpowers-bridge.sh');
103
114
  if (!existsSync(source)) {
104
115
  return { copied: false, source, target };
105
116
  }
117
+ if (dryRun) {
118
+ return { copied: true, source, target };
119
+ }
106
120
  mkdirSync(dirname(target), { recursive: true });
107
121
  copyFileSync(source, target);
108
122
  return { copied: true, source, target };
@@ -117,12 +131,15 @@ function copyBridgeHookIfPresent(userHome) {
117
131
  * runtime entry; the shell script is the canonical artifact distributed
118
132
  * alongside the bridge hook.
119
133
  */
120
- function copyCodeGateHookIfPresent(userHome) {
121
- const source = resolve(__dirname, '..', '..', 'services', 'hooks', 'pre-tool-code-gate.sh');
134
+ function copyCodeGateHookIfPresent(userHome, dryRun = false) {
135
+ const source = resolve(MODULE_DIR, '..', '..', 'services', 'hooks', 'pre-tool-code-gate.sh');
122
136
  const target = resolve(userHome, '.claude', 'skills', 'peaks-code', 'hooks', 'pre-tool-code-gate.sh');
123
137
  if (!existsSync(source)) {
124
138
  return { copied: false, source, target };
125
139
  }
140
+ if (dryRun) {
141
+ return { copied: true, source, target };
142
+ }
126
143
  mkdirSync(dirname(target), { recursive: true });
127
144
  copyFileSync(source, target);
128
145
  return { copied: true, source, target };
@@ -147,8 +164,10 @@ export function registerHooksCommands(program, io) {
147
164
  if (options.dryRun === true) {
148
165
  const plan = planHookInstall(scope, projectRoot, { ide, skipProgress });
149
166
  const dryRunEntries = listExpectedEntriesForIde(ide, skipProgress);
167
+ // `dryRun: true` — a --dry-run must not write anything, and these
168
+ // helpers' target is the user's home directory.
150
169
  const bridgeCopy = scope === 'global'
151
- ? copyBridgeHookIfPresent(process.env.USERPROFILE ?? process.env.HOME ?? '')
170
+ ? copyBridgeHookIfPresent(process.env.USERPROFILE ?? process.env.HOME ?? '', true)
152
171
  : { copied: false, source: '', target: '' };
153
172
  // Slice 2026-08-06-codegate-vendor-neutral: also copy the
154
173
  // code-gate hook script. The runtime gate lives at
@@ -156,7 +175,7 @@ export function registerHooksCommands(program, io) {
156
175
  // in the install output); the script is the build artifact
157
176
  // distributed alongside the bridge hook.
158
177
  const codeGateCopy = scope === 'global'
159
- ? copyCodeGateHookIfPresent(process.env.USERPROFILE ?? process.env.HOME ?? '')
178
+ ? copyCodeGateHookIfPresent(process.env.USERPROFILE ?? process.env.HOME ?? '', true)
160
179
  : { copied: false, source: '', target: '' };
161
180
  printResult(io, ok('hooks.install', {
162
181
  ...plan,
@@ -176,6 +195,12 @@ export function registerHooksCommands(program, io) {
176
195
  permissionsDenyEntries: listSuperpowersDenyEntries()
177
196
  }, [], [
178
197
  `would install ${dryRunEntries.length} peaks-managed hook entries`,
198
+ // Name the target file of every entry: the gate-enforce entry is
199
+ // routed to the machine-local settings file (see
200
+ // `resolveHookTargets`), so a summary that only listed
201
+ // `settingsPath` + the entry names read as if it landed in the
202
+ // committed, shared file.
203
+ ...plan.entryTargets.map((entry) => `would write ${entry.matcher || '(no matcher)'} → ${entry.sentinel} to ${entry.settingsPath}`),
179
204
  `would write ${listSuperpowersDenyEntries().length} permissions.deny entries (Layer 3 worktree governance)`,
180
205
  bridgeCopy.copied
181
206
  ? `would copy bridge hook from ${bridgeCopy.source} to ${bridgeCopy.target}`
@@ -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, AUTO_COMPACT_THRESHOLD_RATIO } from '../context/auto-compact-types.js';
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 ${(AUTO_COMPACT_THRESHOLD_RATIO * 100).toFixed(0)}% threshold.`,
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
- : `Auto-compact PreToolUse hook already installed at ${result.settingsPath}; next Bash/Task tool call will trigger compact in-band at ratio ≥ 95%.`
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
  }
@@ -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.)