peaks-loop 4.0.47 → 4.0.48

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 (104) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/agents/karpathy-reviewer.md +11 -10
  5. package/dist/cli/cli-helpers.d.ts +34 -0
  6. package/dist/cli/cli-helpers.js +57 -0
  7. package/dist/cli/commands/code-job-shape-commands.js +8 -0
  8. package/dist/cli/commands/code-runtime-commands.js +48 -8
  9. package/dist/cli/commands/compact-command.js +112 -0
  10. package/dist/cli/commands/config-commands.js +15 -9
  11. package/dist/cli/commands/dashboard-long-run.js +6 -0
  12. package/dist/cli/commands/dispatch-commands.js +11 -1
  13. package/dist/cli/commands/doctor/invoke-from-code.js +6 -0
  14. package/dist/cli/commands/hooks-commands.js +4 -4
  15. package/dist/cli/commands/job-commands.js +8 -0
  16. package/dist/cli/commands/loop-eval-commands.js +15 -0
  17. package/dist/cli/commands/perf-audit-commands.js +2 -0
  18. package/dist/cli/commands/playwright-commands.js +12 -0
  19. package/dist/cli/commands/prd-commands.js +1 -1
  20. package/dist/cli/commands/qa-commands.js +22 -0
  21. package/dist/cli/commands/request-commands.js +8 -0
  22. package/dist/cli/commands/scan-commands.js +1 -1
  23. package/dist/cli/commands/security-audit-commands.js +2 -0
  24. package/dist/cli/commands/slice-integrate-commands.js +5 -0
  25. package/dist/cli/commands/statusline-commands.js +44 -4
  26. package/dist/cli/commands/sub-agent/detached.d.ts +14 -1
  27. package/dist/cli/commands/sub-agent/detached.js +47 -22
  28. package/dist/cli/commands/sub-agent-shutdown-commands.js +11 -0
  29. package/dist/cli/commands/verdict-aggregate-command.js +95 -13
  30. package/dist/cli/commands/workflow-commands.js +1 -1
  31. package/dist/cli/index.js +5 -45
  32. package/dist/services/artifacts/artifact-prerequisites.d.ts +38 -7
  33. package/dist/services/artifacts/artifact-prerequisites.js +130 -65
  34. package/dist/services/artifacts/request-artifact-service.d.ts +8 -0
  35. package/dist/services/artifacts/request-artifact-service.js +18 -8
  36. package/dist/services/artifacts/request-artifact-state-helpers.d.ts +57 -0
  37. package/dist/services/artifacts/request-artifact-state-helpers.js +91 -10
  38. package/dist/services/audit-independent/perf-audit-service.d.ts +9 -0
  39. package/dist/services/audit-independent/perf-audit-service.js +27 -5
  40. package/dist/services/audit-independent/security-audit-service.d.ts +12 -2
  41. package/dist/services/audit-independent/security-audit-service.js +28 -6
  42. package/dist/services/code/auto-compact-lifecycle.d.ts +119 -0
  43. package/dist/services/code/auto-compact-lifecycle.js +169 -0
  44. package/dist/services/code/auto-compact-orchestrator.js +13 -2
  45. package/dist/services/code/compact-event-settle.d.ts +122 -0
  46. package/dist/services/code/compact-event-settle.js +219 -0
  47. package/dist/services/compact-history/compact-history-service.d.ts +14 -0
  48. package/dist/services/config/config-restore.d.ts +12 -1
  49. package/dist/services/config/config-restore.js +35 -4
  50. package/dist/services/config/config-rollback.js +6 -1
  51. package/dist/services/context/harness-context-witness.d.ts +310 -0
  52. package/dist/services/context/harness-context-witness.js +606 -0
  53. package/dist/services/evidence/evidence-generator.js +86 -49
  54. package/dist/services/final-review/final-review-service.d.ts +9 -0
  55. package/dist/services/final-review/final-review-service.js +36 -12
  56. package/dist/services/ide/ide-registry.d.ts +19 -0
  57. package/dist/services/ide/ide-registry.js +21 -0
  58. package/dist/services/job/job-state-store.js +7 -0
  59. package/dist/services/polyrepo/polyrepo-dispatcher.js +11 -0
  60. package/dist/services/prd/handoff-auto-regen.js +31 -27
  61. package/dist/services/prd/handoff-frontmatter.d.ts +44 -0
  62. package/dist/services/prd/handoff-frontmatter.js +75 -0
  63. package/dist/services/prd/handoff-service.d.ts +41 -2
  64. package/dist/services/prd/handoff-service.js +81 -8
  65. package/dist/services/prd/handoff-types.d.ts +3 -2
  66. package/dist/services/prd/handoff-types.js +3 -2
  67. package/dist/services/qa/qa-business-review-state.js +9 -0
  68. package/dist/services/scan/karpathy-service.js +2 -2
  69. package/dist/services/session/session-checkpoint-service.js +8 -0
  70. package/dist/services/skill/resume-detector.js +29 -11
  71. package/dist/services/skills/hooks-codegate-superpowers.d.ts +6 -0
  72. package/dist/services/skills/hooks-codegate-superpowers.js +61 -2
  73. package/dist/services/skills/hooks-settings-service.js +14 -4
  74. package/dist/services/skills/session-start-hook-constants.d.ts +45 -0
  75. package/dist/services/skills/session-start-hook-constants.js +45 -0
  76. package/dist/services/skills/skill-statusline-service.d.ts +14 -0
  77. package/dist/services/slice/slice-check-service.js +29 -11
  78. package/dist/services/slice/slice-review-state.js +8 -0
  79. package/dist/services/workflow/pipeline-verify-gate-support.d.ts +47 -10
  80. package/dist/services/workflow/pipeline-verify-gate-support.js +212 -93
  81. package/dist/services/workflow/pipeline-verify-service.js +24 -23
  82. package/dist/services/workflow/pipeline-verify-types.d.ts +10 -3
  83. package/dist/services/workspace/claude-settings-template.d.ts +56 -8
  84. package/dist/services/workspace/claude-settings-template.js +98 -20
  85. package/dist/services/workspace/workspace-claude-settings-materializer.js +78 -7
  86. package/package.json +6 -6
  87. package/skills/bee/peaks-prd/SKILL.md +7 -5
  88. package/skills/bee/peaks-qa/SKILL.md +5 -5
  89. package/skills/bee/peaks-qa/references/qa-runbook.md +2 -2
  90. package/skills/bee/peaks-qa/references/qa-transition-gates.md +7 -7
  91. package/skills/bee/peaks-rd/SKILL.md +8 -6
  92. package/skills/bee/peaks-rd/references/artifact-per-request.md +2 -2
  93. package/skills/bee/peaks-rd/references/parallel-review-fanout.md +7 -5
  94. package/skills/bee/peaks-rd/references/rd-fanout-contracts.md +13 -13
  95. package/skills/bee/peaks-rd/references/rd-runbook.md +9 -5
  96. package/skills/bee/peaks-rd/references/rd-transition-gates.md +9 -7
  97. package/skills/bee/peaks-rd/references/writing-handoff-frontmatter.md +6 -6
  98. package/skills/peaks-code/SKILL.md +1 -1
  99. package/skills/peaks-code/references/a2a-artifact-mapping.md +3 -3
  100. package/skills/peaks-code/references/local-artifact-workspace.md +1 -1
  101. package/skills/peaks-code/references/resume-detection.md +13 -7
  102. package/skills/peaks-code/references/runbook.md +3 -2
  103. package/skills/peaks-code/references/session-overload-signal-index.md +2 -1
  104. package/skills/peaks-code/references/workflow-gates-and-types.md +8 -6
