peaks-loop 4.0.41 → 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.
- package/CHANGELOG.md +44 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/commands/_register.js +4 -0
- package/dist/cli/commands/api-diff-commands.d.ts +16 -0
- package/dist/cli/commands/api-diff-commands.js +55 -0
- package/dist/cli/commands/audit-commands.d.ts +16 -3
- package/dist/cli/commands/audit-commands.js +84 -31
- package/dist/cli/commands/job-commands.js +4 -2
- package/dist/cli/commands/scan-commands.js +1 -1
- package/dist/cli/commands/test-commands.d.ts +60 -3
- package/dist/cli/commands/test-commands.js +125 -7
- package/dist/services/audit/audit-goal-service.js +38 -3
- package/dist/services/doctor/doctor-service/checks/ecc-hooks-schema-drift.d.ts +65 -0
- package/dist/services/doctor/doctor-service/checks/ecc-hooks-schema-drift.js +186 -0
- package/dist/services/doctor/doctor-service/plugin-registry.js +2 -0
- package/dist/services/doctor/doctor-service/types.d.ts +20 -0
- package/dist/services/hooks/write-gate.js +32 -9
- package/dist/services/llm/anthropic-runner.d.ts +87 -0
- package/dist/services/llm/anthropic-runner.js +171 -0
- package/dist/services/llm/stub-runner.d.ts +11 -0
- package/dist/services/llm/stub-runner.js +33 -0
- package/dist/services/prd/project-scan-bootstrap-service.js +7 -7
- package/dist/services/scan/api-diff-openapi.d.ts +32 -0
- package/dist/services/scan/api-diff-openapi.js +359 -0
- package/dist/services/scan/api-diff-recorded.d.ts +96 -0
- package/dist/services/scan/api-diff-recorded.js +577 -0
- package/dist/services/scan/api-diff-service.d.ts +34 -0
- package/dist/services/scan/api-diff-service.js +407 -0
- package/dist/services/scan/api-diff-types.d.ts +116 -0
- package/dist/services/scan/api-diff-types.js +46 -0
- package/dist/services/scan/archetype-service.js +27 -1
- package/dist/services/scan/existing-system-service.js +17 -4
- package/dist/services/scan/hook-convention-service.d.ts +26 -0
- package/dist/services/scan/hook-convention-service.js +562 -0
- package/dist/services/scan/scan-types.d.ts +47 -0
- package/dist/services/session/caller-binding-service.d.ts +28 -0
- package/dist/services/session/caller-binding-service.js +10 -2
- package/dist/services/session/caller-id-types.d.ts +12 -2
- package/dist/services/session/index.d.ts +2 -2
- package/dist/services/session/index.js +2 -2
- package/dist/services/session/session-binding-bridge.js +11 -6
- package/dist/services/session/session-manager.d.ts +33 -1
- package/dist/services/session/session-manager.js +84 -25
- package/dist/services/skills/skill-presence-service.d.ts +17 -3
- package/dist/services/skills/skill-presence-service.js +23 -3
- package/package.json +5 -5
- package/skills/bee/peaks-rd/SKILL.md +11 -3
- package/skills/peaks-code/references/existing-system-extraction.md +5 -1
- package/skills/peaks-code/references/frontend-only-mode.md +48 -6
- package/skills/peaks-code/references/project-scan-checklist.md +20 -1
- package/skills/peaks-doctor/references/doctor-check-catalog.md +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,49 @@
|
|
|
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
|
+
|
|
30
|
+
## 4.0.42 — 2026-09-12 (peaks 自己的钩子在每次编辑时报错)
|
|
31
|
+
|
|
32
|
+
**Highlights**:
|
|
33
|
+
|
|
34
|
+
1. **`write-gate.js` 每次编辑都向模型报一次"钩子错误" —— 已修。** 这个钩子挂在 `Write|Edit|MultiEdit` 上,判定是 `p.includes('.peaks/_runtime/') ? 0 : 1`。**而开发者真正编辑的每一个文件都不在 `.peaks/_runtime/` 下**,所以它**每次编辑都返回退出码 1**。
|
|
35
|
+
|
|
36
|
+
Claude Code 的协议里,**只有 exit 2 会阻断**;任何其他非零退出码都是**非阻断错误** —— 工具照跑,但 transcript 里出现 `<hook> hook error` 加 `Failed with non-blocking status code:` 和 stderr 首行;stderr 为空时显示 `No stderr output`。**这正是用户 Mac 日志里那行。**
|
|
37
|
+
|
|
38
|
+
- 更糟的是**文档把它写成了契约**。`HOOKS.md` 与文件头都写着 *"exit 1 = fall through to the gate"* —— **没有 fall-through 这个概念**:所有匹配的 PreToolUse 钩子**并行运行**,结果按 `deny > defer > ask > allow` 合并,不存在"交给下一个"。正确的弃权是 **exit 0 + 无输出**,文档定义为 *"no decision"*。
|
|
39
|
+
- 所以"静默的 fall-through"实际上是**当时可用的最响的结果**。**行为对所有路径完全不变**(它从不阻断),变的只是它不再一边弃权一边报错。
|
|
40
|
+
- **同行早就做对了**:`pre-tool-code-gate.sh` 用 exit 0 弃权、只在拒绝路径用 exit 2;`pre-tool-superpowers-bridge.sh` 全路径 exit 0。`write-gate.js` 是唯一的异类 —— 这是收敛一个文件的偏差,不是重新设计。
|
|
41
|
+
- 三处一起改:代码、`.claude/HOOKS.md` 契约行、以及**钉着 exit-1 映射的 32 用例判定表**(已实测对旧 `decide` 会失败)。判定表现在是"路径清单 + 一条真属性":每条路径都弃权,且两个流都为空。
|
|
42
|
+
|
|
43
|
+
2. **一个必须说明的保留。** 已知有**独立**报告称钩子 exit 0 时标签仍显示 `hook error`。所以**修完不等于那行必然消失** —— 需要实测;若仍在,用 `claude --debug-file` 查看每个钩子的真实退出码。
|
|
44
|
+
|
|
45
|
+
**验证**:三个版本常量一致(4.0.42);`tsc -p tsconfig.build.json` exit 0;宽 `tsconfig.json` 保持 **142** 基线;`tests/unit` **188 files / 1794 passed / 3 skipped / 0 failed**。
|
|
46
|
+
|
|
3
47
|
## 4.0.41 — 2026-09-11 (闸门指导不再鼓励"每次编辑前念一遍")
|
|
4
48
|
|
|
5
49
|
**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** | [](https://www.npmjs.com/package/peaks-loop) — 4.0.
|
|
143
|
+
| **Latest** | [](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
|
-
| **最新版本** | [](https://www.npmjs.com/package/peaks-loop) — 4.0.
|
|
143
|
+
| **最新版本** | [](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:
|
|
17
|
-
|
|
18
|
-
readonly providerBinding: '
|
|
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
|
-
//
|
|
199
|
-
//
|
|
200
|
-
//
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
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>',
|
|
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,
|
|
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 ??
|
|
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(', ')}.`,
|
|
219
|
-
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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
|
|
421
|
+
function auditGoalFailureData(need, projectRoot, providerBinding = 'unresolved', missingEnv) {
|
|
391
422
|
return {
|
|
392
|
-
status: '
|
|
393
|
-
|
|
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)` —
|
|
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 >
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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;
|