peaks-loop 4.0.36 → 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.
Files changed (93) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/bin/peaks.js +71 -1
  5. package/dist/cli/cli-helpers.js +7 -0
  6. package/dist/cli/commands/_register.js +2 -0
  7. package/dist/cli/commands/best-practice-scan-command.d.ts +14 -1
  8. package/dist/cli/commands/best-practice-scan-command.js +67 -9
  9. package/dist/cli/commands/code-runtime-commands.js +21 -5
  10. package/dist/cli/commands/hooks-commands.js +41 -7
  11. package/dist/cli/commands/job-commands.js +107 -25
  12. package/dist/cli/commands/scan-commands.js +1 -1
  13. package/dist/cli/commands/web-commands.d.ts +28 -0
  14. package/dist/cli/commands/web-commands.js +327 -0
  15. package/dist/cli/commands/web-lifecycle-commands.d.ts +49 -0
  16. package/dist/cli/commands/web-lifecycle-commands.js +321 -0
  17. package/dist/services/best-practice/scan-orchestrator.d.ts +22 -0
  18. package/dist/services/best-practice/scan-orchestrator.js +14 -5
  19. package/dist/services/code/orchestrator-can-do.js +27 -4
  20. package/dist/services/context/build-dispatch-system-prompt.d.ts +35 -1
  21. package/dist/services/context/build-dispatch-system-prompt.js +55 -3
  22. package/dist/services/context/context-audit-hint.d.ts +79 -0
  23. package/dist/services/context/context-audit-hint.js +150 -0
  24. package/dist/services/hooks/auto-compact-hook-install.js +10 -1
  25. package/dist/services/hooks/write-gate.js +88 -0
  26. package/dist/services/lint/detect-eslint.d.ts +2 -0
  27. package/dist/services/lint/detect-eslint.js +23 -9
  28. package/dist/services/lint/detect-ocr-18.d.ts +2 -0
  29. package/dist/services/lint/detect-ocr-18.js +36 -5
  30. package/dist/services/lint/npx-resolver.d.ts +6 -0
  31. package/dist/services/lint/npx-resolver.js +38 -14
  32. package/dist/services/lint/ocr-multilang-adapter.js +9 -2
  33. package/dist/services/release/version-precheck-service.js +9 -2
  34. package/dist/services/scan/file-size-scan.d.ts +29 -0
  35. package/dist/services/scan/file-size-scan.js +63 -0
  36. package/dist/services/session/caller-binding-service.d.ts +24 -0
  37. package/dist/services/session/caller-binding-service.js +34 -0
  38. package/dist/services/session/getSessionDir.js +15 -10
  39. package/dist/services/skills/hooks-codegate-superpowers.d.ts +74 -0
  40. package/dist/services/skills/hooks-codegate-superpowers.js +129 -3
  41. package/dist/services/skills/hooks-settings-service.d.ts +26 -0
  42. package/dist/services/skills/hooks-settings-service.js +186 -62
  43. package/dist/services/slice/slice-check-service.d.ts +14 -0
  44. package/dist/services/slice/slice-check-service.js +110 -50
  45. package/dist/services/slice/slice-check-types.d.ts +12 -7
  46. package/dist/services/slice/slice-check-types.js +8 -3
  47. package/dist/services/slice/slice-decompose-runners.js +24 -21
  48. package/dist/services/sop/sop-check-service.js +12 -1
  49. package/dist/services/web/bounded-output.d.ts +34 -0
  50. package/dist/services/web/bounded-output.js +68 -0
  51. package/dist/services/web/browser-acquire.d.ts +14 -0
  52. package/dist/services/web/browser-acquire.js +84 -0
  53. package/dist/services/web/browser-session-manager.d.ts +111 -0
  54. package/dist/services/web/browser-session-manager.js +413 -0
  55. package/dist/services/web/daemon-entry.d.ts +1 -0
  56. package/dist/services/web/daemon-entry.js +65 -0
  57. package/dist/services/web/daemon-registry.d.ts +42 -0
  58. package/dist/services/web/daemon-registry.js +164 -0
  59. package/dist/services/web/daemon-supervisor.d.ts +144 -0
  60. package/dist/services/web/daemon-supervisor.js +455 -0
  61. package/dist/services/web/playwright-loader.d.ts +89 -0
  62. package/dist/services/web/playwright-loader.js +253 -0
  63. package/dist/services/web/snapshot-pruner.d.ts +48 -0
  64. package/dist/services/web/snapshot-pruner.js +241 -0
  65. package/dist/services/web/untrusted-envelope.d.ts +27 -0
  66. package/dist/services/web/untrusted-envelope.js +44 -0
  67. package/dist/services/web/web-artifact-paths.d.ts +79 -0
  68. package/dist/services/web/web-artifact-paths.js +163 -0
  69. package/dist/services/web/web-client.d.ts +19 -0
  70. package/dist/services/web/web-client.js +55 -0
  71. package/dist/services/web/web-daemon-service.d.ts +38 -0
  72. package/dist/services/web/web-daemon-service.js +416 -0
  73. package/dist/services/web/web-fallback.d.ts +70 -0
  74. package/dist/services/web/web-fallback.js +121 -0
  75. package/dist/services/web/web-install-service.d.ts +91 -0
  76. package/dist/services/web/web-install-service.js +346 -0
  77. package/dist/services/web/web-login-profile.d.ts +89 -0
  78. package/dist/services/web/web-login-profile.js +612 -0
  79. package/dist/services/web/web-login-staging.d.ts +27 -0
  80. package/dist/services/web/web-login-staging.js +173 -0
  81. package/dist/services/web/web-protocol.d.ts +58 -0
  82. package/dist/services/web/web-protocol.js +58 -0
  83. package/dist/services/web/web-status-report.d.ts +33 -0
  84. package/dist/services/web/web-status-report.js +47 -0
  85. package/dist/services/workspace/claude-settings-template.d.ts +59 -7
  86. package/dist/services/workspace/claude-settings-template.js +139 -67
  87. package/dist/services/workspace/workspace-claude-settings-materializer.js +46 -19
  88. package/dist/services/workspace/workspace-service.js +33 -0
  89. package/package.json +5 -5
  90. package/scripts/copy-templates.mjs +12 -0
  91. package/scripts/sync-version.mjs +20 -0
  92. package/skills/peaks-code/SKILL.md +10 -0
  93. package/skills/peaks-code/references/browser-workflow.md +10 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,47 @@
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
+
21
+ ## 4.0.37 — 2026-09-10 (Windows spawn 根因 + 会话解析 + 诚实性修复)
22
+
23
+ **Highlights**:
24
+
25
+ 1. **Windows `.cmd` spawn 根因,7 处一并修复** — `execFile`/`spawnSync` 一个 `.cmd` shim:**带 shell 时路径里的空格会被 shell 切开,不带 shell 时 Node ≥20 直接 `EINVAL`**。两条分支都不成立,所以在不含空格的路径上一切正常、在每个测试里都绿。
26
+ - `peaks slice check` 在**路径含空格的项目上整体失效**——这是本仓库自己所在的平台。改为 `process.execPath` + 包的 JS entry(无 shim、无 shell)。
27
+ - `version-precheck`:`projectRoot` 作为 **git 的参数**传入,空格路径下 git 退出 128,该层退化成 "layer skipped" —— **把一个真实存在的 tag 冲突报告成了"只是延后"**。
28
+ - `peaks code orchestrator-can-do`:两个探针在 Windows 上**恒返回失败**,所以 `q2SubAgentAvailable` 永远是 false、`q4ContextRatio` 永远是硬编码的 `0`(那是 fallback,不是读数)。
29
+ - 其余:`slice-decompose-runners`(失败被吞进 `indexed:false`,codegraph 信号静默消失)、`detect-eslint`、`coverage-c8`(`--src=`/`--reports-dir=` 携带项目路径)、若干 release 脚本。
30
+
31
+ 2. **会话解析根因——显式 rebind 此前只对一半 CLI 生效** — 两个函数都在回答"当前是哪个 session",来源却不同:`getCurrentSessionId` 读 `.peaks/_runtime/session.json`,`getSessionIdCanonical` **优先 per-caller binding**。`peaks workspace init --session-id X --allow-session-rebind` 只改前者,于是 caller binding **遮蔽**了显式 rebind。实测后果:`session checkpoint` 与 `24h-mode` 把状态写进**另一个 session 的目录**,而 `peaks job` 读的是正确的那个。现在 rebind 同步更新当前 caller 的 binding——**更新而非清除**,因为清除会丢掉 per-caller 身份且没有任何代码会重建它。未 rebind 的其它 caller 文件**字节不变**,隔离不受影响。
32
+
33
+ 3. **job 可按 session 寻址(D6)+ `--slice-id` 不再静默 no-op(D7)** — 11 个 job 子命令补上 `--session-id`,解析顺序 `flag → PEAKS_SESSION_ID → binding`;job 不在绑定的 session 里时不再吐 `UNHANDLED_ERROR`,而是 `JOB_NOT_IN_SESSION` **指名它在哪个 session** 并给出重跑命令。`--slice-id` 现在接受 label 别名,未匹配则 `SLICE_NOT_FOUND` 并**列出全部合法 id**,且**不写入任何文件**(此前 `ok: true` 却什么都没做)。
34
+
35
+ 4. **`peaks web open --profile <name>`——持久登录终于有人消费** — S4 落的 `~/.peaks/web-profiles/<name>/storageState.json` 此前**没有任何动词读取**。`--profile` 只加在 `open` 上;名字在 **CLI 与 daemon 两侧**各校验一次(走线上的名字不是证据);profile **只读**(自动浏览是否该回写用户级凭据文件是未决设计问题,本轮不替你决定);指定了不存在的 profile **按名字失败**,而不是静默用一个未登录的浏览器。
36
+
37
+ 5. **诚实性修复——工具不再报告它没有的证据** — `peaks best-practice-scan` 此前把 stub 片段渲染成 8 行表格 + "方案 A ★" + 强制 ⚠️ 闸门,**全部来自合成文本**;现在合成结果直接拒绝(`ok:false`、exit 1,无推荐无表格)。`peaks slice check` 的 typecheck 阶段此前跑默认 `tsconfig.json`(272 个既有错误、**全部在 `tests/`**),**永远红**;现在 gate 于干净的 build 配置,宽口径计数对基线比较并在 detail 里如实报出。`peaks scan file-size` 不再把**生成文件**(记忆索引、lockfile)算作超长。
38
+
39
+ 6. **两个"不可能失败"的守卫测试** — 重建被删的 session-dir 静态扫描时发现**原版三个不变量都失效**:`src/` 正则只匹配链条**末尾**的 session id(而它要防的 bug 在**中间**,命中 0 次);`skills/` 遍历只下一层(**完全看不到** `skills/bee/<skill>/references/`,包括它当初就是为了守的那个文件);占位符比自己的注释窄。两侧都补了永久性的可证伪用例。`withEnv` 此前通过**在测试体内部注册 `afterEach`** 来还原环境变量——作用域错误,值会漏给下一个测试;改用 `onTestFinished`。**当前没有测试是因为这个泄漏才通过的**——真实的假通过来源,暂无受害者。
40
+
41
+ 7. **write gate 收紧且与 shell 解耦** — 放行范围从"**除 8 个名字外全部放行**"(那会放行顶层的 `.peaks/<change-id>/`,而 CLAUDE.md 明令禁止)收紧为**仅 `.peaks/_runtime/`**。handler 从 `node -e "<bash 专属转义的内联 JS>"` 改为调用随包发布的脚本文件,因而可像它的兄弟一样 pin PowerShell。顺带查明:**这个 hook 在 Windows 上从未真正生效过**——Claude Code 的 payload 走 stdin,而它读 argv,所以每次写入都落到 fall-through。
42
+
43
+ **验证**:build clean、`tsc -p tsconfig.build.json` exit 0、宽口径 `tsconfig.json` 维持既有 142;全量 **175 files / 1679 passed(3 skipped)**;0 个 `daemon-entry` 进程残留。以上每一项都在**构建产物**(`bin/peaks.js`,非 `tsx src/`)上冒烟过,含"路径含空格的项目"这一验收场景。
44
+
3
45
  ## 4.0.36 — 2026-09-10 (派发提示词瘦身 + 波次调度 + 编排器上下文审计)
