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
|
@@ -28,49 +28,75 @@ const HEAD_LINES_IN_TAIL = 3;
|
|
|
28
28
|
const AUDIT_MIN_RED_LINES = 60;
|
|
29
29
|
const REVIEW_FILE_MIN_BYTES = 20;
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* `
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
31
|
+
* 2026-09-10 D1-follow-up (G5): `tsconfig.build.json` is the type contract the
|
|
32
|
+
* package actually ships (`src/**` only) and it is clean, so the typecheck
|
|
33
|
+
* stage GATES on it. The wider `tsconfig.json` adds `tests/**`, which still
|
|
34
|
+
* carries errors this slice did not cause — 142 as measured on 2026-09-10
|
|
35
|
+
* (`pnpm exec tsc -p tsconfig.json --noEmit 2>&1 | grep -c "error TS"`), all
|
|
36
|
+
* of them under `tests/`, none under `src/`. That count is therefore a
|
|
37
|
+
* baseline, not a gate: the stage fails only when it GROWS, which is the
|
|
38
|
+
* question "did this slice add a type error anywhere?". Re-measure and update
|
|
39
|
+
* this constant when the residue is paid down.
|
|
40
|
+
*/
|
|
41
|
+
export const TYPECHECK_PREEXISTING_BASELINE = 142;
|
|
42
|
+
/**
|
|
43
|
+
* 2026-09-10 D1-follow-up (C6): each name resolves to the JS entry its package
|
|
44
|
+
* declares, as a path relative to `node_modules/` — the same files
|
|
45
|
+
* `node_modules/.bin/<name>` shims point at.
|
|
42
46
|
*
|
|
43
|
-
*
|
|
44
|
-
* `
|
|
47
|
+
* The `.bin` shim these used to spawn is a `.cmd` on Windows, and a `.cmd`
|
|
48
|
+
* cannot be spawned without `shell: true` (EINVAL, Node >= 20). `shell: true`
|
|
49
|
+
* re-parses the command line, so a project path containing a space was split
|
|
50
|
+
* at the space and the stage reported a phantom typecheck/test failure.
|
|
51
|
+
* Running the JS entry through `process.execPath` needs no shell — a spaced
|
|
52
|
+
* path is just an argument — and is the shape the D1 fix uses for `peaks`
|
|
53
|
+
* itself (`orchestrator-can-do.ts`).
|
|
54
|
+
*/
|
|
55
|
+
const LOCAL_CLI_ENTRIES = {
|
|
56
|
+
tsc: ['typescript', 'bin', 'tsc'],
|
|
57
|
+
vitest: ['vitest', 'vitest.mjs']
|
|
58
|
+
};
|
|
59
|
+
/** Named code for "the CLI this stage needs is not installed". */
|
|
60
|
+
export const BINARY_UNRESOLVED_CODE = 'SLICE_CHECK_BINARY_UNRESOLVED';
|
|
61
|
+
function localCliPath(projectRoot, name) {
|
|
62
|
+
return join(projectRoot, 'node_modules', ...LOCAL_CLI_ENTRIES[name]);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Resolve a CLI to the interpreter + JS entry `runCommand` needs, or `null`
|
|
66
|
+
* when that entry is not on disk. Never returns a shell: the returned command
|
|
67
|
+
* is always `process.execPath`.
|
|
45
68
|
*/
|
|
46
69
|
function resolveLocalBinary(projectRoot, name) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const isWin = process.platform === 'win32';
|
|
52
|
-
const candidateNames = isWin ? [`${name}.cmd`, `${name}.ps1`, `${name}`] : [name];
|
|
53
|
-
for (const candidate of candidateNames) {
|
|
54
|
-
const cmdPath = join(projectRoot, 'node_modules', '.bin', candidate);
|
|
55
|
-
if (existsSync(cmdPath)) {
|
|
56
|
-
return { command: cmdPath, args: [], shell: isWin };
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
// Fallback: system npx. On Windows this still has the ENOENT
|
|
60
|
-
// issue, but the fallback is at least informative when it
|
|
61
|
-
// fires (the user can see "npx not found" instead of a
|
|
62
|
-
// silent exit 1).
|
|
63
|
-
return { command: 'npx', args: [name], shell: false };
|
|
70
|
+
const entry = localCliPath(projectRoot, name);
|
|
71
|
+
if (!existsSync(entry))
|
|
72
|
+
return null;
|
|
73
|
+
return { command: process.execPath, args: [entry] };
|
|
64
74
|
}
|
|
65
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Failed stage for an unresolvable CLI. The previous fallback spawned `npx`
|
|
77
|
+
* and reported whatever came back — an ENOENT-shaped "exited 1" that reads
|
|
78
|
+
* exactly like a genuine typecheck/test failure. This names the code and the
|
|
79
|
+
* path that was searched instead.
|
|
80
|
+
*/
|
|
81
|
+
function unresolvedBinaryStage(stage, cli, projectRoot, durationMs) {
|
|
82
|
+
const expected = localCliPath(projectRoot, cli);
|
|
83
|
+
return {
|
|
84
|
+
name: stage,
|
|
85
|
+
description: `${cli}: binary unresolved (${BINARY_UNRESOLVED_CODE})`,
|
|
86
|
+
status: 'fail',
|
|
87
|
+
durationMs,
|
|
88
|
+
detail: `${BINARY_UNRESOLVED_CODE}: ${cli} not found at ${expected}. Install dependencies (pnpm install) so that entry exists.`,
|
|
89
|
+
data: { code: BINARY_UNRESOLVED_CODE, cli, expected }
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function runCommand(command, args, cwd, timeoutMs) {
|
|
66
93
|
const start = Date.now();
|
|
67
94
|
try {
|
|
68
95
|
const stdout = execFileSync(command, args, {
|
|
69
96
|
cwd,
|
|
70
97
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
71
98
|
timeout: timeoutMs,
|
|
72
|
-
maxBuffer: EXEC_MAX_BUFFER_BYTES
|
|
73
|
-
shell
|
|
99
|
+
maxBuffer: EXEC_MAX_BUFFER_BYTES
|
|
74
100
|
}).toString('utf8');
|
|
75
101
|
return {
|
|
76
102
|
status: 'pass',
|
|
@@ -105,22 +131,52 @@ function tailLines(text, max) {
|
|
|
105
131
|
async function runTypecheck(projectRoot) {
|
|
106
132
|
const start = Date.now();
|
|
107
133
|
// Per Windows npx ENOENT (observations 2317+2792 from
|
|
108
|
-
// 2026-06-09), prefer the project-local
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
134
|
+
// 2026-06-09), prefer the project-local tsc; it is installed by
|
|
135
|
+
// pnpm at workspace-install time and avoids the npx PATH-lookup
|
|
136
|
+
// issue. 2026-09-10 C6: that local binary is now the package's
|
|
137
|
+
// JS entry run through `process.execPath`, not the `.bin` shim.
|
|
112
138
|
const tsc = resolveLocalBinary(projectRoot, 'tsc');
|
|
113
|
-
|
|
114
|
-
|
|
139
|
+
if (tsc === null) {
|
|
140
|
+
return unresolvedBinaryStage('typecheck', 'tsc', projectRoot, Date.now() - start);
|
|
141
|
+
}
|
|
142
|
+
// 2026-09-10 G5: this stage used to run a bare `tsc --noEmit`, which picked
|
|
143
|
+
// up the DEFAULT tsconfig.json (src/** + tests/**) and its 142 pre-existing
|
|
144
|
+
// test-side errors — so the stage was permanently red and its one real
|
|
145
|
+
// signal was buried. The gate is now the build tsconfig, which covers
|
|
146
|
+
// exactly what ships.
|
|
147
|
+
const buildConfig = 'tsconfig.build.json';
|
|
148
|
+
const hasBuildConfig = existsSync(join(projectRoot, buildConfig));
|
|
149
|
+
const gate = runCommand(tsc.command, [...tsc.args, '-p', hasBuildConfig ? buildConfig : 'tsconfig.json', '--noEmit'], projectRoot, TYPECHECK_TIMEOUT_MS);
|
|
150
|
+
// Measure the wider count so the residue is surfaced rather than hidden.
|
|
151
|
+
// The baseline comparison only applies when a clean build tsconfig gives it
|
|
152
|
+
// a meaning; without one the gate above already IS this run.
|
|
153
|
+
const wide = hasBuildConfig
|
|
154
|
+
? runCommand(tsc.command, [...tsc.args, '-p', 'tsconfig.json', '--noEmit'], projectRoot, TYPECHECK_TIMEOUT_MS)
|
|
155
|
+
: gate;
|
|
156
|
+
const wideErrors = (wide.stdout + wide.stderr).match(/error TS\d+/g)?.length ?? 0;
|
|
157
|
+
const wideLabel = hasBuildConfig ? 'tsconfig.json (src/** + tests/**)' : 'tsconfig.json';
|
|
158
|
+
const regressed = hasBuildConfig && gate.status === 'pass' && wideErrors > TYPECHECK_PREEXISTING_BASELINE;
|
|
159
|
+
const reason = gate.status !== 'pass'
|
|
160
|
+
? `tsc -p ${hasBuildConfig ? buildConfig : 'tsconfig.json'} --noEmit exited ${gate.exitCode}`
|
|
161
|
+
: regressed
|
|
162
|
+
? `typecheck errors grew to ${wideErrors} (pre-existing baseline ${TYPECHECK_PREEXISTING_BASELINE})`
|
|
163
|
+
: '';
|
|
115
164
|
return {
|
|
116
165
|
name: 'typecheck',
|
|
117
|
-
description:
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
166
|
+
description: hasBuildConfig
|
|
167
|
+
? `tsc -p ${buildConfig} --noEmit (gate) + -p tsconfig.json (baseline report)`
|
|
168
|
+
: `tsc --noEmit (no JS emit, type-only check)`,
|
|
169
|
+
status: reason.length === 0 ? 'pass' : 'fail',
|
|
170
|
+
durationMs: Date.now() - start,
|
|
171
|
+
detail: reason.length === 0
|
|
172
|
+
? `${hasBuildConfig ? `${buildConfig} (src/**) clean; ` : ''}${wideLabel}: ${wideErrors} pre-existing error(s), baseline ${TYPECHECK_PREEXISTING_BASELINE} — reported, not hidden.`
|
|
173
|
+
: `${reason}. ${tailLines(gate.status !== 'pass' ? gate.stdout + gate.stderr : wide.stdout + wide.stderr, TYPECHECK_TAIL_LINES)}`,
|
|
174
|
+
data: {
|
|
175
|
+
exitCode: gate.exitCode,
|
|
176
|
+
wideTsconfigErrors: wideErrors,
|
|
177
|
+
preExistingBaseline: hasBuildConfig ? TYPECHECK_PREEXISTING_BASELINE : null,
|
|
178
|
+
...(regressed ? { regression: wideErrors - TYPECHECK_PREEXISTING_BASELINE } : {})
|
|
179
|
+
}
|
|
124
180
|
};
|
|
125
181
|
}
|
|
126
182
|
function parseVitestSummary(stdout, fallbackDuration) {
|
|
@@ -147,15 +203,19 @@ async function runUnitTests(projectRoot, runTests) {
|
|
|
147
203
|
// `tests/unit/slice-check-service.test.ts` for the regression net.
|
|
148
204
|
// Per Windows npx ENOENT (observations 2317+2792), resolve
|
|
149
205
|
// the project-local vitest binary instead of shelling out
|
|
150
|
-
// through npx.
|
|
206
|
+
// through npx. 2026-09-10 C6: that binary is the package's JS
|
|
207
|
+
// entry run through `process.execPath`, not the `.bin` shim.
|
|
151
208
|
const vitest = resolveLocalBinary(projectRoot, 'vitest');
|
|
209
|
+
if (vitest === null) {
|
|
210
|
+
return unresolvedBinaryStage('unit-tests', 'vitest', projectRoot, Date.now() - start);
|
|
211
|
+
}
|
|
152
212
|
const vitestArgs = runTests
|
|
153
213
|
? ['run', '--reporter=default', '--coverage=false']
|
|
154
214
|
: ['run', '--changed', '--reporter=default', '--coverage=false'];
|
|
155
215
|
const description = runTests
|
|
156
|
-
?
|
|
157
|
-
:
|
|
158
|
-
const result = runCommand(vitest.command, [...vitest.args, ...vitestArgs], projectRoot, UNIT_TESTS_TIMEOUT_MS
|
|
216
|
+
? `vitest run (full test suite, coverage off)`
|
|
217
|
+
: `vitest run --changed (tests for git-changed files only, coverage off)`;
|
|
218
|
+
const result = runCommand(vitest.command, [...vitest.args, ...vitestArgs], projectRoot, UNIT_TESTS_TIMEOUT_MS);
|
|
159
219
|
const summary = parseVitestSummary(result.stdout, result.durationMs);
|
|
160
220
|
// Vitest doesn't always print the per-bucket counts cleanly; infer "passed"
|
|
161
221
|
// as total - failed - skipped when failed/skipped buckets are present.
|
|
@@ -6,10 +6,15 @@
|
|
|
6
6
|
* 4 self-checks that must pass at slice end before the slice is handed
|
|
7
7
|
* off to peaks-qa:
|
|
8
8
|
*
|
|
9
|
-
* 1. typecheck
|
|
9
|
+
* 1. typecheck — the project-local TypeScript entry
|
|
10
|
+
* (`node_modules/typescript/bin/tsc`) run as `node <entry>`: no
|
|
11
|
+
* `npx`, no shell. Gates on `-p tsconfig.build.json --noEmit`; the
|
|
12
|
+
* wider `-p tsconfig.json --noEmit` count is reported against
|
|
13
|
+
* `TYPECHECK_PREEXISTING_BASELINE` and gates only if it grows.
|
|
10
14
|
* 2. unit tests — by default the **changed-only** suite
|
|
11
|
-
* (`
|
|
12
|
-
*
|
|
15
|
+
* (`vitest run --changed`, same `node <entry>` shape via
|
|
16
|
+
* `node_modules/vitest/vitest.mjs`). Pass `--run-tests` to opt in to
|
|
17
|
+
* the full suite (`vitest run`); pass `--skip-tests` to skip
|
|
13
18
|
* entirely (e.g. docs-only or config-only slices).
|
|
14
19
|
* 3. 3-way review fan-out (code-review + security-review + perf-baseline)
|
|
15
20
|
* 4. gate machinery (`peaks workflow verify-pipeline --rid <rid>`)
|
|
@@ -48,8 +53,8 @@ export type SliceCheckResult = {
|
|
|
48
53
|
stages: SliceCheckStage[];
|
|
49
54
|
/**
|
|
50
55
|
* Which unit-test mode actually ran. One of:
|
|
51
|
-
* - `"changed"` — default: `
|
|
52
|
-
* - `"full"` — opt-in via `--run-tests`: `
|
|
56
|
+
* - `"changed"` — default: `vitest run --changed` (tests for git-changed files only)
|
|
57
|
+
* - `"full"` — opt-in via `--run-tests`: `vitest run` (full suite)
|
|
53
58
|
* - `"skipped"` — opt-in via `--skip-tests` (stage not executed)
|
|
54
59
|
* - `"overridden"` — full mode + `--allow-pre-existing-failures` and the run failed;
|
|
55
60
|
* stage downgraded to `skipped` with the pre-existing-failure reason
|
|
@@ -73,9 +78,9 @@ export type SliceCheckOptions = {
|
|
|
73
78
|
*/
|
|
74
79
|
refreshFanout: boolean;
|
|
75
80
|
/**
|
|
76
|
-
* When true, run the **full** `
|
|
81
|
+
* When true, run the **full** `vitest run` suite at the boundary.
|
|
77
82
|
* When false (the default), run the **changed-only** suite
|
|
78
|
-
* (`
|
|
83
|
+
* (`vitest run --changed`) which only exercises tests related to
|
|
79
84
|
* git-changed files. The changed-only mode is the new default as of
|
|
80
85
|
* run 017 — full suite costs 30s+ on this repo; the changed-only
|
|
81
86
|
* mode costs ~1-3s in steady state and is what catches the
|
|
@@ -6,10 +6,15 @@
|
|
|
6
6
|
* 4 self-checks that must pass at slice end before the slice is handed
|
|
7
7
|
* off to peaks-qa:
|
|
8
8
|
*
|
|
9
|
-
* 1. typecheck
|
|
9
|
+
* 1. typecheck — the project-local TypeScript entry
|
|
10
|
+
* (`node_modules/typescript/bin/tsc`) run as `node <entry>`: no
|
|
11
|
+
* `npx`, no shell. Gates on `-p tsconfig.build.json --noEmit`; the
|
|
12
|
+
* wider `-p tsconfig.json --noEmit` count is reported against
|
|
13
|
+
* `TYPECHECK_PREEXISTING_BASELINE` and gates only if it grows.
|
|
10
14
|
* 2. unit tests — by default the **changed-only** suite
|
|
11
|
-
* (`
|
|
12
|
-
*
|
|
15
|
+
* (`vitest run --changed`, same `node <entry>` shape via
|
|
16
|
+
* `node_modules/vitest/vitest.mjs`). Pass `--run-tests` to opt in to
|
|
17
|
+
* the full suite (`vitest run`); pass `--skip-tests` to skip
|
|
13
18
|
* entirely (e.g. docs-only or config-only slices).
|
|
14
19
|
* 3. 3-way review fan-out (code-review + security-review + perf-baseline)
|
|
15
20
|
* 4. gate machinery (`peaks workflow verify-pipeline --rid <rid>`)
|
|
@@ -19,6 +19,12 @@
|
|
|
19
19
|
import { execFileSync } from 'node:child_process';
|
|
20
20
|
import { existsSync, readFileSync } from 'node:fs';
|
|
21
21
|
import { join, relative, dirname } from 'node:path';
|
|
22
|
+
import { resolveNpxInvocation } from '../lint/npx-resolver.js';
|
|
23
|
+
// 2026-09-10: the D1 fix (`orchestrator-can-do.ts`) resolved this tree's own CLI
|
|
24
|
+
// entry so a bare `peaks` never had to be resolved through a Windows `.cmd`
|
|
25
|
+
// shim. `runCodegraph` below needs exactly that, so it reuses the same helper
|
|
26
|
+
// rather than growing a second mechanism.
|
|
27
|
+
import { cliEntryPath, interpreterArgs } from '../web/daemon-supervisor.js';
|
|
22
28
|
export function defaultCodegraphRunner() {
|
|
23
29
|
return {
|
|
24
30
|
async query(text, projectRoot) {
|
|
@@ -88,34 +94,31 @@ export function defaultCodegraphRunner() {
|
|
|
88
94
|
};
|
|
89
95
|
}
|
|
90
96
|
function runCodegraph(args, projectRoot) {
|
|
97
|
+
const execOptions = {
|
|
98
|
+
cwd: projectRoot,
|
|
99
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
100
|
+
timeout: 60_000,
|
|
101
|
+
maxBuffer: 32 * 1024 * 1024
|
|
102
|
+
};
|
|
91
103
|
// Use `peaks codegraph` (the peaks wrapper), which adds --project support.
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
//
|
|
104
|
+
// 2026-09-10: no shell. `peaks` on Windows is a `.cmd` shim, which Node >= 20
|
|
105
|
+
// refuses to spawn without `shell: true` — and `shell: true` concatenates the
|
|
106
|
+
// argv unescaped, so `--project <projectRoot>` was split at the first space in
|
|
107
|
+
// the project path (the same defect that made `peaks slice check` report a
|
|
108
|
+
// phantom failure on such a project). Resolving this tree's own CLI entry and
|
|
109
|
+
// running it through `process.execPath` needs no shim and no shell, so a
|
|
110
|
+
// spaced `projectRoot` is just an argument again.
|
|
95
111
|
try {
|
|
96
|
-
return execFileSync(
|
|
97
|
-
cwd: projectRoot,
|
|
98
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
99
|
-
shell: isWin,
|
|
100
|
-
timeout: 60_000,
|
|
101
|
-
maxBuffer: 32 * 1024 * 1024
|
|
102
|
-
}).toString('utf8');
|
|
112
|
+
return execFileSync(process.execPath, [...interpreterArgs(cliEntryPath()), 'codegraph', ...args], execOptions).toString('utf8');
|
|
103
113
|
}
|
|
104
114
|
catch (error) {
|
|
105
115
|
const err = error;
|
|
106
116
|
if (err.code === 'ENOENT') {
|
|
107
|
-
// Fallback: raw `codegraph` (won't accept --project, drop it)
|
|
117
|
+
// Fallback: raw `codegraph` (won't accept --project, drop it), reached
|
|
118
|
+
// through the npx resolver so the local `.bin` shim is never spawned.
|
|
108
119
|
const fallbackArgs = args.filter((a) => a !== '--project' && !a.startsWith('--project='));
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
const finalArgs = command === 'npx' ? ['codegraph', ...fallbackArgs] : fallbackArgs;
|
|
112
|
-
return execFileSync(command, finalArgs, {
|
|
113
|
-
cwd: projectRoot,
|
|
114
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
115
|
-
shell: isWin,
|
|
116
|
-
timeout: 60_000,
|
|
117
|
-
maxBuffer: 32 * 1024 * 1024
|
|
118
|
-
}).toString('utf8');
|
|
120
|
+
const { command, args: npxArgs, baseEnv } = resolveNpxInvocation(['codegraph', ...fallbackArgs]);
|
|
121
|
+
return execFileSync(command, npxArgs, { ...execOptions, env: baseEnv }).toString('utf8');
|
|
119
122
|
}
|
|
120
123
|
throw error;
|
|
121
124
|
}
|
|
@@ -118,7 +118,18 @@ function evaluateCommand(projectRoot, run, expectExitZero, allowCommands, timeou
|
|
|
118
118
|
}
|
|
119
119
|
let exitCode;
|
|
120
120
|
try {
|
|
121
|
-
|
|
121
|
+
// Windows: this spawn is reachable from `peaks gate enforce` (the
|
|
122
|
+
// PreToolUse hook, which forces `allowCommands: true` below), so it runs
|
|
123
|
+
// on a per-Bash-call budget. Without `windowsHide` the child gets its own
|
|
124
|
+
// visible console window on Windows — one window per guarded Bash call.
|
|
125
|
+
// The option is inert on POSIX (libuv reads it only on Windows), which is
|
|
126
|
+
// why it is set unconditionally rather than platform-branched.
|
|
127
|
+
execFileSync(bin, args, {
|
|
128
|
+
cwd: resolve(projectRoot),
|
|
129
|
+
timeout: timeoutMs,
|
|
130
|
+
stdio: 'ignore',
|
|
131
|
+
windowsHide: true
|
|
132
|
+
});
|
|
122
133
|
exitCode = 0;
|
|
123
134
|
}
|
|
124
135
|
catch (error) {
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte-aware output caps for `peaks web` (slice S1, AC2).
|
|
3
|
+
*
|
|
4
|
+
* `capText` is the only absolute guarantee in the snapshot pipeline: the
|
|
5
|
+
* pruner (`snapshot-pruner.ts`) bounds node count and depth, but only a byte
|
|
6
|
+
* ceiling bounds the rendered string. These are module constants, not options
|
|
7
|
+
* (tech-doc §12).
|
|
8
|
+
*/
|
|
9
|
+
/** Hard ceiling for page text returned by `peaks web text`. */
|
|
10
|
+
export declare const MAX_TEXT_BYTES = 8192;
|
|
11
|
+
/** Hard ceiling for the rendered snapshot returned by `peaks web snap`. */
|
|
12
|
+
export declare const MAX_SNAP_BYTES = 4096;
|
|
13
|
+
/** Maximum number of real (non-marker) nodes emitted by the pruner. */
|
|
14
|
+
export declare const MAX_SNAP_NODES = 120;
|
|
15
|
+
/** Maximum tree depth emitted by the pruner. */
|
|
16
|
+
export declare const MAX_SNAP_DEPTH = 6;
|
|
17
|
+
export interface CappedText {
|
|
18
|
+
readonly text: string;
|
|
19
|
+
readonly truncated: boolean;
|
|
20
|
+
/** UTF-8 bytes removed from the input. `0` when nothing was truncated. */
|
|
21
|
+
readonly droppedBytes: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Cut `text` down to at most `capBytes` UTF-8 bytes.
|
|
25
|
+
*
|
|
26
|
+
* Three properties the AC2 tests pin:
|
|
27
|
+
* - the returned string's byte length is **never** greater than `capBytes`
|
|
28
|
+
* (the truncation marker is reserved out of the budget, not added on top);
|
|
29
|
+
* - a multi-byte codepoint is never split (the cut backs off the UTF-8
|
|
30
|
+
* continuation bytes), so the result is always valid UTF-8;
|
|
31
|
+
* - the cut lands on a line boundary when one exists, so the caller never
|
|
32
|
+
* gets a half-line.
|
|
33
|
+
*/
|
|
34
|
+
export declare function capText(text: string, capBytes: number): CappedText;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Byte-aware output caps for `peaks web` (slice S1, AC2).
|
|
3
|
+
*
|
|
4
|
+
* `capText` is the only absolute guarantee in the snapshot pipeline: the
|
|
5
|
+
* pruner (`snapshot-pruner.ts`) bounds node count and depth, but only a byte
|
|
6
|
+
* ceiling bounds the rendered string. These are module constants, not options
|
|
7
|
+
* (tech-doc §12).
|
|
8
|
+
*/
|
|
9
|
+
/** Hard ceiling for page text returned by `peaks web text`. */
|
|
10
|
+
export const MAX_TEXT_BYTES = 8192;
|
|
11
|
+
/** Hard ceiling for the rendered snapshot returned by `peaks web snap`. */
|
|
12
|
+
export const MAX_SNAP_BYTES = 4096;
|
|
13
|
+
/** Maximum number of real (non-marker) nodes emitted by the pruner. */
|
|
14
|
+
export const MAX_SNAP_NODES = 120;
|
|
15
|
+
/** Maximum tree depth emitted by the pruner. */
|
|
16
|
+
export const MAX_SNAP_DEPTH = 6;
|
|
17
|
+
/** The marker appended to a truncated payload. Counted INSIDE the ceiling. */
|
|
18
|
+
function truncationMarker(droppedBytes) {
|
|
19
|
+
return `\n…[truncated ${droppedBytes} bytes]`;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Cut `text` down to at most `capBytes` UTF-8 bytes.
|
|
23
|
+
*
|
|
24
|
+
* Three properties the AC2 tests pin:
|
|
25
|
+
* - the returned string's byte length is **never** greater than `capBytes`
|
|
26
|
+
* (the truncation marker is reserved out of the budget, not added on top);
|
|
27
|
+
* - a multi-byte codepoint is never split (the cut backs off the UTF-8
|
|
28
|
+
* continuation bytes), so the result is always valid UTF-8;
|
|
29
|
+
* - the cut lands on a line boundary when one exists, so the caller never
|
|
30
|
+
* gets a half-line.
|
|
31
|
+
*/
|
|
32
|
+
export function capText(text, capBytes) {
|
|
33
|
+
const total = Buffer.byteLength(text, 'utf8');
|
|
34
|
+
if (total <= capBytes) {
|
|
35
|
+
return { text, truncated: false, droppedBytes: 0 };
|
|
36
|
+
}
|
|
37
|
+
// Reserve the WIDEST possible marker (the one whose byte count equals the
|
|
38
|
+
// whole input) so the ceiling holds on the first pass: the reported marker
|
|
39
|
+
// can only be narrower than the reserved one.
|
|
40
|
+
const reservedMarkerBytes = Buffer.byteLength(truncationMarker(total), 'utf8');
|
|
41
|
+
if (capBytes < reservedMarkerBytes) {
|
|
42
|
+
// The marker alone would blow the ceiling. The invariant is unconditional,
|
|
43
|
+
// so for a cap too narrow to hold it the whole payload goes.
|
|
44
|
+
return { text: '', truncated: true, droppedBytes: total };
|
|
45
|
+
}
|
|
46
|
+
const kept = cutAtLineBoundary(text, capBytes - reservedMarkerBytes);
|
|
47
|
+
const droppedBytes = total - Buffer.byteLength(kept, 'utf8');
|
|
48
|
+
return { text: kept + truncationMarker(droppedBytes), truncated: true, droppedBytes };
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Largest valid-UTF-8 byte prefix of `text` at or below `budget`, backed off
|
|
52
|
+
* to the last line break. `text` without any newline keeps the raw byte cut.
|
|
53
|
+
*/
|
|
54
|
+
function cutAtLineBoundary(text, budget) {
|
|
55
|
+
if (budget <= 0) {
|
|
56
|
+
return '';
|
|
57
|
+
}
|
|
58
|
+
const bytes = Buffer.from(text, 'utf8');
|
|
59
|
+
let end = Math.min(budget, bytes.length);
|
|
60
|
+
// A continuation byte (0b10xxxxxx) at the cut point means the codepoint that
|
|
61
|
+
// started earlier would be split, so walk back until it is not one.
|
|
62
|
+
while (end > 0 && ((bytes[end] ?? 0) & 0xc0) === 0x80) {
|
|
63
|
+
end -= 1;
|
|
64
|
+
}
|
|
65
|
+
const prefix = bytes.subarray(0, end).toString('utf8');
|
|
66
|
+
const lastBreak = prefix.lastIndexOf('\n');
|
|
67
|
+
return lastBreak > 0 ? prefix.slice(0, lastBreak) : prefix;
|
|
68
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type PwBrowser } from './playwright-loader.js';
|
|
2
|
+
export interface AcquiredChromium {
|
|
3
|
+
readonly browser: PwBrowser;
|
|
4
|
+
readonly version: string;
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* The ordered gate: disable → resolve → cache probe → launch-or-refuse.
|
|
8
|
+
*
|
|
9
|
+
* Every exit here is off the download path, by design — see the module
|
|
10
|
+
* docstring. The `CODE: detail` shape is `web-daemon-service`'s
|
|
11
|
+
* `failureResponse` convention, so the caller gets `WEB_INSTALL_REQUIRED` and
|
|
12
|
+
* not a collapsed `WEB_OP_FAILED`.
|
|
13
|
+
*/
|
|
14
|
+
export declare function acquireChromium(): Promise<AcquiredChromium>;
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Acquire the chromium browser (slice S1, file 10; S3 adds the ordered gate).
|
|
3
|
+
*
|
|
4
|
+
* The order is load-bearing and is the whole of AC5's first half (tech-doc §5.1):
|
|
5
|
+
*
|
|
6
|
+
* 1. `PEAKS_WEB_DISABLED=1` → refuse, BEFORE any resolve, any lock file and
|
|
7
|
+
* any cache touch. A gate evaluated after a spawn is not a gate.
|
|
8
|
+
* 2. resolve the pinned Playwright (no npx, no shell — `playwright-loader`).
|
|
9
|
+
* 3. probe the cache. This is spawn-free and download-free (R6): it answers
|
|
10
|
+
* about the artifact `launchOnce` actually starts — the headless shell —
|
|
11
|
+
* so "is an install needed" never costs 700 MB to ask, and never answers
|
|
12
|
+
* about a browser this code will not launch.
|
|
13
|
+
* 4. launch, or refuse with `WEB_INSTALL_REQUIRED`.
|
|
14
|
+
*
|
|
15
|
+
* **Step 4 is a refusal, not a download** (R3). Installing here meant a blocking
|
|
16
|
+
* `spawnSync` on the daemon's request loop for up to 20 minutes: `/health`
|
|
17
|
+
* starved, `status` reported the daemon `orphaned`, `stop` could not prove
|
|
18
|
+
* ownership and left it running, and the CLI gave up at 30 s and called the op
|
|
19
|
+
* failed while the download carried on invisibly. The download now belongs to
|
|
20
|
+
* `peaks web install`, which runs in the caller's own process, on the caller's
|
|
21
|
+
* terminal, and prints its size before it blocks. The daemon answers the op with
|
|
22
|
+
* the tier-3 envelope instead.
|
|
23
|
+
*
|
|
24
|
+
* We never delete anything in the Playwright cache; recovery is delegated to
|
|
25
|
+
* Playwright's own `install --force` (design §10.1).
|
|
26
|
+
*/
|
|
27
|
+
import { getErrorMessage } from 'peaks-loop-shared/result';
|
|
28
|
+
import { loadPlaywright } from './playwright-loader.js';
|
|
29
|
+
import { installCommandLine, isWebDisabled, probeBrowserInstalled } from './web-install-service.js';
|
|
30
|
+
/** The message Playwright raises when the browser binary was never downloaded. */
|
|
31
|
+
const MISSING_EXECUTABLE_RE = /Executable doesn't exist/i;
|
|
32
|
+
/**
|
|
33
|
+
* The ordered gate: disable → resolve → cache probe → launch-or-refuse.
|
|
34
|
+
*
|
|
35
|
+
* Every exit here is off the download path, by design — see the module
|
|
36
|
+
* docstring. The `CODE: detail` shape is `web-daemon-service`'s
|
|
37
|
+
* `failureResponse` convention, so the caller gets `WEB_INSTALL_REQUIRED` and
|
|
38
|
+
* not a collapsed `WEB_OP_FAILED`.
|
|
39
|
+
*/
|
|
40
|
+
export async function acquireChromium() {
|
|
41
|
+
assertWebEnabled();
|
|
42
|
+
const playwright = await loadPlaywright();
|
|
43
|
+
const probe = await probeBrowserInstalled();
|
|
44
|
+
if (!probe.installed) {
|
|
45
|
+
throw new Error(`WEB_INSTALL_REQUIRED: ${installRequiredDetail(probe.version)}`);
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
return await launchOnce(playwright);
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
if (!MISSING_EXECUTABLE_RE.test(getErrorMessage(error))) {
|
|
52
|
+
throw error;
|
|
53
|
+
}
|
|
54
|
+
// The probe named an existing executable that launch still would not take —
|
|
55
|
+
// a half-written or corrupt revision. `--force` is the documented recovery
|
|
56
|
+
// (R6), and the caller has to run it: this process must not download.
|
|
57
|
+
throw new Error(`WEB_INSTALL_REQUIRED: --force ${installRequiredDetail(probe.version)}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/** One sentence, shared by both refusal paths, naming the command that fixes it. */
|
|
61
|
+
function installRequiredDetail(version) {
|
|
62
|
+
const held = version === null ? 'the pinned Playwright is not resolvable' : `playwright@${version} is cached`;
|
|
63
|
+
return (`${held} but its browser is not launched-ready; run \`peaks web install\` ` +
|
|
64
|
+
`(\`npx ${installCommandLine().join(' ')}\`)`);
|
|
65
|
+
}
|
|
66
|
+
/** Step 1. `PEAKS_WEB_DISABLED=1` means no browser, no spawn, no cache touch. */
|
|
67
|
+
function assertWebEnabled() {
|
|
68
|
+
if (isWebDisabled(process.env)) {
|
|
69
|
+
throw new Error('WEB_DISABLED: PEAKS_WEB_DISABLED=1 — the local browser path is switched off for this process');
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Launch with no options: `headless` defaults true and Playwright resolves
|
|
74
|
+
* `chromium-headless-shell` (`registry.getExecutableName`). That is deliberate —
|
|
75
|
+
* the full `chromium` build under new-headless mode was measured on this machine
|
|
76
|
+
* at **15 101 ms to `close()`**, against **102 ms** for the shell, and S2's
|
|
77
|
+
* teardown has to finish inside `STOP_EXIT_TIMEOUT_MS = 10 s` or the daemon is
|
|
78
|
+
* SIGTERM'd mid-teardown and its browser is orphaned (AC6). `probeBrowserInstalled`
|
|
79
|
+
* is what had to change, not this.
|
|
80
|
+
*/
|
|
81
|
+
async function launchOnce(playwright) {
|
|
82
|
+
const browser = await playwright.chromium.launch();
|
|
83
|
+
return { browser, version: browser.version() };
|
|
84
|
+
}
|