peaks-loop 4.0.36 → 4.0.37

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 (88) hide show
  1. package/CHANGELOG.md +24 -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 +10 -1
  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/context-audit-hint.d.ts +79 -0
  21. package/dist/services/context/context-audit-hint.js +150 -0
  22. package/dist/services/hooks/auto-compact-hook-install.js +10 -1
  23. package/dist/services/hooks/write-gate.js +88 -0
  24. package/dist/services/lint/detect-eslint.d.ts +2 -0
  25. package/dist/services/lint/detect-eslint.js +23 -9
  26. package/dist/services/lint/npx-resolver.d.ts +6 -0
  27. package/dist/services/lint/npx-resolver.js +38 -14
  28. package/dist/services/release/version-precheck-service.js +9 -2
  29. package/dist/services/scan/file-size-scan.d.ts +29 -0
  30. package/dist/services/scan/file-size-scan.js +63 -0
  31. package/dist/services/session/caller-binding-service.d.ts +24 -0
  32. package/dist/services/session/caller-binding-service.js +34 -0
  33. package/dist/services/session/getSessionDir.js +15 -10
  34. package/dist/services/skills/hooks-codegate-superpowers.d.ts +33 -0
  35. package/dist/services/skills/hooks-codegate-superpowers.js +34 -3
  36. package/dist/services/skills/hooks-settings-service.d.ts +10 -0
  37. package/dist/services/skills/hooks-settings-service.js +152 -61
  38. package/dist/services/slice/slice-check-service.d.ts +14 -0
  39. package/dist/services/slice/slice-check-service.js +110 -50
  40. package/dist/services/slice/slice-check-types.d.ts +12 -7
  41. package/dist/services/slice/slice-check-types.js +8 -3
  42. package/dist/services/slice/slice-decompose-runners.js +24 -21
  43. package/dist/services/sop/sop-check-service.js +12 -1
  44. package/dist/services/web/bounded-output.d.ts +34 -0
  45. package/dist/services/web/bounded-output.js +68 -0
  46. package/dist/services/web/browser-acquire.d.ts +14 -0
  47. package/dist/services/web/browser-acquire.js +84 -0
  48. package/dist/services/web/browser-session-manager.d.ts +111 -0
  49. package/dist/services/web/browser-session-manager.js +413 -0
  50. package/dist/services/web/daemon-entry.d.ts +1 -0
  51. package/dist/services/web/daemon-entry.js +65 -0
  52. package/dist/services/web/daemon-registry.d.ts +42 -0
  53. package/dist/services/web/daemon-registry.js +164 -0
  54. package/dist/services/web/daemon-supervisor.d.ts +144 -0
  55. package/dist/services/web/daemon-supervisor.js +455 -0
  56. package/dist/services/web/playwright-loader.d.ts +89 -0
  57. package/dist/services/web/playwright-loader.js +253 -0
  58. package/dist/services/web/snapshot-pruner.d.ts +48 -0
  59. package/dist/services/web/snapshot-pruner.js +241 -0
  60. package/dist/services/web/untrusted-envelope.d.ts +27 -0
  61. package/dist/services/web/untrusted-envelope.js +44 -0
  62. package/dist/services/web/web-artifact-paths.d.ts +79 -0
  63. package/dist/services/web/web-artifact-paths.js +163 -0
  64. package/dist/services/web/web-client.d.ts +19 -0
  65. package/dist/services/web/web-client.js +55 -0
  66. package/dist/services/web/web-daemon-service.d.ts +38 -0
  67. package/dist/services/web/web-daemon-service.js +416 -0
  68. package/dist/services/web/web-fallback.d.ts +70 -0
  69. package/dist/services/web/web-fallback.js +121 -0
  70. package/dist/services/web/web-install-service.d.ts +91 -0
  71. package/dist/services/web/web-install-service.js +346 -0
  72. package/dist/services/web/web-login-profile.d.ts +89 -0
  73. package/dist/services/web/web-login-profile.js +612 -0
  74. package/dist/services/web/web-login-staging.d.ts +27 -0
  75. package/dist/services/web/web-login-staging.js +173 -0
  76. package/dist/services/web/web-protocol.d.ts +58 -0
  77. package/dist/services/web/web-protocol.js +58 -0
  78. package/dist/services/web/web-status-report.d.ts +33 -0
  79. package/dist/services/web/web-status-report.js +47 -0
  80. package/dist/services/workspace/claude-settings-template.d.ts +41 -5
  81. package/dist/services/workspace/claude-settings-template.js +116 -64
  82. package/dist/services/workspace/workspace-claude-settings-materializer.js +5 -1
  83. package/dist/services/workspace/workspace-service.js +33 -0
  84. package/package.json +5 -5
  85. package/scripts/copy-templates.mjs +12 -0
  86. package/scripts/sync-version.mjs +20 -0
  87. package/skills/peaks-code/SKILL.md +10 -0
  88. package/skills/peaks-code/references/browser-workflow.md +10 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.37 — 2026-09-10 (Windows spawn 根因 + 会话解析 + 诚实性修复)