4
46
 
5
47
  **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.17 (2026-08-07) |
143
+ | **Latest** | [![npm](https://img.shields.io/npm/v/peaks-loop?style=for-the-badge&logo=npm&logoColor=white&color=cb3837)](https://www.npmjs.com/package/peaks-loop) — 4.0.38 (2026-09-11) |
144
144
  | **Domains** | Code (`peaks-code`) · Content (`peaks-content`) · Project health (`peaks-doctor`) · Issue sweep (`peaks-issue-fix-orchestrator`) · Custom SOP (`peaks-sop`) · Cross-domain primitives (`peaks-solo` dispatcher · `peaks-resume` · `peaks-status` · `peaks-test` · `peaks-slice-decompose`) |
145
145
  | **Sediment pool** | `~/.peaks/` local pool · twice-clean runs auto-promote to a bee · broken runs come back for you to redefine · the bee grows with your taste |
146
146
  | **Test suite** | 285+ cases · 4 packages (peaks-loop / peaks-loop-mut / peaks-loop-shared-channel / peaks-loop-shared) · **0 timeouts** · 14 BDD caller-binding edge cases |
package/README.md CHANGED
@@ -140,7 +140,7 @@ npm i -g peaks-loop
140
140
 
141
141
  | | |
142
142
  | --- | --- |
143
- | **最新版本** | [![npm](https://img.shields.io/npm/v/peaks-loop?style=for-the-badge&logo=npm&logoColor=white&color=cb3837)](https://www.npmjs.com/package/peaks-loop) — 4.0.32(2026-09-08) |
143
+ | **最新版本** | [![npm](https://img.shields.io/npm/v/peaks-loop?style=for-the-badge&logo=npm&logoColor=white&color=cb3837)](https://www.npmjs.com/package/peaks-loop) — 4.0.38(2026-09-11) |
144
144
  | **覆盖域** | 代码(`peaks-code`) · 内容(`peaks-content`) · 项目健康(`peaks-doctor`) · 批量修 issue(`peaks-issue-fix-orchestrator`) · 自定义 SOP(`peaks-sop`) · 通用原语(`peaks-solo` 分诊 / `peaks-resume` 续 / `peaks-status` 看 / `peaks-test` 测 / `peaks-slice-decompose` 切片) |
145
145
  | **沉淀池** | `~/.peaks/` 本地池 · 跑两次自动晋升成 bee · 跑翻车让你重定义 · bee 跟着你的口味长 |
146
146
  | **测试套件** | 1096 cases · 4 packages (peaks-loop 1015 / runtime 39 / mut 22 / shared-channel 20) · **CI 首次全绿**(ubuntu + windows) · 14 BDD caller-binding coverage |
package/bin/peaks.js CHANGED
@@ -1,2 +1,72 @@
1
1
  #!/usr/bin/env node
2
- import '../dist/cli/index.js';
2
+ /**
3
+ * peaks-loop CLI shim.
4
+ *
5
+ * This file used to be a bare static `import '../dist/cli/index.js'`. That
6
+ * dies with a raw `ERR_MODULE_NOT_FOUND` stack trace — before any peaks code
7
+ * runs — whenever the local build is stale or incomplete. The reproducible
8
+ * trigger: `scripts/sync-version.mjs` (run by `npm run build`, `pretest`,
9
+ * `prepublish`) unlinks `packages/peaks-loop-shared/dist/version.js`, so every
10
+ * `node bin/peaks.js <anything>` fails with an opaque Node error until the
11
+ * workspace packages are rebuilt.
12
+ *
13
+ * The guard below is the only behaviour added. The happy path is unchanged:
14
+ * the same entry module is imported with the same `process.argv`, and the
15
+ * CLI's own exit codes pass through untouched. Only a resolution failure
16
+ * whose missing specifier is OURS — an internal workspace package or any
17
+ * `dist/` artifact — is converted into an actionable message; every other
18
+ * error is rethrown unchanged (a genuinely missing third-party dependency
19
+ * must still surface as the raw Node error).
20
+ */
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const PACKAGE_ROOT = fileURLToPath(new URL('..', import.meta.url));
24
+
25
+ /** Workspace-internal packages whose absence always means "stale build". */
26
+ const INTERNAL_PACKAGE_NAMES = ['peaks-loop-shared', 'peaks-loop-internal-runtime'];
27
+
28
+ /**
29
+ * The specifier Node could not resolve. Prefer the structured `err.url`
30
+ * (a clean `file://` URL); fall back to the quoted target in the message.
31
+ * The importer path is deliberately never parsed — `dist/cli/index.js` is
32
+ * the importer for every error, so matching it would flag third-party
33
+ * misses as stale-build misses.
34
+ */
35
+ function missingSpecifier(err) {
36
+ if (typeof err?.url === 'string' && err.url.length > 0) return err.url;
37
+ const message = typeof err?.message === 'string' ? err.message : '';
38
+ const pkg = /Cannot find package '([^']+)'/.exec(message);
39
+ if (pkg !== null) return pkg[1];
40
+ const mod = /Cannot find module '([^']+)'/.exec(message);
41
+ if (mod !== null) return mod[1];
42
+ return '';
43
+ }
44
+
45
+ function isInternalSpecifier(specifier) {
46
+ const normalized = specifier.replace(/\\/g, '/');
47
+ if (normalized.length === 0) return false;
48
+ for (const name of INTERNAL_PACKAGE_NAMES) {
49
+ if (
50
+ normalized === name ||
51
+ normalized.startsWith(`${name}/`) ||
52
+ normalized.includes(`/${name}/`) ||
53
+ normalized.endsWith(`/${name}`)
54
+ ) {
55
+ return true;
56
+ }
57
+ }
58
+ return normalized.startsWith('dist/') || /\/dist\//.test(normalized);
59
+ }
60
+
61
+ try {
62
+ await import('../dist/cli/index.js');
63
+ } catch (err) {
64
+ const specifier = err?.code === 'ERR_MODULE_NOT_FOUND' ? missingSpecifier(err) : '';
65
+ if (specifier.length === 0 || !isInternalSpecifier(specifier)) throw err;
66
+ process.stderr.write(
67
+ 'peaks-loop: internal module not found — the local build is stale or incomplete.\n' +
68
+ ` missing: ${specifier}\n` +
69
+ ` fix: run \`npm run build\` in ${PACKAGE_ROOT}, then retry.\n`
70
+ );
71
+ process.exitCode = 1;
72
+ }
@@ -6,6 +6,13 @@ export function printResult(io, result, asJson = false) {
6
6
  }
7
7
  if (!result.ok) {
8
8
  io.stderr(`${result.code}: ${result.message}`);
9
+ // S1's F1, at the printer: a warning carried by a FAILED envelope is the
10
+ // only news some failures have — `peaks web login` reports a headed browser
11
+ // it could not close this way. `fail()` hard-codes `warnings: []`, so only
12
+ // an envelope that spreads its own warnings over a `fail()` reaches here.
13
+ for (const warning of result.warnings) {
14
+ io.stderr(`warning: ${warning}`);
15
+ }
9
16
  for (const action of result.nextActions) {
10
17
  io.stderr(`- ${action}`);
11
18
  }
@@ -80,6 +80,7 @@ import { registerVendorDetectCommand } from './vendor-detect.js';
80
80
  import { registerUpgradeCommands } from './upgrade-commands.js';
81
81
  import { registerUserTouchpointCommands } from './user-touchpoint-commands.js';
82
82
  import { registerVerdictAggregateCommands } from './verdict-aggregate-command.js';
83
+ import { registerWebCommands } from './web-commands.js';
83
84
  import { registerWorkflowCommands } from './workflow-commands.js';
84
85
  import { registerWorkflowPlanCommands } from './workflow-plan-commands.js';
85
86
  import { registerWorktreeAuthCommand } from './worktree-auth-commands.js';
@@ -139,6 +140,7 @@ const REGISTRATIONS = [
139
140
  ['session-spill-demo', registerSpillDemoCommand],
140
141
  ['outer-cache-commands', registerOuterCacheCommands],
141
142
  ['vendor-detect', registerVendorDetectCommand],
143
+ ['web-commands', registerWebCommands],
142
144
  ];
143
145
  function dispatchRegister(register, program, io) {
144
146
  if (register.length <= 1) {
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Slice 2026-08-12 best-practice-scan — CLI subcommand.
3
3
  *
4
- * `peaks best-practice-scan --project <path> [--lang <lang>] [--commit]`
4
+ * `peaks best-practice-scan --intent <goal> --project <path> [--lang <lang>] [--commit]`
5
5
  *
6
6
  * Pipeline:
7
7
  * 1. detect language via language-detector (or use --lang override)
@@ -12,6 +12,19 @@
12
12
  * a future slice that opts the artifact into git tracking)
13
13
  * 5. print artifact path + the 8-row table to stdout
14
14
  *
15
+ * `--intent` is REQUIRED: it is the business goal the scan is for (Step 2.5
16
+ * fires from a non-empty business goal), and it is what the artifact slug is
17
+ * derived from. The project path is NOT an intent — falling back to it was
18
+ * the defect this command was fixed for (a `.`-titled "best-practice scan").
19
+ * Omitting `--intent` fails with `BEST_PRACTICE_SCAN_INTENT_REQUIRED`.
20
+ *
21
+ * Honesty gate: v1's Context7 / WebSearch lookups are stubs, so a scan can
22
+ * come back `synthetic: true` (see scan-orchestrator). A synthetic result is
23
+ * NOT a scan result — the command then prints a refusal (no table, no
24
+ * recommendation, no ⚠️ catch gate), records the skip, and exits non-zero
25
+ * with `BEST_PRACTICE_SCAN_SYNTHETIC_LOOKUP` so no automated caller can
26
+ * treat Step 2.5 as satisfied.
27
+ *
15
28
  * Catch-gate (spec §7): the command emits a 3-line prompt asking the
16
29
  * user to ack / pick alt / reject + reason. The gate is read from
17
30
  * stdin via `PEAKS_BEST_PRACTICE_STDIN` (test seam) or the real stdin
@@ -9,6 +9,37 @@ const CATCH_GATE_PROMPT = [
9
9
  '⚠️ 任何跟你真实业务不一样,改 — LLM 推荐可能错。',
10
10
  '回应 (默认 = 接受): 接受 / 接受方案 A|接受方案 B|接受方案 C / 拒绝 + 原因'
11
11
  ].join('\n');
12
+ const INTENT_REQUIRED_MESSAGE = 'best-practice-scan requires --intent <text>: the intent is the business goal the scan is ' +
13
+ 'for, not the project path. Deriving it from the project path labelled a scan with a ' +
14
+ 'non-goal, so there is no fallback.';
15
+ const SYNTHETIC_SOURCE_MESSAGE = 'best-practice-scan: SKIPPED — the Context7 / WebSearch lookups in this build are stubs ' +
16
+ '(synthetic fragments), so no real documentation was consulted and there is nothing to ' +
17
+ 'recommend. No recommendation, no comparison table and no ⚠️ catch gate were produced.';
18
+ function bestPracticeDir(projectRoot) {
19
+ return join(projectRoot, 'best-practice');
20
+ }
21
+ function artifactPathFor(projectRoot, slugSource) {
22
+ const dateStr = new Date().toISOString().slice(0, 10);
23
+ const slug = slugSource.replace(/[^a-zA-Z0-9_-]+/g, '-').slice(0, 32) || 'intent';
24
+ return join(bestPracticeDir(projectRoot), `${dateStr}-${slug}.md`);
25
+ }
26
+ /** Refusal body for a synthetic scan. It is written to the artifact path so
27
+ * the skip survives the fire-and-forget caller (`best-practice-auto-trigger`
28
+ * spawns with `stdio: 'ignore'`, so stdout never reaches anyone). */
29
+ function renderSyntheticRefusal(opts) {
30
+ return [
31
+ `# Best-Practice Scan — ${opts.intent}`,
32
+ '',
33
+ '> SKIPPED — synthetic (stub) lookup. No real documentation lookup was performed.',
34
+ '',
35
+ SYNTHETIC_SOURCE_MESSAGE,
36
+ '',
37
+ `- language: ${opts.language}`,
38
+ `- transport that answered: ${opts.source} (stub)`,
39
+ '- Step 2.5: recorded as skipped, reason "synthetic-lookup". Re-run this step once the real',
40
+ ' lookup lands; a gate cannot be satisfied from a fabricated scan.'
41
+ ].join('\n');
42
+ }
12
43
  async function readUserInput() {
13
44
  const override = process.env.PEAKS_BEST_PRACTICE_STDIN;
14
45
  if (override !== undefined)
@@ -59,25 +90,54 @@ export function registerBestPracticeScanCommand(program, io) {
59
90
  .description('2026-08-12 best-practice-scan: language-aware + business-aware doc-fragment lookup ' +
60
91
  'via Context7 (priority 1) → WebSearch (priority 2) → empty fallback. ' +
61
92
  'Renders the 8-row comparison table from spec §5 + §6 + §7. ' +
62
- 'Writes the artifact to <project>/best-practice/<date>-<intent>.md (gitignored).')
93
+ 'Writes the artifact to <project>/best-practice/<date>-<intent>.md (gitignored). ' +
94
+ '--intent is required; a synthetic (stub) lookup refuses instead of rendering a ' +
95
+ 'recommendation and exits non-zero.')
96
+ .option('--intent <text>', 'business goal the scan is for (required)')
63
97
  .option('--project <path>', 'project root (default cwd)', process.cwd())
64
98
  .option('--lang <lang>', 'language override (skip auto-detect)')
65
99
  .option('--commit', 'reserved flag — opts the artifact into git tracking (future slice)')).action(async (opts) => {
66
100
  try {
101
+ const intent = (opts.intent ?? '').trim();
102
+ if (intent.length === 0) {
103
+ printResult(io, fail('best-practice.scan', 'BEST_PRACTICE_SCAN_INTENT_REQUIRED', INTENT_REQUIRED_MESSAGE, { project: opts.project }, [
104
+ 'Rerun with --intent "<business goal>" — the goal from the PRD artifact'
105
+ ]), opts.json === true);
106
+ process.exitCode = 1;
107
+ return;
108
+ }
67
109
  const detection = opts.lang === undefined ? detectLanguage(opts.project) : null;
68
110
  const language = opts.lang ?? detection?.language ?? 'unknown';
69
- io.stdout(`[best-practice-scan] project=${opts.project} language=${language}`);
111
+ io.stdout(`[best-practice-scan] project=${opts.project} intent=${intent} language=${language}`);
70
112
  const scan = await scanBestPractice({
71
- intent: opts.project,
113
+ intent,
72
114
  language,
73
115
  projectRoot: opts.project,
74
116
  io
75
117
  });
118
+ if (scan.synthetic) {
119
+ const refusal = renderSyntheticRefusal({ intent, language, source: scan.source });
120
+ io.stdout(refusal);
121
+ const refusedPath = artifactPathFor(opts.project, intent);
122
+ mkdirSync(bestPracticeDir(opts.project), { recursive: true });
123
+ writeFileSync(refusedPath, refusal + '\n', 'utf8');
124
+ printResult(io, fail('best-practice.scan', 'BEST_PRACTICE_SCAN_SYNTHETIC_LOOKUP', SYNTHETIC_SOURCE_MESSAGE, {
125
+ project: opts.project,
126
+ intent,
127
+ language,
128
+ scanSource: scan.source,
129
+ synthetic: true,
130
+ step25: 'skipped-synthetic-lookup',
131
+ artifactPath: refusedPath
132
+ }, ['Re-run once real Context7 / WebSearch wiring lands', `Skip record written to ${refusedPath}`]), opts.json === true);
133
+ process.exitCode = 1;
134
+ return;
135
+ }
76
136
  const recommendation = scan.results.length > 0 ? 'A' : 'B';
77
137
  const reasoning = `基于 ${scan.fragments.length} 个 ${scan.source} 文档片段;` +
78
138
  `语言=${language};LLM 推断方案 ${recommendation} 与项目阶段最匹配。`;
79
139
  const table = formatOutputTable({
80
- intent: opts.project,
140
+ intent,
81
141
  language,
82
142
  fragments: scan.results,
83
143
  recommendation,
@@ -92,14 +152,12 @@ export function registerBestPracticeScanCommand(program, io) {
92
152
  io.stdout(CATCH_GATE_PROMPT);
93
153
  const userInput = await readUserInput();
94
154
  const outcome = parseCatchGateReply(userInput, recommendation);
95
- const outDir = join(opts.project, 'best-practice');
96
- mkdirSync(outDir, { recursive: true });
97
- const dateStr = new Date().toISOString().slice(0, 10);
98
- const slug = opts.project.replace(/[^a-zA-Z0-9_-]+/g, '-').slice(0, 32) || 'intent';
99
- const artifactPath = join(outDir, `${dateStr}-${slug}.md`);
155
+ const artifactPath = artifactPathFor(opts.project, intent);
156
+ mkdirSync(bestPracticeDir(opts.project), { recursive: true });
100
157
  writeFileSync(artifactPath, table + '\n', 'utf8');
101
158
  printResult(io, ok('best-practice.scan', {
102
159
  project: opts.project,
160
+ intent,
103
161
  language,
104
162
  scanSource: scan.source,
105
163
  fragments: scan.results,
@@ -14,6 +14,7 @@ import { fail, ok } from 'peaks-loop-shared/result';
14
14
  import { detectPostCompactResume, formatPostCompactResumeLogLine } from '../../services/code/post-compact-detector.js';
15
15
  import { runAutoCompact } from '../../services/code/auto-compact-orchestrator.js';
16
16
  import { auditContext } from '../../services/context/context-audit.js';
17
+ import { buildContextAuditHint } from '../../services/context/context-audit-hint.js';
17
18
  import { evaluateStep08, STEP_08_BACKUP_REGEX } from '../../services/code/step-08-gate.js';
18
19
  import { evaluateEmitHandoff, JOB_NOT_INITIALIZED, JOB_REMAINING_BLOCKED } from '../../services/code/emit-handoff.js';
19
20
  import { readJobShapeDecision, JobShapeDecisionError } from '../../services/code/job-shape-decision.js';
@@ -320,7 +321,8 @@ export function registerCodeRuntimeCommands(code, io) {
320
321
  'fail-closed backup regex when missing. Exit 0 = allow, exit 2 = block. ' +
321
322
  'When the decision says isJob=true AND progress.json exists, the stdout ' +
322
323
  'also carries `Next: slice #N+1 of M (<currentSlice>)` so the LLM cannot ' +
323
- 'wake up cold.')
324
+ 'wake up cold. When the context ratio is ≥ 0.70 the stdout gains ONE more ' +
325
+ 'line naming the largest context consumer (audit cached ≥ 5 min; fail-soft).')
324
326
  .requiredOption('--project <path>', 'target project root (the hook passes "." so resolveCanonicalProjectRoot promotes it to the git root)')
325
327
  .option('--session-id <sid>', 'override session id (default: read from active presence)')
326
328
  .option('--prompt <text>', 'explicit prompt text (default: read last-prompt.txt; stdin ignored)')).action((opts) => {
@@ -340,6 +342,17 @@ export function registerCodeRuntimeCommands(code, io) {
340
342
  printResult(io, envelope, opts.json);
341
343
  return;
342
344
  }
345
+ // Slice 2026-09-10-three-fixes (Slice 2): proactive context-consumer
346
+ // hint. Runs ONLY when the window is ≥ 0.70 full, caches the audit
347
+ // result for ≥ 5 min so the transcript is scanned at most once per
348
+ // TTL window, and is fail-soft (null → no extra line). It never
349
+ // changes the exit code and never blocks.
350
+ const hintLine = buildContextAuditHint({
351
+ projectRoot: opts.project,
352
+ sessionId,
353
+ outerSessionId: resolveOuterSessionId(opts.project, sessionId)
354
+ });
355
+ const hintActions = hintLine === null ? [] : [hintLine];
343
356
  const evalInput = {
344
357
  projectRoot: opts.project,
345
358
  sessionId
@@ -355,7 +368,7 @@ export function registerCodeRuntimeCommands(code, io) {
355
368
  decision: verdict.decision,
356
369
  progress: verdict.progress,
357
370
  nextSlice: result.nextSliceLine
358
- }, [], result.nextSliceLine !== null ? [result.nextSliceLine] : []);
371
+ }, [], [...(result.nextSliceLine !== null ? [result.nextSliceLine] : []), ...hintActions]);
359
372
  printResult(io, envelope, opts.json);
360
373
  return;
361
374
  }
@@ -366,7 +379,8 @@ export function registerCodeRuntimeCommands(code, io) {
366
379
  decision: null,
367
380
  nextSlice: null
368
381
  }, [], [
369
- 'job-shape.json says isJob=false; single-rid mode (gate allows).'
382
+ 'job-shape.json says isJob=false; single-rid mode (gate allows).',
383
+ ...hintActions
370
384
  ]);
371
385
  printResult(io, envelope, opts.json);
372
386
  return;
@@ -381,7 +395,8 @@ export function registerCodeRuntimeCommands(code, io) {
381
395
  backupRegex: STEP_08_BACKUP_REGEX.toString()
382
396
  }, [
383
397
  'Run `peaks code detect-job --is-job true --rationale <text> --suggested-job-id <slug>` to record the Job-shape verdict.',
384
- 'Then re-run the Bash tool call.'
398
+ 'Then re-run the Bash tool call.',
399
+ ...hintActions
385
400
  ]);
386
401
  io.stderr(`${blockMessage}\n`);
387
402
  printResult(io, envelope, opts.json);
@@ -396,7 +411,8 @@ export function registerCodeRuntimeCommands(code, io) {
396
411
  nextSlice: null,
397
412
  promptSource: verdict.promptSource
398
413
  }, [], [
399
- 'No job-shape.json AND no backup-regex match on prompt → allow (most prompts are not Job-shaped).'
414
+ 'No job-shape.json AND no backup-regex match on prompt → allow (most prompts are not Job-shaped).',
415
+ ...hintActions
400
416
  ]);
401
417
  printResult(io, envelope, opts.json);
402
418
  return;
@@ -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}`
@@ -300,6 +325,12 @@ export function registerHooksCommands(program, io) {
300
325
  // install left behind.
301
326
  const settingsPath = status.settingsPath;
302
327
  const settings = existsSync(settingsPath) ? readJsonObjectFile(settingsPath) : {};
328
+ // The gate-enforce entry is materialized into the machine-local
329
+ // settings file (see `resolveHookTargets`), so the on-disk entry list
330
+ // must be read from both files or `status` would report it missing.
331
+ const localSettings = status.localSettingsPath !== undefined && existsSync(status.localSettingsPath)
332
+ ? readJsonObjectFile(status.localSettingsPath)
333
+ : {};
303
334
  // Slice 2026-07-29-worktree-layer3-deny: report the Layer 3 deny
304
335
  // entries actually on disk. We read the existing settings.json
305
336
  // (above) and surface its `permissions.deny` block. Any entry
@@ -311,7 +342,10 @@ export function registerHooksCommands(program, io) {
311
342
  printResult(io, ok('hooks.status', {
312
343
  ...status,
313
344
  ide,
314
- entries: readInstalledEntriesFromSettings(settings, ide),
345
+ entries: [
346
+ ...readInstalledEntriesFromSettings(settings, ide),
347
+ ...readInstalledEntriesFromSettings(localSettings, ide)
348
+ ],
315
349
  permissionsDenyEntries: listSuperpowersDenyEntries(),
316
350
  permissionsDenyOnDisk: onDiskDeny
317
351
  }), options.json);