peaks-loop 4.0.43 → 4.0.44

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 (31) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/codegraph-commands.js +191 -6
  5. package/dist/cli/commands/final-review-commands.d.ts +34 -10
  6. package/dist/cli/commands/final-review-commands.js +130 -34
  7. package/dist/cli/commands/share-commands.d.ts +49 -0
  8. package/dist/cli/commands/share-commands.js +114 -14
  9. package/dist/services/codegraph/codegraph-autorefresh.js +12 -0
  10. package/dist/services/codegraph/codegraph-exclude-integrity.d.ts +61 -0
  11. package/dist/services/codegraph/codegraph-exclude-integrity.js +98 -0
  12. package/dist/services/codegraph/codegraph-exclude-reconciler.d.ts +26 -0
  13. package/dist/services/codegraph/codegraph-exclude-reconciler.js +217 -0
  14. package/dist/services/codegraph/codegraph-exclude-repair.d.ts +102 -0
  15. package/dist/services/codegraph/codegraph-exclude-repair.js +266 -0
  16. package/dist/services/codegraph/codegraph-preflight-service.js +12 -0
  17. package/dist/services/codegraph/codegraph-service.d.ts +0 -1
  18. package/dist/services/codegraph/codegraph-service.js +5 -4
  19. package/dist/services/doctor/doctor-service/checks/codegraph-exclude-integrity.d.ts +29 -0
  20. package/dist/services/doctor/doctor-service/checks/codegraph-exclude-integrity.js +88 -0
  21. package/dist/services/doctor/doctor-service/plugin-registry.js +2 -0
  22. package/dist/services/doctor/doctor-service/types.d.ts +27 -0
  23. package/dist/services/final-review/final-review-service.d.ts +154 -0
  24. package/dist/services/final-review/final-review-service.js +621 -7
  25. package/dist/services/final-review/index.d.ts +1 -1
  26. package/dist/services/final-review/index.js +1 -1
  27. package/dist/services/prd/handoff-auto-regen.js +0 -1
  28. package/dist/services/prd/handoff-service.d.ts +9 -1
  29. package/dist/services/prd/handoff-service.js +48 -6
  30. package/package.json +7 -5
  31. package/skills/peaks-final-review/SKILL.md +43 -32
package/CHANGELOG.md CHANGED
@@ -1,5 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.44 — 2026-09-12 (被静默排除的源文件 + 一个验错东西的验收闸)
4
+
5
+ **Highlights**:
6
+
7
+ 1. **`peaks codegraph status` 报"索引是最新的",而 26 个 git 跟踪的源文件根本不在索引里。** 上游默认 exclude 表**按目录名**匹配,而本仓库恰好把 `artifacts/` `release/` `vendor/` `bin/` `publish/` 用作了源码目录名,于是 5 条默认规则把 26 个真实源文件挡在门外。`status` 说的是"图与上次扫描一致",不是"图覆盖了仓库" —— 所以这个洞是隐形的。
8
+
9
+ 本版按**一条原则**建了 reconcile / repair / verify:被 git 跟踪的源文件不得被 exclude 规则挡住。
10
+
11
+ - 对账用 `picomatch`(从传递依赖提升为直接依赖),与上游**同一个匹配引擎**;此前自实现的 glob 对 `{}` 模式**漏报**,且会灾难性回溯
12
+ - `status` 现在**失败(exit 74)**并指名违规规则与文件,而不是报 OK;doctor 也报这个缺口
13
+ - `init` 自愈,且 preflight / autorefresh 走**同一个 helper** —— 它们此前会跑上游 init 并盖 marker 却**不做修复**,使"全新 clone 自愈"在真实流程里**不可达**
14
+ - 匹配不到任何 tracked 文件的规则**永不删除**;未跟踪文件仍被排除(原语义不变)
15
+
16
+ 2. **4 维人工验收闸干不了活 —— 四层独立失效,每一层都只有真机跑才看得见。**
17
+
18
+ - **(a)** 服务只把目标的 `successCriteria` 喂给模型,**一条证据都不给**,却要求它产出**带证据的裁决**。现在从磁盘收集真实证据(有界),并用**结构而非提示词措辞**保证:支撑源全部缺失的 `pass` 一律降级为 `inconclusive`
19
+ - **(b)** CLI 只能跑 `stub`;`--llm-provider anthropic` 现在真的构造 runner,`providerBinding` 如实上报
20
+ - **(c)** `maxTokens` 硬编码 3000,低于一份完整证据包所需,回复被**截在 JSON 中间**;预算随内联证据量伸缩,截断被诊断为"输出预算失败"而非"JSON 非法",并给出 `PEAKS_FINAL_REVIEW_MAX_OUTPUT_TOKENS` 逃生阀
21
+ - **(d)** 证据预算是**先到先得**,于是最后排序的源 —— 也就是四个维度里的一个 —— 被**永久饿死**;现在每个维度有保留配额
22
+
23
+ ⚠️ **`existing-functionality-intact` 仍然无法通过**:它要求的 `pre-post-diff` 产物**全仓没有任何生产者**。这是缺功能,不是 bug;已在 SKILL.md 记为**工具状态**,而**不是**把一个不相关的源重映射进去把闸弄绿。
24
+
25
+ 3. **同一个"闸门验代理而非性质"的形状,又抓到三处。**
26
+
27
+ - `handoff-auto-regen` 把 `sessionId` **写了两遍**,产出的 frontmatter 仓库自己的 YAML 解析器**拒绝** —— 而 `AUDIT_REQUIRES_HANDOFF` 只做**子串**检查,所以放行
28
+ - `sub-agent finalize --request-id` 扫会话目录下**所有** `.json`,读到 `active-dispatches.json` 就抛;两个分支现在都容忍坏记录,且 `--request-id` 与 `--batch` 共用**优先 queued** 的选择逻辑,并报出选了哪条、为何跳过其它
29
+ - `skills/peaks-final-review/SKILL.md` 断言它自己的 CLI "**尚不存在**",让调用方去手写 service 调用 —— 而它存在且已注册。这句话在本版开发中**真实地浪费了工作**:orchestrator 为一个早已存在的命令手写了一个调用脚本
30
+
31
+ **验证**:三个版本常量一致(**4.0.44**);`tsc -p tsconfig.build.json` exit 0;宽 `tsconfig.json` 保持 **142** 基线;`tests/unit` **213 files / 2116 passed / 3 skipped / 0 failed**;`pnpm build` 的 `build-integrity` OK。
32
+
33
+ **已知未修**:`existing-functionality-intact` 缺证据生产者(见第 2 条);`RUNTIME_NPM_VERSION`(0.0.21)与 internal-runtime 包版本已不同步(4.0.43 起即如此)。
34
+
3
35
  ## 4.0.43 — 2026-09-12 (一个从来拦不住东西的闸门 + 前端接口防腐层)
