peaks-loop 4.0.36 → 4.0.37
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 +24 -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 +10 -1
- 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/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/npx-resolver.d.ts +6 -0
- package/dist/services/lint/npx-resolver.js +38 -14
- 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 +33 -0
- package/dist/services/skills/hooks-codegate-superpowers.js +34 -3
- package/dist/services/skills/hooks-settings-service.d.ts +10 -0
- package/dist/services/skills/hooks-settings-service.js +152 -61
- 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 +41 -5
- package/dist/services/workspace/claude-settings-template.js +116 -64
- package/dist/services/workspace/workspace-claude-settings-materializer.js +5 -1
- 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
|
@@ -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.');
|
|
@@ -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;
|
|
@@ -1,26 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Cross-platform `npx` resolver.
|
|
2
|
+
* Cross-platform `npm` / `npx` resolver.
|
|
3
3
|
*
|
|
4
|
-
* On Windows, `npm` installs `npx` as
|
|
5
|
-
* `child_process.spawnSync` refuses to invoke
|
|
6
|
-
* is set, and `shell: true`
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
4
|
+
* On Windows, `npm` installs `npx` and `npm` as `.cmd` shims; Node's
|
|
5
|
+
* `child_process.spawnSync` refuses to invoke a `.cmd` unless `shell: true`
|
|
6
|
+
* is set, and `shell: true` concatenates (does not escape) the argv — it
|
|
7
|
+
* corrupts quoted `--package` arguments, splits any argument containing a
|
|
8
|
+
* space, and emits DEP0190 on every call. Rather than depend on shell
|
|
9
|
+
* quoting, these helpers resolve the CLI script bundled with the user's
|
|
10
|
+
* `npm` install and invoke it via `node <npm|npx>-cli.js` with the same
|
|
11
|
+
* argv. macOS / Linux continue to use the regular binaries.
|
|
11
12
|
*/
|
|
12
13
|
import { existsSync } from 'node:fs';
|
|
13
14
|
import { join } from 'node:path';
|
|
14
15
|
import process from 'node:process';
|
|
15
|
-
|
|
16
|
+
/** Locate `<npm install>/bin/<scriptFile>`, or `null` when not on disk. */
|
|
17
|
+
function locateNpmCliScript(scriptFile) {
|
|
16
18
|
const candidates = process.platform === 'win32'
|
|
17
19
|
? [
|
|
18
|
-
join(process.execPath, '..', '..', 'node_modules', 'npm', 'bin',
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
join(process.execPath, '..', '..', 'node_modules', 'npm', 'bin', scriptFile),
|
|
21
|
+
`C:/nvm4w/nodejs/node_modules/npm/bin/${scriptFile}`,
|
|
22
|
+
`C:/Program Files/nodejs/node_modules/npm/bin/${scriptFile}`
|
|
21
23
|
]
|
|
22
24
|
: [
|
|
23
|
-
join(process.execPath, '..', '..', 'lib', 'node_modules', 'npm', 'bin',
|
|
25
|
+
join(process.execPath, '..', '..', 'lib', 'node_modules', 'npm', 'bin', scriptFile)
|
|
24
26
|
];
|
|
25
27
|
for (const candidate of candidates) {
|
|
26
28
|
if (existsSync(candidate))
|
|
@@ -30,7 +32,7 @@ function locateNpxCliScript() {
|
|
|
30
32
|
}
|
|
31
33
|
export function resolveNpxInvocation(npxArgs) {
|
|
32
34
|
if (process.platform === 'win32') {
|
|
33
|
-
const cliScript =
|
|
35
|
+
const cliScript = locateNpmCliScript('npx-cli.js');
|
|
34
36
|
if (cliScript !== null) {
|
|
35
37
|
return {
|
|
36
38
|
command: process.execPath,
|
|
@@ -45,3 +47,25 @@ export function resolveNpxInvocation(npxArgs) {
|
|
|
45
47
|
baseEnv: process.env
|
|
46
48
|
};
|
|
47
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* `npm` sibling of `resolveNpxInvocation`, same shim bypass: `node
|
|
52
|
+
* <npm-cli.js> <args>`. A caller must NOT reach for bare `npm` + `shell: true`
|
|
53
|
+
* instead — the shim is the defect, not the justification.
|
|
54
|
+
*/
|
|
55
|
+
export function resolveNpmInvocation(npmArgs) {
|
|
56
|
+
if (process.platform === 'win32') {
|
|
57
|
+
const cliScript = locateNpmCliScript('npm-cli.js');
|
|
58
|
+
if (cliScript !== null) {
|
|
59
|
+
return {
|
|
60
|
+
command: process.execPath,
|
|
61
|
+
args: [cliScript, ...npmArgs],
|
|
62
|
+
baseEnv: process.env
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
command: 'npm',
|
|
68
|
+
args: npmArgs,
|
|
69
|
+
baseEnv: process.env
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -114,9 +114,16 @@ export function runRootVsShared(opts) {
|
|
|
114
114
|
export function runTagCollision(opts) {
|
|
115
115
|
const rootVersion = readRootVersion(opts.projectRoot);
|
|
116
116
|
const tagName = `v${rootVersion}`;
|
|
117
|
-
//
|
|
117
|
+
// 2026-09-10: no shell. `git` is `git.exe` on Windows, so the wrapper bought
|
|
118
|
+
// nothing — and it actively broke this layer, because `projectRoot` is an
|
|
119
|
+
// ARGUMENT to git and a shell wraps the command line unescaped. On a project
|
|
120
|
+
// whose path contains a space the shell split it, git exited 128
|
|
121
|
+
// ("cannot change to '…'"), and the layer fell through to its
|
|
122
|
+
// "git tag --list exited with code 128; layer skipped" WARNING — so a real
|
|
123
|
+
// tag collision was reported as merely deferred. Reproduced on
|
|
124
|
+
// `…\Temp\peaks space demo` with tag v9.9.9 present: warning (should be
|
|
125
|
+
// blocker). It also emitted DEP0190 on every run, on every layer.
|
|
118
126
|
const res = spawnSync('git', ['-C', opts.projectRoot, 'tag', '--list', tagName], {
|
|
119
|
-
shell: process.platform === 'win32',
|
|
120
127
|
encoding: 'utf8',
|
|
121
128
|
timeout: 5_000
|
|
122
129
|
});
|
|
@@ -1,4 +1,29 @@
|
|
|
1
1
|
export declare const DEFAULT_FILE_SIZE_THRESHOLD = 800;
|
|
2
|
+
/**
|
|
3
|
+
* Paths exempt from the file-size cap. The cap is Karpathy's "Simplicity
|
|
4
|
+
* First": it exists to make a human *simplify* an over-long file. A path is
|
|
5
|
+
* therefore exempt exactly when no such simplification exists — which is two
|
|
6
|
+
* kinds of file, both listed below so the reason is visible next to the rule.
|
|
7
|
+
*
|
|
8
|
+
* Tool output (whole-cloth generated, nobody maintains it by hand):
|
|
9
|
+
* `.peaks/**` is Peaks-Loop's own state store and holds three derived indexes
|
|
10
|
+
* already over the cap (`memory/index.json`, `lint/baseline.json`,
|
|
11
|
+
* `retrospective/index.json`); exempting only `memory/` would leave the other
|
|
12
|
+
* two false positives intact. No source module lives there, and its largest
|
|
13
|
+
* hand-authored file is under 500 lines. Lockfiles are regenerated on every
|
|
14
|
+
* install.
|
|
15
|
+
*
|
|
16
|
+
* Append-only records: `CHANGELOG.md` is history, so its length is a function
|
|
17
|
+
* of how long the project has existed, not of anyone's design choices — the
|
|
18
|
+
* only way to "fix" a violation would be to delete the record. Left checked,
|
|
19
|
+
* this gate is reliably red on every release, precisely when it cannot be
|
|
20
|
+
* acted on, which trains people to ignore it. A nested changelog under
|
|
21
|
+
* `packages/` is the same kind of record.
|
|
22
|
+
*
|
|
23
|
+
* Declared once, here — do not add special-cases in the scan loop.
|
|
24
|
+
*/
|
|
25
|
+
export declare const SIZE_CAP_EXEMPT_PATTERNS: readonly string[];
|
|
26
|
+
export declare function isSizeCapExempt(file: string): boolean;
|
|
2
27
|
export type FileSizeViolation = {
|
|
3
28
|
file: string;
|
|
4
29
|
lines: number;
|
|
@@ -7,6 +32,10 @@ export type FileSizeScanResult = {
|
|
|
7
32
|
ok: boolean;
|
|
8
33
|
threshold: number;
|
|
9
34
|
checkedFiles: number;
|
|
35
|
+
/** Paths skipped by SIZE_CAP_EXEMPT_PATTERNS (tool output + append-only
|
|
36
|
+
* records). Reported so the exemption is auditable rather than a silent
|
|
37
|
+
* skip. */
|
|
38
|
+
exemptFiles: string[];
|
|
10
39
|
/** Files that appeared in `git diff` but no longer exist on disk (e.g.
|
|
11
40
|
* deleted in the working tree). Pre-#015 the scan crashed on these via
|
|
12
41
|
* ENOENT; now they are reported here as informational data. */
|
|
@@ -2,6 +2,63 @@ import { execFileSync } from 'node:child_process';
|
|
|
2
2
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
4
|
export const DEFAULT_FILE_SIZE_THRESHOLD = 800;
|
|
5
|
+
/**
|
|
6
|
+
* Paths exempt from the file-size cap. The cap is Karpathy's "Simplicity
|
|
7
|
+
* First": it exists to make a human *simplify* an over-long file. A path is
|
|
8
|
+
* therefore exempt exactly when no such simplification exists — which is two
|
|
9
|
+
* kinds of file, both listed below so the reason is visible next to the rule.
|
|
10
|
+
*
|
|
11
|
+
* Tool output (whole-cloth generated, nobody maintains it by hand):
|
|
12
|
+
* `.peaks/**` is Peaks-Loop's own state store and holds three derived indexes
|
|
13
|
+
* already over the cap (`memory/index.json`, `lint/baseline.json`,
|
|
14
|
+
* `retrospective/index.json`); exempting only `memory/` would leave the other
|
|
15
|
+
* two false positives intact. No source module lives there, and its largest
|
|
16
|
+
* hand-authored file is under 500 lines. Lockfiles are regenerated on every
|
|
17
|
+
* install.
|
|
18
|
+
*
|
|
19
|
+
* Append-only records: `CHANGELOG.md` is history, so its length is a function
|
|
20
|
+
* of how long the project has existed, not of anyone's design choices — the
|
|
21
|
+
* only way to "fix" a violation would be to delete the record. Left checked,
|
|
22
|
+
* this gate is reliably red on every release, precisely when it cannot be
|
|
23
|
+
* acted on, which trains people to ignore it. A nested changelog under
|
|
24
|
+
* `packages/` is the same kind of record.
|
|
25
|
+
*
|
|
26
|
+
* Declared once, here — do not add special-cases in the scan loop.
|
|
27
|
+
*/
|
|
28
|
+
export const SIZE_CAP_EXEMPT_PATTERNS = [
|
|
29
|
+
// tool output
|
|
30
|
+
'.peaks/**',
|
|
31
|
+
'**/pnpm-lock.yaml',
|
|
32
|
+
'**/package-lock.json',
|
|
33
|
+
'**/yarn.lock',
|
|
34
|
+
// append-only records
|
|
35
|
+
'**/CHANGELOG.md'
|
|
36
|
+
];
|
|
37
|
+
/**
|
|
38
|
+
* Glob → RegExp for the shapes above only, in a single split pass (chained
|
|
39
|
+
* string replaces would re-expand the `.*` they had just produced). The
|
|
40
|
+
* directory-wildcard prefix matches zero directories, so a root-level
|
|
41
|
+
* lockfile still counts.
|
|
42
|
+
*/
|
|
43
|
+
function exemptPatternToRegExp(pattern) {
|
|
44
|
+
const body = pattern
|
|
45
|
+
.split(/(\*\*\/|\*\*|\*)/)
|
|
46
|
+
.map((part) => {
|
|
47
|
+
if (part === '**/')
|
|
48
|
+
return '(?:.*/)?';
|
|
49
|
+
if (part === '**')
|
|
50
|
+
return '.*';
|
|
51
|
+
if (part === '*')
|
|
52
|
+
return '[^/]*';
|
|
53
|
+
return part.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
|
54
|
+
})
|
|
55
|
+
.join('');
|
|
56
|
+
return new RegExp(`^${body}$`);
|
|
57
|
+
}
|
|
58
|
+
export function isSizeCapExempt(file) {
|
|
59
|
+
const normalized = file.replace(/\\/g, '/');
|
|
60
|
+
return SIZE_CAP_EXEMPT_PATTERNS.some((pattern) => exemptPatternToRegExp(pattern).test(normalized));
|
|
61
|
+
}
|
|
5
62
|
function getChangedFiles(projectRoot, baseRef) {
|
|
6
63
|
try {
|
|
7
64
|
// --diff-filter=AM keeps only Added + Modified entries. Deleted files
|
|
@@ -28,8 +85,13 @@ export function scanFileSize(options) {
|
|
|
28
85
|
const files = getChangedFiles(options.projectRoot, baseRef);
|
|
29
86
|
const violations = [];
|
|
30
87
|
const deletedFiles = [];
|
|
88
|
+
const exemptFiles = [];
|
|
31
89
|
let checkedFiles = 0;
|
|
32
90
|
for (const file of files) {
|
|
91
|
+
if (isSizeCapExempt(file)) {
|
|
92
|
+
exemptFiles.push(file);
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
33
95
|
const absolute = join(options.projectRoot, file);
|
|
34
96
|
// Pre-#015: readFileSync threw ENOENT for files that appear in
|
|
35
97
|
// `git diff --name-only` but no longer exist on disk (e.g. a refactor
|
|
@@ -62,6 +124,7 @@ export function scanFileSize(options) {
|
|
|
62
124
|
ok: violations.length === 0,
|
|
63
125
|
threshold,
|
|
64
126
|
checkedFiles,
|
|
127
|
+
exemptFiles,
|
|
65
128
|
deletedFiles,
|
|
66
129
|
violations
|
|
67
130
|
};
|
|
@@ -68,6 +68,30 @@ export declare function getCallerBinding(projectRoot: string, callerId: string):
|
|
|
68
68
|
* documented 4.0.14 carry-forward micro-fix from QA's issue #1.
|
|
69
69
|
*/
|
|
70
70
|
export declare function setCallerBinding(projectRoot: string, callerId: string, binding: CallerBinding): void;
|
|
71
|
+
/**
|
|
72
|
+
* Repoint an EXISTING per-caller binding at a new peak session id.
|
|
73
|
+
*
|
|
74
|
+
* Slice 2026-09-10 (rid=rebind-must-update-caller-binding): an explicit
|
|
75
|
+
* `peaks workspace init --session-id <X> --allow-session-rebind` rewrites
|
|
76
|
+
* the project-global `.peaks/_runtime/session.json`. Without this call the
|
|
77
|
+
* per-caller file keeps shadowing it for `getSessionIdCanonical`, so the
|
|
78
|
+
* rebind silently did not take for every command that resolves through
|
|
79
|
+
* that variant (`peaks session checkpoint`, `peaks session 24h-mode`, ...)
|
|
80
|
+
* while `getCurrentSessionId` reported the new session.
|
|
81
|
+
*
|
|
82
|
+
* Only the binding of the caller that performed the rebind is repointed —
|
|
83
|
+
* a second caller keeps its own session, which is the multi-caller
|
|
84
|
+
* isolation the per-caller design exists for.
|
|
85
|
+
*
|
|
86
|
+
* Every other field is preserved: `createdAt` is the session creation
|
|
87
|
+
* stamp the legacy `session.json` dual-write reuses, and `skill` / `mode`
|
|
88
|
+
* / `gate` are live presence state.
|
|
89
|
+
*
|
|
90
|
+
* @returns `true` when a binding existed and was repointed, `false` when
|
|
91
|
+
* the caller had no binding file (nothing was shadowing the rebind, and
|
|
92
|
+
* we do not create one speculatively).
|
|
93
|
+
*/
|
|
94
|
+
export declare function updateCallerBindingSessionId(projectRoot: string, callerId: string, peakSessionId: string): boolean;
|
|
71
95
|
/**
|
|
72
96
|
* Enumerate the per-caller binding files under
|
|
73
97
|
* `.peaks/_runtime/callers/`. Returns the parsed bindings plus the
|
|
@@ -120,6 +120,40 @@ export function setCallerBinding(projectRoot, callerId, binding) {
|
|
|
120
120
|
};
|
|
121
121
|
atomicWriteJson(bindingPath, payload);
|
|
122
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Repoint an EXISTING per-caller binding at a new peak session id.
|
|
125
|
+
*
|
|
126
|
+
* Slice 2026-09-10 (rid=rebind-must-update-caller-binding): an explicit
|
|
127
|
+
* `peaks workspace init --session-id <X> --allow-session-rebind` rewrites
|
|
128
|
+
* the project-global `.peaks/_runtime/session.json`. Without this call the
|
|
129
|
+
* per-caller file keeps shadowing it for `getSessionIdCanonical`, so the
|
|
130
|
+
* rebind silently did not take for every command that resolves through
|
|
131
|
+
* that variant (`peaks session checkpoint`, `peaks session 24h-mode`, ...)
|
|
132
|
+
* while `getCurrentSessionId` reported the new session.
|
|
133
|
+
*
|
|
134
|
+
* Only the binding of the caller that performed the rebind is repointed —
|
|
135
|
+
* a second caller keeps its own session, which is the multi-caller
|
|
136
|
+
* isolation the per-caller design exists for.
|
|
137
|
+
*
|
|
138
|
+
* Every other field is preserved: `createdAt` is the session creation
|
|
139
|
+
* stamp the legacy `session.json` dual-write reuses, and `skill` / `mode`
|
|
140
|
+
* / `gate` are live presence state.
|
|
141
|
+
*
|
|
142
|
+
* @returns `true` when a binding existed and was repointed, `false` when
|
|
143
|
+
* the caller had no binding file (nothing was shadowing the rebind, and
|
|
144
|
+
* we do not create one speculatively).
|
|
145
|
+
*/
|
|
146
|
+
export function updateCallerBindingSessionId(projectRoot, callerId, peakSessionId) {
|
|
147
|
+
const existing = getCallerBinding(projectRoot, callerId);
|
|
148
|
+
if (existing === null)
|
|
149
|
+
return false;
|
|
150
|
+
setCallerBinding(projectRoot, callerId, {
|
|
151
|
+
...existing,
|
|
152
|
+
peakSessionId,
|
|
153
|
+
lastActivityAt: new Date().toISOString()
|
|
154
|
+
});
|
|
155
|
+
return true;
|
|
156
|
+
}
|
|
123
157
|
/**
|
|
124
158
|
* Enumerate the per-caller binding files under
|
|
125
159
|
* `.peaks/_runtime/callers/`. Returns the parsed bindings plus the
|