peaks-loop 4.0.48 → 4.0.49

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 (42) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/compact-command.js +1 -3
  5. package/dist/cli/commands/feedback-commands.d.ts +11 -7
  6. package/dist/cli/commands/feedback-commands.js +49 -17
  7. package/dist/cli/commands/final-review-commands.js +12 -0
  8. package/dist/cli/commands/loop-eval-commands.js +22 -6
  9. package/dist/cli/commands/slice-integrate-commands.js +17 -0
  10. package/dist/services/artifacts/artifact-prerequisites.js +10 -0
  11. package/dist/services/artifacts/request-artifact-service.js +59 -38
  12. package/dist/services/audit/enforcers/active-skill-resolver.js +14 -1
  13. package/dist/services/code/auto-compact-lifecycle.d.ts +75 -0
  14. package/dist/services/code/auto-compact-lifecycle.js +65 -16
  15. package/dist/services/code/auto-compact-orchestrator.js +119 -19
  16. package/dist/services/code/compact-event-settle.d.ts +20 -8
  17. package/dist/services/code/compact-event-settle.js +21 -0
  18. package/dist/services/compact-statusline/compact-statusline-service.js +56 -22
  19. package/dist/services/context/auto-compact-types.d.ts +20 -2
  20. package/dist/services/feedback/feedback-promotion-service.d.ts +137 -14
  21. package/dist/services/feedback/feedback-promotion-service.js +341 -20
  22. package/dist/services/feedback/promotion-artifact-evidence.d.ts +69 -0
  23. package/dist/services/feedback/promotion-artifact-evidence.js +332 -0
  24. package/dist/services/job/job-progress-store.js +18 -3
  25. package/dist/services/observability/jsonl-store.d.ts +19 -0
  26. package/dist/services/observability/jsonl-store.js +27 -2
  27. package/dist/services/observability/observability-service.d.ts +10 -3
  28. package/dist/services/observability/observability-service.js +16 -3
  29. package/dist/services/prd/handoff-service.js +43 -0
  30. package/dist/services/qa/qa-business-review-state.js +19 -5
  31. package/dist/services/sc/sc-service.d.ts +8 -0
  32. package/dist/services/sc/sc-service.js +8 -1
  33. package/dist/services/session/getSessionDir.d.ts +33 -0
  34. package/dist/services/session/getSessionDir.js +60 -0
  35. package/dist/services/slice/slice-review-state.js +19 -4
  36. package/dist/services/workflow/pipeline-verify-gate-support.js +10 -11
  37. package/dist/services/workflow/pipeline-verify-service.d.ts +1 -1
  38. package/dist/services/workflow/pipeline-verify-service.js +23 -10
  39. package/dist/services/workflow/pipeline-verify-types.d.ts +5 -3
  40. package/dist/shared/runtime-root.d.ts +73 -0
  41. package/dist/shared/runtime-root.js +77 -0
  42. package/package.json +5 -5