4
+
5
+ **Highlights**:
6
+
7
+ 1. **Windows `.cmd` spawn 根因,7 处一并修复** — `execFile`/`spawnSync` 一个 `.cmd` shim:**带 shell 时路径里的空格会被 shell 切开,不带 shell 时 Node ≥20 直接 `EINVAL`**。两条分支都不成立,所以在不含空格的路径上一切正常、在每个测试里都绿。
8
+ - `peaks slice check` 在**路径含空格的项目上整体失效**——这是本仓库自己所在的平台。改为 `process.execPath` + 包的 JS entry(无 shim、无 shell)。
9
+ - `version-precheck`:`projectRoot` 作为 **git 的参数**传入,空格路径下 git 退出 128,该层退化成 "layer skipped" —— **把一个真实存在的 tag 冲突报告成了"只是延后"**。
10
+ - `peaks code orchestrator-can-do`:两个探针在 Windows 上**恒返回失败**,所以 `q2SubAgentAvailable` 永远是 false、`q4ContextRatio` 永远是硬编码的 `0`(那是 fallback,不是读数)。
11
+ - 其余:`slice-decompose-runners`(失败被吞进 `indexed:false`,codegraph 信号静默消失)、`detect-eslint`、`coverage-c8`(`--src=`/`--reports-dir=` 携带项目路径)、若干 release 脚本。
12
+
13
+ 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 文件**字节不变**,隔离不受影响。
14
+
15
+ 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` 却什么都没做)。
16
+
17
+ 4. **`peaks web open --profile <name>`——持久登录终于有人消费** — S4 落的 `~/.peaks/web-profiles/<name>/storageState.json` 此前**没有任何动词读取**。`--profile` 只加在 `open` 上;名字在 **CLI 与 daemon 两侧**各校验一次(走线上的名字不是证据);profile **只读**(自动浏览是否该回写用户级凭据文件是未决设计问题,本轮不替你决定);指定了不存在的 profile **按名字失败**,而不是静默用一个未登录的浏览器。
18
+
19
+ 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)算作超长。
20
+
21
+ 6. **两个"不可能失败"的守卫测试** — 重建被删的 session-dir 静态扫描时发现**原版三个不变量都失效**:`src/` 正则只匹配链条**末尾**的 session id(而它要防的 bug 在**中间**,命中 0 次);`skills/` 遍历只下一层(**完全看不到** `skills/bee/<skill>/references/`,包括它当初就是为了守的那个文件);占位符比自己的注释窄。两侧都补了永久性的可证伪用例。`withEnv` 此前通过**在测试体内部注册 `afterEach`** 来还原环境变量——作用域错误,值会漏给下一个测试;改用 `onTestFinished`。**当前没有测试是因为这个泄漏才通过的**——真实的假通过来源,暂无受害者。
22
+
23
+ 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。
24
+
25
+ **验证**: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/`)上冒烟过,含"路径含空格的项目"这一验收场景。
26
+
3
27
  ## 4.0.36 — 2026-09-10 (派发提示词瘦身 + 波次调度 + 编排器上下文审计)
4
28
 
5
29
  **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.37 (2026-09-10) |
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.37(2026-09-10) |
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;
@@ -300,6 +300,12 @@ export function registerHooksCommands(program, io) {
300
300
  // install left behind.
301
301
  const settingsPath = status.settingsPath;
302
302
  const settings = existsSync(settingsPath) ? readJsonObjectFile(settingsPath) : {};
303
+ // The gate-enforce entry is materialized into the machine-local
304
+ // settings file (see `resolveHookTargets`), so the on-disk entry list
305
+ // must be read from both files or `status` would report it missing.
306
+ const localSettings = status.localSettingsPath !== undefined && existsSync(status.localSettingsPath)
307
+ ? readJsonObjectFile(status.localSettingsPath)
308
+ : {};
303
309
  // Slice 2026-07-29-worktree-layer3-deny: report the Layer 3 deny
304
310
  // entries actually on disk. We read the existing settings.json
305
311
  // (above) and surface its `permissions.deny` block. Any entry
@@ -311,7 +317,10 @@ export function registerHooksCommands(program, io) {
311
317
  printResult(io, ok('hooks.status', {
312
318
  ...status,
313
319
  ide,
314
- entries: readInstalledEntriesFromSettings(settings, ide),
320
+ entries: [
321
+ ...readInstalledEntriesFromSettings(settings, ide),
322
+ ...readInstalledEntriesFromSettings(localSettings, ide)
323
+ ],
315
324
  permissionsDenyEntries: listSuperpowersDenyEntries(),
316
325
  permissionsDenyOnDisk: onDiskDeny
317
326
  }), options.json);