package/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.48 — 2026-09-14 (一个 id 没校验就进了路径 + 自己的消费者读不出的交接胶囊 + 结尾才写、开头就读的状态)
4
+
5
+ **Highlights**:
6
+
7
+ 1. **一个 caller 提供的 id 没经校验就进了路径;这次是当作一类来 instrument,不是逐点打补丁。** session / request / job / change 这些 id 会被拼进产物路径,本仓库**对这件事恰好只有一种正确控制**,而**每个拼接点各自重新决定要不要用它** —— 其中一个没决定,CLI 就写到项目根之外,**并照样返回 `ok: true`**。上一轮量到六处这类点位;这一轮**先给探测装上自检**:在逃逸根植一个哨兵、要求猎手找到它,否则每一个 "escaped=no" 都一文不值 —— 关掉守卫同一探测报 **6/6 逃逸**,恢复后 **0/6**(上一轮的第一次扫描往上走了五层、对十四个用例全报"无逃逸",而逃逸目录就在盘上)。修复是**扩展仓库已有的 AST 守卫**而不是新写扫描器:射程从 0 涨到 `src/` 下 **152 个文件**,灵敏度用同样方式证明(去掉一个守卫它就失败并点名站点,恢复即通过),它的盲区被**精确记录而不是放宽**(`_runtime` 之后是钉死字面量时该站点对它不可见;七个这样的拼接点现在全部点名,一个钉死的普查测试让新增的那个失败)。instrument 找到最严重的一处:**`playwright stop --terminal ../../../../X` 返回 `ok: true`,同时 TERMINATE 了它点名的 pid、并 UNLINK 了项目根之外的一个文件** —— 一个 flag 就拿到任意 SIGTERM 加一次删除。它逃逸有两个原因,都值得记:那一行分析的是 `playwrightSessionsDir()`(其 `_runtime` 槽确实是钉死字面量),而 id 是在**另一个不含任何 `_runtime` 字面量的调用**里拼的;以及**两次扫描从未真的执行过 `playwright`** —— 一个否定结论不会留下"背后到底执行过什么"的痕迹。现在在三个会话操作共用的那个调用处设防,并配一个控制组证明**合法 id 仍然会 kill 和 unlink** —— 选择性的,不是一刀切拒绝。
8
+
9
+ 2. **交接胶囊自己的消费者读不出来。** `peaks prd handoff init` 发出的是 `schemaVersion: "2"` 与裸 `handoffHash:`,而 `rd:qa-handoff` 闸要字面 `schemaVersion: 2` 加 `sha256:`,两个 audit loader 又要锚定的 `^schemaVersion:\s*(\d+)\s*$` —— **三个消费者、四条要求,而规范写入者一条都不满足**。上一轮会话精确诊断过这一点,却把分析写进了自己的 gitignored `_runtime` 树,于是下一轮从零重新发现了一遍。这次**修写入者而不是放宽闸**:没有任何子串编辑能教会一个锚定正则去读带引号的值,而一个接受 loader 仍然拒绝之物的闸,只是把分歧挪到闸后面、并且更安静。同一版另有**三处"两个组件对同一件事各说各话"**:`verify-pipeline` 索要 `rd/security-review.md`,而它本该镜像的表早已移到 `audit/security.md`,还索要两个 v2.11.0 已退役的 QA findings 文件 —— 于是**通过 `request transition` 的切片会在下一个检查里失败**;现在要求集从表自己的 tier 顺序经 `getPrerequisitesFor()` 派生。**状态读取者与写入者对"哪一行是权威"看法相反**:`request transition` 在**末尾**追加权威 Status 块,三个读取者取**第一个**匹配 —— 在 QA 追加过四次的文件上,检查器报 `qa-block` 而写入者写的是 `verdict-issued`;现在所有读取者共用一个定位器,**69 个在盘产物经新旧两条路径读出的结果全等,而两者只在那个错的上面不同**,顺手扫出的第三个 first-match 读取者曾把一份被追加过的 PRD 判成 `in-flight` 而非 `resume`。**同一会话里的两个切片静默互相覆盖审计证据**:受闸的产物路径不带 rid,第二个切片的复核替换掉第一个的 —— 而闸保持绿,因为它检查存在与形状,**从不检查归属**;一位审计者注意到并保留了文件、另一位没有,**这个差别是运气不是纪律**。路径现在按 rid 划界、legacy 层留作回退,经 **62 组真实 session×rid 对**验证。
10
+
11
+ 3. **压缩触发点第一次有了目击者,而不是从"ratio 跌没跌"反推。** harness 每次压缩都会广播(`PreCompact` 带 `trigger: manual|auto`,`PostCompact` 在完成后触发),而 peaks-loop 从没在听 —— 它靠"下一个探针发现 ratio 掉了"来推断"发生过压缩"。于是本仓库存在的理由(标定)最想回答的那个问题,**"是 harness 自己压的,还是有人敲了 `/compact`"**,在这里**根本没有目击者**;实测本机全部 transcript:`compactMetadata.trigger` 读到 `manual x2`、`auto x0`。新增 `PostCompact` 钩子条目,事件到达当刻就结算打开的 run,并把 harness 自己的 trigger 写进观测行(**没有打开的 run 的压缩仍然不留痕迹 —— 归因不是证据**)。三件事是测量逼出来的:事件路径**不能把"lifecycle 写失败"报成"没有 run"**(`null` 现在只表示一个意思;写失败连 `lifecycleWritten: false` 一起返回 facts,观测行照写,因为那行是**关于 harness 说了什么**、不是关于我们写没写进去);payload 的 `session_id` 与自己的外层会话 id 比对,**子代理发起的事件不能结算主会话的 run**,而字段缺失或自己的 id 解析不出时**接受** —— 一个看不见自己名字的守卫不能变成死钩子;被事件结算的压缩**仍要闭合它的标定对**,填空推迟到下一个测到低于该 run 自身触发点的探针,**是推迟不是封死**。matcher 用空串而不是文档写的 `auto|manual`:两族钩子的 matcher 语义不同(`PreToolUse` 是工具名正则,`SessionStart` 的 `'compact'` 是精确来源标签),证据支持的是精确相等,空串是唯一不可能"永不触发"的形式。姊妹项把"两个窗口是不是同一个数"**从推断变成比对**:`used_percentage` 在 `src/` 里此前**零消费者**,现在默认渲染器把它捕获进会话级 ledger、`context-now` 拿自己的 ratio 与 harness 的百分比比对。三个设计点承担了风险:两个"窗口"**不是一个字段**(比的是 ratio 对 `used_percentage`,**绝不是窗口对窗口** —— 直接比会给出一个自信的错答案);**容差是论证出来的不是选出来的**(贡献项是 harness 的整数百分比取整、分子口径、以及两个在不同时刻算出的数之间的采样偏斜,偏斜被**减掉**而不是预算 —— 预算会让预算涨 x 而偏差涨 x(1+g),真实 gap 被自己的偏斜抵消);**锐度闸测的是目击者的 ratio 而不是 peaks-loop 的**(旧版闸错了对象,于是真实 3.3% 的 gap 在 r=0.2 处读成 `agree`,恰好落在实测采样偏斜的位置)。验收是一次 **274,188 次比对**的扫描、其最坏被掩蔽的 `agree` 是**空集**,同一扫描对旧闸失败。判决有三个而不是两个:`agree` / `disagree` / `unverifiable` —— 两答案的守卫分不清"正确地沉默"和"坏了"。
12
+
13
+ 4. **上一版自己点名的"尚未处理"被兑现;其中两件的前提被实测证伪,而纠正前提比修复本身更重要。**
14
+
15
+ - **"模板只拥有它声明的条目。"** 4.0.47 把 settings materializer 的整键 `hooks` 所有权记为**潜伏**问题("今天没有东西走那条路")—— **这是错的,而且有一条活路径**:`installAutoCompactHook` 往那个文件里写 `Bash|Task` 条目,而 `peaks workspace init` 拥有整个键。实测:init → 装好 auto-compact(4 条)→ 再 init → **REFRESHED,3 条,`Bash|Task survived? false`** —— 而**没有任何东西会把它装回去,于是 auto-compact 静默停止**,刷新只报成 `refreshed`。现在模板只拥有它**声明**的条目(按 matcher 键控 + 每 matcher 配额),自己的一律排在最前,其余在盘条目**逐字保留**(未声明的 matcher、超出配额的条目、未声明的事件、不合规的条目);会被漏掉的那一半也修了 —— `templateContentMatches` 必须一起改成**多重集包含**,否则合并之后它会永远拿 3 条模板去比 4 条在盘、于是每次 init 都报 `refreshed`,正是整键所有权存在时要防的那个状态。五条验收条件全部**实测而非推理**。
16
+ - **`config restore` 与 `rollback` 对"没有备份"这件事说两种话**:`--list` 像失败一样 exit 1,`rollback` 像事实一样 exit 0 带 `available: false` —— **它们是同一件事实**。现在两者都 exit 0,且 `available` 出现在**每一个**信封上(`false` = 没有备份可恢复,`true` = 有备份、失败的是操作本身),区分从退出码搬进了一个 JSON 键。
17
+ - **`test:integration` 漏了 `--config`**,于是继承基础 config 的 `include`、**选中零个文件**:"No test files found, exiting with code 0" —— 一个**什么都没跑**的绿。脚本现在带上该 flag,`ci.yml` / `vitest.config.integration.ts` / 一个把旧绕行说成永久的测试注释也一并改正;**没有任何 CI 步骤会跑两遍该套件**(那个 job 直接调 vitest,从不走脚本)。
18
+ - **`detached.ts` 挂了一个可证明永远不会运行的 `child.on('error')`** —— Node 把 spawn 失败派发在 nextTick 队列上,该队列在 await 的调用方恢复之前就排空,所以 handler 总是挂得太晚。删掉它,启动失败路径改读 `spawnError`,而不是返回 `ok: true` 带 `pid: -1`;在一个真实 ENOENT 上端到端验证:`ok: false`、`spawnError {ENOENT}`、派发记录 `status=failed`。另外 `ProcessSupervisor.spawn` 曾在失败启动时写零字节 `pid` 文件,而 `Number('') === 0` —— 一次 `kill(Number(read(pid)))` 的清理会**给 pid 0 发信号,也就是整个进程组**;现在什么都不写,因为"不存在"才是真正的不变量。
19
+ - 以及 `--ide` help 列了两个适配器而注册表里有九个(六处不是三处,改为从注册表派生,从此不会漂移)、`tests/integration/_cli-helper.ts` 镜像了一个生产已不再发出的信封形状(爆炸半径是**一个** importer 不是十一个;改为让真实 CLI 与测试 helper 调用同一份 `cli-helpers.ts`,此后不可能各自漂移)。
20
+
21
+ **验证**:三个版本常量一致(**4.0.48**);`pnpm build` 的 `build-integrity: OK`;`tsc -p tsconfig.json` 保持 **140** 基线、**0 在 `src/`**、且 **TS6053 = 0**(孤立的 TS6053 是一次 abort 而不是一棵干净的树 —— 它报"找不到文件"并且什么都不编译,所以 `140 → 1` 会被读成"大幅改善"、`0 → 1` 被读成"新的小问题",两种读法都错);`tests/unit` **248 files / 2627 passed / 3 skipped / 0 failed**。套件的 `write-gate-decision-table` 与 `final-review-service` 用例是**负载敏感**的(并行负载下超时、单独跑通过)—— **点名,不两边计数**。
22
+
23
+ **明确未验证的(不当作已完成)**:**没有人在真实 Claude Code 上观察过一次 `PostCompact`** —— 测试证明的是"命令会结算",**从不是"事件会到达"**;同理,本仓库**从未观察过任何真实的 Claude Code statusline 载荷**(此仓没有 `statusLine` 键,装一个属于需要用户确认的 harness settings 变更),被验证的只是"真实载荷会被正确捕获并比对"。
24
+
25
+ **本轮顺带发现、尚未处理(不属本版修复)**:13 个守卫点位里 10 个没有实测控制组;17 个带 id 的文件既未分类也未点名;约 60 处 binding/env 拼接;31 个未追踪的 CLI 文件上仍有同一种路径逃逸形状;以及最刺眼的一条 —— **扫描清单自己的后续指令仍然排除了这一轮查出最严重缺陷的那个文件**。
26
+
3
27
  ## 4.0.47 — 2026-09-13 (90 个文件第一次进 CI 就绿了 + 弹窗的根在守卫射程 + 说"能做"而做不到的地方)
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.47 (2026-09-13) |
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.48 (2026-09-14) |
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.47(2026-09-13) |
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.48(2026-09-14) |
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 |
@@ -29,7 +29,8 @@ You are a **Karpathy-guidelines enforcement reviewer** for peaks-rd. You inspect
29
29
 
