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.
Files changed (88) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/bin/peaks.js +71 -1
  5. package/dist/cli/cli-helpers.js +7 -0
  6. package/dist/cli/commands/_register.js +2 -0
  7. package/dist/cli/commands/best-practice-scan-command.d.ts +14 -1
  8. package/dist/cli/commands/best-practice-scan-command.js +67 -9
  9. package/dist/cli/commands/code-runtime-commands.js +21 -5
  10. package/dist/cli/commands/hooks-commands.js +10 -1
  11. package/dist/cli/commands/job-commands.js +107 -25
  12. package/dist/cli/commands/scan-commands.js +1 -1
  13. package/dist/cli/commands/web-commands.d.ts +28 -0
  14. package/dist/cli/commands/web-commands.js +327 -0
  15. package/dist/cli/commands/web-lifecycle-commands.d.ts +49 -0
  16. package/dist/cli/commands/web-lifecycle-commands.js +321 -0
  17. package/dist/services/best-practice/scan-orchestrator.d.ts +22 -0
  18. package/dist/services/best-practice/scan-orchestrator.js +14 -5
  19. package/dist/services/code/orchestrator-can-do.js +27 -4
  20. package/dist/services/context/context-audit-hint.d.ts +79 -0
  21. package/dist/services/context/context-audit-hint.js +150 -0
  22. package/dist/services/hooks/auto-compact-hook-install.js +10 -1
  23. package/dist/services/hooks/write-gate.js +88 -0
  24. package/dist/services/lint/detect-eslint.d.ts +2 -0
  25. package/dist/services/lint/detect-eslint.js +23 -9
  26. package/dist/services/lint/npx-resolver.d.ts +6 -0
  27. package/dist/services/lint/npx-resolver.js +38 -14
  28. package/dist/services/release/version-precheck-service.js +9 -2
  29. package/dist/services/scan/file-size-scan.d.ts +29 -0
  30. package/dist/services/scan/file-size-scan.js +63 -0
  31. package/dist/services/session/caller-binding-service.d.ts +24 -0
  32. package/dist/services/session/caller-binding-service.js +34 -0
  33. package/dist/services/session/getSessionDir.js +15 -10
  34. package/dist/services/skills/hooks-codegate-superpowers.d.ts +33 -0
  35. package/dist/services/skills/hooks-codegate-superpowers.js +34 -3
  36. package/dist/services/skills/hooks-settings-service.d.ts +10 -0
  37. package/dist/services/skills/hooks-settings-service.js +152 -61
  38. package/dist/services/slice/slice-check-service.d.ts +14 -0
  39. package/dist/services/slice/slice-check-service.js +110 -50
  40. package/dist/services/slice/slice-check-types.d.ts +12 -7
  41. package/dist/services/slice/slice-check-types.js +8 -3
  42. package/dist/services/slice/slice-decompose-runners.js +24 -21
  43. package/dist/services/sop/sop-check-service.js +12 -1
  44. package/dist/services/web/bounded-output.d.ts +34 -0
  45. package/dist/services/web/bounded-output.js +68 -0
  46. package/dist/services/web/browser-acquire.d.ts +14 -0
  47. package/dist/services/web/browser-acquire.js +84 -0
  48. package/dist/services/web/browser-session-manager.d.ts +111 -0
  49. package/dist/services/web/browser-session-manager.js +413 -0
  50. package/dist/services/web/daemon-entry.d.ts +1 -0
  51. package/dist/services/web/daemon-entry.js +65 -0
  52. package/dist/services/web/daemon-registry.d.ts +42 -0
  53. package/dist/services/web/daemon-registry.js +164 -0
  54. package/dist/services/web/daemon-supervisor.d.ts +144 -0
  55. package/dist/services/web/daemon-supervisor.js +455 -0
  56. package/dist/services/web/playwright-loader.d.ts +89 -0
  57. package/dist/services/web/playwright-loader.js +253 -0
  58. package/dist/services/web/snapshot-pruner.d.ts +48 -0
  59. package/dist/services/web/snapshot-pruner.js +241 -0
  60. package/dist/services/web/untrusted-envelope.d.ts +27 -0
  61. package/dist/services/web/untrusted-envelope.js +44 -0
  62. package/dist/services/web/web-artifact-paths.d.ts +79 -0
  63. package/dist/services/web/web-artifact-paths.js +163 -0
  64. package/dist/services/web/web-client.d.ts +19 -0
  65. package/dist/services/web/web-client.js +55 -0
  66. package/dist/services/web/web-daemon-service.d.ts +38 -0
  67. package/dist/services/web/web-daemon-service.js +416 -0
  68. package/dist/services/web/web-fallback.d.ts +70 -0
  69. package/dist/services/web/web-fallback.js +121 -0
  70. package/dist/services/web/web-install-service.d.ts +91 -0
  71. package/dist/services/web/web-install-service.js +346 -0
  72. package/dist/services/web/web-login-profile.d.ts +89 -0
  73. package/dist/services/web/web-login-profile.js +612 -0
  74. package/dist/services/web/web-login-staging.d.ts +27 -0
  75. package/dist/services/web/web-login-staging.js +173 -0
  76. package/dist/services/web/web-protocol.d.ts +58 -0
  77. package/dist/services/web/web-protocol.js +58 -0
  78. package/dist/services/web/web-status-report.d.ts +33 -0
  79. package/dist/services/web/web-status-report.js +47 -0
  80. package/dist/services/workspace/claude-settings-template.d.ts +41 -5
  81. package/dist/services/workspace/claude-settings-template.js +116 -64
  82. package/dist/services/workspace/workspace-claude-settings-materializer.js +5 -1
  83. package/dist/services/workspace/workspace-service.js +33 -0
  84. package/package.json +5 -5
  85. package/scripts/copy-templates.mjs +12 -0
  86. package/scripts/sync-version.mjs +20 -0
  87. package/skills/peaks-code/SKILL.md +10 -0
  88. 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
