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
@@ -31,19 +31,39 @@ const REQUIRED_DIMENSIONS = [
31
31
  'alternatives',
32
32
  'constraints'
33
33
  ];
34
+ /**
35
+ * The allowed values for the three constrained fields.
36
+ *
37
+ * These are stated in the prompt AND enforced below: a prompt alone is a
38
+ * request, not a gate. Before this, the prompt named only `severity` with no
39
+ * allowed values, the model answered `severity: "high"`, and the gate passed
40
+ * it — leaving every downstream consumer that switches on
41
+ * `info | concern | blocker` holding a value it has never seen.
42
+ */
43
+ const SEVERITIES = ['info', 'concern', 'blocker'];
44
+ const EFFORTS = ['small', 'medium', 'large', 'epic'];
45
+ const CONFIDENCES = ['high', 'medium', 'low'];
34
46
  const SYSTEM_PROMPT = `You are auditing a software development need. Produce a structured JSON response with EXACTLY these fields:
35
47
  - summary (1-2 sentence summary of the need)
36
48
  - audit (array of EXACTLY 6 objects, one per dimension: correctness, completeness, scope, risks, alternatives, constraints; each with dimension, finding, severity)
37
49
  - proposedGoal (what success looks like)
38
50
  - successCriteria (list of acceptance criteria)
39
- - roughEffort (small | medium | large | epic)
40
- - confidence (high | medium | low)
51
+ - roughEffort (one of exactly: small | medium | large | epic)
52
+ - confidence (one of exactly: high | medium | low)
41
53
  - rationale (one paragraph tying audit to goal)
42
54
 
55
+ severity is one of exactly: info | concern | blocker. Do not invent other values.
56
+ Any value outside the lists above is rejected and the whole response is discarded — a value that is merely plausible is not acceptable.
57
+
43
58
  Output ONLY valid JSON, no prose.`;
