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
|
@@ -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
|
|
@@ -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
|
-
|
|
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
|
|
87
|
-
const proc =
|
|
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
|
-
|
|
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`):
|
|
35
|
-
*
|
|
36
|
-
* returns it
|
|
37
|
-
*
|
|
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
|
-
*
|
|
61
|
-
*
|
|
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
|
-
|
|
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
|
|
84
|
-
//
|
|
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));
|