30
30
  1. **The verbatim 4-section Karpathy-guidelines block** (injected by the parent RD loop via `rd-sub-agent-dispatch.md` §"Karpathy-guidelines context"). If this block is missing from the prompt, return `gateAction: 'block'` with a single `think-before-coding` violation.
31
31
  2. **The slice's `git diff` against the base branch** (use `git diff <base>...HEAD` to get the full RD-side change set; fall back to `git diff` if no base is given, then `git status --short` to confirm scope).
32
- 3. **The slice's `rd/tech-doc.md`** (architecture summary, written by RD).
32
+ 3. **The slice's `prd/handoff.md`** (the immutable peaks-prd design / scope record — v2.11.0: it replaces `rd/tech-doc.md`, which no gate requires any more). Read it at `.peaks/_runtime/<sessionId>/prd/handoff.md`; verify the handoff hash matches the value the parent dispatched before relying on it.
33
+ > `rd/tech-doc-<rid>.md` was evaluated as the alternative and rejected: nothing in the pipeline writes a rid-scoped tech-doc (`peaks evidence generate` writes the RIDLESS `rd/tech-doc.md` on purpose, since the `TECH_DOC` prereq was dropped in v2.11.0), so requiring one would BLOCK every slice. The ridless path was worse still — one file shared by every slice in a session, so a stale architecture summary from an earlier slice read as this slice's design doc. `prd/handoff.md` is the record the contract itself requires at `rd:qa-handoff` (`AUDIT_REQUIRES_HANDOFF`), and the fan-out prose already names it (`references/parallel-review-fanout.md`).
33
34
  4. **The slice's PRD body / acceptance criteria** (look at `.peaks/_runtime/<sessionId>/prd/requests/<rid>.md`).