4
36
 
5
37
  **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.43 (2026-09-12) |
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.44 (2026-09-12) |
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.43(2026-09-12) |
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.44(2026-09-12) |
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 |
@@ -2,6 +2,8 @@ import { InvalidArgumentError } from 'commander';
2
2
  import { statSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
4
  import { createCodegraphInvocation, executeCodegraphInvocation, defaultCodegraphInitGuard, writeCodegraphMarker, writeCodegraphAffectedContext, CodegraphInitConflictError } from '../../services/codegraph/codegraph-service.js';
5
+ import { CODEGRAPH_INTEGRITY_EXIT_CODE, inspectCodegraphExcludeIntegrity, isCodegraphExcludeConfigPresent, renderCodegraphExcludeIntegrityLines } from '../../services/codegraph/codegraph-exclude-integrity.js';
6
+ import { repairCodegraphExcludeFromProject } from '../../services/codegraph/codegraph-exclude-repair.js';
5
7
  import { fail, ok } from 'peaks-loop-shared/result';
6
8
  import { getErrorMessage, printResult, redactSensitiveErrorMessage } from '../cli-helpers.js';
7
9
  function addPeaksJsonOption(command) {
@@ -61,6 +63,140 @@ async function runCodegraphCommand(io, command, options, asJson) {
61
63
  printCodegraphFailure(io, command, error, asJson);
62
64
  }
63
65
  }
66
+ /**
67
+ * `--peaks-json` machine report for `status`. Carries the upstream
68
+ * result AND the peaks-loop integrity verdict as one JSON document so a
69
+ * CI job can gate on `data.integrity.gap` / `data.integrity.rulesToRemove`
70
+ * without scraping human text.
71
+ */
72
+ async function runCodegraphStatusJson(io, options, integrity, integrityWarning) {
73
+ let result;
74
+ try {
75
+ result = await executeCodegraphInvocation(createCodegraphInvocation({ subcommand: 'status', project: options.project }));
76
+ }
77
+ catch (error) {
78
+ printCodegraphFailure(io, 'codegraph.status', error, true);
79
+ return;
80
+ }
81
+ const upstream = {
82
+ exitCode: result.exitCode,
83
+ stdout: rewriteBareCodegraphHints(result.stdout).trimEnd(),
84
+ stderr: redactSensitiveErrorMessage(rewriteBareCodegraphHints(result.stderr)).trimEnd()
85
+ };
86
+ const upstreamFailed = result.exitCode !== null && result.exitCode !== 0;
87
+ if (integrity?.gap === true) {
88
+ printResult(io, fail('codegraph.status', 'CODEGRAPH_INDEX_INCOMPLETE', `codegraph index is incomplete: ${integrity.excludedTrackedCount} of ${integrity.trackedSourceCount} tracked source files are excluded by ${integrity.rulesToRemove.length} rule(s).`, { upstream, integrity, integrityWarning }, ['Run `peaks codegraph repair-exclude --project <root>` to drop the offending rules and rebuild the index.']), true);
89
+ }
90
+ else if (upstreamFailed) {
91
+ printResult(io, fail('codegraph.status', 'CODEGRAPH_COMMAND_FAILED', redactSensitiveErrorMessage(upstream.stderr || upstream.stdout || `codegraph exited with code ${String(result.exitCode)}`), { upstream, integrity, integrityWarning }, ['Check the codegraph project path before retrying']), true);
92
+ }
93
+ else {
94
+ printResult(io, ok('codegraph.status', { upstream, integrity, integrityWarning }), true);
95
+ }
96
+ if (upstreamFailed) {
97
+ process.exitCode = result.exitCode ?? 1;
98
+ }
99
+ }
100
+ /**
101
+ * `peaks codegraph status` with an integrity gate.
102
+ *
103
+ * The upstream status is still proxied verbatim (that is what the
104
+ * command has always done), but a clean upstream "index is up to date"
105
+ * is no longer sufficient: when git-tracked source files are being
106
+ * excluded by the config, the command says so, names the rules and
107
+ * files, and exits non-zero.
108
+ *
109
+ * Read-only by construction — it imports the integrity inspector, never
110
+ * the repair writer. Fixing the config is `peaks codegraph init`
111
+ * (fresh) or `peaks codegraph repair-exclude` (explicit).
112
+ */
113
+ async function runCodegraphStatusCommand(io, options, asJson) {
114
+ let integrity = null;
115
+ let integrityWarning = null;
116
+ const projectRoot = resolve(options.project);
117
+ try {
118
+ // Never initialized here → no exclude list is in play, so there is
119
+ // nothing to report. Staying silent keeps `status` honest and
120
+ // unchanged for projects that do not use codegraph at all.
121
+ integrity = isCodegraphExcludeConfigPresent(projectRoot)
122
+ ? inspectCodegraphExcludeIntegrity(projectRoot)
123
+ : null;
124
+ }
125
+ catch (error) {
126
+ // Not a git work tree, no config yet, malformed config — the
127
+ // upstream status is still worth printing, so degrade to a warning
128
+ // instead of failing the whole command.
129
+ integrityWarning = getErrorMessage(error);
130
+ }
131
+ if (asJson === true) {
132
+ await runCodegraphStatusJson(io, options, integrity, integrityWarning);
133
+ }
134
+ else {
135
+ await runCodegraphCommand(io, 'codegraph.status', { subcommand: 'status', project: options.project });
136
+ if (integrityWarning !== null) {
137
+ io.stdout(`[WARN] codegraph exclude integrity not evaluated: ${integrityWarning}`);
138
+ }
139
+ else if (integrity !== null) {
140
+ for (const line of renderCodegraphExcludeIntegrityLines(integrity)) {
141
+ io.stdout(line);
142
+ }
143
+ }
144
+ }
145
+ if (integrity?.gap === true) {
146
+ process.exitCode = CODEGRAPH_INTEGRITY_EXIT_CODE;
147
+ }
148
+ }
149
+ /**
150
+ * Explicit repair path: reconcile → drop offending rules → back up the
151
+ * config → rebuild the index. Mirrors the automatic step `init` runs
152
+ * after a fresh upstream init, for workspaces that were already
153
+ * initialized before the integrity gate existed.
154
+ */
155
+ async function runCodegraphRepairExcludeCommand(io, options, asJson) {
156
+ let projectRoot;
157
+ try {
158
+ const candidate = resolve(options.project);
159
+ if (!statSync(candidate).isDirectory()) {
160
+ throw new Error('Project path must exist and be a directory');
161
+ }
162
+ projectRoot = candidate;
163
+ }
164
+ catch (error) {
165
+ printCodegraphFailure(io, 'codegraph.repair-exclude', error, asJson);
166
+ return;
167
+ }
168
+ const report = await repairCodegraphExcludeFromProject(projectRoot);
169
+ // Where the notes go matters: `printResult` renders every `warnings`
170
+ // entry to stderr with a `warning: ` prefix, so a confirmation parked
171
+ // in the third slot reads as a problem — and a real warning parked
172
+ // there double-prefixes. Confirmations go to `nextActions`; only a
173
+ // genuine `report.warning` reaches `warnings`, verbatim.
174
+ const confirmations = [];
175
+ if (report.applied) {
176
+ confirmations.push(`Removed ${report.rulesRemoved.length} exclude rule(s), recovering ${report.filesRecovered} tracked source file(s). Config backed up to ${report.backupPath}.`);
177
+ }
178
+ else {
179
+ confirmations.push('No tracked source file is excluded by the codegraph config; nothing to repair.');
180
+ }
181
+ if (report.applied) {
182
+ confirmations.push('Re-run `peaks codegraph status --project <root>` to confirm the gap is closed.');
183
+ }
184
+ printResult(io, ok('codegraph.repair-exclude', {
185
+ applied: report.applied,
186
+ rulesRemoved: report.rulesRemoved,
187
+ filesRecovered: report.filesRecovered,
188
+ trackedSourceCount: report.trackedSourceCount,
189
+ configPath: report.configPath,
190
+ backupPath: report.backupPath,
191
+ reindexed: report.reindexed,
192
+ warning: report.warning
193
+ }, report.warning === null ? [] : [report.warning], confirmations), asJson);
194
+ // A repair that could not run to completion (or could not reindex)
195
+ // must not report success to a shell.
196
+ if (report.warning !== null) {
197
+ process.exitCode = 1;
198
+ }
199
+ }
64
200
  /**
65
201
  * rid-CG-006 — init conflict guard. Resolves the project root and
66
202
  * probes `.codegraph/` for the peaks-loop marker before invoking the
@@ -92,7 +228,14 @@ async function runCodegraphInitCommand(io, options, asJson) {
92
228
  guard: guardOutcome.status,
93
229
  codegraphDir: guardOutcome.codegraphDir,
94
230
  markerPresent: true
95
- }, [`.codegraph/ is already managed by peaks-loop; init is a no-op. Marker: ${guardOutcome.codegraphDir}/.peaks-loop-marker`], ['Run `peaks codegraph index` to (re)build the index without touching the schema.']), asJson);
231
+ },
232
+ // A no-op init is a SUCCESS. This message used to sit in the
233
+ // `warnings` slot and was therefore printed as
234
+ // `warning: .codegraph/ is already managed by peaks-loop...`.
235
+ [], [
236
+ `.codegraph/ is already managed by peaks-loop; init is a no-op. Marker: ${guardOutcome.codegraphDir}/.peaks-loop-marker`,
237
+ 'Run `peaks codegraph index` to (re)build the index without touching the schema.'
238
+ ]), asJson);
96
239
  return;
97
240
  }
98
241
  if (guardOutcome.status === 'conflict-foreign-schema') {
@@ -110,8 +253,7 @@ async function runCodegraphInitCommand(io, options, asJson) {
110
253
  try {
111
254
  const invocation = createCodegraphInvocation({
112
255
  subcommand: 'init',
113
- project: options.project,
114
- ...(options.yes === true ? { yes: true } : {})
256
+ project: options.project
115
257
  });
116
258
  const result = await executeCodegraphInvocation(invocation);
117
259
  const didFail = result.exitCode !== null && result.exitCode !== 0;
@@ -137,7 +279,47 @@ async function runCodegraphInitCommand(io, options, asJson) {
137
279
  catch {
138
280
  // intentionally swallowed — surface as warning below
139
281
  }
140
- printResult(io, ok('codegraph.init', { guard: guardOutcome.status, codegraphDir: guardOutcome.codegraphDir, markerWritten: true }, [], [`Stamped peaks-loop marker at ${guardOutcome.codegraphDir}/.peaks-loop-marker`]), asJson);
282
+ // Upstream `init` writes its 99-rule default `exclude` template,
283
+ // some of which collide with real source directories in this
284
+ // project. Left alone, a fresh clone / new machine gets an index
285
+ // that silently omits tracked source files while `status` says it
286
+ // is up to date. Reconcile now, drop the offending rules, and
287
+ // rebuild the index — a fresh init is the one moment this is both
288
+ // safe (nothing has been indexed yet) and necessary (`.codegraph/`
289
+ // is gitignored, so every clone starts from the default template).
290
+ //
291
+ // Never throws: a failure here is reported as a warning, not a
292
+ // failed init (the init itself already succeeded).
293
+ const excludeRepair = await repairCodegraphExcludeFromProject(projectRoot);
294
+ // These are confirmations, not warnings: `printResult` renders every
295
+ // `warnings` entry to stderr behind a `warning: ` prefix, so a fully
296
+ // successful init used to print a wall of `warning:` lines for what
297
+ // were plain success messages.
298
+ const initNotes = [
299
+ `Stamped peaks-loop marker at ${guardOutcome.codegraphDir}/.peaks-loop-marker`
300
+ ];
301
+ if (excludeRepair.applied) {
302
+ initNotes.push(`Removed ${excludeRepair.rulesRemoved.length} exclude rule(s) that blocked tracked source files, recovering ${excludeRepair.filesRecovered} file(s); config backed up to ${excludeRepair.backupPath}.`);
303
+ if (excludeRepair.reindexed) {
304
+ initNotes.push('Rebuilt the codegraph index over the recovered files.');
305
+ }
306
+ }
307
+ printResult(io, ok('codegraph.init', {
308
+ guard: guardOutcome.status,
309
+ codegraphDir: guardOutcome.codegraphDir,
310
+ markerWritten: true,
311
+ excludeRepair: {
312
+ applied: excludeRepair.applied,
313
+ rulesRemoved: excludeRepair.rulesRemoved,
314
+ filesRecovered: excludeRepair.filesRecovered,
315
+ reindexed: excludeRepair.reindexed,
316
+ backupPath: excludeRepair.backupPath,
317
+ warning: excludeRepair.warning
318
+ }
319
+ },
320
+ // Verbatim: `printResult` supplies the `warning: ` prefix, so a
321
+ // prefix added here would render as `warning: warning: ...`.
322
+ excludeRepair.warning === null ? [] : [excludeRepair.warning], initNotes), asJson);
141
323
  }
142
324
  catch (error) {
143
325
  printCodegraphFailure(io, 'codegraph.init', error, asJson);
@@ -211,8 +393,11 @@ async function runCodegraphAffectedCommand(io, files, options, asJson) {
211
393
  }
212
394
  export function registerCodegraphCommands(program, io) {
213
395
  const codegraph = program.command('codegraph').description('Run upstream codegraph commands through the Peaks launcher');
214
- addProjectOption(codegraph.command('status').description('Show codegraph status')).action((options) => runCodegraphCommand(io, 'codegraph.status', { subcommand: 'status', project: options.project }, options.peaksJson));
215
- addProjectOption(codegraph.command('init').description('Initialize codegraph for a project').option('--yes', 'answer yes to upstream prompts')).action((options) => runCodegraphInitCommand(io, options, options.peaksJson));
396
+ addProjectOption(codegraph.command('status').description('Show codegraph status, including the exclude integrity gate')).action((options) => runCodegraphStatusCommand(io, options, options.peaksJson));
397
+ addProjectOption(codegraph
398
+ .command('repair-exclude')
399
+ .description('Drop codegraph exclude rules that block tracked source files, then rebuild the index')).action((options) => runCodegraphRepairExcludeCommand(io, options, options.peaksJson));
400
+ addProjectOption(codegraph.command('init').description('Initialize codegraph for a project')).action((options) => runCodegraphInitCommand(io, options, options.peaksJson));
216
401
  addProjectOption(codegraph
217
402
  .command('index')
218
403
  .description('Index a project with codegraph')
@@ -1,14 +1,21 @@
1
1
  /**
2
- * W5 Fix M2 — `peaks prepare-final-review <rid>` CLI wrapper.
2
+ * `peaks prepare-final-review <rid>` CLI wrapper (W5 Fix M2; real provider
3
+ * binding added by the S3 defect-remediation slice).
3
4
  *
4
- * Exposes the `prepareFinalReview()` service (added in W2 T9 on
5
+ * Exposes the `prepareFinalReview()` service (W2 T9 on
5
6
  * `feature/slice-topology-multipass`) via the CLI surface. The service
6
- * depends on an injected `LlmRunner`; this slice wires the CLI route
7
- * with a `stub` provider that returns a structured "scaffold ready"
8
- * envelope so CI can verify the route without a real LLM. A follow-up
9
- * slice will bind a real provider. Until then, non-stub providers fail
10
- * loudly with `LLM_PROVIDER_NOT_IMPLEMENTED` so callers cannot silently
11
- * no-op.
7
+ * depends on an injected `LlmRunner`; this file owns the binding:
8
+ * - `--llm-provider stub` (default) returns a structured "scaffold ready"
9
+ * envelope WITHOUT calling the service, so CI can verify the route
10
+ * offline. It performs no review, and says so in every hint it emits.
11
+ * - `--llm-provider anthropic` binds the real Messages-API runner
12
+ * (`resolveAnthropicConfig()` + `createAnthropicRunner()`) and runs the
13
+ * service for real, carrying the 4-dim result back in the envelope.
14
+ * An absent credential or model raises `LlmBindingError`, reported under
15
+ * its own error code — never degraded into a scaffold a caller could
16
+ * mistake for a review.
17
+ * Unknown provider names still fail loudly with
18
+ * `LLM_PROVIDER_NOT_IMPLEMENTED` rather than silently falling back to stub.
12
19
  *
13
20
  * Per the dev-preference "Default-no on new CLI commands" rule and the
14
21
  * W4 T14 spec, this is a NEW top-level command (`prepare-final-review`),
@@ -19,13 +26,30 @@
19
26
  */
20
27
  import { Command } from 'commander';
21
28
  import { type ProgramIO } from '../cli-helpers.js';
22
- export type FinalReviewStatus = 'scaffold-only' | 'not-applicable';
29
+ import type { FinalReviewOutput } from '../../services/final-review/final-review-types.js';
30
+ export type FinalReviewStatus = 'scaffold-only' | 'review-complete' | 'not-applicable';
31
+ /**
32
+ * Which LLM produced this envelope. `unknown` is reserved for failure
33
+ * envelopes, where no binding was ever established.
34
+ */
35
+ export type FinalReviewProviderBinding = 'stub' | 'anthropic-messages-api' | 'unknown';
23
36
  export interface FinalReviewData {
24
37
  readonly status: FinalReviewStatus;
25
38
  readonly rid: string;
26
39
  readonly sessionId: string;
27
40
  readonly auditGoalPath: string;
28
41
  readonly serviceWired: boolean;
29
- readonly providerBinding: 'pending-follow-up-slice' | 'unknown';
42
+ readonly providerBinding: FinalReviewProviderBinding;
43
+ /** Model id the bound provider answered with (real-provider runs only). */
44
+ readonly model?: string;
45
+ /** The 4-dim review the service produced (real-provider runs only). */
46
+ readonly review?: FinalReviewOutput;
47
+ /**
48
+ * Environment variables that were absent when binding failed. Surfaced on
49
+ * `data` because `fail()` redacts `message` through
50
+ * `redactSensitiveErrorMessage`, whose catch-all pattern matches the words
51
+ * `token` / `api_key` and would blank out the very names an operator needs.
52
+ */
53
+ readonly missingEnv?: readonly string[];
30
54
  }
31
55
  export declare function registerFinalReviewCommands(program: Command, io: ProgramIO): void;
@@ -1,14 +1,21 @@
1
1
  /**
2
- * W5 Fix M2 — `peaks prepare-final-review <rid>` CLI wrapper.
2
+ * `peaks prepare-final-review <rid>` CLI wrapper (W5 Fix M2; real provider
3
+ * binding added by the S3 defect-remediation slice).
3
4
  *
4
- * Exposes the `prepareFinalReview()` service (added in W2 T9 on
5
+ * Exposes the `prepareFinalReview()` service (W2 T9 on
5
6
  * `feature/slice-topology-multipass`) via the CLI surface. The service
6
- * depends on an injected `LlmRunner`; this slice wires the CLI route
7
- * with a `stub` provider that returns a structured "scaffold ready"
8
- * envelope so CI can verify the route without a real LLM. A follow-up
9
- * slice will bind a real provider. Until then, non-stub providers fail
10
- * loudly with `LLM_PROVIDER_NOT_IMPLEMENTED` so callers cannot silently
11
- * no-op.
7
+ * depends on an injected `LlmRunner`; this file owns the binding:
8
+ * - `--llm-provider stub` (default) returns a structured "scaffold ready"
9
+ * envelope WITHOUT calling the service, so CI can verify the route
10
+ * offline. It performs no review, and says so in every hint it emits.
11
+ * - `--llm-provider anthropic` binds the real Messages-API runner
12
+ * (`resolveAnthropicConfig()` + `createAnthropicRunner()`) and runs the
13
+ * service for real, carrying the 4-dim result back in the envelope.
14
+ * An absent credential or model raises `LlmBindingError`, reported under
15
+ * its own error code — never degraded into a scaffold a caller could
16
+ * mistake for a review.
17
+ * Unknown provider names still fail loudly with
18
+ * `LLM_PROVIDER_NOT_IMPLEMENTED` rather than silently falling back to stub.
12
19
  *
13
20
  * Per the dev-preference "Default-no on new CLI commands" rule and the
14
21
  * W4 T14 spec, this is a NEW top-level command (`prepare-final-review`),
@@ -21,8 +28,18 @@ import { existsSync, statSync } from 'node:fs';
21
28
  import { join, resolve } from 'node:path';
22
29
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
23
30
  import { fail, ok } from 'peaks-loop-shared/result';
31
+ import { createAnthropicRunner, LlmBindingError, LlmRequestError, resolveAnthropicConfig, } from '../../services/llm/anthropic-runner.js';
32
+ import { IncompleteFinalReviewError, prepareFinalReview, } from '../../services/final-review/final-review-service.js';
24
33
  /** Whitelist of supported `--llm-provider` values for `peaks prepare-final-review`. */
25
- const SUPPORTED_LLM_PROVIDERS = ['stub'];
34
+ const SUPPORTED_LLM_PROVIDERS = ['anthropic', 'stub'];
35
+ /**
36
+ * Default stays `stub` (unlike `peaks audit goal`, whose default is the real
37
+ * provider): the scaffold route is what CI and the registered e2e contract
38
+ * exercise without credentials, and the stub envelope is labelled
39
+ * `status: 'scaffold-only'` / `providerBinding: 'stub'` so it can never be
40
+ * read as a review.
41
+ */
42
+ const DEFAULT_LLM_PROVIDER = 'stub';
26
43
  function isSupportedLlmProvider(value) {
27
44
  return SUPPORTED_LLM_PROVIDERS.includes(value);
28
45
  }
@@ -32,7 +49,7 @@ function isSupportedLlmProvider(value) {
32
49
  * "this is a placeholder on a failure" apart from "this is a real
33
50
  * scaffold-only success".
34
51
  */
35
- function emptyFinalReviewData(rid, sessionId, auditGoalPath) {
52
+ function emptyFinalReviewData(rid, sessionId, auditGoalPath, missingEnv) {
36
53
  return {
37
54
  status: 'not-applicable',
38
55
  rid,
@@ -40,6 +57,7 @@ function emptyFinalReviewData(rid, sessionId, auditGoalPath) {
40
57
  auditGoalPath,
41
58
  serviceWired: false,
42
59
  providerBinding: 'unknown',
60
+ ...(missingEnv === undefined ? {} : { missingEnv }),
43
61
  };
44
62
  }
45
63
  function validateProjectRoot(projectArg) {
@@ -95,7 +113,7 @@ export function registerFinalReviewCommands(program, io) {
95
113
  .description('Prepare the 4-dimension business review (final-review primitive) for human acceptance (W2 T9 service; CLI surface in W5 M2)')
96
114
  .requiredOption('--project <path>', 'target project root')
97
115
  .requiredOption('--session-id <sid>', 'session id whose .peaks/_runtime/<sid>/audit-goal/<rid>.json is the approved goal source')
98
- .option('--llm-provider <name>', 'LLM provider name (default: stub)', 'stub')).action(async (rid, options) => {
116
+ .option('--llm-provider <name>', `LLM provider name: ${SUPPORTED_LLM_PROVIDERS.join(' | ')} (default: ${DEFAULT_LLM_PROVIDER} — performs no review)`, DEFAULT_LLM_PROVIDER)).action(async (rid, options) => {
99
117
  // 1. Project root must exist and be a directory.
100
118
  const projectValidation = validateProjectRoot(options.project);
101
119
  if (!projectValidation.ok) {
@@ -128,34 +146,112 @@ export function registerFinalReviewCommands(program, io) {
128
146
  process.exitCode = 1;
129
147
  return;
130
148
  }
131
- // 5. Provider check: only `stub` is wired in this slice.
132
- const provider = options.llmProvider ?? 'stub';
149
+ // 5. Provider check: unknown names fail loudly — a silent fallback to
150
+ // `stub` would hand the caller a scaffold envelope that reads as a
151
+ // review route, which is the defect class this gate exists to stop.
152
+ const provider = options.llmProvider ?? DEFAULT_LLM_PROVIDER;
133
153
  if (!isSupportedLlmProvider(provider)) {
134
- printResult(io, fail('final-review.prepare', 'LLM_PROVIDER_NOT_IMPLEMENTED', `Provider '${provider}' is not yet wired. The CLI surface is in place; real provider binding is a follow-up slice. Use --llm-provider stub to validate the route without invoking the LLM.`, emptyFinalReviewData(rid, sessionValidation.sessionId, auditGoalPath), [
135
- 'Re-run with `--llm-provider stub` (default) to validate the route.',
136
- 'Real provider binding is tracked as a follow-up slice.',
154
+ printResult(io, fail('final-review.prepare', 'LLM_PROVIDER_NOT_IMPLEMENTED', `LLM provider "${provider}" is not implemented. Supported providers: ${SUPPORTED_LLM_PROVIDERS.join(', ')}.`, emptyFinalReviewData(rid, sessionValidation.sessionId, auditGoalPath), [
155
+ `Re-run with \`--llm-provider anthropic\` for a real 4-dim review, or \`--llm-provider ${DEFAULT_LLM_PROVIDER}\` for an offline scaffold.`,
137
156
  ]), options.json);
138
157
  process.exitCode = 1;
139
158
  return;
140
159
  }
141
160
  // 6. Stub path: surface a structured "scaffold ready" envelope.
142
- // We DO NOT call the service in this slice — the service depends
143
- // on an injected `LlmRunner` interface, and no real provider is
144
- // bound yet. The envelope confirms the route is wired end-to-end
145
- // and reports the audit-goal path the service WOULD read.
146
- const data = {
147
- status: 'scaffold-only',
148
- rid,
149
- sessionId: sessionValidation.sessionId,
150
- auditGoalPath,
151
- serviceWired: true,
152
- providerBinding: 'pending-follow-up-slice',
153
- };
154
- const envelope = ok('final-review.prepare', data, [], [
155
- 'prepareFinalReview() service is wired and reachable. The stub provider returns a scaffold envelope so CI can verify the route without a real LLM.',
156
- `Audit-goal file is present at: ${auditGoalPath}`,
157
- 'A follow-up slice will bind a real LLM provider; until then, non-stub providers fail loudly with `LLM_PROVIDER_NOT_IMPLEMENTED`.',
158
- ]);
159
- printResult(io, envelope, options.json);
161
+ // We DO NOT call the service here — the stub runner answers the
162
+ // audit-goal shape, not the 4-dim review shape, so running it through
163
+ // `prepareFinalReview()` would only manufacture a malformed review.
164
+ // The envelope confirms the route is wired end-to-end and reports the
165
+ // audit-goal path the service WOULD read.
166
+ if (provider === 'stub') {
167
+ const data = {
168
+ status: 'scaffold-only',
169
+ rid,
170
+ sessionId: sessionValidation.sessionId,
171
+ auditGoalPath,
172
+ serviceWired: true,
173
+ providerBinding: 'stub',
174
+ };
175
+ const envelope = ok('final-review.prepare', data, [], [
176
+ 'Stub provider: no 4-dim review was performed. This envelope only proves the route is wired and reachable.',
177
+ `Audit-goal file is present at: ${auditGoalPath}`,
178
+ 'Re-run with `--llm-provider anthropic` to produce a real review.',
179
+ ]);
180
+ printResult(io, envelope, options.json);
181
+ return;
182
+ }
183
+ // 7. Real provider path: bind a real `LlmRunner` and run the service.
184
+ // Binding happens INSIDE the try so an absent credential surfaces as
185
+ // `LlmBindingError`'s own code instead of escaping as a crash. No
186
+ // envelope is emitted on that path — a "successful" empty review would
187
+ // be worse than an error.
188
+ try {
189
+ const config = resolveAnthropicConfig();
190
+ const llmRunner = createAnthropicRunner(config);
191
+ const review = await prepareFinalReview(rid, {
192
+ projectRoot: projectValidation.projectRoot,
193
+ sessionId: sessionValidation.sessionId,
194
+ llmRunner,
195
+ });
196
+ const data = {
197
+ status: 'review-complete',
198
+ rid,
199
+ sessionId: sessionValidation.sessionId,
200
+ auditGoalPath,
201
+ serviceWired: true,
202
+ providerBinding: 'anthropic-messages-api',
203
+ model: config.model,
204
+ review,
205
+ };
206
+ const envelope = ok('final-review.prepare', data, review.allPass ? [] : [
207
+ `Dimensions needing human attention: ${review.needsAttention.join(', ') || 'none flagged'}.`,
208
+ ], [
209
+ `4-dim review produced by anthropic-messages-api (model: ${config.model}).`,
210
+ `allPass: ${String(review.allPass)}.`,
211
+ ]);
212
+ printResult(io, envelope, options.json);
213
+ }
214
+ catch (error) {
215
+ const code = finalReviewErrorCode(error);
216
+ printResult(io, fail('final-review.prepare', code, getErrorMessage(error), emptyFinalReviewData(rid, sessionValidation.sessionId, auditGoalPath, error instanceof LlmBindingError ? error.missingEnv : undefined), finalReviewNextActions(code)), options.json);
217
+ process.exitCode = 1;
218
+ }
160
219
  });
161
220
  }
221
+ /**
222
+ * Map a thrown error to the CLI's error code. The LLM-layer errors already
223
+ * carry codes precise enough to act on (`LLM_CREDENTIAL_MISSING`,
224
+ * `LLM_REQUEST_FAILED`, …), so they are passed through rather than flattened
225
+ * into one opaque failure.
226
+ */
227
+ function finalReviewErrorCode(error) {
228
+ if (error instanceof LlmBindingError ||
229
+ error instanceof LlmRequestError ||
230
+ error instanceof IncompleteFinalReviewError) {
231
+ return error.code;
232
+ }
233
+ return 'FINAL_REVIEW_FAILED';
234
+ }
235
+ function finalReviewNextActions(code) {
236
+ switch (code) {
237
+ case 'LLM_CREDENTIAL_MISSING':
238
+ return [
239
+ 'Export ANTHROPIC_AUTH_TOKEN (or ANTHROPIC_API_KEY) in the environment that launches peaks, then re-run.',
240
+ 'For an offline scaffold instead of a review, re-run with `--llm-provider stub` — it performs NO review.',
241
+ ];
242
+ case 'LLM_MODEL_MISSING':
243
+ return [
244
+ 'Export ANTHROPIC_MODEL (or CLAUDE_CODE_SUBAGENT_MODEL) in the environment that launches peaks, then re-run.',
245
+ ];
246
+ case 'LLM_REQUEST_FAILED':
247
+ return [
248
+ 'Check ANTHROPIC_BASE_URL and network reachability, then re-run — a transport failure produces no review.',
249
+ ];
250
+ case 'INCOMPLETE_FINAL_REVIEW':
251
+ return [
252
+ 'The LLM reply was not valid JSON or omitted a required dimension; re-run so the gate is never read as complete.',
253
+ ];
254
+ default:
255
+ return ['Re-run with `--llm-provider stub` to validate the CLI route without a real LLM.'];
256
+ }
257
+ }
@@ -12,6 +12,55 @@
12
12
  */
13
13
  import type { Command } from 'commander';
14
14
  import { type ProgramIO } from '../cli-helpers.js';
15
+ /**
16
+ * A `dispatch-*.json` candidate for `finalize --request-id`, as read off disk.
17
+ * The record's own `createdAt` is carried as the recency key — NOT the file's
18
+ * mtime, which any heartbeat or finalize rewrites, so a `done` record can look
19
+ * newer than the `queued` one that superseded it. Keeping the key on the
20
+ * candidate makes the selection rule a pure function of the records and
21
+ * testable without a filesystem.
22
+ */
23
+ export interface FinalizeCandidate {
24
+ readonly recordPath: string;
25
+ readonly requestId: string;
26
+ readonly status: string;
27
+ readonly createdAt: string;
28
+ }
29
+ /**
30
+ * What `--request-id` actually did, reported in the envelope. N3: the branch
31
+ * used to resolve silently and could not say WHY a record was or was not the
32
+ * one finalized.
33
+ */
34
+ export interface FinalizeSelection {
35
+ readonly requestId: string;
36
+ readonly rule: string;
37
+ readonly matched: number;
38
+ readonly chosen: string | null;
39
+ readonly rejected: readonly {
40
+ readonly recordPath: string;
41
+ readonly status: string;
42
+ readonly reason: string;
43
+ }[];
44
+ }
45
+ /** The one selection rule `--request-id` and `--batch` now share. */
46
+ export declare const FINALIZE_SELECTION_RULE: string;
47
+ /**
48
+ * N3 — pick the record `finalize --request-id` should act on.
49
+ *
50
+ * The branch this replaces `break`ed on the FIRST file whose record carried
51
+ * the requestId and never looked at `status`. With a re-dispatched request
52
+ * (this session holds six records for `2026-09-12-defect-remediation`) it
53
+ * always resolved to the OLDEST one — the already-`done` RD record — so
54
+ * finalizing reported success while the newer `queued` QA record stayed
55
+ * queued forever. `--batch` never had that bug: it filters on `queued`.
56
+ *
57
+ * Both branches are now the same rule. Return `null` when nothing is queued:
58
+ * a record that already left `queued` is precisely the one that must NOT be
59
+ * re-finalized, so the caller reports the survivors instead of touching one.
60
+ */
61
+ export declare function selectFinalizeTarget(candidates: readonly FinalizeCandidate[]): FinalizeCandidate | null;
62
+ /** Why a candidate was not the chosen one — reported, never guessed at. */
63
+ export declare function describeFinalizeRejection(candidate: FinalizeCandidate, chosen: FinalizeCandidate | null): string;
15
64
  export declare function registerShareCommand(parent: Command, io: ProgramIO): void;
16
65
  export declare function registerSharedReadCommand(parent: Command, io: ProgramIO): void;
17
66
  export declare function registerAwaitCommand(parent: Command, io: ProgramIO): void;