peaks-loop 4.0.36 → 4.0.38
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 +42 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/bin/peaks.js +71 -1
- package/dist/cli/cli-helpers.js +7 -0
- package/dist/cli/commands/_register.js +2 -0
- package/dist/cli/commands/best-practice-scan-command.d.ts +14 -1
- package/dist/cli/commands/best-practice-scan-command.js +67 -9
- package/dist/cli/commands/code-runtime-commands.js +21 -5
- package/dist/cli/commands/hooks-commands.js +41 -7
- package/dist/cli/commands/job-commands.js +107 -25
- package/dist/cli/commands/scan-commands.js +1 -1
- package/dist/cli/commands/web-commands.d.ts +28 -0
- package/dist/cli/commands/web-commands.js +327 -0
- package/dist/cli/commands/web-lifecycle-commands.d.ts +49 -0
- package/dist/cli/commands/web-lifecycle-commands.js +321 -0
- package/dist/services/best-practice/scan-orchestrator.d.ts +22 -0
- package/dist/services/best-practice/scan-orchestrator.js +14 -5
- package/dist/services/code/orchestrator-can-do.js +27 -4
- package/dist/services/context/build-dispatch-system-prompt.d.ts +35 -1
- package/dist/services/context/build-dispatch-system-prompt.js +55 -3
- package/dist/services/context/context-audit-hint.d.ts +79 -0
- package/dist/services/context/context-audit-hint.js +150 -0
- package/dist/services/hooks/auto-compact-hook-install.js +10 -1
- package/dist/services/hooks/write-gate.js +88 -0
- package/dist/services/lint/detect-eslint.d.ts +2 -0
- package/dist/services/lint/detect-eslint.js +23 -9
- package/dist/services/lint/detect-ocr-18.d.ts +2 -0
- package/dist/services/lint/detect-ocr-18.js +36 -5
- package/dist/services/lint/npx-resolver.d.ts +6 -0
- package/dist/services/lint/npx-resolver.js +38 -14
- package/dist/services/lint/ocr-multilang-adapter.js +9 -2
- package/dist/services/release/version-precheck-service.js +9 -2
- package/dist/services/scan/file-size-scan.d.ts +29 -0
- package/dist/services/scan/file-size-scan.js +63 -0
- package/dist/services/session/caller-binding-service.d.ts +24 -0
- package/dist/services/session/caller-binding-service.js +34 -0
- package/dist/services/session/getSessionDir.js +15 -10
- package/dist/services/skills/hooks-codegate-superpowers.d.ts +74 -0
- package/dist/services/skills/hooks-codegate-superpowers.js +129 -3
- package/dist/services/skills/hooks-settings-service.d.ts +26 -0
- package/dist/services/skills/hooks-settings-service.js +186 -62
- package/dist/services/slice/slice-check-service.d.ts +14 -0
- package/dist/services/slice/slice-check-service.js +110 -50
- package/dist/services/slice/slice-check-types.d.ts +12 -7
- package/dist/services/slice/slice-check-types.js +8 -3
- package/dist/services/slice/slice-decompose-runners.js +24 -21
- package/dist/services/sop/sop-check-service.js +12 -1
- package/dist/services/web/bounded-output.d.ts +34 -0
- package/dist/services/web/bounded-output.js +68 -0
- package/dist/services/web/browser-acquire.d.ts +14 -0
- package/dist/services/web/browser-acquire.js +84 -0
- package/dist/services/web/browser-session-manager.d.ts +111 -0
- package/dist/services/web/browser-session-manager.js +413 -0
- package/dist/services/web/daemon-entry.d.ts +1 -0
- package/dist/services/web/daemon-entry.js +65 -0
- package/dist/services/web/daemon-registry.d.ts +42 -0
- package/dist/services/web/daemon-registry.js +164 -0
- package/dist/services/web/daemon-supervisor.d.ts +144 -0
- package/dist/services/web/daemon-supervisor.js +455 -0
- package/dist/services/web/playwright-loader.d.ts +89 -0
- package/dist/services/web/playwright-loader.js +253 -0
- package/dist/services/web/snapshot-pruner.d.ts +48 -0
- package/dist/services/web/snapshot-pruner.js +241 -0
- package/dist/services/web/untrusted-envelope.d.ts +27 -0
- package/dist/services/web/untrusted-envelope.js +44 -0
- package/dist/services/web/web-artifact-paths.d.ts +79 -0
- package/dist/services/web/web-artifact-paths.js +163 -0
- package/dist/services/web/web-client.d.ts +19 -0
- package/dist/services/web/web-client.js +55 -0
- package/dist/services/web/web-daemon-service.d.ts +38 -0
- package/dist/services/web/web-daemon-service.js +416 -0
- package/dist/services/web/web-fallback.d.ts +70 -0
- package/dist/services/web/web-fallback.js +121 -0
- package/dist/services/web/web-install-service.d.ts +91 -0
- package/dist/services/web/web-install-service.js +346 -0
- package/dist/services/web/web-login-profile.d.ts +89 -0
- package/dist/services/web/web-login-profile.js +612 -0
- package/dist/services/web/web-login-staging.d.ts +27 -0
- package/dist/services/web/web-login-staging.js +173 -0
- package/dist/services/web/web-protocol.d.ts +58 -0
- package/dist/services/web/web-protocol.js +58 -0
- package/dist/services/web/web-status-report.d.ts +33 -0
- package/dist/services/web/web-status-report.js +47 -0
- package/dist/services/workspace/claude-settings-template.d.ts +59 -7
- package/dist/services/workspace/claude-settings-template.js +139 -67
- package/dist/services/workspace/workspace-claude-settings-materializer.js +46 -19
- package/dist/services/workspace/workspace-service.js +33 -0
- package/package.json +5 -5
- package/scripts/copy-templates.mjs +12 -0
- package/scripts/sync-version.mjs +20 -0
- package/skills/peaks-code/SKILL.md +10 -0
- package/skills/peaks-code/references/browser-workflow.md +10 -1
|
@@ -93,6 +93,47 @@ export const REPORT_CAP_BLOCK = `## Final report cap (mandatory)
|
|
|
93
93
|
|
|
94
94
|
Your FINAL report to the parent MUST be ≤ 40 lines and ≤ 2 KB. Write any longer detail into the artifact file you already own — the parent can \`Read\` that file for the full detail, so nothing is lost. The report itself MUST still carry: changed files (one line each), the exact commands you ran, pass/fail counts, tsc status, and any blocker. Do NOT paste file contents, full tool output, or logs into the report.
|
|
95
95
|
`;
|
|
96
|
+
/**
|
|
97
|
+
* Slice 2026-09-10-fact-force-gate-adaptation: stand IN FRONT of an external
|
|
98
|
+
* `PreToolUse` gate instead of explaining its denial after the fact.
|
|
99
|
+
*
|
|
100
|
+
* ECC (a third-party plugin under `~/.claude/plugins/`) registers
|
|
101
|
+
* `gateguard-fact-force.js` on `Edit|Write|MultiEdit`. It denies the first
|
|
102
|
+
* edit of a file whose facts the agent has not established, and its four-item
|
|
103
|
+
* message never says two things the agent needs: that the file must be READ
|
|
104
|
+
* first, and that the edit was NOT applied. Measured failure mode
|
|
105
|
+
* (session 2026-09-10-session-528a63): the sub-agent reads the denial as
|
|
106
|
+
* "the tool is broken" and abandons the edit.
|
|
107
|
+
*
|
|
108
|
+
* So the block below leads with the STANDING RULE (read before you edit) and
|
|
109
|
+
* names the paths it covers, and mentions the denial only as the consequence
|
|
110
|
+
* of skipping that rule. A sub-agent that has never seen the gate can read
|
|
111
|
+
* this once and never trip it.
|
|
112
|
+
*
|
|
113
|
+
* The gate itself is untouched: peaks-loop adapts to it, and does NOT disable,
|
|
114
|
+
* bypass, or re-implement it. Nor is `ECC_GATEGUARD=off` part of this.
|
|
115
|
+
*
|
|
116
|
+
* Always rendered — no opt-out flag and no role split. Every role edits files
|
|
117
|
+
* outside `.peaks/**`, and only sub-agent #1 of a session sees a denial (the
|
|
118
|
+
* gate fires once per file), so a role-scoped or opt-in block would leave the
|
|
119
|
+
* rest of the fleet untold. It joins the stable boilerplate prefix: constant
|
|
120
|
+
* bytes for every dispatch, prompt-cache friendly.
|
|
121
|
+
*
|
|
122
|
+
* The `.peaks/**` exemption is real and pre-dates this slice — peaks-loop
|
|
123
|
+
* materialises `.claude/settings.local.json` so the gate skips `.peaks/**`
|
|
124
|
+
* (slice 2.0.1-bug3-fact-forcing-bypass; see
|
|
125
|
+
* `src/cli/commands/workspace/init-command.ts`).
|
|
126
|
+
*/
|
|
127
|
+
export const FACT_FORCE_GATE_BLOCK = `## Read before you edit (Fact-Forcing Gate)
|
|
128
|
+
|
|
129
|
+
Read a file BEFORE your first \`Edit\` / \`Write\` / \`MultiEdit\` on it — the normal way to work here, not an optional step. It applies to every path OUTSIDE \`.peaks/**\` (source, tests, docs, config); \`.peaks/**\` writes are exempt.
|
|
130
|
+
|
|
131
|
+
Skipping that read trips a \`PreToolUse\` plugin gate (ECC's "Fact-Forcing Gate"), which denies the edit. A denial is NOT a failure and the tool is NOT broken — your edit was NOT applied. Read the file, state the four facts the gate asks for (importers, affected API, data schemas if any, the user's verbatim instruction), then retry the same operation. Do not switch tools, do not give up, do not re-attempt blindly.
|
|
132
|
+
`;
|
|
133
|
+
/** Always-on renderer for {@link FACT_FORCE_GATE_BLOCK}. */
|
|
134
|
+
export function renderFactForceGateBlock() {
|
|
135
|
+
return `${FACT_FORCE_GATE_BLOCK}\n`;
|
|
136
|
+
}
|
|
96
137
|
/**
|
|
97
138
|
* Compose the system-prompt body for a sub-agent dispatch.
|
|
98
139
|
*
|
|
@@ -104,7 +145,7 @@ Your FINAL report to the parent MUST be ≤ 40 lines and ≤ 2 KB. Write any lon
|
|
|
104
145
|
* Byte-identical degradation contract (slice 2026-07-22-orchestrator-memory-preflight
|
|
105
146
|
* controller brief): when the memory block is unavailable, the composed body is
|
|
106
147
|
* exactly `formatTestToolDetection() + "\n\n" + L1 + "\n" + LIFECYCLE +
|
|
107
|
-
* "\n" + REPORT_CAP + "\n" + contextBlock + taskBody`, so the unavailable
|
|
148
|
+
* "\n" + REPORT_CAP + "\n" + FACT_FORCE_GATE + "\n" + contextBlock + taskBody`, so the unavailable
|
|
108
149
|
* branch MUST return `taskBody` unwrapped (NOT a `# title\n\n` wrap).
|
|
109
150
|
* (REPORT_CAP joined the stable prefix in slice
|
|
110
151
|
* 2026-09-10-context-audit-and-discipline, Slice C.)
|
|
@@ -128,15 +169,17 @@ export function buildDispatchSystemPrompt(input) {
|
|
|
128
169
|
// for every role — the composer owns the injection so callers MUST NOT
|
|
129
170
|
// prepend `formatTestToolDetection()` themselves (double injection).
|
|
130
171
|
const testToolText = `${formatTestToolDetection()}\n\n`;
|
|
172
|
+
// 2026-09-10-fact-force-gate-adaptation: always-on, both branches.
|
|
173
|
+
const factForceGateText = renderFactForceGateBlock();
|
|
131
174
|
const contextBlock = renderContextBlock(contextProbe ?? null);
|
|
132
175
|
const codegraphText = renderCodegraphBlock(codegraphBlock);
|
|
133
176
|
const projectStackText = renderProjectStackBlock(projectStackBlock);
|
|
134
177
|
const freshContextText = renderFreshContextBlock(freshContextBlock);
|
|
135
178
|
const capsuleText = renderCapsulePointer(capsule);
|
|
136
179
|
if (memoryBlock.available === true && typeof memoryBlock.block === 'string') {
|
|
137
|
-
return `${testToolText}${L1_WORKTREE_GOVERNANCE_BLOCK}\n${LIFECYCLE_RULES}\n${REPORT_CAP_BLOCK}\n${contextBlock}${codegraphText}${projectStackText}${freshContextText}${capsuleText}${memoryBlock.block}\n## Task\n${taskBody}`;
|
|
180
|
+
return `${testToolText}${L1_WORKTREE_GOVERNANCE_BLOCK}\n${LIFECYCLE_RULES}\n${REPORT_CAP_BLOCK}\n${factForceGateText}${contextBlock}${codegraphText}${projectStackText}${freshContextText}${capsuleText}${memoryBlock.block}\n## Task\n${taskBody}`;
|
|
138
181
|
}
|
|
139
|
-
return `${testToolText}${L1_WORKTREE_GOVERNANCE_BLOCK}\n${LIFECYCLE_RULES}\n${REPORT_CAP_BLOCK}\n${contextBlock}${codegraphText}${projectStackText}${freshContextText}${capsuleText}${taskBody}`;
|
|
182
|
+
return `${testToolText}${L1_WORKTREE_GOVERNANCE_BLOCK}\n${LIFECYCLE_RULES}\n${REPORT_CAP_BLOCK}\n${factForceGateText}${contextBlock}${codegraphText}${projectStackText}${freshContextText}${capsuleText}${taskBody}`;
|
|
140
183
|
}
|
|
141
184
|
/**
|
|
142
185
|
* Slice 2026-09-10-dispatch-token-and-swarm §4 — session capsule pointer.
|
|
@@ -294,6 +337,15 @@ export const BINDING_RULE_TOKENS = [
|
|
|
294
337
|
'changed files (one line each)',
|
|
295
338
|
'pass/fail counts',
|
|
296
339
|
'tsc status',
|
|
340
|
+
// fact-forcing gate (slice 2026-09-10-fact-force-gate-adaptation)
|
|
341
|
+
'## Read before you edit (Fact-Forcing Gate)',
|
|
342
|
+
'Read a file BEFORE your first `Edit` / `Write` / `MultiEdit` on it',
|
|
343
|
+
'every path OUTSIDE `.peaks/**`',
|
|
344
|
+
'`PreToolUse` plugin gate',
|
|
345
|
+
'A denial is NOT a failure and the tool is NOT broken',
|
|
346
|
+
'your edit was NOT applied',
|
|
347
|
+
'retry the same operation',
|
|
348
|
+
'do not re-attempt blindly',
|
|
297
349
|
// test scope — ONE unified block, byte-identical for EVERY role
|
|
298
350
|
'## Test Tool Detection (mandatory)',
|
|
299
351
|
'`package.json#scripts.test`',
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice 2026-09-10-three-fixes (Slice 2) — proactive context-consumer hint.
|
|
3
|
+
*
|
|
4
|
+
* `peaks code context-audit` (Slice A, same day) already reports WHAT fills
|
|
5
|
+
* the window, but only when someone remembers to run it. The Step 0.8
|
|
6
|
+
* PreToolUse gate (`peaks code gate-step-08`) runs before every Bash call, so
|
|
7
|
+
* it is the natural place to surface the single largest consumer — provided
|
|
8
|
+
* that costs nothing measurable.
|
|
9
|
+
*
|
|
10
|
+
* Cost contract (the whole point of this module):
|
|
11
|
+
* - The ratio probe is the cheap, adapter-driven `readContextPercent`
|
|
12
|
+
* (env-var → statusline file read; the transcript fallback is a
|
|
13
|
+
* bounded backwards scan, never a whole-file read).
|
|
14
|
+
* - The expensive part — the transcript scan inside `auditContext` — runs
|
|
15
|
+
* at most ONCE per TTL window. Its result is cached under
|
|
16
|
+
* `.peaks/_runtime/<sid>/context-audit-hint.json`; a fresh entry
|
|
17
|
+
* short-circuits the scan entirely.
|
|
18
|
+
* - Every failure is fail-soft: a missing/corrupt cache, an unavailable
|
|
19
|
+
* transcript, an adapter that cannot locate one — all yield `null`
|
|
20
|
+
* (emit nothing). The gate never blocks and never gains more than ONE
|
|
21
|
+
* line.
|
|
22
|
+
*
|
|
23
|
+
* The cache deliberately records FAILED audits too (with `available:false`),
|
|
24
|
+
* so a broken transcript cannot turn every Bash call back into a scan.
|
|
25
|
+
*/
|
|
26
|
+
import { type ContextAuditInput, type ContextAuditResult } from './context-audit.js';
|
|
27
|
+
/** Only surface a hint once the window is this full. */
|
|
28
|
+
export declare const CONTEXT_HINT_RATIO_THRESHOLD = 0.7;
|
|
29
|
+
/** Minimum cache TTL — a fresh entry must skip the transcript scan. */
|
|
30
|
+
export declare const CONTEXT_HINT_CACHE_TTL_MS: number;
|
|
31
|
+
/** Per-session cache file name (under `.peaks/_runtime/<sid>/`). */
|
|
32
|
+
export declare const CONTEXT_HINT_CACHE_FILE_NAME = "context-audit-hint.json";
|
|
33
|
+
/** Cached top-consumer snapshot — one audit result, no transcript content. */
|
|
34
|
+
export interface ContextHintCacheEntry {
|
|
35
|
+
/** Epoch ms when the audit ran (TTL anchor). */
|
|
36
|
+
readonly cachedAt: number;
|
|
37
|
+
/** False when the audit could not read the transcript. */
|
|
38
|
+
readonly available: boolean;
|
|
39
|
+
/** Ratio observed when the audit ran (informational). */
|
|
40
|
+
readonly ratio: number;
|
|
41
|
+
readonly tool: string | null;
|
|
42
|
+
readonly key: string | null;
|
|
43
|
+
readonly bytes: number;
|
|
44
|
+
readonly pctOfTotal: number;
|
|
45
|
+
readonly count: number;
|
|
46
|
+
}
|
|
47
|
+
export interface ContextAuditHintInput {
|
|
48
|
+
readonly projectRoot: string;
|
|
49
|
+
readonly sessionId: string;
|
|
50
|
+
readonly outerSessionId?: string | null | undefined;
|
|
51
|
+
readonly env?: NodeJS.ProcessEnv | undefined;
|
|
52
|
+
/** Clock override (test seam). */
|
|
53
|
+
readonly nowMs?: number | undefined;
|
|
54
|
+
/** Cache path override (test seam; default is per-session under `_runtime`). */
|
|
55
|
+
readonly cachePath?: string | undefined;
|
|
56
|
+
/** Ratio probe override (test seam). Returns null when unknown. */
|
|
57
|
+
readonly probeRatio?: (() => number | null) | undefined;
|
|
58
|
+
/** Audit runner override (test seam; lets a test count scans). */
|
|
59
|
+
readonly runAudit?: ((input: ContextAuditInput) => ContextAuditResult) | undefined;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* `<projectRoot>/.peaks/_runtime/<sid>/context/context-audit-hint.json`
|
|
63
|
+
* (gitignored). The `context/` bucket mirrors the existing `<sid>/txt/`
|
|
64
|
+
* convention so session-id artifacts stay under `.peaks/_runtime/<sid>/`.
|
|
65
|
+
*/
|
|
66
|
+
export declare function contextHintCachePath(projectRoot: string, sessionId: string): string;
|
|
67
|
+
/**
|
|
68
|
+
* The single line the gate may append. Names ONE consumer — the largest by
|
|
69
|
+
* tool-result bytes — plus the share and call count that justify the number.
|
|
70
|
+
*/
|
|
71
|
+
export declare function formatContextHintLine(entry: ContextHintCacheEntry): string;
|
|
72
|
+
/**
|
|
73
|
+
* Build the ONE optional hint line for the Step 0.8 gate.
|
|
74
|
+
*
|
|
75
|
+
* Returns `null` when: the ratio is unknown or below 0.70, the cache (fresh)
|
|
76
|
+
* says the audit was unavailable, the fresh audit found no groups, or
|
|
77
|
+
* anything throws. Never throws, never blocks.
|
|
78
|
+
*/
|
|
79
|
+
export declare function buildContextAuditHint(input: ContextAuditHintInput): string | null;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice 2026-09-10-three-fixes (Slice 2) — proactive context-consumer hint.
|
|
3
|
+
*
|
|
4
|
+
* `peaks code context-audit` (Slice A, same day) already reports WHAT fills
|
|
5
|
+
* the window, but only when someone remembers to run it. The Step 0.8
|
|
6
|
+
* PreToolUse gate (`peaks code gate-step-08`) runs before every Bash call, so
|
|
7
|
+
* it is the natural place to surface the single largest consumer — provided
|
|
8
|
+
* that costs nothing measurable.
|
|
9
|
+
*
|
|
10
|
+
* Cost contract (the whole point of this module):
|
|
11
|
+
* - The ratio probe is the cheap, adapter-driven `readContextPercent`
|
|
12
|
+
* (env-var → statusline file read; the transcript fallback is a
|
|
13
|
+
* bounded backwards scan, never a whole-file read).
|
|
14
|
+
* - The expensive part — the transcript scan inside `auditContext` — runs
|
|
15
|
+
* at most ONCE per TTL window. Its result is cached under
|
|
16
|
+
* `.peaks/_runtime/<sid>/context-audit-hint.json`; a fresh entry
|
|
17
|
+
* short-circuits the scan entirely.
|
|
18
|
+
* - Every failure is fail-soft: a missing/corrupt cache, an unavailable
|
|
19
|
+
* transcript, an adapter that cannot locate one — all yield `null`
|
|
20
|
+
* (emit nothing). The gate never blocks and never gains more than ONE
|
|
21
|
+
* line.
|
|
22
|
+
*
|
|
23
|
+
* The cache deliberately records FAILED audits too (with `available:false`),
|
|
24
|
+
* so a broken transcript cannot turn every Bash call back into a scan.
|
|
25
|
+
*/
|
|
26
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
27
|
+
import { join } from 'node:path';
|
|
28
|
+
import { atomicWriteJson } from '../ide/shared/atomic-json.js';
|
|
29
|
+
import { readContextPercent } from './auto-compact-reader.js';
|
|
30
|
+
import { auditContext } from './context-audit.js';
|
|
31
|
+
/** Only surface a hint once the window is this full. */
|
|
32
|
+
export const CONTEXT_HINT_RATIO_THRESHOLD = 0.7;
|
|
33
|
+
/** Minimum cache TTL — a fresh entry must skip the transcript scan. */
|
|
34
|
+
export const CONTEXT_HINT_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
35
|
+
/** Per-session cache file name (under `.peaks/_runtime/<sid>/`). */
|
|
36
|
+
export const CONTEXT_HINT_CACHE_FILE_NAME = 'context-audit-hint.json';
|
|
37
|
+
/**
|
|
38
|
+
* `<projectRoot>/.peaks/_runtime/<sid>/context/context-audit-hint.json`
|
|
39
|
+
* (gitignored). The `context/` bucket mirrors the existing `<sid>/txt/`
|
|
40
|
+
* convention so session-id artifacts stay under `.peaks/_runtime/<sid>/`.
|
|
41
|
+
*/
|
|
42
|
+
export function contextHintCachePath(projectRoot, sessionId) {
|
|
43
|
+
const safeSessionId = sessionId.replace(/[^A-Za-z0-9._-]/g, '_').replace(/^\.+/, '');
|
|
44
|
+
return join(projectRoot, '.peaks', '_runtime', safeSessionId.length > 0 ? safeSessionId : 'unknown', 'context', CONTEXT_HINT_CACHE_FILE_NAME);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* The single line the gate may append. Names ONE consumer — the largest by
|
|
48
|
+
* tool-result bytes — plus the share and call count that justify the number.
|
|
49
|
+
*/
|
|
50
|
+
export function formatContextHintLine(entry) {
|
|
51
|
+
const ratioPct = (entry.ratio * 100).toFixed(1);
|
|
52
|
+
const sharePct = entry.pctOfTotal.toFixed(1);
|
|
53
|
+
const calls = entry.count === 1 ? '1 call' : `${entry.count} calls`;
|
|
54
|
+
return `Context ${ratioPct}% used — top consumer: ${entry.tool} \`${entry.key}\` (${sharePct}% of tool-result bytes, ${calls}). Run \`peaks code context-audit\` for the full breakdown.`;
|
|
55
|
+
}
|
|
56
|
+
function readCache(cachePath) {
|
|
57
|
+
if (!existsSync(cachePath))
|
|
58
|
+
return null;
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(readFileSync(cachePath, 'utf8'));
|
|
61
|
+
if (typeof parsed.cachedAt !== 'number' || !Number.isFinite(parsed.cachedAt))
|
|
62
|
+
return null;
|
|
63
|
+
if (typeof parsed.available !== 'boolean')
|
|
64
|
+
return null;
|
|
65
|
+
return {
|
|
66
|
+
cachedAt: parsed.cachedAt,
|
|
67
|
+
available: parsed.available,
|
|
68
|
+
ratio: typeof parsed.ratio === 'number' && Number.isFinite(parsed.ratio) ? parsed.ratio : 0,
|
|
69
|
+
tool: typeof parsed.tool === 'string' ? parsed.tool : null,
|
|
70
|
+
key: typeof parsed.key === 'string' ? parsed.key : null,
|
|
71
|
+
bytes: typeof parsed.bytes === 'number' && Number.isFinite(parsed.bytes) ? parsed.bytes : 0,
|
|
72
|
+
pctOfTotal: typeof parsed.pctOfTotal === 'number' && Number.isFinite(parsed.pctOfTotal) ? parsed.pctOfTotal : 0,
|
|
73
|
+
count: typeof parsed.count === 'number' && Number.isFinite(parsed.count) ? parsed.count : 0,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
/** Never throws: an unavailable ratio probe degrades to "no hint". */
|
|
81
|
+
function probeRatioSafe(input) {
|
|
82
|
+
if (input.probeRatio !== undefined) {
|
|
83
|
+
try {
|
|
84
|
+
const value = input.probeRatio();
|
|
85
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
try {
|
|
92
|
+
const probe = readContextPercent({
|
|
93
|
+
projectRoot: input.projectRoot,
|
|
94
|
+
sessionId: input.sessionId,
|
|
95
|
+
outerSessionId: input.outerSessionId ?? undefined,
|
|
96
|
+
env: input.env ?? process.env,
|
|
97
|
+
});
|
|
98
|
+
return Number.isFinite(probe.ratio) ? probe.ratio : null;
|
|
99
|
+
}
|
|
100
|
+
catch {
|
|
101
|
+
return null;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Build the ONE optional hint line for the Step 0.8 gate.
|
|
106
|
+
*
|
|
107
|
+
* Returns `null` when: the ratio is unknown or below 0.70, the cache (fresh)
|
|
108
|
+
* says the audit was unavailable, the fresh audit found no groups, or
|
|
109
|
+
* anything throws. Never throws, never blocks.
|
|
110
|
+
*/
|
|
111
|
+
export function buildContextAuditHint(input) {
|
|
112
|
+
try {
|
|
113
|
+
const nowMs = input.nowMs ?? Date.now();
|
|
114
|
+
const ratio = probeRatioSafe(input);
|
|
115
|
+
if (ratio === null || ratio < CONTEXT_HINT_RATIO_THRESHOLD)
|
|
116
|
+
return null;
|
|
117
|
+
const cachePath = input.cachePath ?? contextHintCachePath(input.projectRoot, input.sessionId);
|
|
118
|
+
const cached = readCache(cachePath);
|
|
119
|
+
// Fresh entry → the transcript scan is skipped entirely (cost guard).
|
|
120
|
+
if (cached !== null && nowMs - cached.cachedAt < CONTEXT_HINT_CACHE_TTL_MS) {
|
|
121
|
+
return cached.available && cached.tool !== null ? formatContextHintLine(cached) : null;
|
|
122
|
+
}
|
|
123
|
+
// Cache miss / stale → at most one scan per TTL window.
|
|
124
|
+
const result = (input.runAudit ?? auditContext)({
|
|
125
|
+
outerSessionId: input.outerSessionId ?? null,
|
|
126
|
+
topN: 1,
|
|
127
|
+
});
|
|
128
|
+
const top = result.available ? (result.entries[0] ?? null) : null;
|
|
129
|
+
const entry = {
|
|
130
|
+
cachedAt: nowMs,
|
|
131
|
+
available: result.available,
|
|
132
|
+
ratio,
|
|
133
|
+
tool: top?.tool ?? null,
|
|
134
|
+
key: top?.key ?? null,
|
|
135
|
+
bytes: top?.bytes ?? 0,
|
|
136
|
+
pctOfTotal: top?.pctOfTotal ?? 0,
|
|
137
|
+
count: top?.count ?? 0,
|
|
138
|
+
};
|
|
139
|
+
try {
|
|
140
|
+
atomicWriteJson(cachePath, entry);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// Fail-soft: an unwritable cache must not lose this turn's hint.
|
|
144
|
+
}
|
|
145
|
+
return top !== null ? formatContextHintLine(entry) : null;
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
*/
|
|
33
33
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
34
34
|
import { dirname, join } from 'node:path';
|
|
35
|
+
import { resolveHookShell } from '../skills/hooks-codegate-superpowers.js';
|
|
35
36
|
/**
|
|
36
37
|
* Stable matcher for the auto-compact hook. Single source of truth
|
|
37
38
|
* — `install` and `remove` both key off this constant so the
|
|
@@ -105,6 +106,13 @@ export function installAutoCompactHook(input) {
|
|
|
105
106
|
if (alreadyInstalled) {
|
|
106
107
|
return { action: 'already-installed', settingsPath };
|
|
107
108
|
}
|
|
109
|
+
// The matcher is `Bash|Task`, so on Windows this runs on the same
|
|
110
|
+
// Git-Bash / MSYS2 shell-form path as the other peaks Bash hooks, which
|
|
111
|
+
// force-allocates a console window on every matching tool call. The
|
|
112
|
+
// entry is written only into the machine-local, gitignored
|
|
113
|
+
// `.claude/settings.local.json`, so a machine-specific `shell` cannot
|
|
114
|
+
// leak into a shared file. `undefined` on POSIX omits the key entirely.
|
|
115
|
+
const shell = resolveHookShell();
|
|
108
116
|
const nextPreToolUse = [
|
|
109
117
|
...preToolUse,
|
|
110
118
|
{
|
|
@@ -112,7 +120,8 @@ export function installAutoCompactHook(input) {
|
|
|
112
120
|
hooks: [
|
|
113
121
|
{
|
|
114
122
|
type: 'command',
|
|
115
|
-
command: AUTO_COMPACT_HOOK_COMMAND
|
|
123
|
+
command: AUTO_COMPACT_HOOK_COMMAND,
|
|
124
|
+
...(shell !== undefined ? { shell } : {})
|
|
116
125
|
}
|
|
117
126
|
]
|
|
118
127
|
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* write-gate.js — peaks Write|Edit|MultiEdit PreToolUse path gate.
|
|
4
|
+
*
|
|
5
|
+
* Slice c5-write-hook-exec-form (session 2026-09-10-session-528a63).
|
|
6
|
+
* VERBATIM RELOCATION: the predicate chain below was character-for-character
|
|
7
|
+
* the chain that used to be inlined into `claude-settings-template.ts` as a
|
|
8
|
+
* `node -e "<js>"` one-liner.
|
|
9
|
+
*
|
|
10
|
+
* Slice c5b-write-gate-polarity (same session) NARROWED the decision to match
|
|
11
|
+
* the contract `.claude/HOOKS.md` documents for this handler. The relocated
|
|
12
|
+
* chain read its eight directory names as an EXCLUSION list, so `_runtime`
|
|
13
|
+
* AND every other `.peaks/<slug>/` were allowed; it now allows only paths
|
|
14
|
+
* under `.peaks/_runtime/` and falls through on everything else. That earlier
|
|
15
|
+
* polarity also permitted a top-level `.peaks/<change-id>/` write, which
|
|
16
|
+
* `CLAUDE.md`'s hard ban forbids outright.
|
|
17
|
+
*
|
|
18
|
+
* Why it moved into a file: the inlined form was shell-dialect-coupled. Its
|
|
19
|
+
* escaping contract was defined in terms of bash reducing `\\` to `\` inside a
|
|
20
|
+
* `"..."` wrapper, which PowerShell does NOT do — so the handler could not take
|
|
21
|
+
* the platform `shell` pin its Bash siblings carry. `node <path>` has no inline
|
|
22
|
+
* payload, so there is nothing left to escape and no dialect to couple to.
|
|
23
|
+
*
|
|
24
|
+
* Why `.js` and not `.sh` (the convention for the other hook scripts here):
|
|
25
|
+
* - `.sh` needs `bash`; on Windows that means Git Bash, so it is itself a
|
|
26
|
+
* shell dependency and the escaping problem merely moves to the
|
|
27
|
+
* `bash <script>` boundary.
|
|
28
|
+
* - `node` is already a hard dependency — the previous form invoked it.
|
|
29
|
+
* - A plain `.js` needs no compile step, so the SAME relative filename
|
|
30
|
+
* exists in `src/` (used by `tsx` + vitest) and in `dist/` (copied by
|
|
31
|
+
* `scripts/copy-templates.mjs`), which lets one emitted path string be
|
|
32
|
+
* valid for both the repo and an installed consumer.
|
|
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.
|
|
38
|
+
*
|
|
39
|
+
* Path source: the hook payload arrives as JSON on STDIN (Claude Code's
|
|
40
|
+
* documented channel; it appends no argv). `process.argv[2]` is honoured as a
|
|
41
|
+
* fallback so a positional-arg invocation keeps working.
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
/** First string found at any of the candidate keys of `root`. */
|
|
45
|
+
function pathFrom(root) {
|
|
46
|
+
if (!root || typeof root !== 'object') return '';
|
|
47
|
+
for (const key of ['file_path', 'path', 'notebook_path']) {
|
|
48
|
+
if (typeof root[key] === 'string') return root[key];
|
|
49
|
+
}
|
|
50
|
+
return '';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Pull the candidate file path out of a parsed hook payload. */
|
|
54
|
+
function candidatePath(payload) {
|
|
55
|
+
if (!payload || typeof payload !== 'object') return '';
|
|
56
|
+
return pathFrom(payload.tool_input) || pathFrom(payload);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The gate decision: allow (0) only for paths under `.peaks/_runtime/`;
|
|
61
|
+
* everything else falls through to the gate (1).
|
|
62
|
+
*/
|
|
63
|
+
function decide(p) {
|
|
64
|
+
return p.includes('.peaks/_runtime/') ? 0 : 1;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const ARGV_PATH = typeof process.argv[2] === 'string' ? process.argv[2] : '';
|
|
68
|
+
|
|
69
|
+
// No stdin to read (a human running this by hand): decide on argv alone
|
|
70
|
+
// instead of blocking forever waiting for an 'end' that never comes.
|
|
71
|
+
if (process.stdin.isTTY) {
|
|
72
|
+
process.exit(decide(ARGV_PATH));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
let raw = '';
|
|
76
|
+
process.stdin.setEncoding('utf8');
|
|
77
|
+
process.stdin.on('data', (chunk) => { raw += chunk; });
|
|
78
|
+
process.stdin.on('end', () => {
|
|
79
|
+
let payload;
|
|
80
|
+
try {
|
|
81
|
+
payload = JSON.parse(raw);
|
|
82
|
+
} 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]`.
|
|
85
|
+
payload = undefined;
|
|
86
|
+
}
|
|
87
|
+
process.exit(decide(candidatePath(payload) || ARGV_PATH));
|
|
88
|
+
});
|
|
@@ -7,4 +7,6 @@ export type EslintDetectResult = {
|
|
|
7
7
|
readonly warnings: readonly string[];
|
|
8
8
|
readonly nextActions: readonly string[];
|
|
9
9
|
};
|
|
10
|
+
/** Named code for "npm itself could not be launched" — distinct from a registry miss. */
|
|
11
|
+
export declare const NPM_PROBE_UNRESOLVED_CODE = "NPM_PROBE_UNRESOLVED";
|
|
10
12
|
export declare function detectEslint(): EslintDetectResult;
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* unified Gate B5 verdict.
|
|
5
5
|
*/
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
|
-
import { resolveNpxInvocation } from './npx-resolver.js';
|
|
7
|
+
import { resolveNpmInvocation, resolveNpxInvocation } from './npx-resolver.js';
|
|
8
8
|
import { ESLINT_PACKAGE_PINS } from './eslint-runner.js';
|
|
9
9
|
const PACKAGES_TO_PROBE = [
|
|
10
10
|
'eslint',
|
|
@@ -23,14 +23,23 @@ function probeNpx() {
|
|
|
23
23
|
const probe = spawnSync(command, args, { encoding: 'utf8', env: baseEnv });
|
|
24
24
|
return probe.status === 0;
|
|
25
25
|
}
|
|
26
|
+
/** Named code for "npm itself could not be launched" — distinct from a registry miss. */
|
|
27
|
+
export const NPM_PROBE_UNRESOLVED_CODE = 'NPM_PROBE_UNRESOLVED';
|
|
26
28
|
function probePackage(key) {
|
|
27
29
|
const pkg = packageNameFor(key);
|
|
28
30
|
const pin = ESLINT_PACKAGE_PINS[key];
|
|
29
|
-
//
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
31
|
+
// 2026-09-10: `npm` on Windows is an `npm.cmd` shim, which Node >= 20 refuses
|
|
32
|
+
// to spawn at all without `shell: true` — and `shell: true` concatenates the
|
|
33
|
+
// argv unescaped (DEP0190 on every call, and any argument containing a space
|
|
34
|
+
// is split). So the shim is bypassed: `resolveNpmInvocation` resolves npm's
|
|
35
|
+
// own JS entry and this runs it through `process.execPath`. Same shape as the
|
|
36
|
+
// npx probe above and as `eslint-runner.ts`.
|
|
37
|
+
const { command, args, baseEnv } = resolveNpmInvocation(['view', `${pkg}@${pin}`, 'version']);
|
|
38
|
+
const result = spawnSync(command, args, { encoding: 'utf8', env: baseEnv });
|
|
39
|
+
return {
|
|
40
|
+
ok: result.status === 0,
|
|
41
|
+
error: result.error === undefined || result.error === null ? null : result.error.message
|
|
42
|
+
};
|
|
34
43
|
}
|
|
35
44
|
export function detectEslint() {
|
|
36
45
|
const nextActions = [];
|
|
@@ -45,9 +54,14 @@ export function detectEslint() {
|
|
|
45
54
|
};
|
|
46
55
|
}
|
|
47
56
|
for (const key of PACKAGES_TO_PROBE) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
57
|
+
const probe = probePackage(key);
|
|
58
|
+
if (probe.ok)
|
|
59
|
+
continue;
|
|
60
|
+
const target = `${packageNameFor(key)}@${ESLINT_PACKAGE_PINS[key]}`;
|
|
61
|
+
warnings.push(probe.error === null
|
|
62
|
+
? `npm registry cannot resolve ${target}`
|
|
63
|
+
: `${NPM_PROBE_UNRESOLVED_CODE}: could not launch npm to probe ${target} (${probe.error}). ` +
|
|
64
|
+
'Ensure Node.js >= 20 with its bundled npm is installed.');
|
|
51
65
|
}
|
|
52
66
|
if (warnings.length > 0) {
|
|
53
67
|
nextActions.push('Re-run `peaks code lint --json` after npm connectivity is restored.');
|
|
@@ -7,4 +7,6 @@ export type Ocr18DetectResult = {
|
|
|
7
7
|
readonly warnings: readonly string[];
|
|
8
8
|
readonly nextActions: readonly string[];
|
|
9
9
|
};
|
|
10
|
+
/** Named code for "npx itself could not be launched" — distinct from "npx is absent". */
|
|
11
|
+
export declare const NPX_PROBE_UNRESOLVED_CODE = "NPX_PROBE_UNRESOLVED";
|
|
10
12
|
export declare function detectOcr18(): Ocr18DetectResult;
|
|
@@ -2,22 +2,53 @@
|
|
|
2
2
|
* 5-state OCR 1.8.x detect. Mirrors the ECC detect shape.
|
|
3
3
|
*/
|
|
4
4
|
import { spawnSync } from 'node:child_process';
|
|
5
|
+
import { resolveNpxInvocation } from './npx-resolver.js';
|
|
5
6
|
import { OCR_18_PACKAGE } from './ocr-multilang-adapter.js';
|
|
7
|
+
/** Named code for "npx itself could not be launched" — distinct from "npx is absent". */
|
|
8
|
+
export const NPX_PROBE_UNRESOLVED_CODE = 'NPX_PROBE_UNRESOLVED';
|
|
9
|
+
// 2026-09-10: `npx` on Windows is an `npx.cmd` shim, which Node refuses to spawn
|
|
10
|
+
// without `shell: true` — a bare `spawnSync('npx', …)` failed with ENOENT and was
|
|
11
|
+
// then reported as "npx is not on PATH" on machines where `npx --version` exits 0.
|
|
12
|
+
// The shim is bypassed through `resolveNpxInvocation` (same helper as
|
|
13
|
+
// `detect-eslint.ts`), and a launch failure is reported as its OWN reason rather
|
|
14
|
+
// than being collapsed into "absent".
|
|
6
15
|
function probeNpx() {
|
|
7
|
-
const
|
|
8
|
-
|
|
16
|
+
const { command, args, baseEnv } = resolveNpxInvocation(['--version']);
|
|
17
|
+
const probe = spawnSync(command, args, { encoding: 'utf8', env: baseEnv });
|
|
18
|
+
if (probe.status === 0)
|
|
19
|
+
return { available: true };
|
|
20
|
+
const error = probe.error;
|
|
21
|
+
if (error !== undefined && error !== null) {
|
|
22
|
+
// `command !== 'npx'` ⇒ the resolver located a real npx CLI entry and it STILL
|
|
23
|
+
// could not be launched — a different failure from "npx is not on PATH".
|
|
24
|
+
return command !== 'npx'
|
|
25
|
+
? { available: false, reason: 'not-launchable', detail: error.message }
|
|
26
|
+
: { available: false, reason: 'not-on-path', detail: error.message };
|
|
27
|
+
}
|
|
28
|
+
return { available: false, reason: 'probe-failed', detail: `npx --version exited ${probe.status ?? 'null'}` };
|
|
9
29
|
}
|
|
10
30
|
function probeOcr18() {
|
|
11
|
-
const
|
|
31
|
+
const { command, args, baseEnv } = resolveNpxInvocation(['--package', OCR_18_PACKAGE, '--', 'ocr', 'version']);
|
|
32
|
+
const result = spawnSync(command, args, { encoding: 'utf8', env: baseEnv });
|
|
12
33
|
return result.status === 0;
|
|
13
34
|
}
|
|
14
35
|
export function detectOcr18() {
|
|
15
|
-
|
|
36
|
+
const probe = probeNpx();
|
|
37
|
+
if (!probe.available) {
|
|
38
|
+
if (probe.reason === 'not-launchable') {
|
|
39
|
+
return {
|
|
40
|
+
state: 'detection-failed',
|
|
41
|
+
npxAvailable: false,
|
|
42
|
+
package: OCR_18_PACKAGE,
|
|
43
|
+
warnings: [`${NPX_PROBE_UNRESOLVED_CODE}: could not launch npx to probe (${probe.detail}).`],
|
|
44
|
+
nextActions: ['Ensure Node.js >= 20 with its bundled npm is installed; `npx --version` must succeed.']
|
|
45
|
+
};
|
|
46
|
+
}
|
|
16
47
|
return {
|
|
17
48
|
state: 'ocr18-missing',
|
|
18
49
|
npxAvailable: false,
|
|
19
50
|
package: OCR_18_PACKAGE,
|
|
20
|
-
warnings: ['npx is not on PATH'],
|
|
51
|
+
warnings: [probe.reason === 'not-on-path' ? 'npx is not on PATH' : probe.detail],
|
|
21
52
|
nextActions: ['Install Node.js ≥ 20 with npm to enable `npx --package`.']
|
|
22
53
|
};
|
|
23
54
|
}
|
|
@@ -4,3 +4,9 @@ export type NpxInvocation = {
|
|
|
4
4
|
readonly baseEnv: NodeJS.ProcessEnv;
|
|
5
5
|
};
|
|
6
6
|
export declare function resolveNpxInvocation(npxArgs: readonly string[]): NpxInvocation;
|
|
7
|
+
/**
|
|
8
|
+
* `npm` sibling of `resolveNpxInvocation`, same shim bypass: `node
|
|
9
|
+
* <npm-cli.js> <args>`. A caller must NOT reach for bare `npm` + `shell: true`
|
|
10
|
+
* instead — the shim is the defect, not the justification.
|
|
11
|
+
*/
|
|
12
|
+
export declare function resolveNpmInvocation(npmArgs: readonly string[]): NpxInvocation;
|