34
35
  5. **Optional: the slice's `rd/code-review.md` / `rd/security-review.md` / `rd/perf-baseline.md`** for cross-context only — do not duplicate their findings.
35
36
 
@@ -47,7 +48,7 @@ If any of inputs 1-4 is unreadable / missing, return `gateAction: 'block'` with
47
48
  - The diff adds a regex / parser / serializer without naming the input shape (language, locale, edge cases).
48
49
  - The diff introduces a CLI option whose default value is not justified (why this default? what changed if a user picks a different value?).
49
50
  - The diff contains the phrase "we'll handle that later" / "TODO: think about" / "TBD" in a code path.
50
- - The slice's `rd/tech-doc.md` "Architecture" or "Trade-offs" section is missing or empty.
51
+ - The slice's `prd/handoff.md` body — the architecture / trade-offs record — is missing or empty.
51
52
 
52
53
  ### 3.2 Simplicity First
53
54
 
@@ -85,7 +86,7 @@ If any of inputs 1-4 is unreadable / missing, return `gateAction: 'block'` with
85
86
  - The diff adds a new public function / exported type / CLI subcommand with NO test in the same diff.
86
87
  - The slice's `qa/test-cases/<rid>.md` is empty or has < 3 test rows.
87
88
  - The diff claims a behavior change in a commit message / PR body / docstring but no test exercises the change.
88
- - The diff's `rd/tech-doc.md` "Acceptance checks" section is missing or empty.
89
+ - The slice's `prd/handoff.md` frontmatter carries no `acceptanceCriteria` entries — the handoff's equivalent of the old "Acceptance checks" section.
89
90
  - The diff adds a new regex / parser / runtime check whose failure path is unreachable from any test.
90
91
 
91
92
  ## 4. Output (compact JSON envelope)
@@ -163,8 +164,8 @@ When `evaluationCost` is reported, the envelope is:
163
164
  |---|---|
164
165
  | 0 violations | `'pass'` |
165
166
  | 1+ violations of any kind, none of which is a HARD-blocker | `'warn'` |
166
- | `## Karpathy-Gate` header missing from `rd/karpathy-review.md` file write | `'block'` |
167
- | Inputs 1-4 (guidelines verbatim / diff / tech-doc / PRD-AC) missing or unreadable | `'block'` |
167
+ | `## Karpathy-Gate` header missing from `rd/karpathy-review-<rid>.md` file write | `'block'` |
168
+ | Inputs 1-4 (guidelines verbatim / diff / prd-handoff / PRD-AC) missing or unreadable | `'block'` |
168
169
  | Diff is empty (no changed files) but the slice claims a feature change | `'block'` |
169
170
  | The slice introduces secrets, executes arbitrary code, or modifies `~/.claude/settings.json` / `~/.claude/agents/` / `~/.claude/hooks/` | `'block'` |
170
171
  | 3+ violations across 3+ different `kind` values | `'block'` (the slice is structurally misaligned, not just imperfect) |
