peaks-loop 4.0.42 → 4.0.43

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 (51) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/_register.js +4 -0
  5. package/dist/cli/commands/api-diff-commands.d.ts +16 -0
  6. package/dist/cli/commands/api-diff-commands.js +55 -0
  7. package/dist/cli/commands/audit-commands.d.ts +16 -3
  8. package/dist/cli/commands/audit-commands.js +84 -31
  9. package/dist/cli/commands/job-commands.js +4 -2
  10. package/dist/cli/commands/scan-commands.js +1 -1
  11. package/dist/cli/commands/test-commands.d.ts +60 -3
  12. package/dist/cli/commands/test-commands.js +125 -7
  13. package/dist/services/audit/audit-goal-service.js +38 -3
  14. package/dist/services/doctor/doctor-service/checks/ecc-hooks-schema-drift.d.ts +65 -0
  15. package/dist/services/doctor/doctor-service/checks/ecc-hooks-schema-drift.js +186 -0
  16. package/dist/services/doctor/doctor-service/plugin-registry.js +2 -0
  17. package/dist/services/doctor/doctor-service/types.d.ts +20 -0
  18. package/dist/services/llm/anthropic-runner.d.ts +87 -0
  19. package/dist/services/llm/anthropic-runner.js +171 -0
  20. package/dist/services/llm/stub-runner.d.ts +11 -0
  21. package/dist/services/llm/stub-runner.js +33 -0
  22. package/dist/services/prd/project-scan-bootstrap-service.js +7 -7
  23. package/dist/services/scan/api-diff-openapi.d.ts +32 -0
  24. package/dist/services/scan/api-diff-openapi.js +359 -0
  25. package/dist/services/scan/api-diff-recorded.d.ts +96 -0
  26. package/dist/services/scan/api-diff-recorded.js +577 -0
  27. package/dist/services/scan/api-diff-service.d.ts +34 -0
  28. package/dist/services/scan/api-diff-service.js +407 -0
  29. package/dist/services/scan/api-diff-types.d.ts +116 -0
  30. package/dist/services/scan/api-diff-types.js +46 -0
  31. package/dist/services/scan/archetype-service.js +27 -1
  32. package/dist/services/scan/existing-system-service.js +17 -4
  33. package/dist/services/scan/hook-convention-service.d.ts +26 -0
  34. package/dist/services/scan/hook-convention-service.js +562 -0
  35. package/dist/services/scan/scan-types.d.ts +47 -0
  36. package/dist/services/session/caller-binding-service.d.ts +28 -0
  37. package/dist/services/session/caller-binding-service.js +10 -2
  38. package/dist/services/session/caller-id-types.d.ts +12 -2
  39. package/dist/services/session/index.d.ts +2 -2
  40. package/dist/services/session/index.js +2 -2
  41. package/dist/services/session/session-binding-bridge.js +11 -6
  42. package/dist/services/session/session-manager.d.ts +33 -1
  43. package/dist/services/session/session-manager.js +84 -25
  44. package/dist/services/skills/skill-presence-service.d.ts +17 -3
  45. package/dist/services/skills/skill-presence-service.js +23 -3
  46. package/package.json +5 -5
  47. package/skills/bee/peaks-rd/SKILL.md +11 -3
  48. package/skills/peaks-code/references/existing-system-extraction.md +5 -1
  49. package/skills/peaks-code/references/frontend-only-mode.md +48 -6
  50. package/skills/peaks-code/references/project-scan-checklist.md +20 -1
  51. package/skills/peaks-doctor/references/doctor-check-catalog.md +1 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,32 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.0.43 — 2026-09-12 (一个从来拦不住东西的闸门 + 前端接口防腐层)
