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.
Files changed (52) hide show
  1. package/CHANGELOG.md +44 -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/hooks/write-gate.js +32 -9
  19. package/dist/services/llm/anthropic-runner.d.ts +87 -0
  20. package/dist/services/llm/anthropic-runner.js +171 -0
  21. package/dist/services/llm/stub-runner.d.ts +11 -0
  22. package/dist/services/llm/stub-runner.js +33 -0
  23. package/dist/services/prd/project-scan-bootstrap-service.js +7 -7
  24. package/dist/services/scan/api-diff-openapi.d.ts +32 -0
  25. package/dist/services/scan/api-diff-openapi.js +359 -0
  26. package/dist/services/scan/api-diff-recorded.d.ts +96 -0
  27. package/dist/services/scan/api-diff-recorded.js +577 -0
  28. package/dist/services/scan/api-diff-service.d.ts +34 -0
  29. package/dist/services/scan/api-diff-service.js +407 -0
  30. package/dist/services/scan/api-diff-types.d.ts +116 -0
  31. package/dist/services/scan/api-diff-types.js +46 -0
  32. package/dist/services/scan/archetype-service.js +27 -1
  33. package/dist/services/scan/existing-system-service.js +17 -4
  34. package/dist/services/scan/hook-convention-service.d.ts +26 -0
  35. package/dist/services/scan/hook-convention-service.js +562 -0
  36. package/dist/services/scan/scan-types.d.ts +47 -0
  37. package/dist/services/session/caller-binding-service.d.ts +28 -0
  38. package/dist/services/session/caller-binding-service.js +10 -2
  39. package/dist/services/session/caller-id-types.d.ts +12 -2
  40. package/dist/services/session/index.d.ts +2 -2
  41. package/dist/services/session/index.js +2 -2
  42. package/dist/services/session/session-binding-bridge.js +11 -6
  43. package/dist/services/session/session-manager.d.ts +33 -1
  44. package/dist/services/session/session-manager.js +84 -25
  45. package/dist/services/skills/skill-presence-service.d.ts +17 -3
  46. package/dist/services/skills/skill-presence-service.js +23 -3
  47. package/package.json +5 -5
  48. package/skills/bee/peaks-rd/SKILL.md +11 -3
  49. package/skills/peaks-code/references/existing-system-extraction.md +5 -1
  50. package/skills/peaks-code/references/frontend-only-mode.md +48 -6
  51. package/skills/peaks-code/references/project-scan-checklist.md +20 -1
  52. package/skills/peaks-doctor/references/doctor-check-catalog.md +1 -0
@@ -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) {
@@ -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
@@ -31,10 +31,11 @@
31
31
  * `scripts/copy-templates.mjs`), which lets one emitted path string be
32
32
  * valid for both the repo and an installed consumer.
33
33
  *
34
- * Contract (`.claude/HOOKS.md`): exit 0 = allow, exit 1 = fall through to the
35
- * gate — NOT a deny. Only exit 2 blocks a tool call, and this handler never
36
- * returns it. This handler's job is to stay silent on the paths the gate is
37
- * meant to skip.
34
+ * Contract (`.claude/HOOKS.md`): this handler ALWAYS ABSTAINS: exit 0, no
35
+ * output, on every path. Only exit 2 blocks a tool call, and this handler
36
+ * never returns it, so any other non-zero exit is a non-blocking ERROR
37
+ * rather than a decision. See `decide` below for the correction this file
38
+ * went through.
38
39
  *
39
40
  * Path source: the hook payload arrives as JSON on STDIN (Claude Code's
40
41
  * documented channel; it appends no argv). `process.argv[2]` is honoured as a
@@ -57,11 +58,31 @@ function candidatePath(payload) {
57
58
  }
58
59
 
59
60
  /**
60
- * The gate decision: allow (0) only for paths under `.peaks/_runtime/`;
61
- * everything else falls through to the gate (1).
61
+ * This handler ABSTAINS on every path, including `.peaks/_runtime/`.
62
+ *
63
+ * It used to return 1 for anything outside `.peaks/_runtime/`, documented as
64
+ * "fall through to the gate". That concept does not exist in the Claude Code
65
+ * hook protocol. Exit 2 is the only code that blocks; every other non-zero
66
+ * exit is a NON-BLOCKING ERROR — the action still proceeds, but the transcript
67
+ * shows `<hook> hook error` followed by `Failed with non-blocking status code:
68
+ * No stderr output`. The "silent fall-through" was therefore the LOUDEST
69
+ * available outcome, reported once per edit for every path a developer
70
+ * actually touches.
71
+ *
72
+ * The documented abstention is exit 0 with no output: "no decision". All
73
+ * matching PreToolUse hooks run in parallel and their results are merged
74
+ * (`deny` > `defer` > `ask` > `allow`), so abstaining neither approves the call
75
+ * nor suppresses a sibling's deny — the fact gate still applies to every path
76
+ * it always applied to.
77
+ *
78
+ * Runtime behaviour is unchanged for every path. What changes is that the
79
+ * handler stops reporting a spurious error while abstaining.
62
80
  */
63
81
  function decide(p) {
64
- return p.includes('.peaks/_runtime/') ? 0 : 1;
82
+ // The path is still read (it is the payload's whole point) but no longer
83
+ // branches: every path abstains.
84
+ void p;
85
+ return 0;
65
86
  }
66
87
 
67
88
  const ARGV_PATH = typeof process.argv[2] === 'string' ? process.argv[2] : '';
@@ -80,8 +101,10 @@ process.stdin.on('end', () => {
80
101
  try {
81
102
  payload = JSON.parse(raw);
82
103
  } catch {
83
- // Malformed or empty payload → no path → deny, which is the same
84
- // outcome the old form produced for an absent `process.argv[1]`.
104
+ // Malformed or empty payload → no path. This is NOT a deny: `decide`
105
+ // abstains for every input including the empty string, and a deny would
106
+ // take exit 2. The old comment here said "deny" while returning 1, which
107
+ // is the non-blocking error — the same misreading this file corrects.
85
108
  payload = undefined;
86
109
  }
87
110
  process.exit(decide(candidatePath(payload) || ARGV_PATH));