@@ -181,7 +182,7 @@ If the `evaluationCost` field is absent (older reviewer, or the orchestrator did
181
182
 
182
183
  ## 5. File write contract
183
184
 
184
- You MUST also write `rd/karpathy-review.md` (relative to the slice's project root). The file must contain EXACTLY these section headers, in this order, with title-case capitalization (the existing `KARPATHY_REVIEW` prereq in `src/services/artifacts/artifact-prerequisites.ts` enforces this). The 5 literal lines below are indented by 4 spaces to keep them out of the sibling-reference heading inventory; copy them verbatim into the file you write.
185
+ You MUST also write `rd/karpathy-review-<rid>.md` (relative to the slice's project root), replacing `<rid>` with this slice's request id — the same id that names `.peaks/_runtime/<sessionId>/rd/requests/<rid>.md`. The rid is part of the filename because every slice in a session shares `rd/`; writing the ridless `rd/karpathy-review.md` silently destroys the previous slice's review (it did on 2026-09-13). The file must contain EXACTLY these section headers, in this order, with title-case capitalization (the existing `KARPATHY_REVIEW` prereq in `src/services/artifacts/artifact-prerequisites.ts` enforces this). The 5 literal lines below are indented by 4 spaces to keep them out of the sibling-reference heading inventory; copy them verbatim into the file you write.
185
186
 
186
187
  ```md
187
188
  # Karpathy review — <rid>
@@ -209,14 +210,14 @@ You MUST also write `rd/karpathy-review.md` (relative to the slice's project roo
209
210
  <bullet-list of evidence, one per finding, or "No violations" if clean>
210
211
  ```
211
212
 
212
- If you cannot write this file (read-only filesystem, path collision, permission denied), include a `writeError: '<reason>'` field in your JSON envelope and set `gateAction: 'block'`. The transition CLI gate will refuse the `qa-handoff` transition if `rd/karpathy-review.md` is missing.
213
+ If you cannot write this file (read-only filesystem, path collision, permission denied), include a `writeError: '<reason>'` field in your JSON envelope and set `gateAction: 'block'`. The transition CLI gate will refuse the `qa-handoff` transition if `rd/karpathy-review-<rid>.md` is missing.
213
214
 
214
215
  ## 6. Hard prohibitions
215
216
 
216
217
  In addition to the 4-sub-agent block:
217
218
 
218
219
  - **MUST NOT write code** — you review, you do not implement. The RD main loop owns code edits.
219
- - **MUST NOT modify the request artifact** (`.peaks/_runtime/<sessionId>/rd/requests/<rid>.md` or the PRD body). Your only write target is `rd/karpathy-review.md`.
220
+ - **MUST NOT modify the request artifact** (`.peaks/_runtime/<sessionId>/rd/requests/<rid>.md` or the PRD body). Your only write target is `rd/karpathy-review-<rid>.md`.
220
221
  - **MUST NOT call `peaks request transition`** — only the parent RD loop owns the transition state machine.
221
222
  - **MUST NOT install hooks, agents, MCP servers, or modify settings** (this is the global peaks-rd red line; it applies to sub-agents too).
222
223
  - **MUST NOT touch Slice 1+2+3+4+5 products** (zero regression). If the diff includes changes to `karpathy-service.ts` / `scan-commands.ts` / `artifact-prerequisites.ts` / `peaks-rd/SKILL.md` / `rd-fanout-contracts.md` / `rd-sub-agent-dispatch.md` / `karpathy-5way-fanout.test.ts` / `rd/karpathy-review.md`, flag them as `surgical-changes` violations unless the slice's PRD explicitly authorizes the touch.
@@ -234,11 +235,11 @@ In addition to the 4-sub-agent block:
234
235
 
235
236
  ## 8. Review process
236
237
 
237
- 1. Read the 4 inputs (guidelines / diff / tech-doc / PRD-AC).
238
+ 1. Read the 4 inputs (guidelines / diff / prd-handoff / PRD-AC).
238
239
  2. For each of the 4 guidelines, walk the diff and apply the detection rules in §3.
239
240
  3. Collect violations into a list, capped at 5 per kind.
240
241
  4. Decide `gateAction` per the §4 decision table.
241
- 5. Write `rd/karpathy-review.md` with the 4 title-case section headers.
242
+ 5. Write `rd/karpathy-review-<rid>.md` with the 4 title-case section headers.
242
243
  6. Emit the compact JSON envelope on the last line of your response.
243
244
 
244
245
  ## 9. Review summary format (informational, not part of envelope)
@@ -58,6 +58,40 @@ export declare function printCliEnvelope(io: ProgramIO, r: CliEnvelope): void;
58
58
  * `src/cli/program.ts:162` is hereby resolved by this helper.
59
59
  */
60
60
  export declare function printErrorEnvelope(io: ProgramIO, command: string, code: string, message: string, data: Record<string, unknown>, nextActions: string[]): void;
61
+ /**
62
+ * The subcommand path the caller actually invoked, in the same tokens they
63
+ * typed (`release canary`). Derived from the registered command tree by
64
+ * consuming leading non-`-` argv tokens, so it never disagrees with what
65
+ * Commander dispatched on.
66
+ *
67
+ * Commander's `CommanderError` carries a code and a message but no command
68
+ * reference — `missingMandatoryOptionValue` is raised by the leaf command and
69
+ * throws out of `parseAsync` with nothing naming it. Without this walk the
70
+ * missing-option envelope could only say `command: "cli"`, which is the field
71
+ * the caller needs to act on.
72
+ */
73
+ export declare function resolveInvokedCommandPath(program: Command, argv: readonly string[]): string;
74
+ /**
75
+ * Render the `MISSING_REQUIRED_OPTION` envelope for a Commander
76
+ * `commander.missingMandatoryOptionValue`.
77
+ *
78
+ * A `.requiredOption()` the caller did not supply is raised as a plain
79
+ * `Error`-shaped `CommanderError`, so an unhandled `.catch()` used to file it
80
+ * under `UNHANDLED_ERROR` — "you left out an argument" reported as a crash,
81
+ * with empty `nextActions`, `command: "cli"` and no way to see WHICH option.
82
+ * The flags string Commander formats carries both the name and the accepted
83
+ * values (`--percent <10|50>`), so it is worth extracting rather than
84
+ * paraphrasing: it is the option's own declaration.
85
+ *
86
+ * Exported because it has TWO callers that must not drift: the real wrapper
87
+ * (`src/cli/index.ts`) and the in-process test runner
88
+ * (`tests/integration/_cli-helper.ts`), which re-creates the wrapper's
89
+ * behaviour for tests that call the program directly. That file used to
90
+ * hand-mirror the pre-fix shape, so every integration test that hit a missing
91
+ * required option asserted an envelope production no longer emits. Sharing the
92
+ * builder is what makes the mirror incapable of lying.
93
+ */
94
+ export declare function printMissingRequiredOptionEnvelope(io: ProgramIO, invokedCommand: string, message: string): void;
61
95
  export declare function isRecommendationWorkflow(value: string): value is RecommendationWorkflow;
62
96
  export declare function isArtifactProvider(value: string): value is ArtifactProvider;
63
97
  export declare function isArtifactSetupStep(value: string): value is GuidedArtifactSetup['step'];
@@ -88,6 +88,63 @@ export function printErrorEnvelope(io, command, code, message, data, nextActions
88
88
  io.stderr(JSON.stringify(envelope, null, 2));
89
89
  process.exitCode = 1;
90
90
  }
91
+ /**
92
+ * The subcommand path the caller actually invoked, in the same tokens they
93
+ * typed (`release canary`). Derived from the registered command tree by
94
+ * consuming leading non-`-` argv tokens, so it never disagrees with what
95
+ * Commander dispatched on.
96
+ *
97
+ * Commander's `CommanderError` carries a code and a message but no command
98
+ * reference — `missingMandatoryOptionValue` is raised by the leaf command and
99
+ * throws out of `parseAsync` with nothing naming it. Without this walk the
100
+ * missing-option envelope could only say `command: "cli"`, which is the field
101
+ * the caller needs to act on.
102
+ */
103
+ export function resolveInvokedCommandPath(program, argv) {
104
+ let cmd = program;
105
+ const parts = [];
106
+ for (const token of argv) {
107
+ if (token.startsWith('-'))
108
+ break;
109
+ const next = cmd.commands.find((c) => c.name() === token || c.aliases().includes(token));
110
+ if (next === undefined)
111
+ break;
112
+ parts.push(next.name());
113
+ cmd = next;
114
+ }
115
+ return parts.length > 0 ? parts.join(' ') : program.name();
116
+ }
117
+ /**
118
+ * Render the `MISSING_REQUIRED_OPTION` envelope for a Commander
119
+ * `commander.missingMandatoryOptionValue`.
120
+ *
121
+ * A `.requiredOption()` the caller did not supply is raised as a plain
122
+ * `Error`-shaped `CommanderError`, so an unhandled `.catch()` used to file it
123
+ * under `UNHANDLED_ERROR` — "you left out an argument" reported as a crash,
124
+ * with empty `nextActions`, `command: "cli"` and no way to see WHICH option.
125
+ * The flags string Commander formats carries both the name and the accepted
126
+ * values (`--percent <10|50>`), so it is worth extracting rather than
127
+ * paraphrasing: it is the option's own declaration.
128
+ *
129
+ * Exported because it has TWO callers that must not drift: the real wrapper
130
+ * (`src/cli/index.ts`) and the in-process test runner
131
+ * (`tests/integration/_cli-helper.ts`), which re-creates the wrapper's
132
+ * behaviour for tests that call the program directly. That file used to
133
+ * hand-mirror the pre-fix shape, so every integration test that hit a missing
134
+ * required option asserted an envelope production no longer emits. Sharing the
135
+ * builder is what makes the mirror incapable of lying.
136
+ */
137
+ export function printMissingRequiredOptionEnvelope(io, invokedCommand, message) {
138
+ const option = /required option '([^']+)'/.exec(message)?.[1];
139
+ printErrorEnvelope(io, invokedCommand, 'MISSING_REQUIRED_OPTION', option === undefined
140
+ ? message
141
+ : `Missing required option '${option}' for \`peaks ${invokedCommand}\`.`, { option: option ?? null }, [
142
+ option === undefined
143
+ ? `Run \`peaks ${invokedCommand} --help\` to see the options this command requires.`
144
+ : `Supply ${option} — it is required, so the command has no default for it.`,
145
+ `Run \`peaks ${invokedCommand} --help\` for the option's accepted values and its siblings.`
146
+ ]);
147
+ }
91
148
  export function isRecommendationWorkflow(value) {
92
149
  return value === 'code-refactor' || value === 'product-refactor' || value === 'frontend-design';
93
150
  }
@@ -7,6 +7,7 @@
7
7
  import { existsSync, readFileSync } from 'node:fs';
8
8
  import { join } from 'node:path';
9
9
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
10
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
10
11
  import { fail, ok } from 'peaks-loop-shared/result';
11
12
  import { readJobShapeDecision, writeJobShapeDecision, JobShapeDecisionError, JOB_SHAPE_NOT_DECIDED, JOB_SHAPE_ALREADY_DECIDED } from '../../services/code/job-shape-decision.js';
12
13
  import { findProjectRoot } from '../../services/config/config-safety.js';
@@ -60,6 +61,13 @@ export function registerCodeJobShapeCommands(code, io) {
60
61
  process.exitCode = 1;
61
62
  return;
62
63
  }
64
+ // Sid axis. `--session-id` reaches both the `last-prompt.txt` read and
65
+ // `writeJobShapeDecision`, so one guard at resolution covers the file.
66
+ if (isUnsafePathInput(sessionId)) {
67
+ printResult(io, fail('code.detect-job', 'INVALID_SESSION_ID', `Invalid session id: ${sessionId} (must be a single path segment)`, { provided: sessionId }, ['Pass a session id that is a single path segment']), opts.json);
68
+ process.exitCode = 1;
69
+ return;
70
+ }
63
71
  // Prompt source: explicit --prompt > last-prompt.txt > empty.
64
72
  let promptText = opts.prompt ?? '';
65
73
  if (opts.prompt === undefined) {
@@ -16,6 +16,7 @@ import { runAutoCompact } from '../../services/code/auto-compact-orchestrator.js
16
16
  import { auditContext } from '../../services/context/context-audit.js';
17
17
  import { syncHarnessWindowForProject } from '../../services/context/auto-compact-reader.js';
18
18
  import { describeHarnessWindowSync, harnessWindowSyncWarning } from '../../services/context/harness-window-config.js';
19
+ import { describeHarnessWitness, readAndCompareHarnessWitness } from '../../services/context/harness-context-witness.js';
19
20
  import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
20
21
  import { buildContextAuditHint } from '../../services/context/context-audit-hint.js';
21
22
  import { evaluateStep08, STEP_08_BACKUP_REGEX } from '../../services/code/step-08-gate.js';
@@ -249,6 +250,16 @@ export function registerCodeRuntimeCommands(code, io) {
249
250
  env: process.env,
250
251
  promptSizeBytes
251
252
  });
253
+ // Promote `--project .` (what the PreToolUse hook passes) to the git
254
+ // root ONCE, for both of the consumers below: the settings path the
255
+ // harness window is written to, and the session directory the witness
256
+ // is read from, must not depend on the caller's cwd — and an absolute
257
+ // path is what the envelope then reports back to the operator. This
258
+ // resolves through `git rev-parse --show-toplevel`, i.e. a whole
259
+ // process spawn (measured 2026-09-14: med 111.8 ms per call on this
260
+ // host), so calling it twice with the same argument charged the witness
261
+ // read it precedes — 0.027 ms — roughly 4,000x its own cost.
262
+ const canonicalProjectRoot = resolveCanonicalProjectRoot(opts.project);
252
263
  // Slice 2026-09-13-auto-compact-trigger-ownership (T1 + T2): materialize
253
264
  // the window this probe just divided by into the harness's own settings,
254
265
  // so "85%" here and the harness's own trigger are one point on one
@@ -256,11 +267,7 @@ export function registerCodeRuntimeCommands(code, io) {
256
267
  // no-op when the probe carried no token window — peaks-loop never
257
268
  // invents a number it did not measure.
258
269
  const harnessWindow = syncHarnessWindowForProject({
259
- // Promote `--project .` (what the PreToolUse hook passes) to the git
260
- // root first: the settings path this writes to must not depend on
261
- // the caller's cwd, and an absolute path is what the envelope then
262
- // reports back to the operator.
263
- projectRoot: resolveCanonicalProjectRoot(opts.project),
270
+ projectRoot: canonicalProjectRoot,
264
271
  env: process.env,
265
272
  tokens: probe.capacityTokens ?? null
266
273
  });
@@ -271,6 +278,23 @@ export function registerCodeRuntimeCommands(code, io) {
271
278
  // sentence; this one line rides `warnings` so a JSON consumer cannot
272
279
  // miss it either.
273
280
  const harnessWindowWarning = harnessWindowSyncWarning(harnessWindow);
281
+ // Slice 2026-09-13-statusline-window-witness (AC2/AC3): the harness's
282
+ // own number for the same quantity, captured by the statusline. This is
283
+ // OBSERVATION ONLY — it never feeds `verdict` / `action` / any threshold
284
+ // below. A wrong reading here must not be able to fire a compact.
285
+ const harnessWitness = readAndCompareHarnessWitness({
286
+ // The same promoted root as the harness-window write above, for the
287
+ // same reason: the statusline resolves its root from the harness
288
+ // payload (absolute), so a `--project .` from a hook would otherwise
289
+ // look for the witness somewhere else and report `absent` forever.
290
+ projectRoot: canonicalProjectRoot,
291
+ sessionId,
292
+ peaksRatio: probe.ratio,
293
+ peaksTokens: probe.rawTokens ?? null,
294
+ peaksWindowTokens: probe.capacityTokens ?? null,
295
+ outerSessionId: outerSessionId ?? null
296
+ });
297
+ const witnessNotice = describeHarnessWitness(harnessWitness);
274
298
  const ratioPct = (probe.ratio * 100).toFixed(1);
275
299
  let action = 'ok';
276
300
  let next = null;
@@ -331,8 +355,16 @@ export function registerCodeRuntimeCommands(code, io) {
331
355
  // window sync did on this probe. Reported rather than silent — the
332
356
  // harness tells a user who overrides the window only via
333
357
  // `/autocompact`, so peaks-loop must be the one that says it.
334
- harnessWindow
335
- }, harnessWindowWarning === null ? [] : [harnessWindowWarning], [
358
+ harnessWindow,
359
+ // Slice 2026-09-13-statusline-window-witness: the second scale.
360
+ harnessWitness
361
+ }, [
362
+ ...(harnessWindowWarning === null ? [] : [harnessWindowWarning]),
363
+ // AC3: the disagreement rides `warnings` — it is a fact about the
364
+ // state, not an instruction — and it is one-way. Never an
365
+ // AskUserQuestion (see .peaks/memory/auto-compact-threshold-policy.md).
366
+ ...(witnessNotice === null ? [] : [witnessNotice])
367
+ ], [
336
368
  action === 'red-line'
337
369
  ? `RED LINE: ≥ 95%. Next: \`${next}\` — peaks-loop asks the harness to compact and KEEPS WORKING (dispatch is not blocked); re-probe to confirm it landed.`
338
370
  : action === 'auto-compact-now'
@@ -343,7 +375,15 @@ export function registerCodeRuntimeCommands(code, io) {
343
375
  gateModeNotice,
344
376
  // Single wording, shared with `peaks code auto-compact` — see
345
377
  // `describeHarnessWindowSync`.
346
- describeHarnessWindowSync(harnessWindow)
378
+ describeHarnessWindowSync(harnessWindow),
379
+ // AC3: the one-way hint that accompanies the `warnings` entry. Both
380
+ // channels carry the SAME fact in the shape each is read for
381
+ // (machine-readable warning vs human/LLM advice) — see
382
+ // `harnessWindowSyncWarning` / `describeHarnessWindowSync` above for
383
+ // the same split, and note this one never asks a question.
384
+ ...(witnessNotice === null
385
+ ? []
386
+ : ['Re-probe with `peaks code context-now` to confirm; this is reported, not blocking.'])
347
387
  ]),
348
388
  // Slice 2026-09-13-auto-compact-trigger-ownership: was hard-coded
349
389
  // `true`, which made the declared `--json` flag a no-op and left the
@@ -9,6 +9,48 @@ import { readHarnessWindowState, resolveHarnessWindowLocation } from '../../serv
9
9
  import { disableHarnessWindowSync, reenableHarnessWindowSync, resetHarnessWindow } from '../../services/context/harness-window-config.js';
10
10
  import { buildRecommendEnvelopePure, dryRunCompact, suggestCompact } from '../../services/compact/suggest-service.js';
11
11
  import { PHASES, SURVIVAL_TABLE, isPhase, lookupPhaseTransition } from '../../services/compact/decision-tables.js';
12
+ import { readSessionIdFromHookPayload, readTriggerFromHookPayload, settleCompactFromHarnessEvent } from '../../services/code/compact-event-settle.js';
13
+ /**
14
+ * Read the `PostCompact` hook payload from stdin.
15
+ *
16
+ * `PEAKS_HOOK_STDIN` is the test seam, verbatim the one `peaks gate enforce`
17
+ * uses (`gate-commands.ts`) — the CLI side owns `process.stdin`'s lifecycle, so
18
+ * the reader lives with the command rather than in the service.
19
+ *
20
+ * Returns `null` for every shape that is not a JSON object: empty stdin, a TTY
21
+ * (the `--json` diagnostic path run by a human), malformed JSON. A payload that
22
+ * cannot be read is not a failure — it is a payload with no `trigger`, which is
23
+ * an input this path is explicitly designed to accept.
24
+ */
25
+ async function readHookPayload() {
26
+ const override = process.env.PEAKS_HOOK_STDIN;
27
+ let raw;
28
+ if (override !== undefined) {
29
+ raw = override;
30
+ }
31
+ else if (process.stdin.isTTY) {
32
+ raw = '';
33
+ }
34
+ else {
35
+ raw = await new Promise((resolveStdin) => {
36
+ let data = '';
37
+ process.stdin.setEncoding('utf8');
38
+ process.stdin.on('data', (chunk) => {
39
+ data += chunk;
40
+ });
41
+ process.stdin.on('end', () => resolveStdin(data));
42
+ process.stdin.on('error', () => resolveStdin(data));
43
+ });
44
+ }
45
+ if (raw.trim().length === 0)
46
+ return null;
47
+ try {
48
+ return JSON.parse(raw);
49
+ }
50
+ catch { // TODO(g2): legacy silent catch — grace: 1 minor release (v2.14.0)
51
+ return null;
52
+ }
53
+ }
12
54
  function splitList(value) {
13
55
  if (!value)
14
56
  return [];
@@ -429,4 +471,74 @@ export function registerCompactCommands(program, io) {
429
471
  process.exitCode = 1;
430
472
  }
431
473
  }));
474
+ // 8. peaks compact settle [--json]
475
+ // (slice 2026-09-13-compact-event-settle)
476
+ //
477
+ // The `PostCompact` hook's transport. Before this command, peaks-loop knew a
478
+ // compaction had landed only because a LATER probe measured a ratio that had
479
+ // fallen — an inference. This command lets the harness's own event settle the
480
+ // run, and records what the harness said the compaction WAS (`manual` /
481
+ // `auto`), which is the one question a ratio can never answer.
482
+ //
483
+ // STDOUT IS CONTEXT HERE. `PostCompact`'s output contract is truncated in the
484
+ // retrievable docs, so the safe assumption is the `SessionStart` one: stdout
485
+ // is added to the model's context. The hook path therefore prints NOTHING and
486
+ // exits 0 for every outcome — success, no open run, unresolvable session,
487
+ // unparseable stdin. An error sentence in the model's context is a false
488
+ // problem handed to it mid-slice. Same discipline as `session reinject`;
489
+ // `--json` is the diagnostic surface, and the hook never passes it.
490
+ addJsonOption(compact
491
+ .command('settle')
492
+ .description('PostCompact hook handler: settle the open compact run because the HARNESS reported a ' +
493
+ 'compaction completed, and append an `observed` compact-history row carrying the ' +
494
+ 'harness-reported trigger (manual | auto). Reads the hook JSON on stdin; prints nothing ' +
495
+ 'and exits 0 when run by the hook. Pass --json for the diagnostic envelope.')
496
+ .option('--project <path>', 'project root (defaults to git root or cwd)')
497
+ .option('--session-id <sid>', 'override the active session id (defaults to the canonical binding)')).action(async (options) => {
498
+ // One parse, two fields: reading stdin twice would let the two readers
499
+ // disagree about the payload they were handed.
500
+ const payload = await readHookPayload();
501
+ const trigger = readTriggerFromHookPayload(payload);
502
+ const hookSessionId = readSessionIdFromHookPayload(payload);
503
+ // The hook path returns before any `process.exitCode` assignment below, so
504
+ // the harness never sees a non-zero exit from this command.
505
+ try {
506
+ const project = options.project !== undefined
507
+ ? resolveCanonicalProjectRoot(options.project)
508
+ : (findProjectRoot(process.cwd()) ?? process.cwd());
509
+ const session = resolveSessionId(project, options.sessionId);
510
+ if (session.error !== null) {
511
+ if (options.json !== true)
512
+ return;
513
+ printResult(io, fail('compact.settle', session.error.code, session.error.message, { projectRoot: project }, session.error.nextActions), true);
514
+ return;
515
+ }
516
+ const result = settleCompactFromHarnessEvent({
517
+ projectRoot: project,
518
+ sessionId: session.sid,
519
+ trigger,
520
+ hookSessionId
521
+ });
522
+ if (options.json !== true)
523
+ return;
524
+ printResult(io, ok('compact.settle', { projectRoot: project, sessionId: session.sid, trigger: trigger ?? null, ...result }, [], [
525
+ result.settled
526
+ ? result.lifecycleWritten
527
+ ? result.historyWritten
528
+ ? `Settled run ${result.runId}; appended a kind:'observed' row with pathway 'post-compact-hook'.`
529
+ : `Settled run ${result.runId}, but the kind:'observed' row could NOT be appended — check that .peaks/_runtime/<sid>/ is writable.`
530
+ : `The harness reported a compaction for run ${result.runId}, but the lifecycle record could NOT be written — that run is still open, so a later probe will settle it from its own measurement.${result.historyWritten
531
+ ? " A kind:'observed' row was still appended for this event."
532
+ : " The kind:'observed' row could not be appended either."}`
533
+ : result.reason === 'different-session'
534
+ ? `The hook payload names a different harness session, so this project's open compact run was left alone.`
535
+ : 'No compact run was open, so nothing was settled and no history row was appended.'
536
+ ]), true);
537
+ }
538
+ catch (error) {
539
+ if (options.json !== true)
540
+ return;
541
+ printResult(io, fail('compact.settle', 'COMPACT_SETTLE_FAILED', getErrorMessage(error), {}, ['Verify the project path and session binding']), true);
542
+ }
543
+ });
432
544
  }