4
+
5
+ **Highlights**:
6
+
7
+ 1. **`peaks audit goal` 是门面 —— 现在是真的了。** 它是所有 peaks-* 工作流的入口闸门:把一个需求变成 6 维审计加一个目标,并在审计不完整时**拒绝**让自主工作继续。而它无论输入什么都返回同一个 `scaffold-only` 空壳,所以它什么都没拦。校验服务本身是好的,**缺的只是 provider 绑定**。本版建了 peaks-loop 的第一个 LLM 客户端:读会话已经带着的环境(`ANTHROPIC_BASE_URL`;`ANTHROPIC_AUTH_TOKEN` 或 `ANTHROPIC_API_KEY`;`ANTHROPIC_MODEL` 或 `CLAUDE_CODE_SUBAGENT_MODEL`),走全局 `fetch`,**零新增运行时依赖**。
8
+
9
+ - **一个静默回退会让这个闸门和以前一样假**,所以凭据缺失时它响亮失败:exit 1,并指名缺失的那个变量。`stub` 保留给 CI,且明确标注自己是 stub。
10
+ - 真机跑出的第一份审计暴露了第二个洞:**提示词从没写过 `severity` 的合法取值**,模型于是自己发明了 `high` —— 而闸门放它过去了。也就是说它验了**覆盖**和**字段存在**,没验**取值合法**。现在提示词明写合法值,解析器真校验,并**从不规整坏值**:把坏值改写成好值,正是让枚举变成装饰品的做法。
11
+
12
+ 2. **同一个项目开两个窗口会互相串 session —— 已修。** `getCurrentSessionId` 只读一个项目级全局文件,最后开窗口的赢;而 **17 个命令**从它解析 session(job / dispatch / worktree / share / vm / web / slice-* …)。报出来的症状只是无害的那一半(`peaks job status` 报 `JOB_NOT_IN_SESSION`);有害的一半是**两个窗口各有同名 job 或 slice 时,写入落到另一个窗口的目录树里,而且不报错**。现在 caller 优先 —— 由 `peaks session info --active` 用的同一个来源解析 —— 没有绑定时回退全局文件,所以 CI 与非 IDE 调用的行为逐字节不变,且**17 个调用点一行未改**。
13
+
14
+ - 对抗 QA 随后发现底下的信任模型没有防护:绑定指向的 session 目录已不存在时仍被信任,而轮转从不清理绑定,于是轮转后 `peaks job init` 会解析到旧 id、并在其下重建 job 目录树。现在绑定分 `bound | stale | absent`,目录不存在即视为陈旧,且**回落这件事是可观测的**。
15
+ - `lastActivityAt` **删除而非启用**,理由值得记下:它的写入侧本来就是坏的(只在首次绑定和显式重绑时写、复用时不写),所以时间戳在一个**活着的**窗口上照常变老 —— 做 TTL 反而会把"活着但空闲"的窗口解绑,复活刚修掉的那个串档。而时钟也看不见它报的两个缺陷(目录没了、轮转)。
16
+
17
+ 3. **前端/全栈的接口防腐层。** 新增 `peaks scan api-diff <doc>`:解析 OpenAPI 3.x 文档,与项目**已经**产出的三份东西对账(mock-plan、`*-api.types.ts`、TXT handoff 端点清单),输出分成**精确段**(两侧都解析过)与**候选段**(名字 grep,明标会过报漏报)。不建新产物、不做 codegen、不加运行时依赖。
18
+
19
+ - 关键取舍写在**输出里**而不是文档里:**生成的类型只能保证"文档↔代码",永远保证不了"文档↔服务器"。** 如果文档本身过时,类型、客户端、mapper 会从**同一份错文档**重新生成、**编译干净通过**、页面渲染 `undefined` —— 而"文档到手"正是这个需求的触发点。所以**诚实的边界是功能的一部分**,不是免责声明。
20
+ - 两侧遵循同一条规则:**读不全就不说。** 任何无法完整读取的接口或位置都被压制,并附一条能定位到文件/接口/操作 + 原因的 note。这条规则必须同时应用在记录侧**和**文档侧 —— 两者不对称是最后被发现的那处结构性缺口。
21
+ - 配套:项目扫描新增**三态**集成模式(`full-stack` / `prd-plus-interface-doc` / `prd-only`,从已有信号确定性推导,不新增探测)、缺失的 `## API` 扫描节、RD **按模式路由**(否则三态只是个没人读的死字段)、以及读取 hook **内容**并报告观察到的约定,以**不一致**为信号。
22
+ - 明确**不做**的事:不检测"组件直接消费 DTO"。那是**类型流**性质,正则表达式只会命中唯一合法的 mapper import、却漏掉真正的违规 —— 一条会打偏的规则比没有规则更糟。
23
+
24
+ 4. **`peaks test <file>` 在 Windows 上从来就没跑通过。** 它 spawn 裸名 `vitest`,而 Windows 上 runner 是 `node_modules/.bin/vitest.cmd` —— 而**这个命令自己的文档**写着"会替你解析本地二进制,Windows 感知"。现在解析项目本地 runner。两种形式都是**实测而非猜测**:直接 spawn `.CMD` 返回 `EINVAL`,而那个"显然的修法" `{shell:true}` 会**破坏 argv**(`tests/a b/x.test.ts` 会变成 3 个参数),所以走显式 `cmd.exe`。找不到 runner 时列出**每一条探过的路径**加修法,而不是抛裸 `ENOENT`。
25
+
26
+ 5. **一处三方分歧:生成的 `frontendOnly` 写在一个标题下、模板写在另一个、读取方从第三个读。** 实际是**四方** —— 找出来的第四方是一份**写入指令**,不改它,下一次 LLM 写的扫描就会重新制造这个漂移。生成器已对齐,新守卫**同时断言该行存在、且在旧位置缺席**(只断言存在的守卫,正是这一整类缺陷反复出厂的原因)。
27
+
28
+ **验证**:三个版本常量一致(**4.0.43**);`tsc -p tsconfig.build.json` exit 0;宽 `tsconfig.json` 保持 **142** 基线;`tests/unit` **201 files / 1975 passed / 3 skipped / 0 failed**;`pnpm build` 的 `build-integrity` OK。
29
+
3
30
  ## 4.0.42 — 2026-09-12 (peaks 自己的钩子在每次编辑时报错)
4
31
 