- * Resolve a CLI binary to a project-local path, falling back to
32
- * the system `npx`. pnpm (and npm/yarn) all create
33
- * `node_modules/.bin/<name>`:
34
- *
35
- * - On Unix, this is a symlink to the package's executable.
36
- * - On Windows, this is a `.cmd` shim; `execFileSync` only
37
- * resolves `.cmd` through the shell (PATHEXT), so we pass
38
- * `shell: true` when invoking one. Without this, the
39
- * Windows `npx ENOENT` false-positive from
40
- * observations 2317 + 2792 reproduces for every local
41
- * binary.
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
- * Returns the command + args + a `shell` flag that the
44
- * `runCommand` helper threads into `execFileSync`.
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
- // pnpm creates `node_modules/.bin/<name>` (symlink on Unix,
48
- // `.cmd` shim on Windows). We probe both shapes; the
49
- // `process.platform === 'win32'` extension probe is the most
50
- // portable approach.
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
- function runCommand(command, args, cwd, timeoutMs, shell = false) {
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 `node_modules/.bin/tsc`
109
- // (symlink on Unix, .cmd on Windows). The local binary is
110
- // installed by pnpm at workspace-install time and avoids the
111
- // npx PATH-lookup issue.
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
- const result = runCommand(tsc.command, [...tsc.args, '--noEmit'], projectRoot, TYPECHECK_TIMEOUT_MS, tsc.shell);
114
- const testFiles = result.stdout.match(/(tests?\/.*\.test\.ts)/g) ?? [];
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: `${tsc.command} --noEmit (no JS emit, type-only check)`,
118
- status: result.status,
119
- durationMs: result.durationMs,
120
- detail: result.status === 'pass'
121
- ? `Typecheck passed in ${result.durationMs}ms.`
122
- : tailLines(result.stdout + result.stderr, TYPECHECK_TAIL_LINES) || `tsc exited with code ${result.exitCode}.`,
123
- data: { exitCode: result.exitCode }
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
- ? `${vitest.command} run (full test suite, coverage off)`
157
- : `${vitest.command} run --changed (tests for git-changed files only, coverage off)`;
158
- const result = runCommand(vitest.command, [...vitest.args, ...vitestArgs], projectRoot, UNIT_TESTS_TIMEOUT_MS, vitest.shell);
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 (`npx tsc --noEmit`)
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
- * (`npx vitest run --changed`). Pass `--run-tests` to opt in to the
12
- * full suite (`npx vitest run`); pass `--skip-tests` to skip
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: `npx vitest run --changed` (tests for git-changed files only)
52
- * - `"full"` — opt-in via `--run-tests`: `npx vitest run` (full suite)
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** `npx vitest run` suite at the boundary.
81
+ * When true, run the **full** `vitest run` suite at the boundary.
77
82
  * When false (the default), run the **changed-only** suite
78
- * (`npx vitest run --changed`) which only exercises tests related to
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 (`npx tsc --noEmit`)
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
- * (`npx vitest run --changed`). Pass `--run-tests` to opt in to the
12
- * full suite (`npx vitest run`); pass `--skip-tests` to skip
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
- // Falls back to raw `codegraph` (no --project) if peaks is not on PATH.
93
- const isWin = process.platform === 'win32';
94
- // Try `peaks codegraph` first (the wrapper that understands --project).
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('peaks', ['codegraph', ...args], {
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 localBin = join(projectRoot, 'node_modules', '.bin', 'codegraph');
110
- const command = existsSync(localBin) ? localBin : 'npx';
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
- execFileSync(bin, args, { cwd: resolve(projectRoot), timeout: timeoutMs, stdio: 'ignore' });
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
+ }