package/CHANGELOG.md CHANGED
@@ -1,5 +1,25 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.49 — 2026-09-15 (闸门验的是描述产物的话而不是产物本身 + id 轴三次布防后改走结构 + 两处吞异常与一个不是证据的 mtime + 套件自己把夹具写进了收集 glob)
4
+
5
+ **Highlights**:
6
+
7
+ 1. **Gate H 验的是"描述产物的那句话",不是产物本身。** 三层检查都是 `text.includes()`。一棵**作为拒收而建**的树全部通过:注册表**不是合法 JSON**、模板上写着 `do NOT add a matcher`、`mode-gate.ts` 里是一句 `// TODO: … DELIBERATELY NOT a hard-floor category`。**同一份字节下 `enforceBashCommand` 返回 `{"decision":"allow"}`** —— 既满足了闸门,又解除了闸门所要证明的东西,而 `registry.json` 是 git 跟踪的。现在逐层解析并断言形状,产物不可读时**失败关闭**(缺失 / 非合法 JSON / 形状不对 / 解析成功但为空 / 不可读,五种全部拒绝);C 层要求**从文档里解析出来的引用**,而不是一个子串匹配到的名字。修的过程中发现闸门一直在**为一种不存在的保护背书**:`collectMembers` 把 union 与数组摊平、从任一边接受成员,而 `isHardFloorCategory` 只读**数组** —— 于是一个只写在 union 里的成员让闸门报 BACKED,而 `shouldPauseAtGate` 返回 `shouldPause: false`。**闸门为一道不会暂停任何东西的"硬地板"背书,并且与它自己的报错信息相矛盾。** 残留**如实披露而非关闭**:C 层的引用通道**就是散文**,所以一份"点名了路径、实际管的是另一个成员"的文档仍会通过 —— 这次买到的是 **cited ≠ mentioned**。
8
+
9
+ 2. **id 轴:三次布防,最后一次比它替换掉的坏谓词更差 —— 于是改走结构。** 第一次把"一个 caller 提供的 id 进了路径"当作**一类**来 instrument。它没成立:守卫只盖**第一个** id、第二个敞着(四个命令各带一句"一道守卫盖住整族"的注释),而且**那次提交自己新引入了一处扫描器射程之外的双 id join,把项目根 README 覆盖掉了**。第二次修扫描器:12 个夹具里只抓住 **4** 个,而它替换掉的**按名字**谓词抓住 **8** 个 —— **五个是回归**。第三次把攻击从 4/12 提到 10/12,并**主动放弃**唯一能到 12/12 的改法:代价是活代码上 **7 处假阳性**,其中两处是结构性的(守卫合法地住在一个**助手函数**里,被守卫的值合法地作为**参数**传入)。判决:**按表达式文本的静态规则在这里做不健全** —— "守卫与 join 之间重新赋值"和"守卫在 join 之后"是**同一函数内部的支配性失败**,对任何按文本取键的办法都不可见;而收窄射程也不是免费的。于是换成**性质**:`src/shared/runtime-root.ts` 把守卫过的分段品牌在一个**非导出的 unique symbol** 后面、`RuntimeRoot` 用**私有 `#path`**、`join` 要求**至少一个**守卫分段(所以零参调用拿不回裸根)—— **新写一处未守卫的 join 直接编译不过(TS2345)**,而"拆掉守卫再变红"只能证明删除会被注意到。**诚实否定,明说而非糊过去**:旧站点根本不"取得一个根",它们把 `join(projectRoot,'.peaks','_runtime',id,…)` **直接拼在行内** —— 没有访问器可供类型加固,而 TS 无法拒绝一个字符串字面量的拼写;所以性质**只在用了缝的地方**成立。另外披露:`as GuardedSegment` 在模块外可以伪造品牌,`dir()` 是刻意的旁路。**95 个未守卫槽位 / 48 个文件**,编译器就是工作清单。
10
+
11
+ 3. **压缩循环:两处吞异常、一行断言了从未发生的结算、一个不是证据的 mtime。** `settleOpenLifecycleRun` 的生命周期写入失败被吞掉 → run 停在 `armed` → **之后每一次探针都会再结算一次、再追加一行**,无界。两条结算路径中先只修了一条,另一条照旧追加;现在两条都**推迟**那一行并如实说明,run 记为**未结算**,而不是在仍处 `armed` 时报成功。状态线**把 history 文件的 mtime 当作"刚刚压缩过"的证据** —— 而那个文件**每次被"询问"就写一行 dispatch**,所以**新鲜度恰恰在什么都没压缩的时候达到峰值**(仓库自己的会话在该端点有 **1075 行 dispatch、0 次压缩**);现在要求窗口内存在一行 `observed`,且配了非真空性对照证明旧谓词确实会接受同一个夹具。`getSessionDir` 增加一个**总体变体**,让"不能抛"的调用方有东西可调而不是吞掉;可观测性服务恢复它被记录过两次的 never-throws 契约。
12
+
13
+ 4. **套件自己把夹具写进了它收集的 glob 里。** `bdd-reporter.test.ts` 在 `tests/unit/` **里面**创建夹具、在 `afterEach` 删掉,而那是 vitest 的收集范围 —— 并发跑会在收集到它之后**四秒**导入失败,在**无关的套件**里报 `Cannot find module`。这件事**此前被记录过四次、一次都没修**(三份产物加一份已提交的记忆)。修完顺带量到一条比修复本身更大的事:**与测试同时跑的 `tsc` 不是证据** —— 中止时总数**错低**(一条孤立 `TS6053`、墙钟塌到约两秒),而**真实夹具在场时污染是隐形的**(总数恰好等于干净基线),是个**静默的错数**而不是显眼的错数;三选一里的判决是"在安静的树上取,并要求 `TS6053 = 0`"。非真空性对照:把夹具配置的 `include` 指向不匹配的 glob → 四个用例**全部失败**,证明它们确实依赖嵌套运行真的执行那个夹具。
14
+
15
+ 5. **一行观测被追加了 2054 次,而原因是我们自己的基准测试。** 一次"只读"性能评审的微基准对着**真实** history 文件跑了两遍(`readFileSync` 取最后一行 → `appendFileSync` 写回同一文件 → 在计时循环里重复);因为载荷是**从文件里读回来**的,每次重放**逐字节相同、连 `ts` 都相同**。算术精确闭合:**1 条真事件 + 503 + 1550 = 2054 行**,占该文件的 **53.8%**。**没有产品缺陷** —— 两个候选机制(写入端有循环 / 存在一个 drain)**都被证伪**(orchestrator 里 0 个循环,且 `ts` 在对象字面量里构造,所以 N 次产品调用必然给出 N 个不同戳)。证据**一行未删**:删掉它让文件显得整洁,正是这条线存在的理由所要消灭的动作。
16
+
17
+ **验证**:三个版本常量一致(**4.0.49**);`pnpm build` 的 `build-integrity: OK` —— 并且**套件自己抓住了这次 bump 的遗漏**(`lockstep-three-packages` 报 `CLI_VERSION in dist/version.js is 4.0.48, but root package.json#version is 4.0.49`,即源码改了而构建陈旧;重建后绿);`tsc -p tsconfig.json` 保持 **140** 基线、**0 在 `src/`**、**`TS6053` = 0**;`tests/unit` **253 files / 2706 passed / 3 skipped / 0 failed**(在安静的树上取,先确认 0 个 vitest 进程)。套件的 `write-gate-decision-table` 与 `final-review-service` 用例是**负载敏感**的(并行负载下超时、单独跑通过)—— **点名,不两边计数**。
18
+
19
+ **明确未验证的(不当作已完成)**:**没有任何一次真实的 Claude Code `PostCompact` 事件被观察到** —— 两条结算路径的行为是用只读 store 驱动的,证明的是"命令会结算",**从不是"事件会到达"**;`handoff-service.ts` 未被路由到新缝(把它的相对形式接进来会把 5 号修掉的那条片段往返载体重新引回来);结构性质的 95 个剩余槽位**未迁移**,所以它目前只在 2 个文件上成立。
20
+
21
+ **本轮顺带发现、尚未处理(不属本版修复)**:**结构缝自己的注释里有一句假话**(`runtime-root.ts:36-37` 声称模块外 `x as GuardedSegment` 是类型错误,**实测为假**);行内拼写仍编译干净,而用 AST 普查做一条字面量禁令(本仓已写过两次这个模式)可以补上;`dir()` 旁路;规则 D 自己的套件在 import 时扫描整个 `tests/` 并会与并发夹具写入者竞争(本版修的正是这个类);49 个文件上的 95 个未守卫槽位;六个原始 rid 的闸门状态仍停在 `spec-locked`。
22
+
3
23
  ## 4.0.48 — 2026-09-14 (一个 id 没校验就进了路径 + 自己的消费者读不出的交接胶囊 + 结尾才写、开头就读的状态)
4
24
 