5
32
  **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.42 (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.43 (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.42(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.43(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 |
@@ -60,6 +60,7 @@ import { registerReviewerCommands } from './reviewer-commands.js';
60
60
  import { registerRoleCommands } from './role-commands.js';
61
61
  import { registerRuntimeCommands } from './runtime-commands.js';
62
62
  import { registerScanCommands } from './scan-commands.js';
63
+ import { registerApiDiffCommands } from './api-diff-commands.js';
63
64
  import { registerShadcnCommands } from './shadcn-commands.js';
64
65
  import { registerSecurityAuditCommands } from './security-audit-commands.js';
65
66
  import { registerSedimentCommands } from './sediment-commands.js';
@@ -99,6 +100,9 @@ const REGISTRATIONS = [
99
100
  ['project-commands', registerProjectCommands], ['prd-commands', registerPrdCommands],
100
101
  ['request-commands', registerRequestCommands], ['retrospective-commands', registerRetrospectiveCommands],
101
102
  ['scan-commands', registerScanCommands], ['shadcn-commands', registerShadcnCommands],
103
+ // Registered after `scan-commands` on purpose: it attaches `scan api-diff`
104
+ // to the existing parent instead of creating a second `scan` command.
105
+ ['api-diff-commands', registerApiDiffCommands],
102
106
  ['slice-commands', registerSliceCommands],
103
107
  ['sop-commands', registerSopCommands], ['feedback-commands', registerFeedbackCommands],
104
108
  ['fork-commands', registerForkCommands], ['impact-commands', registerImpactCommands],
@@ -0,0 +1,16 @@
1
+ /**
2
+ * S1 / rid=api-diff-report — `peaks scan api-diff <doc>`.
3
+ *
4
+ * Group choice: `scan`, not a new top-level `api` group. `scan` already hosts
5
+ * `api-surface` — a read-only, `--project`-scoped API analysis that writes no
6
+ * artifact — so `api-diff` is a sibling of an existing command with identical
7
+ * semantics and an identical option shape. A new `peaks api` group would be a
8
+ * brand-new verb family whose cost the design (docs/superpowers/specs/
9
+ * 2026-09-12-frontend-acl-contract-design.md §3/§5) records as medium and
10
+ * acknowledged; nothing about this slice needs it.
11
+ *
12
+ * The command is read-only: it creates no contract artifact and writes no file.
13
+ */
14
+ import type { Command } from 'commander';
15
+ import { type ProgramIO } from '../cli-helpers.js';
16
+ export declare function registerApiDiffCommands(program: Command, io: ProgramIO): void;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * S1 / rid=api-diff-report — `peaks scan api-diff <doc>`.
3
+ *
4
+ * Group choice: `scan`, not a new top-level `api` group. `scan` already hosts
5
+ * `api-surface` — a read-only, `--project`-scoped API analysis that writes no
6
+ * artifact — so `api-diff` is a sibling of an existing command with identical
7
+ * semantics and an identical option shape. A new `peaks api` group would be a
8
+ * brand-new verb family whose cost the design (docs/superpowers/specs/
9
+ * 2026-09-12-frontend-acl-contract-design.md §3/§5) records as medium and
10
+ * acknowledged; nothing about this slice needs it.
11
+ *
12
+ * The command is read-only: it creates no contract artifact and writes no file.
13
+ */
14
+ import { ApiDiffInputError, diffApiDocument, formatApiDiffText } from '../../services/scan/api-diff-service.js';
15
+ import { fail, ok } from 'peaks-loop-shared/result';
16
+ import { addJsonOption, printResult } from '../cli-helpers.js';
17
+ export function registerApiDiffCommands(program, io) {
18
+ // Reuse the existing `scan` parent — the add-a-new-subcommand-check-for-
19
+ // existing-top-level-first rule (same guard as bee-commands / asset-commands).
20
+ const scan = program.commands.find((c) => c.name() === 'scan') ?? program
21
+ .command('scan')
22
+ .description('Read-only project scans for tech-doc and RD handoffs');
23
+ addJsonOption(scan
24
+ .command('api-diff')
25
+ .description('Diff an OpenAPI 3.x document (.json/.yaml/.yml) against what this project already recorded — ' +
26
+ 'the mock-plan, the recorded *-api.types.ts interfaces, and the TXT handoff endpoint list. ' +
27
+ 'Read-only: creates no contract artifact. Output separates an Exact section (both sides parsed) ' +
28
+ 'from a Candidate mentions section (name-grep, may over- and under-report), and always prints ' +
29
+ 'what this command cannot detect.')
30
+ .argument('<doc>', 'path to the OpenAPI 3.x document (relative to --project, or absolute)')
31
+ .option('--project <path>', 'consumer project root (default: cwd)')).action((doc, options) => {
32
+ const projectRoot = options.project ?? process.cwd();
33
+ const asJson = options.json ?? false;
34
+ try {
35
+ const report = diffApiDocument({ projectRoot, docPath: doc });
36
+ const nextActions = report.notes.length > 0
37
+ ? ['Read the notes: at least one recorded source was missing, so the Exact section is partial by construction.']
38
+ : [];
39
+ if (asJson) {
40
+ printResult(io, ok('scan.api-diff', report, [], nextActions), true);
41
+ return;
42
+ }
43
+ io.stdout(`${formatApiDiffText(report)}\n`);
44
+ }
45
+ catch (error) {
46
+ // A non-OpenAPI input must exit non-zero AND produce no diff output — a
47
+ // silent empty report is the exact failure this command exists to prevent.
48
+ const code = error instanceof ApiDiffInputError ? error.code : 'API_DIFF_FAILED';
49
+ printResult(io, fail('scan.api-diff', code, error.message, { document: doc }, [
50
+ 'Pass a path to an OpenAPI 3.x document: a top-level `openapi: 3.x` string plus a non-empty `paths` object.'
51
+ ]), asJson);
52
+ process.exitCode = 1;
53
+ }
54
+ });
55
+ }
@@ -10,14 +10,27 @@
10
10
  import { Command } from 'commander';
11
11
  import { type AgentShieldState } from '../../services/audit/static-service.js';
12
12
  import { type ProgramIO } from '../cli-helpers.js';
13
+ import type { AuditGoalOutput } from '../../services/audit/audit-goal-types.js';
13
14
  import type { RedLineAudit } from '../../services/audit/types.js';
14
15
  import { type AuditDecisionRecord } from '../../services/audit/decision-writer.js';
16
+ /** `audit-failed` is a failure envelope's status — never a scaffold, never a success. */
17
+ export type AuditGoalStatus = 'audit-complete' | 'scaffold-only' | 'audit-failed';
15
18
  export interface AuditGoalData {
16
- readonly status: 'scaffold-only';
17
- readonly serviceWired: true;
18
- readonly providerBinding: 'pending-follow-up-slice';
19
+ readonly status: AuditGoalStatus;
20
+ /** Which LLM produced (or failed to produce) the audit. `unresolved` = rejected before binding. */
21
+ readonly providerBinding: 'anthropic-messages-api' | 'stub' | 'unresolved';
19
22
  readonly need: string;
20
23
  readonly projectRoot: string;
24
+ /** The validated 6-dimension audit. Present on success only. */
25
+ readonly result?: AuditGoalOutput;
26
+ /** The bound model. Present on a real run only. */
27
+ readonly model?: string;
28
+ /**
29
+ * Environment variables the binding needed and did not find. Present on a
30
+ * binding failure only, and verbatim: `fail()` redacts `message`, so this
31
+ * is the channel that reliably names what the operator must set.
32
+ */
33
+ readonly missingEnv?: readonly string[];
21
34
  }
22
35
  export interface StaticAuditData {
23
36
  readonly audit: RedLineAudit;
@@ -11,6 +11,9 @@ import { existsSync, readFileSync, statSync } from 'node:fs';
11
11
  import { resolve } from 'node:path';
12
12
  import { runRedLinesAudit } from '../../services/audit/red-lines-service.js';
13
13
  import { runStaticAudit } from '../../services/audit/static-service.js';
14
+ import { auditGoal, IncompleteAuditError } from '../../services/audit/audit-goal-service.js';
15
+ import { createAnthropicRunner, LlmBindingError, LlmRequestError, resolveAnthropicConfig, } from '../../services/llm/anthropic-runner.js';
16
+ import { createStubRunner } from '../../services/llm/stub-runner.js';
14
17
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
15
18
  import { fail, ok } from 'peaks-loop-shared/result';
16
19
  import { writeAuditDecision } from '../../services/audit/decision-writer.js';
@@ -26,7 +29,13 @@ function isSupportedArtifactKind(value) {
26
29
  return SUPPORTED_ARTIFACT_KINDS.includes(value);
27
30
  }
28
31
  /** Whitelist of supported `--llm-provider` values for `peaks audit goal`. */
29
- const SUPPORTED_LLM_PROVIDERS = ['stub'];
32
+ const SUPPORTED_LLM_PROVIDERS = ['anthropic', 'stub'];
33
+ /**
34
+ * The real provider is the default: `peaks audit goal` is the entry gate for
35
+ * every peaks-* workflow, so it must audit by default and only scaffold when
36
+ * a caller explicitly asks for `stub`.
37
+ */
38
+ const DEFAULT_LLM_PROVIDER = 'anthropic';
30
39
  function isSupportedLlmProvider(value) {
31
40
  return SUPPORTED_LLM_PROVIDERS.includes(value);
32
41
  }
@@ -195,47 +204,69 @@ export function registerAuditCommands(program, io) {
195
204
  process.exitCode = 1;
196
205
  }
197
206
  });
198
- // Fix M1 (W5) — `peaks audit goal` CLI wrapper around `auditGoal()`.
199
- // The service is correctly implemented but is NOT yet wired to a real
200
- // LLM provider; this CLI exposes the route with a `stub` provider that
201
- // returns a scaffold envelope. A follow-up slice will bind a real
202
- // provider. Until then, non-stub providers fail loudly with
203
- // `LLM_PROVIDER_NOT_IMPLEMENTED` so callers cannot silently no-op.
207
+ // Slice 2026-09-12-llm-provider-binding — `peaks audit goal` now runs the
208
+ // gate it advertises. `auditGoal()` was already correct (one `LlmRunner`
209
+ // call, 6-dimension validation, `IncompleteAuditError` on a partial audit);
210
+ // what was missing was a provider binding, so the command answered with a
211
+ // fixed `scaffold-only` envelope no matter what it was asked.
212
+ //
213
+ // `anthropic` is now the default and reads the session's own environment
214
+ // (see `resolveAnthropicConfig`). `stub` stays for CI/tests but is
215
+ // reported as a scaffold, and a missing credential fails loudly — a silent
216
+ // fall back to the scaffold envelope would leave the gate exactly as fake
217
+ // as it was before this slice.
204
218
  addJsonOption(audit
205
219
  .command('goal')
206
220
  .description('Audit a human need across 6 dimensions and propose a goal (peaks-audit primitive)')
207
221
  .requiredOption('--project <path>', 'target project root')
208
222
  .requiredOption('--need <text>', 'the human need to audit (becomes input.need for auditGoal())')
209
- .option('--llm-provider <name>', 'LLM provider name (default: stub)', 'stub')).action(async (options) => {
223
+ .option('--llm-provider <name>', `LLM provider (${SUPPORTED_LLM_PROVIDERS.join(' | ')}); stub performs no audit`, DEFAULT_LLM_PROVIDER)).action(async (options) => {
210
224
  const validation = validateProjectRoot(options.project);
211
225
  if (!validation.ok) {
212
- printResult(io, fail('audit.goal', validation.code, validation.message, emptyAuditGoalData(options.need, options.project), ['Verify the project path exists and is a directory']), options.json);
226
+ printResult(io, fail('audit.goal', validation.code, validation.message, auditGoalFailureData(options.need, options.project), ['Verify the project path exists and is a directory']), options.json);
213
227
  process.exitCode = 1;
214
228
  return;
215
229
  }
216
- const provider = options.llmProvider ?? 'stub';
230
+ const provider = options.llmProvider ?? DEFAULT_LLM_PROVIDER;
217
231
  if (!isSupportedLlmProvider(provider)) {
218
- printResult(io, fail('audit.goal', 'LLM_PROVIDER_NOT_IMPLEMENTED', `LLM provider "${provider}" is not implemented. Supported providers: ${SUPPORTED_LLM_PROVIDERS.join(', ')}.`, emptyAuditGoalData(options.need, validation.projectRoot), [
219
- 'Re-run with `--llm-provider stub` (default) to exercise the wired route.',
220
- 'Real provider binding is tracked as a follow-up slice; see peaks-audit skill notes.'
232
+ printResult(io, fail('audit.goal', 'LLM_PROVIDER_NOT_IMPLEMENTED', `LLM provider "${provider}" is not implemented. Supported providers: ${SUPPORTED_LLM_PROVIDERS.join(', ')}.`, auditGoalFailureData(options.need, validation.projectRoot), [
233
+ `Re-run with \`--llm-provider ${DEFAULT_LLM_PROVIDER}\` for a real audit, or \`--llm-provider stub\` for an offline scaffold.`
221
234
  ]), options.json);
222
235
  process.exitCode = 1;
223
236
  return;
224
237
  }
225
- // Stub provider: surface a structured "scaffold ready" envelope so the
226
- // CLI route is wired and a CI test can verify it without a real LLM.
227
- const data = {
228
- status: 'scaffold-only',
229
- serviceWired: true,
230
- providerBinding: 'pending-follow-up-slice',
231
- need: options.need,
232
- projectRoot: validation.projectRoot,
233
- };
234
- const envelope = ok('audit.goal', data, [], [
235
- 'auditGoal() service is wired and reachable. The stub provider returns a scaffold envelope so CI can verify the route without a real LLM.',
236
- 'A follow-up slice will bind a real LLM provider; until then, non-stub providers fail loudly with `LLM_PROVIDER_NOT_IMPLEMENTED`.'
237
- ]);
238
- printResult(io, envelope, options.json);
238
+ const isStub = provider === 'stub';
239
+ const providerBinding = isStub ? 'stub' : 'anthropic-messages-api';
240
+ try {
241
+ let model;
242
+ let llmRunner;
243
+ if (isStub) {
244
+ llmRunner = createStubRunner();
245
+ }
246
+ else {
247
+ const config = resolveAnthropicConfig();
248
+ model = config.model;
249
+ llmRunner = createAnthropicRunner(config);
250
+ }
251
+ const result = await auditGoal({ need: options.need }, llmRunner);
252
+ const data = {
253
+ status: isStub ? 'scaffold-only' : 'audit-complete',
254
+ providerBinding,
255
+ need: options.need,
256
+ projectRoot: validation.projectRoot,
257
+ result,
258
+ ...(model === undefined ? {} : { model }),
259
+ };
260
+ const envelope = ok('audit.goal', data, [], isStub
261
+ ? [`Stub provider: the 6 dimensions below are placeholders, not findings. Re-run with \`--llm-provider ${DEFAULT_LLM_PROVIDER}\` for a real audit.`]
262
+ : [`Audit produced by ${providerBinding}${model === undefined ? '' : ` (model: ${model})`}.`]);
263
+ printResult(io, envelope, options.json);
264
+ }
265
+ catch (error) {
266
+ const code = auditGoalErrorCode(error);
267
+ printResult(io, fail('audit.goal', code, getErrorMessage(error), auditGoalFailureData(options.need, validation.projectRoot, providerBinding, error instanceof LlmBindingError ? error.missingEnv : undefined), auditGoalNextActions(code)), options.json);
268
+ process.exitCode = 1;
269
+ }
239
270
  });
240
271
  // ---------------------------------------------------------------------------
241
272
  // peaks audit artifact write — Slice 2026-06-26-audit-artifact-writer-generalization
@@ -387,15 +418,37 @@ function emptyStaticAuditData() {
387
418
  },
388
419
  };
389
420
  }
390
- function emptyAuditGoalData(need, projectRoot) {
421
+ function auditGoalFailureData(need, projectRoot, providerBinding = 'unresolved', missingEnv) {
391
422
  return {
392
- status: 'scaffold-only',
393
- serviceWired: true,
394
- providerBinding: 'pending-follow-up-slice',
423
+ status: 'audit-failed',
424
+ providerBinding,
395
425
  need,
396
426
  projectRoot,
427
+ ...(missingEnv === undefined ? {} : { missingEnv }),
397
428
  };
398
429
  }
430
+ /** The slice-owned error codes are carried verbatim so callers can gate on them. */
431
+ function auditGoalErrorCode(error) {
432
+ if (error instanceof IncompleteAuditError || error instanceof LlmBindingError || error instanceof LlmRequestError) {
433
+ return error.code;
434
+ }
435
+ return 'AUDIT_GOAL_FAILED';
436
+ }
437
+ function auditGoalNextActions(code) {
438
+ switch (code) {
439
+ case 'LLM_CREDENTIAL_MISSING':
440
+ return [
441
+ 'Export ANTHROPIC_AUTH_TOKEN (or ANTHROPIC_API_KEY) in the environment that launches peaks, then re-run.',
442
+ 'For an offline scaffold instead of an audit, re-run with `--llm-provider stub` — it performs NO audit.',
443
+ ];
444
+ case 'LLM_MODEL_MISSING':
445
+ return ['Export ANTHROPIC_MODEL (or CLAUDE_CODE_SUBAGENT_MODEL) in the environment that launches peaks, then re-run.'];
446
+ case 'INCOMPLETE_AUDIT':
447
+ return ['The LLM reply omitted a required dimension; re-run so autonomous work never proceeds on a partial audit.'];
448
+ default:
449
+ return ['Inspect the failure above, then re-run with the same --need.'];
450
+ }
451
+ }
399
452
  function emptyProseRatioResult() {
400
453
  return {
401
454
  totalRedLines: 0,
@@ -56,7 +56,8 @@ function findSessionHoldingJob(project, jobId) {
56
56
  * `.peaks/_runtime/session.json` binding points at another session):
57
57
  * 1. `--session-id` flag (explicit override)
58
58
  * 2. `PEAKS_SESSION_ID` env var
59
- * 3. `getCurrentSessionId(project)` — reads `.peaks/_runtime/session.json`
59
+ * 3. `getCurrentSessionId(project)` — this caller's session binding, falling back
60
+ * to `.peaks/_runtime/session.json` when no caller binding is resolvable
60
61
  * 4. Error (NO_ACTIVE_SESSION) — must never silently fall back to a random uuid
61
62
  *
62
63
  * When `jobId` is passed and it is absent from the resolved session, the thrown
@@ -110,7 +111,8 @@ export function registerJobCommands(program, io = { stdout: (t) => process.stdou
110
111
  .option('--project <repo>')
111
112
  .action(async (opts) => {
112
113
  const project = projectRoot(opts);
113
- // Resolve sessionId: explicit flag > PEAKS_SESSION_ID > canonical session binding > FAIL.
114
+ // Resolve sessionId: explicit flag > PEAKS_SESSION_ID > caller-first session binding
115
+ // (this caller's binding, else the project-global session.json) > FAIL.
114
116
  // Per spec §3.3, Job state lives at .peaks/_runtime/<sessionId>/job/<jobId>/state.json —
115
117
  // a random UUID would scatter state across dirs and break resume/auto-compact.
116
118
  let sessionId = opts.sessionId ?? process.env.PEAKS_SESSION_ID ?? getCurrentSessionId(project);
@@ -30,7 +30,7 @@ export function registerScanCommands(program, io) {
30
30
  const scan = program.command('scan').description('Deterministic project scans (archetype, existing system) for Peaks workflows');
31
31
  addJsonOption(scan
32
32
  .command('archetype')
33
- .description('Detect project archetype, frontend-only mode, and supporting signals from the filesystem (read-only)')
33
+ .description('Detect project archetype, integration mode (three scenarios), frontend-only mode, and supporting signals from the filesystem (read-only)')
34
34
  .requiredOption('--project <path>', 'target project root')).action(async (options) => {
35
35
  try {
36
36
  const report = await scanArchetype({ projectRoot: options.project });
@@ -6,14 +6,18 @@
6
6
  *
7
7
  * 1. Auto-detects the framework from package.json (devDependencies +
8
8
  * dependencies) via detectTestFramework().
9
- * 2. Spawns the framework's CLI with --cache enabled (overriding any
9
+ * 2. Resolves the project-LOCAL runner binary (node_modules) and spawns
10
+ * that, so the command works where the runner is not on PATH — notably
11
+ * Windows, where node_modules/.bin/vitest.cmd is not spawnable without
12
+ * a shell. PATH is a last resort and is reported, not silent.
13
+ * 3. Spawns the framework's CLI with --cache enabled (overriding any
10
14
  * --no-cache in the consumer's `test` script). The user can
11
15
  * opt back into no-cache via `peaks test --no-cache` or
12
16
  * `peaks test --passthrough`.
13
- * 3. Skips tests where (fileMtime, fileSha256) is unchanged AND the
17
+ * 4. Skips tests where (fileMtime, fileSha256) is unchanged AND the
14
18
  * previous run status was 'passed' (per-test fingerprint cache at
15
19
  * `<projectRoot>/.peaks/_runtime/test-cache/<hash>.json`).
16
- * 4. Exits 0 on all-pass / all-skip; exits 1 on any failure.
20
+ * 5. Exits 0 on all-pass / all-skip; exits 1 on any failure.
17
21
  *
18
22
  * The CLI is invoked by USER (not just by skill) per slice 2.5.0
19
23
  * sub-fix B (G16) — a documented exception to the
@@ -29,6 +33,7 @@
29
33
  * peaks test --passthrough — do NOT override the consumer's argv
30
34
  * peaks test --framework <name> — force a specific framework
31
35
  */
36
+ import { spawn } from 'node:child_process';
32
37
  import type { Command } from 'commander';
33
38
  import { type ProgramIO } from '../cli-helpers.js';
34
39
  import { type TestFramework } from '../../services/test-cache/test-cache-service.js';
@@ -44,4 +49,56 @@ export declare function buildRunnerArgv(framework: TestFramework, patterns: stri
44
49
  cache?: boolean;
45
50
  passthrough?: boolean;
46
51
  }): string[];
52
+ /** Successful resolution — everything `spawn` needs, plus provenance. */
53
+ export type RunnerFound = {
54
+ ok: true;
55
+ /** Executable to spawn: node itself, a local shim, or a PATH hit. */
56
+ command: string;
57
+ /** argv for `command` (includes the JS entry when spawning node). */
58
+ args: string[];
59
+ /** How the runner was found — named in the PATH-fallback notice. */
60
+ via: string;
61
+ /** True only for the PATH fallback, which the caller surfaces visibly. */
62
+ fromPath: boolean;
63
+ };
64
+ export type RunnerResolution = RunnerFound | {
65
+ ok: false;
66
+ searched: string[];
67
+ };
68
+ /** Injection seams for tests — production passes nothing. */
69
+ export type ResolveRunnerDeps = {
70
+ platform?: NodeJS.Platform;
71
+ existsSync?: (path: string) => boolean;
72
+ readFileSync?: (path: string) => string;
73
+ env?: NodeJS.ProcessEnv;
74
+ /** Node executable used to run the runner's JS entry. */
75
+ nodeExecPath?: string;
76
+ };
77
+ export type RunRunnerDeps = ResolveRunnerDeps & {
78
+ spawnFn?: typeof spawn;
79
+ };
80
+ /**
81
+ * Resolve the consumer project's LOCAL runner, and the form of it that
82
+ * `spawn` can actually launch on this platform.
83
+ *
84
+ * Probed, in order (every probe is reported when nothing is found):
85
+ * 1. `<root>/node_modules/.bin/<runner>` (+ `.cmd`/`.exe` on Windows) —
86
+ * the project-local runner the command documents.
87
+ * 2. `<root>/node_modules/<runner>/package.json` → its `bin` JS entry.
88
+ * 3. PATH — last resort only; the caller prints a visible notice.
89
+ *
90
+ * Between 1 and 2 the **JS entry** wins: `spawn` runs it as
91
+ * `node <entry> …`, which is identical on Windows and POSIX and never
92
+ * routes argv through a shell. The `.cmd` shim is only a fallback because
93
+ * it needs cmd.exe to launch it (see `toSpawnable`).
94
+ */
95
+ export declare function resolveRunner(framework: TestFramework, argv: string[], projectRoot: string, deps?: ResolveRunnerDeps): RunnerResolution;
96
+ /** Actionable message for the no-runner case — never a raw ENOENT. */
97
+ export declare function formatRunnerNotFound(framework: TestFramework, searched: string[]): string;
98
+ export declare function runRunner(framework: TestFramework, argv: string[], projectRoot: string, deps?: RunRunnerDeps): Promise<{
99
+ code: number;
100
+ stdout: string;
101
+ stderr: string;
102
+ notice: string | null;
103
+ }>;
47
104
  export declare function registerTestCommands(program: Command, _io: ProgramIO): void;
@@ -6,14 +6,18 @@
6
6
  *
7
7
  * 1. Auto-detects the framework from package.json (devDependencies +
8
8
  * dependencies) via detectTestFramework().
9
- * 2. Spawns the framework's CLI with --cache enabled (overriding any
9
+ * 2. Resolves the project-LOCAL runner binary (node_modules) and spawns
10
+ * that, so the command works where the runner is not on PATH — notably
11
+ * Windows, where node_modules/.bin/vitest.cmd is not spawnable without
12
+ * a shell. PATH is a last resort and is reported, not silent.
13
+ * 3. Spawns the framework's CLI with --cache enabled (overriding any
10
14
  * --no-cache in the consumer's `test` script). The user can
11
15
  * opt back into no-cache via `peaks test --no-cache` or
12
16
  * `peaks test --passthrough`.
13
- * 3. Skips tests where (fileMtime, fileSha256) is unchanged AND the
17
+ * 4. Skips tests where (fileMtime, fileSha256) is unchanged AND the
14
18
  * previous run status was 'passed' (per-test fingerprint cache at
15
19
  * `<projectRoot>/.peaks/_runtime/test-cache/<hash>.json`).
16
- * 4. Exits 0 on all-pass / all-skip; exits 1 on any failure.
20
+ * 5. Exits 0 on all-pass / all-skip; exits 1 on any failure.
17
21
  *
18
22
  * The CLI is invoked by USER (not just by skill) per slice 2.5.0
19
23
  * sub-fix B (G16) — a documented exception to the
@@ -30,6 +34,8 @@
30
34
  * peaks test --framework <name> — force a specific framework
31
35
  */
32
36
  import { spawn } from 'node:child_process';
37
+ import { existsSync, readFileSync } from 'node:fs';
38
+ import { dirname, join, resolve } from 'node:path';
33
39
  import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
34
40
  import { getErrorMessage } from '../cli-helpers.js';
35
41
  import { clearTestCache, detectTestFramework } from '../../services/test-cache/test-cache-service.js';
@@ -81,10 +87,120 @@ export function buildRunnerArgv(framework, patterns, options) {
81
87
  // mocha — no built-in --cache flag; we just pass the patterns.
82
88
  return [...patterns];
83
89
  }
84
- function runRunner(framework, argv, projectRoot) {
90
+ /** `<pkg>/package.json#bin`, resolved to the absolute JS entry it points at. */
91
+ function readBinEntry(pkgJsonPath, name, read) {
92
+ let pkg = {};
93
+ try {
94
+ pkg = JSON.parse(read(pkgJsonPath));
95
+ }
96
+ catch {
97
+ // Not fatal: fall through to the `.bin` shim below, and the not-found
98
+ // error still names this package.json in `searched`.
99
+ pkg = {};
100
+ }
101
+ const rel = typeof pkg.bin === 'string' ? pkg.bin : pkg.bin?.[name];
102
+ if (typeof rel !== 'string' || rel.length === 0)
103
+ return null;
104
+ return resolve(dirname(pkgJsonPath), rel);
105
+ }
106
+ /**
107
+ * Convert a resolved executable + argv into a form `spawn` can launch with
108
+ * `shell: false`.
109
+ *
110
+ * A Windows `.cmd`/`.bat` shim cannot be spawned directly (spawn → EINVAL).
111
+ * The obvious fix, `spawn(shim, argv, { shell: true })`, re-splits argv inside
112
+ * cmd.exe: a pattern `tests/a b/x.test.ts` arrives as THREE args (measured).
113
+ * Invoking cmd.exe ourselves with `/d /s /c` keeps argv intact.
114
+ */
115
+ function toSpawnable(exe, argv, platform, env) {
116
+ if (platform === 'win32' && /\.(?:cmd|bat)$/i.test(exe)) {
117
+ return { command: env.ComSpec ?? 'cmd.exe', args: ['/d', '/s', '/c', exe, ...argv] };
118
+ }
119
+ return { command: exe, args: argv };
120
+ }
121
+ /** Spawnable PATH hits, in preference order (`where`/`which` avoided so this
122
+ * stays in-process and testable). */
123
+ function resolveFromPath(name, platform, env, exists) {
124
+ const dirs = (env.PATH ?? '').split(platform === 'win32' ? ';' : ':').filter((d) => d.length > 0);
125
+ const exts = platform === 'win32' ? ['.exe', '.cmd', '.bat', ''] : [''];
126
+ for (const dir of dirs) {
127
+ for (const ext of exts) {
128
+ const candidate = join(dir, name + ext);
129
+ if (exists(candidate))
130
+ return candidate;
131
+ }
132
+ }
133
+ return null;
134
+ }
135
+ /**
136
+ * Resolve the consumer project's LOCAL runner, and the form of it that
137
+ * `spawn` can actually launch on this platform.
138
+ *
139
+ * Probed, in order (every probe is reported when nothing is found):
140
+ * 1. `<root>/node_modules/.bin/<runner>` (+ `.cmd`/`.exe` on Windows) —
141
+ * the project-local runner the command documents.
142
+ * 2. `<root>/node_modules/<runner>/package.json` → its `bin` JS entry.
143
+ * 3. PATH — last resort only; the caller prints a visible notice.
144
+ *
145
+ * Between 1 and 2 the **JS entry** wins: `spawn` runs it as
146
+ * `node <entry> …`, which is identical on Windows and POSIX and never
147
+ * routes argv through a shell. The `.cmd` shim is only a fallback because
148
+ * it needs cmd.exe to launch it (see `toSpawnable`).
149
+ */
150
+ export function resolveRunner(framework, argv, projectRoot, deps = {}) {
151
+ const platform = deps.platform ?? process.platform;
152
+ const exists = deps.existsSync ?? existsSync;
153
+ const read = deps.readFileSync ?? ((path) => readFileSync(path, 'utf8'));
154
+ const env = deps.env ?? process.env;
155
+ const nodeExec = deps.nodeExecPath ?? process.execPath;
156
+ const searched = [];
157
+ // 1. Project-local `.bin` shim.
158
+ const binDir = join(projectRoot, 'node_modules', '.bin');
159
+ const shimExts = platform === 'win32' ? ['.cmd', '.exe'] : [''];
160
+ let shim = null;
161
+ for (const ext of shimExts) {
162
+ const candidate = join(binDir, framework + ext);
163
+ searched.push(candidate);
164
+ if (shim === null && exists(candidate))
165
+ shim = candidate;
166
+ }
167
+ // 2. Project-local package entry.
168
+ const pkgJsonPath = join(projectRoot, 'node_modules', framework, 'package.json');
169
+ searched.push(pkgJsonPath);
170
+ const entry = exists(pkgJsonPath) ? readBinEntry(pkgJsonPath, framework, read) : null;
171
+ if (entry !== null && exists(entry)) {
172
+ return { ok: true, command: nodeExec, args: [entry, ...argv], via: `local ${entry}`, fromPath: false };
173
+ }
174
+ if (shim !== null) {
175
+ return { ok: true, ...toSpawnable(shim, argv, platform, env), via: `local ${shim}`, fromPath: false };
176
+ }
177
+ // 3. PATH — last resort.
178
+ const onPath = resolveFromPath(framework, platform, env, exists);
179
+ searched.push(`PATH lookup for "${framework}"`);
180
+ if (onPath !== null) {
181
+ return { ok: true, ...toSpawnable(onPath, argv, platform, env), via: `PATH: ${onPath}`, fromPath: true };
182
+ }
183
+ return { ok: false, searched };
184
+ }
185
+ /** Actionable message for the no-runner case — never a raw ENOENT. */
186
+ export function formatRunnerNotFound(framework, searched) {
187
+ return [
188
+ `RUNNER_NOT_FOUND: no ${framework} runner found for this project. Looked for:`,
189
+ ...searched.map((p) => ` - ${p}`),
190
+ `Install it (e.g. \`npm i -D ${framework}\`) or pass --framework <name>.`
191
+ ].join('\n');
192
+ }
193
+ export function runRunner(framework, argv, projectRoot, deps = {}) {
194
+ const resolution = resolveRunner(framework, argv, projectRoot, deps);
195
+ if (!resolution.ok) {
196
+ return Promise.reject(new Error(formatRunnerNotFound(framework, resolution.searched)));
197
+ }
198
+ const notice = resolution.fromPath
199
+ ? `[peaks test] no local ${framework} under ${join(projectRoot, 'node_modules')}; falling back to ${resolution.via}\n`
200
+ : null;
85
201
  return new Promise((resolveRun, reject) => {
86
- const cmd = framework === 'vitest' ? 'vitest' : framework === 'jest' ? 'jest' : 'mocha';
87
- const proc = spawn(cmd, argv, {
202
+ const spawnFn = deps.spawnFn ?? spawn;
203
+ const proc = spawnFn(resolution.command, resolution.args, {
88
204
  cwd: projectRoot,
89
205
  env: process.env,
90
206
  stdio: ['ignore', 'pipe', 'pipe']
@@ -95,7 +211,7 @@ function runRunner(framework, argv, projectRoot) {
95
211
  proc.stderr.on('data', (chunk) => { stderr += chunk.toString('utf8'); });
96
212
  proc.on('error', (err) => reject(err));
97
213
  proc.on('close', (code) => {
98
- resolveRun({ code: code ?? 0, stdout, stderr });
214
+ resolveRun({ code: code ?? 0, stdout, stderr, notice });
99
215
  });
100
216
  });
101
217
  }
@@ -173,6 +289,8 @@ export function registerTestCommands(program, _io) {
173
289
  });
174
290
  // Stream the runner's output to the user.
175
291
  const result = await runRunner(framework, argv, projectRoot);
292
+ if (result.notice !== null)
293
+ process.stderr.write(result.notice);
176
294
  process.stdout.write(result.stdout);
177
295
  process.stderr.write(result.stderr);
178
296
  if (result.code !== 0) {