44
59
  export async function auditGoal(input, llmRunner) {
45
60
  const userPrompt = `Need: ${input.need}\n\nAudit this need across the 6 dimensions and propose a goal.`;
46
- const response = await llmRunner.call(SYSTEM_PROMPT, userPrompt, { maxTokens: 2000 });
61
+ // 8000, not 2000: the reply is a six-dimension JSON audit, and a reasoning
62
+ // model shares this budget with its own thinking blocks — measured against
63
+ // the session's provider, a 2000-token cap returns `stop_reason: max_tokens`
64
+ // with the JSON cut off mid-string. That is still ONE bounded call; it is
65
+ // just one the model can finish.
66
+ const response = await llmRunner.call(SYSTEM_PROMPT, userPrompt, { maxTokens: 8000 });
47
67
  let parsed;
48
68
  try {
49
69
  parsed = JSON.parse(response.output);
@@ -59,6 +79,21 @@ export async function auditGoal(input, llmRunner) {
59
79
  if (missing.length > 0) {
60
80
  throw new IncompleteAuditError(`Missing required audit dimensions: ${missing.join(', ')}`);
61
81
  }
82
+ // Coverage is not validity: a reply can carry all six dimensions and still
83
+ // hand downstream consumers enum values that do not exist. An out-of-enum
84
+ // value fails here — it is never coerced into a valid one, because silently
85
+ // repairing the model's output is what makes the enum decorative.
86
+ for (const entry of parsed.audit) {
87
+ if (!SEVERITIES.includes(entry.severity)) {
88
+ throw new IncompleteAuditError(`Invalid severity for dimension "${entry.dimension}": ${JSON.stringify(entry.severity)}. Allowed values: ${SEVERITIES.join(', ')}.`);
89
+ }
90
+ }
91
+ if (!EFFORTS.includes(parsed.roughEffort)) {
92
+ throw new IncompleteAuditError(`Invalid roughEffort: ${JSON.stringify(parsed.roughEffort)}. Allowed values: ${EFFORTS.join(', ')}.`);
93
+ }
94
+ if (!CONFIDENCES.includes(parsed.confidence)) {
95
+ throw new IncompleteAuditError(`Invalid confidence: ${JSON.stringify(parsed.confidence)}. Allowed values: ${CONFIDENCES.join(', ')}.`);
96
+ }
62
97
  return parsed;
63
98
  }
64
99
  function isAuditGoalOutput(value) {
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Check: the third-party ECC plugin ships `hooks/hooks.json` keys that
3
+ * Claude Code's plugin hook schema ignores
4
+ * (`integration:ecc-hooks-schema-drift`).
5
+ *
6
+ * 2026-09-12 — Claude Code prints this at startup when the ECC plugin
7
+ * (github.com/affaan-m/ECC) is installed:
8
+ *
9
+ * ecc: hooks.json: unknown keys "$schema", "description" in
10
+ * hooks.PreToolUse[0], "id" in hooks.PreToolUse[0], ... and 42 more ignored
11
+ *
12
+ * Claude Code's plugin hook schema accepts exactly `{ matcher, hooks }`
13
+ * on a matcher group, and at the document root `hooks` plus an OPTIONAL
14
+ * top-level `description`. ECC ships a
15
+ * root `$schema` plus `description` AND `id` on each of its 23 matcher
16
+ * groups across 7 events — 47 ignored keys, matching the warning
17
+ * verbatim. The warning is cosmetic (the 23 hooks still load).
18
+ *
19
+ * peaks-loop does NOT write this file, and every ECC release checked
20
+ * (v2.2.0 / v2.2.1 / main) carries the same keys, so upgrading the
21
+ * plugin does not clear the warning. This check exists so a user who
22
+ * hits the startup line does not have to re-investigate it from
23
+ * scratch.
24
+ *
25
+ * Probing is split out of the check so the check itself stays a pure
26
+ * mapping over `EccHooksDriftProbeResult`. Tests inject the probe to
27
+ * keep the real `~/.claude/plugins/` tree out of fixtures.
28
+ */
29
+ import type { DoctorCheckPlugin, EccHooksDriftProbeResult } from '../types.js';
30
+ /**
31
+ * Unknown keys found in a plugin `hooks.json`. Mirrors the shape of
32
+ * Claude Code's own startup warning so the doctor message can name the
33
+ * same things.
34
+ */
35
+ export type EccHooksDriftFinding = {
36
+ /** Total ignored keys (`rootKeys` + every unknown key on every matcher group). */
37
+ readonly unknownKeyCount: number;
38
+ /** Unknown keys directly under the document root (e.g. `$schema`). */
39
+ readonly rootKeys: ReadonlyArray<string>;
40
+ /** Distinct unknown keys seen on matcher groups (e.g. `description`, `id`). */
41
+ readonly entryKeys: ReadonlyArray<string>;
42
+ /** Matcher groups carrying at least one unknown key. */
43
+ readonly entryCount: number;
44
+ };
45
+ /**
46
+ * Pure mapping over a parsed plugin `hooks.json` payload. Exported so
47
+ * tests drive the key scan without touching the real plugin tree.
48
+ */
49
+ export declare function findEccHooksSchemaDrift(payload: unknown): EccHooksDriftFinding;
50
+ /**
51
+ * Resolve the ECC plugin's install path from Claude Code's plugin
52
+ * manifest (`~/.claude/plugins/installed_plugins.json`), which is the
53
+ * only version-agnostic way to find the versioned cache directory
54
+ * (`…/plugins/cache/ecc/ecc/<version>/`). Exported so tests drive the
55
+ * lookup with an explicit manifest path.
56
+ */
57
+ export declare function readEccInstallPath(manifestPath: string): string | null;
58
+ /**
59
+ * Default probe: reads the ECC plugin manifest + its `hooks/hooks.json`.
60
+ * `homeDir` is injectable so tests can drive the real probe against a
61
+ * temp dir; the zero-arg call keeps using the real homedir, so the
62
+ * function stays assignable to `EccHooksDriftProbe`.
63
+ */
64
+ export declare function defaultEccHooksDriftProbe(homeDir?: string): EccHooksDriftProbeResult;
65
+ export declare const check: DoctorCheckPlugin;
@@ -0,0 +1,186 @@
1
+ /**
2
+ * Check: the third-party ECC plugin ships `hooks/hooks.json` keys that
3
+ * Claude Code's plugin hook schema ignores
4
+ * (`integration:ecc-hooks-schema-drift`).
5
+ *
6
+ * 2026-09-12 — Claude Code prints this at startup when the ECC plugin
7
+ * (github.com/affaan-m/ECC) is installed:
8
+ *
9
+ * ecc: hooks.json: unknown keys "$schema", "description" in
10
+ * hooks.PreToolUse[0], "id" in hooks.PreToolUse[0], ... and 42 more ignored
11
+ *
12
+ * Claude Code's plugin hook schema accepts exactly `{ matcher, hooks }`
13
+ * on a matcher group, and at the document root `hooks` plus an OPTIONAL
14
+ * top-level `description`. ECC ships a
15
+ * root `$schema` plus `description` AND `id` on each of its 23 matcher
16
+ * groups across 7 events — 47 ignored keys, matching the warning
17
+ * verbatim. The warning is cosmetic (the 23 hooks still load).
18
+ *
19
+ * peaks-loop does NOT write this file, and every ECC release checked
20
+ * (v2.2.0 / v2.2.1 / main) carries the same keys, so upgrading the
21
+ * plugin does not clear the warning. This check exists so a user who
22
+ * hits the startup line does not have to re-investigate it from
23
+ * scratch.
24
+ *
25
+ * Probing is split out of the check so the check itself stays a pure
26
+ * mapping over `EccHooksDriftProbeResult`. Tests inject the probe to
27
+ * keep the real `~/.claude/plugins/` tree out of fixtures.
28
+ */
29
+ import { existsSync, readFileSync } from 'node:fs';
30
+ import { homedir } from 'node:os';
31
+ import { join } from 'node:path';
32
+ import { getErrorMessage } from 'peaks-loop-shared/result';
33
+ const CHECK_ID = 'integration:ecc-hooks-schema-drift';
34
+ /** Claude Code's plugin hook schema accepts exactly these keys on a matcher group. */
35
+ const ALLOWED_MATCHER_GROUP_KEYS = ['matcher', 'hooks'];
36
+ /**
37
+ * …and at the document root: `hooks`, plus an OPTIONAL top-level
38
+ * `description` (Claude Code's plugin hooks docs, "Reference scripts by
39
+ * path", document a top-level `description` for `hooks/hooks.json` and
40
+ * place it as a sibling of `hooks`). A top-level `description` is
41
+ * therefore legal — and is exactly the shape an upstream ECC fix would
42
+ * land on when it consolidates its 23 per-matcher descriptions into one.
43
+ */
44
+ const ALLOWED_ROOT_KEYS = ['hooks', 'description'];
45
+ const NO_DRIFT = {
46
+ unknownKeyCount: 0,
47
+ rootKeys: [],
48
+ entryKeys: [],
49
+ entryCount: 0
50
+ };
51
+ function isPlainObject(value) {
52
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
53
+ }
54
+ /**
55
+ * Pure mapping over a parsed plugin `hooks.json` payload. Exported so
56
+ * tests drive the key scan without touching the real plugin tree.
57
+ */
58
+ export function findEccHooksSchemaDrift(payload) {
59
+ if (!isPlainObject(payload))
60
+ return NO_DRIFT;
61
+ const rootKeys = Object.keys(payload).filter((key) => !ALLOWED_ROOT_KEYS.includes(key));
62
+ const hooks = payload.hooks;
63
+ if (!isPlainObject(hooks)) {
64
+ return { ...NO_DRIFT, unknownKeyCount: rootKeys.length, rootKeys };
65
+ }
66
+ const entryKeys = new Set();
67
+ let entryKeyTotal = 0;
68
+ let entryCount = 0;
69
+ for (const groups of Object.values(hooks)) {
70
+ if (!Array.isArray(groups))
71
+ continue;
72
+ for (const group of groups) {
73
+ if (!isPlainObject(group))
74
+ continue;
75
+ const unknown = Object.keys(group).filter((key) => !ALLOWED_MATCHER_GROUP_KEYS.includes(key));
76
+ if (unknown.length === 0)
77
+ continue;
78
+ entryCount += 1;
79
+ entryKeyTotal += unknown.length;
80
+ for (const key of unknown)
81
+ entryKeys.add(key);
82
+ }
83
+ }
84
+ return {
85
+ unknownKeyCount: rootKeys.length + entryKeyTotal,
86
+ rootKeys,
87
+ entryKeys: [...entryKeys].sort(),
88
+ entryCount
89
+ };
90
+ }
91
+ function readJsonIfPresent(path) {
92
+ if (!existsSync(path))
93
+ return null;
94
+ // Parse errors intentionally propagate to the check's own catch, which
95
+ // reports them as a `skipping check` message instead of swallowing them.
96
+ return JSON.parse(readFileSync(path, 'utf8'));
97
+ }
98
+ /**
99
+ * Resolve the ECC plugin's install path from Claude Code's plugin
100
+ * manifest (`~/.claude/plugins/installed_plugins.json`), which is the
101
+ * only version-agnostic way to find the versioned cache directory
102
+ * (`…/plugins/cache/ecc/ecc/<version>/`). Exported so tests drive the
103
+ * lookup with an explicit manifest path.
104
+ */
105
+ export function readEccInstallPath(manifestPath) {
106
+ const manifest = readJsonIfPresent(manifestPath);
107
+ if (!isPlainObject(manifest))
108
+ return null;
109
+ const plugins = manifest.plugins;
110
+ if (!isPlainObject(plugins))
111
+ return null;
112
+ for (const [name, records] of Object.entries(plugins)) {
113
+ if (!name.startsWith('ecc@'))
114
+ continue;
115
+ if (!Array.isArray(records))
116
+ continue;
117
+ for (const record of records) {
118
+ if (!isPlainObject(record))
119
+ continue;
120
+ const installPath = record.installPath;
121
+ if (typeof installPath === 'string' && installPath.length > 0)
122
+ return installPath;
123
+ }
124
+ }
125
+ return null;
126
+ }
127
+ /**
128
+ * Default probe: reads the ECC plugin manifest + its `hooks/hooks.json`.
129
+ * `homeDir` is injectable so tests can drive the real probe against a
130
+ * temp dir; the zero-arg call keeps using the real homedir, so the
131
+ * function stays assignable to `EccHooksDriftProbe`.
132
+ */
133
+ export function defaultEccHooksDriftProbe(homeDir = homedir()) {
134
+ const manifestPath = join(homeDir, '.claude', 'plugins', 'installed_plugins.json');
135
+ const installPath = readEccInstallPath(manifestPath);
136
+ if (installPath === null)
137
+ return { hooksPath: null, hooks: null };
138
+ const hooksPath = join(installPath, 'hooks', 'hooks.json');
139
+ return { hooksPath, hooks: readJsonIfPresent(hooksPath) };
140
+ }
141
+ function run({ options }) {
142
+ const probe = options.eccHooksDriftProbe ?? defaultEccHooksDriftProbe;
143
+ try {
144
+ const { hooksPath, hooks } = probe();
145
+ if (hooksPath === null) {
146
+ return [{
147
+ id: CHECK_ID,
148
+ ok: true,
149
+ message: 'ECC plugin not installed (no `ecc@*` entry in ~/.claude/plugins/installed_plugins.json); no plugin hook schema drift to report'
150
+ }];
151
+ }
152
+ if (hooks === null) {
153
+ return [{
154
+ id: CHECK_ID,
155
+ ok: true,
156
+ message: `No readable ECC plugin hooks.json at ${hooksPath}; no plugin hook schema drift to report`
157
+ }];
158
+ }
159
+ const finding = findEccHooksSchemaDrift(hooks);
160
+ if (finding.unknownKeyCount === 0) {
161
+ return [{
162
+ id: CHECK_ID,
163
+ ok: true,
164
+ message: `ECC plugin hooks.json at ${hooksPath} carries only the keys Claude Code accepts (matcher/hooks per matcher group); the startup "unknown keys ... ignored" warning will not appear`
165
+ }];
166
+ }
167
+ const rootPart = finding.rootKeys.length === 0 ? '' : `at the root: ${finding.rootKeys.join(', ')}; `;
168
+ return [{
169
+ id: CHECK_ID,
170
+ ok: false,
171
+ severity: 'warning',
172
+ message: `ECC plugin hooks.json at ${hooksPath} carries ${finding.unknownKeyCount} key(s) that Claude Code's plugin hook schema ignores (${rootPart}on ${finding.entryCount} matcher group(s): ${finding.entryKeys.join(', ')}). Claude Code prints \`ecc: hooks.json: unknown keys ... ignored\` at startup; every hook still loads, so this warning is cosmetic. Source: the third-party ECC plugin (github.com/affaan-m/ECC) ships these keys in every release (v2.2.0 / v2.2.1 / main) — peaks-loop does NOT write this file. Fix: none inside peaks-loop; upstream ECC must drop them from its hooks/hooks.json (its scripts/ci/validate-hooks.js validates shape only, never a key allow-list, so ECC's own CI stays green). Upgrading ECC will not help.`
173
+ }];
174
+ }
175
+ catch (error) {
176
+ return [{
177
+ id: CHECK_ID,
178
+ ok: true,
179
+ message: `ECC hooks schema-drift probe failed (${getErrorMessage(error)}); skipping check`
180
+ }];
181
+ }
182
+ }
183
+ export const check = {
184
+ name: 'ecc-hooks-schema-drift',
185
+ run
186
+ };
@@ -46,6 +46,7 @@ import { check as distSourceVersion } from './checks/dist-source-version.js';
46
46
  import { check as multiBinaryDrift } from './checks/multi-binary-drift.js';
47
47
  import { check as workspaceLayout } from './checks/workspace-layout.js';
48
48
  import { check as gateguardConflict } from './checks/gateguard-conflict.js';
49
+ import { check as eccHooksSchemaDrift } from './checks/ecc-hooks-schema-drift.js';
49
50
  import { check as checkIdSchema } from './checks/check-id-schema.js';
50
51
  import { check as l3OrphanSessions } from './checks/l3-orphan-sessions.js';
51
52
  import { check as l3MemoryHealth } from './checks/l3-memory-health.js';
@@ -73,6 +74,7 @@ export const PLUGINS = [
73
74
  multiBinaryDrift, // id "build:multi-binary-drift"
74
75
  workspaceLayout, // id "build:workspace-layout-canonical"
75
76
  gateguardConflict, // id "integration:gateguard-peaks-conflict"
77
+ eccHooksSchemaDrift, // id "integration:ecc-hooks-schema-drift"
76
78
  checkIdSchema, // id "doctor-self:check-id-pattern"
77
79
  l3OrphanSessions, // id "L3:l3-orphan-sessions"
78
80
  l3MemoryHealth, // id "L3:l3-memory-health"
@@ -165,6 +165,24 @@ export type GateguardProbeResult = {
165
165
  projectSettings: unknown;
166
166
  };
167
167
  export type GateguardProbe = () => GateguardProbeResult;
168
+ /**
169
+ * 2026-09-12 — the third-party ECC plugin (github.com/affaan-m/ECC)
170
+ * ships `$schema` at the root of its `hooks/hooks.json` plus
171
+ * `description` + `id` on every matcher group. Claude Code's plugin
172
+ * hook schema accepts only `{ matcher, hooks }` per matcher group, and
173
+ * at the root `hooks` plus an OPTIONAL top-level `description`, so it
174
+ * prints an `unknown keys ... ignored`
175
+ * line at startup for the 47 extra keys (cosmetic — the hooks still
176
+ * load). The probe is injected so tests never read the real
177
+ * `~/.claude/plugins/` tree.
178
+ */
179
+ export type EccHooksDriftProbeResult = {
180
+ /** Absolute path to the ECC plugin's `hooks/hooks.json` (null when the plugin is not installed). */
181
+ hooksPath: string | null;
182
+ /** Parsed `hooks/hooks.json` payload (null when missing / unreadable). */
183
+ hooks: unknown;
184
+ };
185
+ export type EccHooksDriftProbe = () => EccHooksDriftProbeResult;
168
186
  /**
169
187
  * Subset of SkillPresence consumed by the doctor (slice-3b: the full
170
188
  * `SkillPresence` type lives in `src/services/skills/skill-presence-service.ts`;
@@ -230,6 +248,8 @@ export type DoctorOptions = {
230
248
  workspaceLayoutProbe?: WorkspaceLayoutProbe;
231
249
  /** Injected for the integration:gateguard-peaks-conflict check (defaults to defaultGateguardProbe on disk). */
232
250
  gateguardProbe?: GateguardProbe;
251
+ /** Injected for the integration:ecc-hooks-schema-drift check (defaults to defaultEccHooksDriftProbe on disk). */
252
+ eccHooksDriftProbe?: EccHooksDriftProbe;
233
253
  /**
234
254
  * Slice 2026-06-13-repair-pre-existing-test-failures: injected
235
255
  * root for the L3:l3-memory-health check (defaults to
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The first real `LlmRunner` in peaks-loop: it binds a caller to the
3
+ * Anthropic Messages API shape at `<ANTHROPIC_BASE_URL>/v1/messages`,
4
+ * which is the same endpoint the running session is already using.
5
+ *
6
+ * Why this exists: `peaks audit goal` is the entry gate for every
7
+ * peaks-* workflow, and until this file landed the CLI could only answer
8
+ * with a fixed `scaffold-only` envelope — a gate that gated nothing.
9
+ *
10
+ * No SDK and no new runtime dependency: Node 18+ global `fetch` covers it.
11
+ * Every transport concern (auth scheme, timeout, text extraction) lives
12
+ * here so the CLI layer never speaks HTTP itself, and so tests can inject
13
+ * a fake transport instead of reaching the network.
14
+ */
15
+ import type { LlmRunner } from '../audit/audit-goal-service.js';
16
+ /**
17
+ * The subset of `Response` this client touches. Declared narrowly so tests
18
+ * can inject a plain object and stay off the network.
19
+ */
20
+ export interface LlmHttpResponse {
21
+ readonly ok: boolean;
22
+ readonly status: number;
23
+ json(): Promise<unknown>;
24
+ text(): Promise<string>;
25
+ }
26
+ export interface LlmFetchInit {
27
+ readonly method: string;
28
+ readonly headers: Record<string, string>;
29
+ readonly body: string;
30
+ readonly signal: AbortSignal;
31
+ }
32
+ export type FetchLike = (url: string, init: LlmFetchInit) => Promise<LlmHttpResponse>;
33
+ /** Which header carries the credential. */
34
+ export type AnthropicAuthScheme = 'bearer' | 'x-api-key';
35
+ export interface AnthropicConfig {
36
+ readonly baseUrl: string;
37
+ readonly authToken: string;
38
+ readonly authScheme: AnthropicAuthScheme;
39
+ readonly model: string;
40
+ }
41
+ /**
42
+ * Thrown when the environment cannot name a usable LLM. `code` distinguishes
43
+ * an absent credential from an absent model so the CLI can name the env var
44
+ * to set instead of degrading into a scaffold.
45
+ */
46
+ export declare class LlmBindingError extends Error {
47
+ readonly code: 'LLM_CREDENTIAL_MISSING' | 'LLM_MODEL_MISSING';
48
+ /**
49
+ * The environment variables that were absent, verbatim.
50
+ *
51
+ * `message` also names them, but `fail()` runs every envelope message
52
+ * through `redactSensitiveErrorMessage`, whose catch-all pattern matches
53
+ * the words `token` / `api_key` and would strip them out of this very
54
+ * message. Callers must surface `missingEnv` on a channel the redactor
55
+ * does not touch (envelope `data` / `nextActions`) so the operator is told
56
+ * exactly what to set.
57
+ */
58
+ readonly missingEnv: readonly string[];
59
+ constructor(code: 'LLM_CREDENTIAL_MISSING' | 'LLM_MODEL_MISSING', message: string, missingEnv: readonly string[]);
60
+ }
61
+ /** Thrown when a bound LLM could not be reached, answered non-2xx, or answered without text. */
62
+ export declare class LlmRequestError extends Error {
63
+ readonly code: "LLM_REQUEST_FAILED";
64
+ constructor(message: string);
65
+ }
66
+ /**
67
+ * Resolve the session's LLM from the environment.
68
+ *
69
+ * Precedence:
70
+ * - credential: `ANTHROPIC_AUTH_TOKEN` first (it is what a Claude-Code
71
+ * session exports for a gateway), else `ANTHROPIC_API_KEY`.
72
+ * - auth header: `Authorization: Bearer` for `ANTHROPIC_AUTH_TOKEN`,
73
+ * `x-api-key` for `ANTHROPIC_API_KEY` — each matches its own convention.
74
+ * - model: `ANTHROPIC_MODEL`, else `CLAUDE_CODE_SUBAGENT_MODEL`.
75
+ * - base URL: `ANTHROPIC_BASE_URL`, else the public Anthropic endpoint.
76
+ * Expected WITHOUT a trailing `/v1` — this function appends `/v1/messages`.
77
+ *
78
+ * Throws `LlmBindingError` rather than defaulting: a gate that quietly
79
+ * falls back is the bug this file was written to remove.
80
+ */
81
+ export declare function resolveAnthropicConfig(env?: NodeJS.ProcessEnv): AnthropicConfig;
82
+ export interface AnthropicRunnerOptions {
83
+ /** Injected transport. Tests pass a fake so no test performs a network call. */
84
+ readonly fetchImpl?: FetchLike;
85
+ readonly timeoutMs?: number;
86
+ }
87
+ export declare function createAnthropicRunner(config: AnthropicConfig, options?: AnthropicRunnerOptions): LlmRunner;
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The first real `LlmRunner` in peaks-loop: it binds a caller to the
3
+ * Anthropic Messages API shape at `<ANTHROPIC_BASE_URL>/v1/messages`,
4
+ * which is the same endpoint the running session is already using.
5
+ *
6
+ * Why this exists: `peaks audit goal` is the entry gate for every
7
+ * peaks-* workflow, and until this file landed the CLI could only answer
8
+ * with a fixed `scaffold-only` envelope — a gate that gated nothing.
9
+ *
10
+ * No SDK and no new runtime dependency: Node 18+ global `fetch` covers it.
11
+ * Every transport concern (auth scheme, timeout, text extraction) lives
12
+ * here so the CLI layer never speaks HTTP itself, and so tests can inject
13
+ * a fake transport instead of reaching the network.
14
+ */
15
+ import { getErrorMessage } from 'peaks-loop-shared/result';
16
+ /** Public Anthropic endpoint. `ANTHROPIC_BASE_URL` overrides it (gateways, local proxies). */
17
+ const DEFAULT_BASE_URL = 'https://api.anthropic.com';
18
+ /** One call is bounded: an unbounded gate is a hung gate. */
19
+ const DEFAULT_TIMEOUT_MS = 120_000;
20
+ /** Enough of an error body to name the cause without dumping a payload into a message. */
21
+ const MAX_BODY_SNIPPET = 200;
22
+ /**
23
+ * Wrapped rather than aliased: `fetch` takes a wider `RequestInit`, which
24
+ * does not satisfy `LlmFetchInit` under `strictFunctionTypes`.
25
+ */
26
+ const defaultFetch = (url, init) => fetch(url, init);
27
+ /**
28
+ * Thrown when the environment cannot name a usable LLM. `code` distinguishes
29
+ * an absent credential from an absent model so the CLI can name the env var
30
+ * to set instead of degrading into a scaffold.
31
+ */
32
+ export class LlmBindingError extends Error {
33
+ code;
34
+ /**
35
+ * The environment variables that were absent, verbatim.
36
+ *
37
+ * `message` also names them, but `fail()` runs every envelope message
38
+ * through `redactSensitiveErrorMessage`, whose catch-all pattern matches
39
+ * the words `token` / `api_key` and would strip them out of this very
40
+ * message. Callers must surface `missingEnv` on a channel the redactor
41
+ * does not touch (envelope `data` / `nextActions`) so the operator is told
42
+ * exactly what to set.
43
+ */
44
+ missingEnv;
45
+ constructor(code, message, missingEnv) {
46
+ super(message);
47
+ this.name = 'LlmBindingError';
48
+ this.code = code;
49
+ this.missingEnv = missingEnv;
50
+ }
51
+ }
52
+ /** Thrown when a bound LLM could not be reached, answered non-2xx, or answered without text. */
53
+ export class LlmRequestError extends Error {
54
+ code = 'LLM_REQUEST_FAILED';
55
+ constructor(message) {
56
+ super(message);
57
+ this.name = 'LlmRequestError';
58
+ }
59
+ }
60
+ /** A blank value is as unusable as an absent one. */
61
+ function readEnv(env, name) {
62
+ const value = env[name]?.trim();
63
+ return value ? value : undefined;
64
+ }
65
+ /**
66
+ * Resolve the session's LLM from the environment.
67
+ *
68
+ * Precedence:
69
+ * - credential: `ANTHROPIC_AUTH_TOKEN` first (it is what a Claude-Code
70
+ * session exports for a gateway), else `ANTHROPIC_API_KEY`.
71
+ * - auth header: `Authorization: Bearer` for `ANTHROPIC_AUTH_TOKEN`,
72
+ * `x-api-key` for `ANTHROPIC_API_KEY` — each matches its own convention.
73
+ * - model: `ANTHROPIC_MODEL`, else `CLAUDE_CODE_SUBAGENT_MODEL`.
74
+ * - base URL: `ANTHROPIC_BASE_URL`, else the public Anthropic endpoint.
75
+ * Expected WITHOUT a trailing `/v1` — this function appends `/v1/messages`.
76
+ *
77
+ * Throws `LlmBindingError` rather than defaulting: a gate that quietly
78
+ * falls back is the bug this file was written to remove.
79
+ */
80
+ export function resolveAnthropicConfig(env = process.env) {
81
+ const authToken = readEnv(env, 'ANTHROPIC_AUTH_TOKEN');
82
+ const apiKey = readEnv(env, 'ANTHROPIC_API_KEY');
83
+ const credential = authToken ?? apiKey;
84
+ if (!credential) {
85
+ throw new LlmBindingError('LLM_CREDENTIAL_MISSING', 'No LLM credential in the environment: set ANTHROPIC_AUTH_TOKEN (or ANTHROPIC_API_KEY) so the audit gate can reach the LLM this session already uses.', ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY']);
86
+ }
87
+ const model = readEnv(env, 'ANTHROPIC_MODEL') ?? readEnv(env, 'CLAUDE_CODE_SUBAGENT_MODEL');
88
+ if (!model) {
89
+ throw new LlmBindingError('LLM_MODEL_MISSING', 'No LLM model in the environment: set ANTHROPIC_MODEL (or CLAUDE_CODE_SUBAGENT_MODEL) so the audit gate knows which model to bind to.', ['ANTHROPIC_MODEL', 'CLAUDE_CODE_SUBAGENT_MODEL']);
90
+ }
91
+ return {
92
+ baseUrl: (readEnv(env, 'ANTHROPIC_BASE_URL') ?? DEFAULT_BASE_URL).replace(/\/+$/, ''),
93
+ authToken: credential,
94
+ authScheme: authToken ? 'bearer' : 'x-api-key',
95
+ model
96
+ };
97
+ }
98
+ function isTextBlock(block) {
99
+ return block.type === 'text' && typeof block.text === 'string';
100
+ }
101
+ /** `fetch` rejects on abort with a DOMException whose `name` says why. */
102
+ function isAbort(error) {
103
+ return error instanceof Error && (error.name === 'TimeoutError' || error.name === 'AbortError');
104
+ }
105
+ export function createAnthropicRunner(config, options = {}) {
106
+ const fetchImpl = options.fetchImpl ?? defaultFetch;
107
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
108
+ const url = `${config.baseUrl}/v1/messages`;
109
+ return {
110
+ async call(systemPrompt, userPrompt, opts) {
111
+ const body = JSON.stringify({
112
+ model: config.model,
113
+ max_tokens: opts.maxTokens,
114
+ system: systemPrompt,
115
+ messages: [{ role: 'user', content: userPrompt }]
116
+ });
117
+ let response;
118
+ try {
119
+ response = await fetchImpl(url, {
120
+ method: 'POST',
121
+ headers: {
122
+ 'content-type': 'application/json',
123
+ 'anthropic-version': '2023-06-01',
124
+ ...(config.authScheme === 'bearer'
125
+ ? { authorization: `Bearer ${config.authToken}` }
126
+ : { 'x-api-key': config.authToken })
127
+ },
128
+ body,
129
+ signal: AbortSignal.timeout(timeoutMs)
130
+ });
131
+ }
132
+ catch (error) {
133
+ if (isAbort(error)) {
134
+ throw new LlmRequestError(`LLM request to ${url} timed out after ${timeoutMs}ms`);
135
+ }
136
+ throw new LlmRequestError(`LLM request to ${url} failed: ${getErrorMessage(error)}`);
137
+ }
138
+ if (!response.ok) {
139
+ throw new LlmRequestError(`LLM request to ${url} failed: HTTP ${response.status}${await bodySnippet(response)}`);
140
+ }
141
+ let payload;
142
+ try {
143
+ payload = (await response.json());
144
+ }
145
+ catch (error) {
146
+ throw new LlmRequestError(`LLM reply from ${url} was not valid JSON: ${getErrorMessage(error)}`);
147
+ }
148
+ const blocks = Array.isArray(payload.content) ? payload.content : [];
149
+ const output = blocks.filter(isTextBlock).map((block) => block.text).join('');
150
+ if (!output) {
151
+ throw new LlmRequestError(`LLM reply from ${url} carried no text block`);
152
+ }
153
+ return {
154
+ output,
155
+ tokens: {
156
+ input: payload.usage?.input_tokens ?? 0,
157
+ output: payload.usage?.output_tokens ?? 0
158
+ }
159
+ };
160
+ }
161
+ };
162
+ }
163
+ async function bodySnippet(response) {
164
+ try {
165
+ const text = (await response.text()).trim();
166
+ return text ? `: ${text.slice(0, MAX_BODY_SNIPPET)}` : '';
167
+ }
168
+ catch {
169
+ return '';
170
+ }
171
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Offline `LlmRunner` behind `peaks audit goal --llm-provider stub`.
3
+ *
4
+ * It exists so CI and unit tests can exercise the CLI route — including
5
+ * `auditGoal()`'s 6-dimension validation — without a network call. It
6
+ * performs NO audit: every finding below is a placeholder, which is why
7
+ * the CLI reports this run as `scaffold-only` / `providerBinding: 'stub'`
8
+ * and never as an audit.
9
+ */
10
+ import type { LlmRunner } from '../audit/audit-goal-service.js';
11
+ export declare function createStubRunner(): LlmRunner;
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Offline `LlmRunner` behind `peaks audit goal --llm-provider stub`.
3
+ *
4
+ * It exists so CI and unit tests can exercise the CLI route — including
5
+ * `auditGoal()`'s 6-dimension validation — without a network call. It
6
+ * performs NO audit: every finding below is a placeholder, which is why
7
+ * the CLI reports this run as `scaffold-only` / `providerBinding: 'stub'`
8
+ * and never as an audit.
9
+ */
10
+ const NOT_AUDITED = 'Stub provider: this placeholder is not an audit finding.';
11
+ const STUB_REPLY = JSON.stringify({
12
+ summary: 'Stub provider: no audit was performed.',
13
+ audit: [
14
+ { dimension: 'correctness', finding: NOT_AUDITED, severity: 'info' },
15
+ { dimension: 'completeness', finding: NOT_AUDITED, severity: 'info' },
16
+ { dimension: 'scope', finding: NOT_AUDITED, severity: 'info' },
17
+ { dimension: 'risks', finding: NOT_AUDITED, severity: 'info' },
18
+ { dimension: 'alternatives', finding: NOT_AUDITED, severity: 'info' },
19
+ { dimension: 'constraints', finding: NOT_AUDITED, severity: 'info' }
20
+ ],
21
+ proposedGoal: 'Stub provider: no goal proposed.',
22
+ successCriteria: ['Stub provider: no acceptance criteria produced.'],
23
+ roughEffort: 'small',
24
+ confidence: 'low',
25
+ rationale: 'Stub provider: the six dimensions above are placeholders so the CLI route can be exercised without a network call. Treating this as an audit would defeat the gate.'
26
+ });
27
+ export function createStubRunner() {
28
+ return {
29
+ async call() {
30
+ return { output: STUB_REPLY, tokens: { input: 0, output: 0 } };
31
+ }
32
+ };
33
+ }