5
25
  **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.48 (2026-09-14) |
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.49 (2026-09-15) |
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.48(2026-09-14) |
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.49(2026-09-15) |
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 |
@@ -527,9 +527,7 @@ export function registerCompactCommands(program, io) {
527
527
  ? result.historyWritten
528
528
  ? `Settled run ${result.runId}; appended a kind:'observed' row with pathway 'post-compact-hook'.`
529
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."}`
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 and append the row then. Nothing was recorded for this event: a row here would assert a settlement the store never made.`
533
531
  : result.reason === 'different-session'
534
532
  ? `The hook payload names a different harness session, so this project's open compact run was left alone.`
535
533
  : 'No compact run was open, so nothing was settled and no history row was appended.'
@@ -5,13 +5,17 @@
5
5
  * - `peaks feedback check-unpromoted --project <path> [--strict]`
6
6
  *
7
7
  * Companion to `sops/feedback-promotion-sop.md`. The promote command
8
- * generates a stub for the chosen enforcement layer (A: peaks-sop
9
- * gate, B: peaks-hooks PreToolUse, C: mode-gate hardFloorCategory)
10
- * and writes the promotion marker + sidecar + RD envelope. The
11
- * check-unpromoted command scans `.peaks/memory/*.md` for feedback
12
- * memories without a promotion marker and emits a structured list.
13
- * `--strict` flips exit code to non-zero when any unpromoted feedback
14
- * is found — used by `peaks workflow verify-pipeline` Gate H.
8
+ * writes the promotion marker + sidecar + RD envelope, and — for the
9
+ * layers whose artifact it can produce (A: a registered SOP manifest)
10
+ * — the enforcement artifact itself. Layers B and C live in shared
11
+ * files it does not own, so there it records the requirement and
12
+ * reports `effective: false` instead of claiming success.
13
+ *
14
+ * The check-unpromoted command scans `.peaks/memory/*.md` for feedback
15
+ * memories whose promotion is missing OR not backed by its layer's
16
+ * artifact, and emits a structured list. `--strict` flips exit code to
17
+ * non-zero when any is found — used by `peaks workflow verify-pipeline`
18
+ * Gate H.
15
19
  */
16
20
  import type { Command } from 'commander';
17
21
  import { type ProgramIO } from '../cli-helpers.js';
@@ -5,18 +5,22 @@
5
5
  * - `peaks feedback check-unpromoted --project <path> [--strict]`
6
6
  *
7
7
  * Companion to `sops/feedback-promotion-sop.md`. The promote command
8
- * generates a stub for the chosen enforcement layer (A: peaks-sop
9
- * gate, B: peaks-hooks PreToolUse, C: mode-gate hardFloorCategory)
10
- * and writes the promotion marker + sidecar + RD envelope. The
11
- * check-unpromoted command scans `.peaks/memory/*.md` for feedback
12
- * memories without a promotion marker and emits a structured list.
13
- * `--strict` flips exit code to non-zero when any unpromoted feedback
14
- * is found — used by `peaks workflow verify-pipeline` Gate H.
8
+ * writes the promotion marker + sidecar + RD envelope, and — for the
9
+ * layers whose artifact it can produce (A: a registered SOP manifest)
10
+ * — the enforcement artifact itself. Layers B and C live in shared
11
+ * files it does not own, so there it records the requirement and
12
+ * reports `effective: false` instead of claiming success.
13
+ *
14
+ * The check-unpromoted command scans `.peaks/memory/*.md` for feedback
15
+ * memories whose promotion is missing OR not backed by its layer's
16
+ * artifact, and emits a structured list. `--strict` flips exit code to
17
+ * non-zero when any is found — used by `peaks workflow verify-pipeline`
18
+ * Gate H.
15
19
  */
16
20
  import { resolve as resolvePath } from 'node:path';
17
21
  import { findProjectRoot } from '../../services/config/config-safety.js';
18
22
  import { getCurrentSessionId } from '../../services/skills/skill-presence-service.js';
19
- import { generatePromotionStub, isPromotionLayer, listUnpromotedFeedback, parseFeedbackMemory, PROMOTION_LAYER_DETAILS, PROMOTION_LAYERS, promoteFeedback } from '../../services/feedback/feedback-promotion-service.js';
23
+ import { generatePromotionStub, isPromotionLayer, listPromotionExempt, listUnpromotedFeedback, missingArtifacts, parseFeedbackMemory, PROMOTION_LAYER_DETAILS, PROMOTION_LAYERS, promoteFeedback, promotionArtifactChecks } from '../../services/feedback/feedback-promotion-service.js';
20
24
  import { fail, ok } from 'peaks-loop-shared/result';
21
25
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
22
26
  export function registerFeedbackCommands(program, io) {
@@ -26,15 +30,16 @@ export function registerFeedbackCommands(program, io) {
26
30
  addJsonOption(feedback
27
31
  .command('promote <memory-file>')
28
32
  .description('Promote a feedback memory to one of the 3 enforcement layers (A=peaks-sop gate, B=peaks-hooks PreToolUse, C=mode-gate hardFloorCategory). ' +
29
- 'Reads `.peaks/memory/<file>.md`, generates a code stub for the chosen layer, and writes ' +
30
- 'the promotion marker (HTML comment + sidecar .promotion.json) + an RD envelope at ' +
31
- '`.peaks/_runtime/<sid>/rd/feedback-promote-<name>.json`. ' +
33
+ 'Reads `.peaks/memory/<file>.md` and writes the promotion marker (HTML comment + sidecar .promotion.json) + an RD envelope at ' +
34
+ '`.peaks/_runtime/<sid>/rd/feedback-promote-<name>.json`. Layer A additionally generates and registers the SOP manifest the ' +
35
+ 'SOP engine reads. Layers B and C live in shared files the command does not own, so it records the requirement and exits ' +
36
+ 'non-zero (PROMOTION_NOT_EFFECTIVE) until the registration is present. ' +
32
37
  'Without --layer, the CLI lists the 3 layer options as nextActions and exits with code 0 ' +
33
38
  '(use --layer <A|B|C> to actually promote, or pass --dry-run to preview the stub).')
34
39
  .option('--layer <A|B|C>', `enforcement layer (${PROMOTION_LAYERS.join(' | ')})`)
35
40
  .option('--project <path>', 'project root (default: cwd)')
36
41
  .option('--promoted-by <id>', 'identity string for the audit envelope (default: peaks-rd fork agent)')
37
- .option('--dry-run', 'preview the stub without writing the marker / sidecar / envelope')).action((memoryFile, opts) => {
42
+ .option('--dry-run', 'preview the stub without writing the marker / sidecar / envelope')).action(async (memoryFile, opts) => {
38
43
  try {
39
44
  const projectRoot = opts.project ?? findProjectRoot(process.cwd()) ?? process.cwd();
40
45
  const memoryPath = memoryFile.endsWith('.md')
@@ -91,7 +96,7 @@ export function registerFeedbackCommands(program, io) {
91
96
  const layer = opts.layer;
92
97
  const sessionId = getCurrentSessionId(projectRoot) ?? 'unknown-sid';
93
98
  const promotedBy = opts.promotedBy ?? 'peaks-rd fork agent';
94
- const envelope = promoteFeedback({
99
+ const envelope = await promoteFeedback({
95
100
  feedbackPath: memoryPath,
96
101
  layer,
97
102
  promotedBy,
@@ -99,11 +104,26 @@ export function registerFeedbackCommands(program, io) {
99
104
  projectRoot,
100
105
  dryRun: false
101
106
  });
102
- printResult(io, ok('feedback.promote', envelope, [], [
107
+ // rid 2026-09-14-gate-h-promotion: report what was written, not what
108
+ // the stub wished for. A promotion whose layer artifact is absent is
109
+ // recorded but NOT effective, and says so with a non-zero exit.
110
+ const notes = [
103
111
  `Promoted feedback "${envelope.name}" to layer ${envelope.layer} (${envelope.layerDetail}).`,
104
112
  `Generated files: ${envelope.generatedFiles.join(', ')}`,
105
113
  `Envelope written to .peaks/_runtime/${sessionId}/rd/feedback-promote-${envelope.name}.json`
114
+ ];
115
+ if (envelope.effective) {
116
+ printResult(io, ok('feedback.promote', envelope, [], notes), opts.json);
117
+ return;
118
+ }
119
+ const missing = missingArtifacts(promotionArtifactChecks(envelope.name, envelope.layer), projectRoot);
120
+ printResult(io, fail('feedback.promote', 'PROMOTION_NOT_EFFECTIVE', `Promotion recorded but not effective — layer ${envelope.layer} is not backed by its artifact: ${missing.join('; ')}`, envelope, [
121
+ ...notes,
122
+ ...(envelope.layer === 'C'
123
+ ? ['Layer C lives in source code: register the hard-floor category in src/services/code/mode-gate.ts, then re-run promote to record it.']
124
+ : ['Apply the snippet to the file(s) named above, then re-run promote to record it.'])
106
125
  ]), opts.json);
126
+ process.exitCode = 1;
107
127
  }
108
128
  catch (err) {
109
129
  printResult(io, fail('feedback.promote', 'PROMOTE_FAILED', getErrorMessage(err), { memoryFile }, ['Verify the file is a feedback memory and re-run']), opts.json);
@@ -119,23 +139,35 @@ export function registerFeedbackCommands(program, io) {
119
139
  .option('--strict', 'exit non-zero when any unpromoted feedback is found (used by verify-pipeline Gate H)')).action((opts) => {
120
140
  try {
121
141
  const unpromoted = listUnpromotedFeedback({ projectRoot: opts.project });
142
+ const exempt = listPromotionExempt({ projectRoot: opts.project });
122
143
  const count = unpromoted.length;
123
144
  if (count === 0) {
124
- printResult(io, ok('feedback.check-unpromoted', { count: 0, unpromoted: [] }, [], [`No unpromoted feedback found in .peaks/memory/.`]), opts.json);
145
+ printResult(io, ok('feedback.check-unpromoted', { count: 0, unpromoted: [], exempt }, [], [
146
+ `No unpromoted feedback found in .peaks/memory/.`,
147
+ ...(exempt.length === 0
148
+ ? []
149
+ : [`${exempt.length} memor${exempt.length === 1 ? 'y' : 'ies'} declare themselves not-to-be-promoted: ${exempt.map((e) => `${e.name} (${e.code})`).join('; ')}.`])
150
+ ]), opts.json);
125
151
  return;
126
152
  }
127
153
  const message = `${count} feedback memor${count === 1 ? 'y is' : 'ies are'} not yet promoted to an enforcement layer.`;
128
154
  const nextActions = [
129
155
  `Run \`peaks feedback promote <memory-file> --layer <A|B|C>\` for each entry above.`,
130
156
  'A = peaks-sop gate, B = peaks-hooks PreToolUse, C = mode-gate hardFloorCategory.',
157
+ 'A marker alone does not count: the entry above names the artifact its layer still owes.',
131
158
  'See sops/feedback-promotion-sop.md for the SOP and the layer-choice rubric.'
132
159
  ];
133
160
  if (opts.strict === true) {
134
- printResult(io, fail('feedback.check-unpromoted', 'UNPROMOTED_FEEDBACK_FOUND', message, { count, unpromoted }, nextActions), opts.json);
161
+ printResult(io, fail('feedback.check-unpromoted', 'UNPROMOTED_FEEDBACK_FOUND', message, { count, unpromoted, exempt }, nextActions), opts.json);
135
162
  process.exitCode = 1;
136
163
  return;
137
164
  }
138
- printResult(io, ok('feedback.check-unpromoted', { count, unpromoted }, [message], nextActions), opts.json);
165
+ printResult(io, ok('feedback.check-unpromoted', { count, unpromoted, exempt }, [
166
+ message,
167
+ ...(exempt.length === 0
168
+ ? []
169
+ : [`${exempt.length} memor${exempt.length === 1 ? 'y' : 'ies'} declare themselves not-to-be-promoted: ${exempt.map((e) => `${e.name} (${e.code})`).join('; ')}.`])
170
+ ], nextActions), opts.json);
139
171
  }
140
172
  catch (err) {
141
173
  printResult(io, fail('feedback.check-unpromoted', 'CHECK_FAILED', getErrorMessage(err), { project: opts.project }, ['Verify --project path and re-run']), opts.json);
@@ -26,6 +26,7 @@
26
26
  */
27
27
  import { existsSync, statSync } from 'node:fs';
28
28
  import { join, resolve } from 'node:path';
29
+ import { REQUEST_ID_PATTERN } from '../../services/artifacts/request-artifact-service.js';
29
30
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
30
31
  import { fail, ok } from 'peaks-loop-shared/result';
31
32
  import { createAnthropicRunner, LlmBindingError, LlmRequestError, resolveAnthropicConfig, } from '../../services/llm/anthropic-runner.js';
@@ -135,6 +136,17 @@ export function registerFinalReviewCommands(program, io) {
135
136
  // in two places: the pre-flight existence check (here) and the
136
137
  // stub-path envelope (below). The service computes it identically
137
138
  // at `src/services/final-review/final-review-service.ts:45-52`.
139
+ //
140
+ // Rid axis. The rid is the CLI positional and it is the FILENAME
141
+ // segment below, so it is checked here, at the join. Added 2026-09-14
142
+ // (repair R1): this join was a second-slot hole — the `--session-id`
143
+ // validation above passed and the rid slot escaped. Found by the
144
+ // widened rule D, not by a human reading the file.
145
+ if (!REQUEST_ID_PATTERN.test(rid)) {
146
+ printResult(io, fail('final-review.prepare', 'RID_INVALID', `Invalid request id: ${rid} (expected letters, digits, dots, underscores, or dashes)`, emptyFinalReviewData(rid, options.sessionId, ''), ['Pass the rid of the slice, e.g. 2026-09-14-some-slug']), options.json);
147
+ process.exitCode = 1;
148
+ return;
149
+ }
138
150
  const auditGoalPath = join(projectValidation.projectRoot, '.peaks', '_runtime', sessionValidation.sessionId, 'audit-goal', `${rid}.json`);
139
151
  // 4. Pre-flight: if the audit-goal file is missing, surface a 404
140
152
  // BEFORE the stub/provider check so the caller learns the real
@@ -166,14 +166,25 @@ export function registerWorkflowEvalCommands(program, io) {
166
166
  process.exitCode = 1;
167
167
  return;
168
168
  }
169
- // Sid axis. `--session` reaches two joins below (`dir` and, through
170
- // `nextEvalCaptureIndex`, the same literal), so one guard here covers
171
- // the whole `--capture-score` write path.
169
+ // Sid axis AND rid axis — the join below takes TWO ids, not one.
170
+ // Corrected 2026-09-14 (repair R1): this comment used to say one guard
171
+ // covers "the whole `--capture-score` write path". The security audit of
172
+ // `2026-09-14-cli-id-escape-instrumentation` (F1b) measured that false —
173
+ // the `--session` guard passed and the `rid` slot escaped:
174
+ // `peaks loop eval '../../../../…/EVILCYC' --capture-score --session
175
+ // <legal>` created `<projectRoot>/../EVILCYC/cycles/cycle-1.json` under
176
+ // an `ok: true` envelope. The rid is the CLI positional and has no pinned
177
+ // format, so the segment check is the control for that axis.
172
178
  if (isUnsafePathInput(options.session)) {
173
179
  printResult(io, fail('loop.eval', 'INVALID_SESSION_ID', `Invalid session id: ${options.session} (must be a single path segment)`, { provided: options.session }, ['Pass a session id that is a single path segment']), options.json);
174
180
  process.exitCode = 1;
175
181
  return;
176
182
  }
183
+ if (isUnsafePathInput(rid)) {
184
+ printResult(io, fail('loop.eval', 'INVALID_REQUEST_ID', `Invalid request id: ${rid} (must be a single path segment)`, { provided: rid }, ['Pass a request id that is a single path segment']), options.json);
185
+ process.exitCode = 1;
186
+ return;
187
+ }
177
188
  const score = envelope.degraded ? 0.25 : (envelope.gateAction === 'pass' ? 1.0 : envelope.gateAction === 'warn' ? 0.5 : 0.0);
178
189
  const dir = join(projectRoot, '.peaks', '_runtime', options.session, 'loop', rid, 'cycles');
179
190
  const n = nextEvalCaptureIndex(projectRoot, options.session, rid);
@@ -484,12 +495,17 @@ export function registerWorkflowEvalCommands(program, io) {
484
495
  * writes. The run-driver also writes to the same dir; the next
485
496
  * index = max(prior)+1, or 1. */
486
497
  function nextEvalCaptureIndex(projectRoot, sid, rid) {
487
- // Sid axis: this function is the join for the capture-score cycle dir. The
488
- // action guards `options.session` before calling in, but the seam is callable
489
- // on its own, so the join states its own contract.
498
+ // BOTH id axes: this function is the join for the capture-score cycle dir.
499
+ // The action guards `options.session` and `rid` before calling in, but the
500
+ // seam is callable on its own, so the join states its own contract. The rid
501
+ // half was missing until 2026-09-14 (repair R1) — the same second-slot hole
502
+ // the action had, in the same file, one function below it.
490
503
  if (isUnsafePathInput(sid)) {
491
504
  throw new Error(`Invalid session id: ${sid} (must be a single path segment)`);
492
505
  }
506
+ if (isUnsafePathInput(rid)) {
507
+ throw new Error(`Invalid request id: ${rid} (must be a single path segment)`);
508
+ }
493
509
  const dir = join(projectRoot, '.peaks', '_runtime', sid, 'loop', rid, 'cycles');
494
510
  if (!existsSync(dir))
495
511
  return 1;
@@ -10,6 +10,7 @@ import { existsSync, readFileSync } from 'node:fs';
10
10
  import { join, resolve } from 'node:path';
11
11
  import { getCurrentSessionId } from '../../services/skills/skill-presence-service.js';
12
12
  import { isUnsafePathInput } from '../../shared/path-safety.js';
13
+ import { SLICE_ID_PATTERN } from '../../services/sc/sc-service.js';
13
14
  import { integrateSlices } from '../../services/slice/slice-integration.js';
14
15
  import { fail, ok } from 'peaks-loop-shared/result';
15
16
  import { addJsonOption, printResult } from '../cli-helpers.js';
@@ -23,6 +24,22 @@ function loadContractsForSlices(projectRoot, sessionId, sliceIds) {
23
24
  return [];
24
25
  const result = [];
25
26
  for (const sliceId of sliceIds) {
27
+ // Slice-id axis: the id becomes the FILENAME below, so `--slices a,../..`
28
+ // reads a JSON file outside the contracts dir. Guarded 2026-09-14 (repair
29
+ // R1, security audit F3 of `2026-09-14-cli-id-escape-instrumentation`) —
30
+ // the session guard above covers the other axis and this one was open.
31
+ // Skipped with a warning rather than thrown, matching this function's
32
+ // existing contract for a contract it cannot read.
33
+ //
34
+ // NOTE for the instrument: rule D in
35
+ // `tests/unit/runtime/no-runtime-input-guard.test.ts` does NOT see this
36
+ // site — its root is the local `const dir` above, not a same-file call, so
37
+ // it is that rule's limit (m). The guard is real; the coverage is not
38
+ // claimed.
39
+ if (!SLICE_ID_PATTERN.test(sliceId)) {
40
+ console.warn(`loadContractsForSlices: skipping invalid slice id ${JSON.stringify(sliceId)} (must be a single filename segment)`);
41
+ continue;
42
+ }
26
43
  const file = join(dir, `${sliceId}.json`);
27
44
  if (!existsSync(file))
28
45
  continue;
@@ -2,6 +2,7 @@ import { join, dirname, basename } from 'node:path';
2
2
  import { readFile, readdir } from 'node:fs/promises';
3
3
  import { pathExists } from 'peaks-loop-shared/fs';
4
4
  import { emitObservabilityEvent } from '../observability/observability-service.js';
5
+ import { isUnsafePathInput } from '../../shared/path-safety.js';
5
6
  export const VALID_REQUEST_TYPES = [
6
7
  'feature',
7
8
  'bugfix',
@@ -426,6 +427,15 @@ export async function checkPrerequisites(options) {
426
427
  if (requirements.length === 0) {
427
428
  return { ok: true, missing: [], warnings: [] };
428
429
  }
430
+ // Repair R5. The session id is joined into BOTH roots below and then probed
431
+ // on disk. `transitionRequestArtifact` is the only caller and it passes
432
+ // `existing.sessionId`, which pre-R5 was the caller's raw `--session-id`: with
433
+ // `../../../PWNED-R34` the two joins resolved outside the project root and
434
+ // the prerequisite probes ran there. Guarded at the sink, not at the caller,
435
+ // for the same reason `requestArtifactRequestsDir` is.
436
+ if (options.sessionId !== undefined && isUnsafePathInput(options.sessionId)) {
437
+ throw new Error(`Invalid session id: ${options.sessionId} (must be a single path segment)`);
438
+ }
429
439
  // Slice 006 simplifies the resolution to a 2-tier fallback. The
430
440
  // per-change-id scope (`.peaks/_runtime/<sessionId>/<role>/`) is gone — new
431
441
  // artifacts go to the session dir directly. The 2 tiers are:
@@ -12,6 +12,7 @@ import { ensureSession, getSessionIdCanonical } from '../session/session-manager
12
12
  import { getNextNumber, buildNumberedFilename, slugifyDescription } from '../../shared/incrementing-number.js';
13
13
  import { lintRequestArtifact } from './artifact-lint-service.js';
14
14
  import { isUnsafePathInput } from '../../shared/path-safety.js';
15
+ import { guardRuntimeSegment, runtimeRoot } from '../../shared/runtime-root.js';
15
16
  import { checkTypeSanity } from '../scan/type-sanity-service.js';
16
17
  import { requireUserConfirmation } from '../mode/mode-enforcement.js';
17
18
  import { scanFileSize } from '../scan/file-size-scan.js';
@@ -46,6 +47,30 @@ function dateSlugFromIso(iso) {
46
47
  function defaultSessionId(iso) {
47
48
  return `${dateSlugFromIso(iso)}-session`;
48
49
  }
50
+ /**
51
+ * The single place a request-artifact directory is built, and therefore the
52
+ * single place the ids that reach it are guarded.
53
+ *
54
+ * Repair R5. This join used to be written at four sites; `createRequestArtifact`
55
+ * guarded its session id and the other three did not. The two ids are the whole
56
+ * of the invariant — `role` is a path segment too, and the closed-set check its
57
+ * callers perform is not visible from here.
58
+ *
59
+ * The guard belongs HERE and not at the entry points because the invariant is
60
+ * the resolved path, not the flag. `transitionRequestArtifact` is the measured
61
+ * cost: it performs no join of its own (it delegates the path to
62
+ * `showRequestArtifact`), so it had no guard and no join to hang one on.
63
+ * `--session-id ../../../PWNED-R34` resolved `.peaks/_runtime/../../../PWNED-R34`,
64
+ * wrote `state: blocked` to a file outside the project root, and only then threw
65
+ * — from `emitObservabilityEvent`'s own session-id check, i.e. after the write.
66
+ */
67
+ function requestArtifactRequestsDir(projectRoot, sessionId, role) {
68
+ // Slice 2026-09-15 (runtime-path-unrepresentable): the two ids are branded by
69
+ // `guardRuntimeSegment`, which performs the same `isUnsafePathInput` check
70
+ // this function used to spell inline. The join itself now *requires* the
71
+ // brand, so a caller that reaches this dir without a guard does not compile.
72
+ return runtimeRoot(projectRoot).join(guardRuntimeSegment(sessionId, 'session id'), guardRuntimeSegment(role, 'role'), guardRuntimeSegment('requests', 'leaf'));
73
+ }
49
74
  export async function createRequestArtifact(options) {
50
75
  if (!VALID_ROLES.has(options.role)) {
51
76
  throw new Error(`Invalid role: ${String(options.role)} (expected prd, ui, rd, qa, or sc)`);
@@ -95,7 +120,7 @@ export async function createRequestArtifact(options) {
95
120
  // `mkdir(..., { recursive: true })`.
96
121
  const LOOKS_LIKE_SESSION_ID = /^\d{4}-\d{2}-\d{2}-session-/;
97
122
  if (LOOKS_LIKE_SESSION_ID.test(sessionId)) {
98
- const sessionDir = join(options.projectRoot, '.peaks', '_runtime', sessionId);
123
+ const sessionDir = runtimeRoot(options.projectRoot).join(guardRuntimeSegment(sessionId, 'session id'));
99
124
  if (!(await isDirectory(sessionDir))) {
100
125
  const canonicalSid = getSessionIdCanonical(options.projectRoot);
101
126
  const hint = canonicalSid !== null
@@ -105,7 +130,7 @@ export async function createRequestArtifact(options) {
105
130
  }
106
131
  }
107
132
  // Build numbered path under the session dir (canonical post-F3 home).
108
- const requestsDir = join(options.projectRoot, '.peaks', '_runtime', sessionId, options.role, 'requests');
133
+ const requestsDir = requestArtifactRequestsDir(options.projectRoot, sessionId, options.role);
109
134
  // Check if a file with this requestId already exists (regardless of number prefix)
110
135
  if (await isDirectory(requestsDir)) {
111
136
  const existingFiles = await listMarkdownFiles(requestsDir);
@@ -133,7 +158,7 @@ export async function createRequestArtifact(options) {
133
158
  // Slice 2026-06-29-change-id-root-removal: scopeDir is the
134
159
  // session-axis dir (`.peaks/_runtime/<sid>/`). Pre-resolved here
135
160
  // so dry-run output reports the canonical scope location.
136
- const scopeDir = join(options.projectRoot, '.peaks', '_runtime', sessionId);
161
+ const scopeDir = runtimeRoot(options.projectRoot).join(guardRuntimeSegment(sessionId, 'session id'));
137
162
  return {
138
163
  role: options.role,
139
164
  requestId: options.requestId,
@@ -150,7 +175,7 @@ export async function createRequestArtifact(options) {
150
175
  // Create QA initiated marker so rd:qa-handoff gate can verify QA was invoked.
151
176
  // The marker lives under the SESSION dir (canonical post-F3 home).
152
177
  if (options.role === 'qa') {
153
- const qaDir = join(options.projectRoot, '.peaks', '_runtime', sessionId, 'qa');
178
+ const qaDir = runtimeRoot(options.projectRoot).join(guardRuntimeSegment(sessionId, 'session id'), guardRuntimeSegment('qa', 'role'));
154
179
  const initiatedPath = join(qaDir, '.initiated');
155
180
  if (!existsSync(initiatedPath)) {
156
181
  await mkdir(qaDir, { recursive: true });
@@ -164,7 +189,7 @@ export async function createRequestArtifact(options) {
164
189
  path,
165
190
  content,
166
191
  applied: true,
167
- scopeDir: join(options.projectRoot, '.peaks', '_runtime', sessionId),
192
+ scopeDir: runtimeRoot(options.projectRoot).join(guardRuntimeSegment(sessionId, 'session id')),
168
193
  ...(options.callerId !== undefined ? { callerId: options.callerId } : {})
169
194
  };
170
195
  }
@@ -202,31 +227,27 @@ function extractMetadata(markdown) {
202
227
  base.sessionId = sessionId;
203
228
  return base;
204
229
  }
205
- async function readSummary(projectRoot, sessionId, role, fileName) {
206
- const path = join(projectRoot, '.peaks', sessionId, role, 'requests', fileName);
230
+ async function readSummary(dir, role, fileName, sessionId) {
231
+ const path = join(dir, fileName);
207
232
  const body = await readFile(path, 'utf8');
208
233
  const { state, createdAt, requestType, sessionId: bodySessionId } = extractMetadata(body);
209
234
  // Strip numbered prefix (e.g., "001-requestId.md" -> "requestId")
210
235
  // Only strip 3-digit zero-padded prefixes (our incrementing number format)
211
236
  const requestId = fileName.replace(/^0\d{2}-/, '').replace(/\.md$/, '');
212
- // The `sessionId` parameter is the *scope* path fragment
213
- // (`_runtime/<sid>`); consumers expect the bare session id. Strip
214
- // the `_runtime/` prefix when recording the summary so downstream
215
- // calls (observability emit, prereq check, lint gate) see just the
216
- // session id. Pre-2.19.0 the field carried the scope verbatim, which
217
- // caused the observability metrics file to land at
218
- // `.peaks/_runtime/_runtime/<sid>/...` instead of the canonical
219
- // `.peaks/_runtime/<sid>/metrics/...`. `writerSessionId` falls back
220
- // to the parsed body session line (or the bare sid) — same intent.
221
- const bareSessionId = sessionId.replace(/^_runtime[\\/]/, '');
237
+ // Repair R5: this used to take the *scope* path fragment (`_runtime/<sid>`)
238
+ // and re-join it onto the project root, then strip the prefix back off to
239
+ // recover the bare session id. The round-trip was the escape's carrier — an
240
+ // unsafe id rode it into `path` unguarded. The directory now arrives already
241
+ // resolved and already guarded (`requestArtifactRequestsDir`), and the bare
242
+ // session id arrives as itself, so neither is re-derived here.
222
243
  const summary = {
223
244
  role,
224
- sessionId: bareSessionId,
245
+ sessionId,
225
246
  requestId,
226
247
  path,
227
248
  state,
228
249
  requestType,
229
- writerSessionId: bodySessionId ?? bareSessionId
250
+ writerSessionId: bodySessionId ?? sessionId
230
251
  };
231
252
  if (createdAt !== undefined) {
232
253
  summary.createdAt = createdAt;
@@ -254,26 +275,28 @@ export async function listRequestArtifacts(options) {
254
275
  // scanned. The user has forbidden the `.peaks/_runtime/<id>/` root layout —
255
276
  // the CLI guarantees no such dirs are created. See
256
277
  // `.peaks/memory/2026-06-21-peaks-request-session-id-leaks-into-change-id.md`.
278
+ // Repair R5: `scopes` holds bare session ids. It used to hold the joined
279
+ // fragment `_runtime/<sid>` so that `readSummary` could re-join it to the
280
+ // project root; the directory is built once, below, by the guard.
257
281
  const scopes = [];
258
282
  if (options.sessionId !== undefined) {
259
- scopes.push(join('_runtime', options.sessionId));
283
+ scopes.push(options.sessionId);
260
284
  }
261
285
  else {
262
- const runtimeRoot = join(peaksRoot, '_runtime');
263
- if (await isDirectory(runtimeRoot)) {
264
- for (const sid of await listDirectories(runtimeRoot)) {
265
- scopes.push(join('_runtime', sid));
266
- }
286
+ // Read-only enumeration of the root itself, so `dir()` and not `join()`.
287
+ const runtimeDir = runtimeRoot(options.projectRoot).dir();
288
+ if (await isDirectory(runtimeDir)) {
289
+ scopes.push(...(await listDirectories(runtimeDir)));
267
290
  }
268
291
  }
269
292
  const roles = options.role !== undefined ? [options.role] : Array.from(VALID_ROLES);
270
293
  const summaries = [];
271
294
  for (const scope of scopes) {
272
295
  for (const role of roles) {
273
- const dir = join(peaksRoot, scope, role, 'requests');
296
+ const dir = requestArtifactRequestsDir(options.projectRoot, scope, role);
274
297
  const fileNames = await listMarkdownFiles(dir);
275
298
  for (const fileName of fileNames) {
276
- summaries.push(await readSummary(options.projectRoot, scope, role, fileName));
299
+ summaries.push(await readSummary(dir, role, fileName, scope));
277
300
  }
278
301
  }
279
302
  }
@@ -311,24 +334,22 @@ export async function showRequestArtifact(options) {
311
334
  // `.peaks/_runtime/<sid>/<role>/requests/` legacy home is no longer
312
335
  // scanned. The user has forbidden the `.peaks/_runtime/<id>/` root layout.
313
336
  if (options.sessionId !== undefined) {
314
- const dir = join(options.projectRoot, '.peaks', '_runtime', options.sessionId, options.role, 'requests');
315
- const scope = join('_runtime', options.sessionId);
337
+ const dir = requestArtifactRequestsDir(options.projectRoot, options.sessionId, options.role);
316
338
  const found = await findFileInDir(dir);
317
339
  if (found === null) {
318
340
  return null;
319
341
  }
320
- return await readRequestArtifact(options.projectRoot, scope, options.role, found);
342
+ return await readRequestArtifact(dir, options.role, found, options.sessionId);
321
343
  }
322
- const peaksRoot = join(options.projectRoot, '.peaks');
323
- const runtimeRoot = join(peaksRoot, '_runtime');
324
- if (!(await isDirectory(runtimeRoot))) {
344
+ const runtimeDir = runtimeRoot(options.projectRoot).dir();
345
+ if (!(await isDirectory(runtimeDir))) {
325
346
  return null;
326
347
  }
327
- for (const sid of await listDirectories(runtimeRoot)) {
328
- const dir = join(runtimeRoot, sid, options.role, 'requests');
348
+ for (const sid of await listDirectories(runtimeDir)) {
349
+ const dir = requestArtifactRequestsDir(options.projectRoot, sid, options.role);
329
350
  const found = await findFileInDir(dir);
330
351
  if (found !== null) {
331
- return await readRequestArtifact(options.projectRoot, join('_runtime', sid), options.role, found);
352
+ return await readRequestArtifact(dir, options.role, found, sid);
332
353
  }
333
354
  }
334
355
  return null;
@@ -336,8 +357,8 @@ export async function showRequestArtifact(options) {
336
357
  /** Read the summary + content for a found request file; treat a read
337
358
  * error on the content as "not found" so the caller can fall through
338
359
  * to the next candidate (the on-disk file may be partially written). */
339
- async function readRequestArtifact(projectRoot, scope, role, found) {
340
- const summary = await readSummary(projectRoot, scope, role, found.fileName);
360
+ async function readRequestArtifact(dir, role, found, sessionId) {
361
+ const summary = await readSummary(dir, role, found.fileName, sessionId);
341
362
  try {
342
363
  const content = await readFile(found.path, 'utf8');
343
364
  return { ...summary, content };
@@ -64,7 +64,20 @@ export function resolveActiveSkillForCaller(projectRoot, opts) {
64
64
  // single skill per resolution. When the lease dir is empty (e.g.
65
65
  // ad-hoc / pre-migration projects) we fall through to the legacy
66
66
  // walk below.
67
- const sessionDir = getSessionDir(projectRoot, sessionId);
67
+ // `getSessionDir` refuses an unsafe session id by throwing (slice
68
+ // 2026-09-14-getsessiondir-guard). This function's contract is the
69
+ // resolution order's "graceful degradation — never throws", so an unsafe
70
+ // id degrades to the same `source: 'none'` shape an absent session dir
71
+ // produces, exactly as it did before that guard existed. Without this,
72
+ // the throw escapes to the nearest caller `catch` — for `hook handle`
73
+ // that catch is a fail-open that skips the SOP gate.
74
+ let sessionDir;
75
+ try {
76
+ sessionDir = getSessionDir(projectRoot, sessionId);
77
+ }
78
+ catch {
79
+ return { skill: null, callerId: null, sessionId: null, mode: null, source: 'none' };
80
+ }
68
81
  if (!existsSync(sessionDir)) {
69
82
  return { skill: null, callerId: null, sessionId, mode: null, source: 'none' };
70
83
  }