@@ -91,10 +91,11 @@ function registerConfigMigrationCommands(config, io) {
91
91
  printResult(io, ok('config.rollback', { ...plan, applied: false }), options.json);
92
92
  }
93
93
  catch (error) {
94
+ // No `NO_BACKUP` branch: a missing `.bak` is no longer an error, it is
95
+ // an `available: false` SUCCESS envelope (see `config-rollback.ts`).
94
96
  const message = getErrorMessage(error);
95
- const code = message.startsWith('NO_BACKUP') ? 'NO_BACKUP' : 'CONFIG_ROLLBACK_FAILED';
96
- io.stderr(`${code}: ${message}`);
97
- printResult(io, fail('config.rollback', code, message, {}, ['Re-run peaks config migrate --apply to recreate the .bak']), options.json);
97
+ io.stderr(`CONFIG_ROLLBACK_FAILED: ${message}`);
98
+ printResult(io, fail('config.rollback', 'CONFIG_ROLLBACK_FAILED', message, {}, ['Re-run peaks config migrate --apply to recreate the .bak']), options.json);
98
99
  process.exitCode = 1;
99
100
  }
100
101
  });
@@ -108,9 +109,14 @@ function registerConfigMigrationCommands(config, io) {
108
109
  .option('--json', 'JSON envelope output')
109
110
  .action((options) => {
110
111
  try {
112
+ // `available` is on every envelope this command prints (see
113
+ // `config-restore.ts`): `false` ⇒ this machine has no `.bak`, exit 0,
114
+ // nothing to restore; `true` on a FAILURE envelope ⇒ the backup is
115
+ // there and the field/guard was the problem, exit 1. One key replaces
116
+ // the exit code as the "was there ever a backup?" signal.
111
117
  if (options.list === true || !options.field) {
112
- const fields = listAvailableFields();
113
- printResult(io, ok('config.restore', { fields, applied: false }), options.json);
118
+ const { available, fields } = listAvailableFields();
119
+ printResult(io, ok('config.restore', { available, fields, applied: false }), options.json);
114
120
  return;
115
121
  }
116
122
  const apply = options.apply === true;
@@ -119,15 +125,15 @@ function registerConfigMigrationCommands(config, io) {
119
125
  }
120
126
  catch (error) {
121
127
  const message = getErrorMessage(error);
128
+ // A missing `.bak` no longer reaches this block, so anything that does
129
+ // implies the backup exists — which is what `available: true` reports.
122
130
  let code = 'CONFIG_RESTORE_FAILED';
123
- if (message.startsWith('NO_BACKUP'))
124
- code = 'NO_BACKUP';
125
- else if (message.startsWith('RESTORE_GUARDED'))
131
+ if (message.startsWith('RESTORE_GUARDED'))
126
132
  code = 'RESTORE_GUARDED';
127
133
  else if (message.startsWith('FIELD_NOT_FOUND'))
128
134
  code = 'FIELD_NOT_FOUND';
129
135
  io.stderr(`${code}: ${message}`);
130
- printResult(io, fail('config.restore', code, message, {}, ['Use --list to see available fields']), options.json);
136
+ printResult(io, fail('config.restore', code, message, { available: true, ...(options.field !== undefined ? { field: options.field } : {}) }, ['Use --list to see available fields']), options.json);
131
137
  process.exitCode = 1;
132
138
  }
133
139
  });
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
4
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
4
5
  import { read24hState } from '../../services/24h-mode/index.js';
5
6
  import { getErrorMessage } from '../cli-helpers.js';
6
7
  const SINCE_PATTERN = /^(\d+)([smhd])$/i;
@@ -24,6 +25,11 @@ function parseSince(raw) {
24
25
  return { ok: true, ms: Math.min(WINDOW_CAP_MS, value * multiplier) };
25
26
  }
26
27
  function readSlices(projectRoot, sessionId) {
28
+ // Sid axis: `--session-id` reaches this join unmodified, and a traversal
29
+ // value resolved `metrics/slices.jsonl` outside every project root.
30
+ if (isUnsafePathInput(sessionId)) {
31
+ throw new Error(`Invalid session id: ${sessionId} (must be a single path segment)`);
32
+ }
27
33
  const path = join(projectRoot, '.peaks', '_runtime', sessionId, 'metrics', 'slices.jsonl');
28
34
  if (!existsSync(path))
29
35
  return 0;
@@ -127,7 +127,17 @@ export function registerDispatchCommand(parent, io) {
127
127
  ? { maxConcurrent }
128
128
  : {}),
129
129
  });
130
- printResult(io, ok(result.command, result.data, result.warnings ?? [], result.nextActions ?? []), asJson);
130
+ // The handler's `ok` is the launch outcome (a vendor CLI that is not
131
+ // installed is a failure, not a footnote) — the caller must not
132
+ // re-wrap it as `ok()` unconditionally, which is how `ok: true` with
133
+ // `pid: -1` reached the orchestrator.
134
+ if (result.ok) {
135
+ printResult(io, ok(result.command, result.data, result.warnings ?? [], result.nextActions ?? []), asJson);
136
+ }
137
+ else {
138
+ printResult(io, fail(result.command, 'DISPATCH_DETACHED_SPAWN_FAILED', `Could not launch the vendor CLI for a detached dispatch; nothing was started.`, result.data, result.nextActions ?? []), asJson);
139
+ process.exitCode = 1;
140
+ }
131
141
  }
132
142
  catch (error) {
133
143
  printResult(io, fail('sub-agent.dispatch', 'DISPATCH_DETACHED_ERROR', getErrorMessage(error), {
@@ -7,7 +7,13 @@
7
7
  */
8
8
  import { mkdirSync, writeFileSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
+ import { isUnsafePathInput } from '../../../shared/path-safety.js';
10
11
  export async function doctorInvokeFromCode(opts) {
12
+ // Sid axis: `opts.sid` is the sole path segment between the runtime root and
13
+ // the write below, and nothing upstream validated it.
14
+ if (isUnsafePathInput(opts.sid)) {
15
+ throw new Error(`Invalid session id: ${opts.sid} (must be a single path segment)`);
16
+ }
11
17
  const dir = join('.peaks', '_runtime', opts.sid, 'doctor');
12
18
  mkdirSync(dir, { recursive: true });
13
19
  const proposalPath = join(dir, 'proposal.md');