peaks-loop 4.0.38 → 4.0.40

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 (46) hide show
  1. package/CHANGELOG.md +47 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/cli/commands/code-review-commands.js +43 -1
  5. package/dist/cli/commands/code-runtime-commands.js +16 -4
  6. package/dist/cli/commands/core/skill-command.d.ts +44 -0
  7. package/dist/cli/commands/core/skill-command.js +67 -3
  8. package/dist/cli/commands/dispatch-commands.js +42 -12
  9. package/dist/services/code/auto-compact-orchestrator.js +2 -2
  10. package/dist/services/context/auto-compact-dispatcher.js +3 -1
  11. package/dist/services/hooks/auto-compact-hook-install.d.ts +14 -1
  12. package/dist/services/hooks/auto-compact-hook-install.js +39 -15
  13. package/dist/services/lint/detect-ocr-18.d.ts +7 -1
  14. package/dist/services/lint/detect-ocr-18.js +81 -8
  15. package/dist/services/lint/ocr-18-acquire.d.ts +113 -0
  16. package/dist/services/lint/ocr-18-acquire.js +350 -0
  17. package/dist/services/lint/ocr-multilang-adapter.js +3 -0
  18. package/dist/services/web/playwright-loader.js +5 -24
  19. package/dist/services/workflow/provision-dispatch-node.d.ts +42 -0
  20. package/dist/services/workflow/provision-dispatch-node.js +66 -0
  21. package/dist/services/workspace/workspace-claude-settings-materializer.js +65 -6
  22. package/dist/shared/npm-cache.d.ts +2 -0
  23. package/dist/shared/npm-cache.js +31 -0
  24. package/package.json +5 -5
  25. package/skills/bee/peaks-perf-audit/SKILL.md +31 -3
  26. package/skills/bee/peaks-prd/SKILL.md +28 -0
  27. package/skills/bee/peaks-qa/SKILL.md +28 -0
  28. package/skills/bee/peaks-rd/SKILL.md +28 -0
  29. package/skills/bee/peaks-reviewer/SKILL.md +28 -0
  30. package/skills/bee/peaks-sc/SKILL.md +28 -0
  31. package/skills/bee/peaks-security-audit/SKILL.md +31 -3
  32. package/skills/bee/peaks-txt/SKILL.md +28 -0
  33. package/skills/bee/peaks-ui/SKILL.md +28 -0
  34. package/skills/peaks-audit/SKILL.md +28 -0
  35. package/skills/peaks-code/SKILL.md +28 -0
  36. package/skills/peaks-content/SKILL.md +28 -0
  37. package/skills/peaks-doctor/SKILL.md +28 -0
  38. package/skills/peaks-final-review/SKILL.md +28 -0
  39. package/skills/peaks-ide/SKILL.md +28 -0
  40. package/skills/peaks-issue-fix-orchestrator/SKILL.md +29 -1
  41. package/skills/peaks-resume/SKILL.md +28 -0
  42. package/skills/peaks-slice-decompose/SKILL.md +28 -0
  43. package/skills/peaks-solo/SKILL.md +28 -0
  44. package/skills/peaks-sop/SKILL.md +29 -1
  45. package/skills/peaks-status/SKILL.md +28 -0
  46. package/skills/peaks-test/SKILL.md +28 -0
@@ -2,8 +2,11 @@
2
2
  * 5-state OCR 1.8.x detect. Mirrors the ECC detect shape.
3
3
  */
4
4
  import { spawnSync } from 'node:child_process';
5
+ import { readFileSync, readdirSync } from 'node:fs';
6
+ import { dirname, join, resolve } from 'node:path';
5
7
  import { resolveNpxInvocation } from './npx-resolver.js';
6
8
  import { OCR_18_PACKAGE } from './ocr-multilang-adapter.js';
9
+ import { npmExecCacheRoots } from '../../shared/npm-cache.js';
7
10
  /** Named code for "npx itself could not be launched" — distinct from "npx is absent". */
8
11
  export const NPX_PROBE_UNRESOLVED_CODE = 'NPX_PROBE_UNRESOLVED';
9
12
  // 2026-09-10: `npx` on Windows is an `npx.cmd` shim, which Node refuses to spawn
@@ -14,7 +17,9 @@ export const NPX_PROBE_UNRESOLVED_CODE = 'NPX_PROBE_UNRESOLVED';
14
17
  // than being collapsed into "absent".
15
18
  function probeNpx() {
16
19
  const { command, args, baseEnv } = resolveNpxInvocation(['--version']);
17
- const probe = spawnSync(command, args, { encoding: 'utf8', env: baseEnv });
20
+ // `windowsHide` on every spawn (repo convention): without it this probe pops
21
+ // a console window on the user's desktop.
22
+ const probe = spawnSync(command, args, { encoding: 'utf8', windowsHide: true, env: baseEnv });
18
23
  if (probe.status === 0)
19
24
  return { available: true };
20
25
  const error = probe.error;
@@ -27,12 +32,77 @@ function probeNpx() {
27
32
  }
28
33
  return { available: false, reason: 'probe-failed', detail: `npx --version exited ${probe.status ?? 'null'}` };
29
34
  }
30
- function probeOcr18() {
31
- const { command, args, baseEnv } = resolveNpxInvocation(['--package', OCR_18_PACKAGE, '--', 'ocr', 'version']);
32
- const result = spawnSync(command, args, { encoding: 'utf8', env: baseEnv });
33
- return result.status === 0;
35
+ /** The scoped directory and exact version a `name@version` spec names. */
36
+ function splitSpec(spec) {
37
+ const at = spec.lastIndexOf('@');
38
+ return { dir: spec.slice(0, at), version: spec.slice(at + 1) };
34
39
  }
35
- export function detectOcr18() {
40
+ /** The version the `package.json` at `modulesRoot/<dir>` declares, or `null`. */
41
+ function installedVersion(modulesRoot, dir) {
42
+ try {
43
+ const parsed = JSON.parse(readFileSync(join(modulesRoot, dir, 'package.json'), 'utf8'));
44
+ return typeof parsed.version === 'string' ? parsed.version : null;
45
+ }
46
+ catch {
47
+ // Nothing installed under this root is the normal case for most of them.
48
+ return null;
49
+ }
50
+ }
51
+ function safeReaddir(dir) {
52
+ try {
53
+ return readdirSync(dir);
54
+ }
55
+ catch {
56
+ // No exec cache at this location is the normal case on a fresh machine.
57
+ return [];
58
+ }
59
+ }
60
+ /**
61
+ * Every `node_modules` tree npm consults BEFORE it installs `--package <spec>`.
62
+ *
63
+ * 2026-09-11: this probe used to run `npx --package <pkg> -- ocr version`, which
64
+ * INSTALLED the package when it was not already resolvable — a network fetch, a
65
+ * write into the npm cache, and a console window titled `npm i …` on the user's
66
+ * desktop for seconds. A probe may look; it may not fetch. Presence is decided
67
+ * the way npm decides it (`libnpmexec/lib/index.js`, npm 11.9.0, read on this
68
+ * machine): the LOCAL tree first, matched by `node.pkgid === spec.raw` (name AND
69
+ * exact version), then the `_npx` cache entry; only a double miss installs.
70
+ */
71
+ function resolvableModuleRoots(cwd) {
72
+ const roots = [];
73
+ // The local tree: npm anchors on the project root above the cwd and Node
74
+ // resolves modules by walking up, so an ancestor's `node_modules` counts too.
75
+ let dir = resolve(cwd);
76
+ for (;;) {
77
+ roots.push(join(dir, 'node_modules'));
78
+ const parent = dirname(dir);
79
+ if (parent === dir)
80
+ break;
81
+ dir = parent;
82
+ }
83
+ for (const cacheRoot of npmExecCacheRoots()) {
84
+ for (const entry of safeReaddir(cacheRoot)) {
85
+ roots.push(join(cacheRoot, entry, 'node_modules'));
86
+ }
87
+ }
88
+ return roots;
89
+ }
90
+ /**
91
+ * Whether the pinned package is already resolvable. LOOKS, never fetches: it
92
+ * spawns nothing at all, so there is no install and no window to hide. The
93
+ * version must match the pin exactly — any other version is one npm exec would
94
+ * install over, which is the write this probe exists to avoid.
95
+ */
96
+ function probeOcr18(cwd) {
97
+ const { dir, version } = splitSpec(OCR_18_PACKAGE);
98
+ return resolvableModuleRoots(cwd).some((modulesRoot) => installedVersion(modulesRoot, dir) === version);
99
+ }
100
+ /**
101
+ * `cwd` is where the local npm tree is looked for (defaults to the process
102
+ * cwd, like `runOcr18`'s own `cwd`); the npx cache roots are the machine's.
103
+ */
104
+ export function detectOcr18(options = {}) {
105
+ const cwd = options.cwd ?? process.cwd();
36
106
  const probe = probeNpx();
37
107
  if (!probe.available) {
38
108
  if (probe.reason === 'not-launchable') {
@@ -52,13 +122,16 @@ export function detectOcr18() {
52
122
  nextActions: ['Install Node.js ≥ 20 with npm to enable `npx --package`.']
53
123
  };
54
124
  }
55
- if (!probeOcr18()) {
125
+ if (!probeOcr18(cwd)) {
56
126
  return {
57
127
  state: 'ocr18-missing',
58
128
  npxAvailable: true,
59
129
  package: OCR_18_PACKAGE,
60
130
  warnings: [`could not resolve ${OCR_18_PACKAGE}`],
61
- nextActions: ['Run `npm i @alibaba-group/open-code-review@1.8.9` to install the reviewer.']
131
+ // 2026-09-11: names the explicit acquisition verb rather than an `npm i`
132
+ // for a human to hand-type. Acquiring is its own VISIBLE step; this probe
133
+ // stays read-only and never performs it (see `ocr-18-acquire.ts`).
134
+ nextActions: ['Run `peaks code-review acquire-ocr-18` to fetch the reviewer.']
62
135
  };
63
136
  }
64
137
  return {
@@ -0,0 +1,113 @@
1
+ import { type ShellProbeRunner } from '../env/shell-probe.js';
2
+ /**
3
+ * The network wait, named BEFORE it starts and never skipped silently.
4
+ *
5
+ * The size is not quoted as a figure the way `INSTALL_SIZE_WARNING` quotes
6
+ * chromium's 704 MiB: this package's download size is not measured here, and a
7
+ * guessed number in a warning is worse than no number. What the user needs to
8
+ * know before the block is that this touches the network and is one-time.
9
+ */
10
+ export declare const ACQUIRE_NETWORK_WARNING = "fetching @alibaba-group/open-code-review@1.8.9 from the npm registry (needs network, one time)";
11
+ /** Which shell the acquisition runs through, in preference order. */
12
+ export type AcquireShellKind = 'bash' | 'powershell' | 'direct';
13
+ export interface AcquireShell {
14
+ readonly kind: AcquireShellKind;
15
+ /** Absolute path to the shell, or `null` for `direct` (nothing is spawned). */
16
+ readonly path: string | null;
17
+ /**
18
+ * ALWAYS populated: which shell was chosen, and when it is not the first
19
+ * choice, why. A caller that drops this makes the choice silent again.
20
+ */
21
+ readonly note: string;
22
+ }
23
+ export interface AcquireOutcome {
24
+ readonly ok: boolean;
25
+ /** `''` on success, else `OCR18_ACQUIRE_BUSY` | `OCR18_ACQUIRE_FAILED` | `OCR18_ACQUIRE_TIMEOUT`. */
26
+ readonly code: string;
27
+ readonly message: string;
28
+ /** Which shell ran it, and whether that was the preferred one. */
29
+ readonly shell: AcquireShell;
30
+ readonly durationMs: number;
31
+ /** `[ACQUIRE_NETWORK_WARNING]` whenever this call actually spawned the installer. */
32
+ readonly warnings: readonly string[];
33
+ }
34
+ /**
35
+ * `<homedir>/.peaks/ocr/install.lock` — the OCR acquisition lock.
36
+ *
37
+ * MACHINE-GLOBAL, like the web install lock and for the same reason: what it
38
+ * guards is npm's per-user exec cache (`~/.npm/_npx`, `%LOCALAPPDATA%
39
+ * \npm-cache\_npx`), which every project and every session of this user
40
+ * shares. A per-project lock would serialize nothing.
41
+ */
42
+ export declare function ocrAcquireLockPath(): string;
43
+ /**
44
+ * Take the OCR acquire lock (O_EXCL create), reclaiming a STALE one — dead
45
+ * owner, unreadable body, or older than `ACQUIRE_LOCK_STALE_MS`. `false` when
46
+ * another process holds a live lock.
47
+ *
48
+ * Same protocol as `web-install-service.acquireInstallLock`, deliberately
49
+ * including the R15 detail that **the reclaim is a rename, not an unlink**:
50
+ * `O_EXCL` serializes the create only if the removal before it cannot be
51
+ * replayed against a fresh file — `A unlink → A create → B unlink (A's FRESH
52
+ * lock) → B create` leaves both holders. A file can only be moved once, so
53
+ * exactly one reclaimer wins and the create that follows decides.
54
+ *
55
+ * The two copies are not shared because the web module's lock is bound to its
56
+ * own artifact root and this change does not reach into it. If a third
57
+ * acquirer appears, lift the pair into a shared helper with a lock-path
58
+ * parameter rather than writing a third copy.
59
+ */
60
+ export declare function acquireOcrLock(): boolean;
61
+ /** Release the OCR acquire lock, but only when this process is its owner. */
62
+ export declare function releaseOcrLock(): void;
63
+ export interface ResolveAcquireShellOptions {
64
+ /** Override `process.platform` (tests simulate a host without Git Bash). */
65
+ readonly platform?: NodeJS.Platform;
66
+ /** Override `process.env` (the `PEAKS_GIT_BASH` pin and `SystemRoot` live here). */
67
+ readonly env?: NodeJS.ProcessEnv;
68
+ /**
69
+ * Test seam for BOTH lookups: it answers the Git Bash default paths inside
70
+ * `probeShell` and the PowerShell path below, so one injected map can
71
+ * simulate "Windows with no Git Bash" on a host that has it.
72
+ */
73
+ readonly probeFile?: (absPath: string) => boolean | Promise<boolean>;
74
+ /** Test seam forwarded to `probeShell` for its `where bash` PATH sweep. */
75
+ readonly runner?: ShellProbeRunner;
76
+ }
77
+ /**
78
+ * Resolve the shell the acquisition runs through: Git Bash → PowerShell →
79
+ * no shell. Never throws, and never returns a shell without a `note`.
80
+ */
81
+ export declare function resolveAcquireShell(options?: ResolveAcquireShellOptions): Promise<AcquireShell>;
82
+ /**
83
+ * The exact argv of the one-time acquisition. Exported so a unit test can
84
+ * assert the arguments WITHOUT spawning anything — an install is not a test's
85
+ * side effect (same rule as `installCommandLine`).
86
+ *
87
+ * `--yes` is what makes this non-interactive: without it `npx` stops to ask
88
+ * "Ok to proceed?" on a machine that has not cached the package, which is the
89
+ * hang this verb exists to avoid.
90
+ */
91
+ export declare function acquireCommandArgs(): readonly string[];
92
+ /**
93
+ * The same argv as ONE shell line, for the bash / PowerShell branches.
94
+ *
95
+ * Every token is double-quoted, and that is load-bearing rather than
96
+ * cosmetic: a bare `@alibaba-group/…` is PowerShell's array syntax. Every
97
+ * token here is a module constant built from `OCR_18_PACKAGE`, so quoting is
98
+ * sufficient — no token carries a `"`, `\`, `$` or backtick that the two
99
+ * shells would escape differently, and none is caller-supplied.
100
+ */
101
+ export declare function acquireCommandLine(): string;
102
+ export interface AcquireOcr18Options {
103
+ /** True when the caller prints a JSON envelope to stdout and must not be handed npm's. */
104
+ readonly asJson?: boolean;
105
+ /** Pre-resolved shell. Production callers omit it; tests inject one. */
106
+ readonly shell?: AcquireShell;
107
+ }
108
+ /**
109
+ * Run the acquisition once, under the lock. Returns an outcome on every path —
110
+ * a held lock, a non-zero exit, a timeout, a thrown spawn — so no caller ever
111
+ * sees an exception from here, and never a silent no-op.
112
+ */
113
+ export declare function acquireOcr18(options?: AcquireOcr18Options): Promise<AcquireOutcome>;
@@ -0,0 +1,350 @@
1
+ /**
2
+ * OCR 1.8.x acquisition — the ONE step that is allowed to install the reviewer.
3
+ *
4
+ * ## Why this exists as its own module
5
+ *
6
+ * `detect-ocr-18` is a *read-only probe*, but it used to produce its answer by
7
+ * running `npx --package <pin> -- ocr version`, which INSTALLS the package when
8
+ * it is not already cached. Dogfooding 4.0.38: a window titled `npm i @…`
9
+ * appeared on the user's desktop, opened by a command whose own help text says
10
+ * "Read-only probe". The probe no longer spawns at all (see
11
+ * `detect-ocr-18.ts`); the install it was performing as a side effect lives
12
+ * HERE, where it is explicit, named, and on the human's channel — the same
13
+ * split `peaks web status` (probe) / `peaks web install` (acquire) already
14
+ * uses, and whose discipline this module mirrors rather than reinvents.
15
+ *
16
+ * ## Visibility is the requirement, not a nicety
17
+ *
18
+ * The user's words: *"最起码可以看到进度"* — at minimum the progress must be
19
+ * visible. So the installer's stdio is INHERITED, never piped-and-dropped and
20
+ * never detached: npm's own progress lands on the terminal the caller is
21
+ * already watching. Two consequences, both deliberate:
22
+ *
23
+ * - **`windowsHide: true` is not the opposite of visibility.** It stops
24
+ * Windows from allocating a NEW console window; inherited stdio keeps
25
+ * writing to the console that already exists. The window in the bug report
26
+ * was *new*; the progress is *inherited*. `detached: true` is deliberately
27
+ * absent — a silent background install is exactly what was rejected.
28
+ * - **In `--json` mode the child's STDOUT is dropped** (`'ignore'`) so npm's
29
+ * output cannot corrupt the JSON envelope `printResult` writes to stdout,
30
+ * while its STDERR stays inherited — npm puts progress and warnings there,
31
+ * so the wait is still explained on screen. Human mode inherits both.
32
+ *
33
+ * ## Shell preference — and why this is NOT `resolveHookShell`
34
+ *
35
+ * Order: **Git Bash, then PowerShell, then a plain spawn with no shell.**
36
+ *
37
+ * Slice `4637baa8` pinned the *hook* shell to PowerShell on Windows, and it is
38
+ * still right: a hook fires on every Bash tool call, and MSYS2's bash
39
+ * force-allocates its own console window, so the hook must avoid bash. An
40
+ * ACQUISITION is the opposite case — it runs once, takes seconds, and the user
41
+ * wants to watch it.
42
+ *
43
+ * **The two preferences are not a contradiction waiting to be "reconciled" —
44
+ * they answer two different questions** ("run constantly, be invisible" vs
45
+ * "run once, be visible"). Changing `resolveHookShell` to this order would put
46
+ * a console window back on every tool call; changing this order to match the
47
+ * hook would take away the shell the user explicitly asked for
48
+ * (*"优先使用git bash没有才是powershell"*). Leave both alone.
49
+ *
50
+ * The Git Bash half is NOT re-implemented: `probeShell`
51
+ * (`src/services/env/shell-probe.ts`) already owns the lookup (the
52
+ * `PEAKS_GIT_BASH` pin, the default install paths, the `where bash` PATH
53
+ * sweep) and already refuses to fall back silently. This module adds the
54
+ * PowerShell step and keeps `probeShell`'s refusal visible: the resolution
55
+ * carries a `note` naming the shell that was used and, when it is not the
56
+ * first choice, why it is not. Silently using a different shell is how this
57
+ * confusion started.
58
+ */
59
+ import { spawnSync } from 'node:child_process';
60
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
61
+ import { homedir } from 'node:os';
62
+ import { dirname, join } from 'node:path';
63
+ import { getErrorMessage } from 'peaks-loop-shared/result';
64
+ import { probeShell } from '../env/shell-probe.js';
65
+ import { isProcessAlive } from '../web/daemon-registry.js';
66
+ import { resolveNpxInvocation } from './npx-resolver.js';
67
+ import { OCR_18_PACKAGE } from './ocr-multilang-adapter.js';
68
+ /**
69
+ * The network wait, named BEFORE it starts and never skipped silently.
70
+ *
71
+ * The size is not quoted as a figure the way `INSTALL_SIZE_WARNING` quotes
72
+ * chromium's 704 MiB: this package's download size is not measured here, and a
73
+ * guessed number in a warning is worse than no number. What the user needs to
74
+ * know before the block is that this touches the network and is one-time.
75
+ */
76
+ export const ACQUIRE_NETWORK_WARNING = `fetching ${OCR_18_PACKAGE} from the npm registry (needs network, one time)`;
77
+ /**
78
+ * A held lock older than this is reclaimed even if its owner is alive.
79
+ * Deliberately longer than `ACQUIRE_TIMEOUT_MS`, so a timed-out install still
80
+ * owns its lock while it winds down.
81
+ */
82
+ const ACQUIRE_LOCK_STALE_MS = 30 * 60_000;
83
+ /**
84
+ * A blocking `spawnSync` needs a ceiling or "never hang" is not true. The
85
+ * package is far smaller than chromium, so this is the web installer's 20 min
86
+ * cut down; the child is killed at this point and a partial fetch is recovered
87
+ * by simply running the verb again (npm exec is idempotent).
88
+ */
89
+ const ACQUIRE_TIMEOUT_MS = 10 * 60_000;
90
+ /** 0600 mirrors the web install lock's reasoning: ours, and nobody else's. */
91
+ const LOCK_FILE_MODE = 0o600;
92
+ /**
93
+ * `<homedir>/.peaks/ocr/install.lock` — the OCR acquisition lock.
94
+ *
95
+ * MACHINE-GLOBAL, like the web install lock and for the same reason: what it
96
+ * guards is npm's per-user exec cache (`~/.npm/_npx`, `%LOCALAPPDATA%
97
+ * \npm-cache\_npx`), which every project and every session of this user
98
+ * shares. A per-project lock would serialize nothing.
99
+ */
100
+ export function ocrAcquireLockPath() {
101
+ return join(homedir(), '.peaks', 'ocr', 'install.lock');
102
+ }
103
+ /**
104
+ * Take the OCR acquire lock (O_EXCL create), reclaiming a STALE one — dead
105
+ * owner, unreadable body, or older than `ACQUIRE_LOCK_STALE_MS`. `false` when
106
+ * another process holds a live lock.
107
+ *
108
+ * Same protocol as `web-install-service.acquireInstallLock`, deliberately
109
+ * including the R15 detail that **the reclaim is a rename, not an unlink**:
110
+ * `O_EXCL` serializes the create only if the removal before it cannot be
111
+ * replayed against a fresh file — `A unlink → A create → B unlink (A's FRESH
112
+ * lock) → B create` leaves both holders. A file can only be moved once, so
113
+ * exactly one reclaimer wins and the create that follows decides.
114
+ *
115
+ * The two copies are not shared because the web module's lock is bound to its
116
+ * own artifact root and this change does not reach into it. If a third
117
+ * acquirer appears, lift the pair into a shared helper with a lock-path
118
+ * parameter rather than writing a third copy.
119
+ */
120
+ export function acquireOcrLock() {
121
+ const target = ocrAcquireLockPath();
122
+ mkdirSync(dirname(target), { recursive: true });
123
+ if (tryCreateLock(target)) {
124
+ return ownsLock(target);
125
+ }
126
+ if (isLiveLock(readLock(target))) {
127
+ return false;
128
+ }
129
+ const claim = `${target}.reclaim-${String(process.pid)}`;
130
+ try {
131
+ renameSync(target, claim);
132
+ }
133
+ catch {
134
+ // Another reclaimer moved it first (ENOENT), or it cannot be moved.
135
+ return false;
136
+ }
137
+ if (isLiveLock(readLock(claim))) {
138
+ // We moved a lock a racer had just legitimately (re)created: put it back
139
+ // rather than steal a lock its owner is already installing under.
140
+ try {
141
+ renameSync(claim, target);
142
+ }
143
+ catch {
144
+ try {
145
+ unlinkSync(claim);
146
+ }
147
+ catch {
148
+ // Nothing left to clean up.
149
+ }
150
+ }
151
+ return false;
152
+ }
153
+ try {
154
+ unlinkSync(claim);
155
+ }
156
+ catch {
157
+ // Already gone; the create below is the decider.
158
+ }
159
+ return tryCreateLock(target) && ownsLock(target);
160
+ }
161
+ /** Release the OCR acquire lock, but only when this process is its owner. */
162
+ export function releaseOcrLock() {
163
+ const target = ocrAcquireLockPath();
164
+ const existing = readLock(target);
165
+ // An UNREADABLE body is not proof of ownership, so it is left alone: the
166
+ // worst case is one `ACQUIRE_LOCK_STALE_MS` wait, where unlinking a racer's
167
+ // half-written `wx` create would hand its lock to whoever asked next.
168
+ if (existing === null || existing.pid !== process.pid) {
169
+ return;
170
+ }
171
+ try {
172
+ unlinkSync(target);
173
+ }
174
+ catch {
175
+ // Already released (or never held).
176
+ }
177
+ }
178
+ function isLiveLock(body) {
179
+ return body !== null && isProcessAlive(body.pid) && Date.now() - Date.parse(body.startedAt) <= ACQUIRE_LOCK_STALE_MS;
180
+ }
181
+ function ownsLock(target) {
182
+ return readLock(target)?.pid === process.pid;
183
+ }
184
+ function tryCreateLock(target) {
185
+ const body = { pid: process.pid, startedAt: new Date().toISOString() };
186
+ try {
187
+ writeFileSync(target, JSON.stringify(body), { flag: 'wx', encoding: 'utf8', mode: LOCK_FILE_MODE });
188
+ return true;
189
+ }
190
+ catch {
191
+ // `EEXIST` (held) and any other write failure both mean "not acquired".
192
+ return false;
193
+ }
194
+ }
195
+ function readLock(target) {
196
+ try {
197
+ const parsed = JSON.parse(readFileSync(target, 'utf8'));
198
+ const { pid, startedAt } = parsed;
199
+ if (typeof pid !== 'number' || typeof startedAt !== 'string') {
200
+ return null;
201
+ }
202
+ return { pid, startedAt };
203
+ }
204
+ catch {
205
+ return null;
206
+ }
207
+ }
208
+ /**
209
+ * Resolve the shell the acquisition runs through: Git Bash → PowerShell →
210
+ * no shell. Never throws, and never returns a shell without a `note`.
211
+ */
212
+ export async function resolveAcquireShell(options = {}) {
213
+ const platform = options.platform ?? process.platform;
214
+ const env = options.env ?? process.env;
215
+ const probeFile = options.probeFile ?? existsSync;
216
+ const probe = await probeShell({
217
+ platform,
218
+ env,
219
+ probeFile,
220
+ ...(options.runner !== undefined ? { runner: options.runner } : {})
221
+ });
222
+ if (probe.available && probe.path !== null) {
223
+ return { kind: 'bash', path: probe.path, note: `bash: ${probe.path} (${probe.reason})` };
224
+ }
225
+ const powershell = await findPowershell(platform, env, probeFile);
226
+ if (powershell !== null) {
227
+ return {
228
+ kind: 'powershell',
229
+ path: powershell,
230
+ note: `PowerShell: ${powershell} — Git Bash is absent on this host (${probe.reason})`
231
+ };
232
+ }
233
+ return {
234
+ kind: 'direct',
235
+ path: null,
236
+ note: 'no shell: this host has neither Git Bash nor PowerShell, so npx is launched directly'
237
+ };
238
+ }
239
+ /**
240
+ * The Windows PowerShell that ships with the OS, or `null`.
241
+ *
242
+ * Its location is not searched for on PATH: `%SystemRoot%\System32\
243
+ * WindowsPowerShell\v1.0\powershell.exe` is where Windows has put it since
244
+ * Windows 7, and a PATH that cannot see it is a PATH problem the `direct`
245
+ * branch below already survives.
246
+ */
247
+ async function findPowershell(platform, env, probeFile) {
248
+ if (platform !== 'win32') {
249
+ return null;
250
+ }
251
+ const root = env['SystemRoot'] ?? env['windir'] ?? 'C:\\Windows';
252
+ const candidate = join(root, 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe');
253
+ return (await probeFile(candidate)) ? candidate : null;
254
+ }
255
+ /**
256
+ * The exact argv of the one-time acquisition. Exported so a unit test can
257
+ * assert the arguments WITHOUT spawning anything — an install is not a test's
258
+ * side effect (same rule as `installCommandLine`).
259
+ *
260
+ * `--yes` is what makes this non-interactive: without it `npx` stops to ask
261
+ * "Ok to proceed?" on a machine that has not cached the package, which is the
262
+ * hang this verb exists to avoid.
263
+ */
264
+ export function acquireCommandArgs() {
265
+ return ['--yes', '--package', OCR_18_PACKAGE, '--', 'ocr', 'version'];
266
+ }
267
+ /**
268
+ * The same argv as ONE shell line, for the bash / PowerShell branches.
269
+ *
270
+ * Every token is double-quoted, and that is load-bearing rather than
271
+ * cosmetic: a bare `@alibaba-group/…` is PowerShell's array syntax. Every
272
+ * token here is a module constant built from `OCR_18_PACKAGE`, so quoting is
273
+ * sufficient — no token carries a `"`, `\`, `$` or backtick that the two
274
+ * shells would escape differently, and none is caller-supplied.
275
+ */
276
+ export function acquireCommandLine() {
277
+ return ['npx', ...acquireCommandArgs()].map((token) => `"${token}"`).join(' ');
278
+ }
279
+ /**
280
+ * Run the acquisition once, under the lock. Returns an outcome on every path —
281
+ * a held lock, a non-zero exit, a timeout, a thrown spawn — so no caller ever
282
+ * sees an exception from here, and never a silent no-op.
283
+ */
284
+ export async function acquireOcr18(options = {}) {
285
+ const startedAt = Date.now();
286
+ const shell = options.shell ?? (await resolveAcquireShell());
287
+ let locked = false;
288
+ try {
289
+ locked = acquireOcrLock();
290
+ if (!locked) {
291
+ return failure('OCR18_ACQUIRE_BUSY', 'another OCR 1.8.x acquisition is already running on this machine; ' +
292
+ `the lock (${ocrAcquireLockPath()}) prevents a second download — retry once it finishes`, shell, startedAt);
293
+ }
294
+ // R2's discipline, mirrored: name the network wait BEFORE the block, on the
295
+ // channel a human reads, so the pause is never unexplained.
296
+ process.stderr.write(`peaks code-review: ${ACQUIRE_NETWORK_WARNING}\n`);
297
+ const result = runOcrAcquire(shell, options.asJson === true);
298
+ if (result.error !== undefined && result.error !== null) {
299
+ return spawnFailure(result.error, shell, startedAt);
300
+ }
301
+ if (result.status !== 0) {
302
+ return failure('OCR18_ACQUIRE_FAILED', `\`npx --package ${OCR_18_PACKAGE} -- ocr version\` exited with status ` +
303
+ `${String(result.status)} (shell: ${shell.kind})`, shell, startedAt);
304
+ }
305
+ return {
306
+ ok: true,
307
+ code: '',
308
+ message: '',
309
+ shell,
310
+ durationMs: Date.now() - startedAt,
311
+ warnings: [ACQUIRE_NETWORK_WARNING]
312
+ };
313
+ }
314
+ catch (error) {
315
+ return failure('OCR18_ACQUIRE_FAILED', getErrorMessage(error), shell, startedAt);
316
+ }
317
+ finally {
318
+ // Every path releases, including a thrown spawn — but only if THIS call
319
+ // took the lock; releasing someone else's would let a second install in.
320
+ if (locked) {
321
+ releaseOcrLock();
322
+ }
323
+ }
324
+ }
325
+ /**
326
+ * One blocking acquisition. See the module docstring for why `stdio` is the
327
+ * visibility switch and why `windowsHide` complements rather than hides it.
328
+ */
329
+ function runOcrAcquire(shell, asJson) {
330
+ const stdio = asJson ? ['ignore', 'ignore', 'inherit'] : 'inherit';
331
+ const options = { stdio, timeout: ACQUIRE_TIMEOUT_MS, windowsHide: true };
332
+ if (shell.kind === 'powershell' && shell.path !== null) {
333
+ return spawnSync(shell.path, ['-NoProfile', '-NonInteractive', '-Command', acquireCommandLine()], options);
334
+ }
335
+ if (shell.kind === 'bash' && shell.path !== null) {
336
+ return spawnSync(shell.path, ['-c', acquireCommandLine()], options);
337
+ }
338
+ // No shell at all: bypass the Windows `npx.cmd` shim the way every other
339
+ // spawn in this repo does (see `resolveNpxInvocation`). `shell: true` is NOT
340
+ // an alternative — it concatenates and splits the `--package` argv.
341
+ const invocation = resolveNpxInvocation(acquireCommandArgs());
342
+ return spawnSync(invocation.command, [...invocation.args], { ...options, env: invocation.baseEnv });
343
+ }
344
+ function spawnFailure(error, shell, startedAt) {
345
+ const code = error.code === 'ETIMEDOUT' ? 'OCR18_ACQUIRE_TIMEOUT' : 'OCR18_ACQUIRE_FAILED';
346
+ return failure(code, `the acquisition did not complete: ${error.message}`, shell, startedAt);
347
+ }
348
+ function failure(code, message, shell, startedAt) {
349
+ return { ok: false, code, message, shell, durationMs: Date.now() - startedAt, warnings: [] };
350
+ }
@@ -76,6 +76,9 @@ export function runOcr18(options) {
76
76
  const spawnOptions = {
77
77
  cwd: options.cwd,
78
78
  encoding: 'utf8',
79
+ // Repo convention: without this the child pops a console window on the
80
+ // user's desktop — and this child can run for the length of a full review.
81
+ windowsHide: true,
79
82
  timeout: options.timeoutMs ?? 60_000,
80
83
  maxBuffer: 32 * 1024 * 1024
81
84
  };
@@ -30,9 +30,9 @@
30
30
  */
31
31
  import { readdirSync, readFileSync, realpathSync, statSync } from 'node:fs';
32
32
  import { createRequire } from 'node:module';
33
- import { homedir } from 'node:os';
34
33
  import { basename, dirname, join } from 'node:path';
35
34
  import { fileURLToPath, pathToFileURL } from 'node:url';
35
+ import { npmExecCacheRoots } from '../../shared/npm-cache.js';
36
36
  import { isInsidePath } from '../../shared/path-utils.js';
37
37
  /**
38
38
  * Exact pin, no caret (tech-doc §3.2). AC2 is a BYTE-COUNT contract and the
@@ -88,7 +88,10 @@ export function resolvePlaywrightModule() {
88
88
  * exact pin exists to prevent.
89
89
  */
90
90
  function resolveFromNpxCache() {
91
- for (const cacheRoot of npxCacheRoots()) {
91
+ // The roots themselves live in `shared/npm-cache.ts` — the exec cache has
92
+ // exactly one definition, shared with the OCR probe, so the two cannot
93
+ // disagree about what "already installed" means.
94
+ for (const cacheRoot of npmExecCacheRoots()) {
92
95
  for (const entry of safeReaddir(cacheRoot)) {
93
96
  const resolved = tryResolveFrom(join(cacheRoot, entry, 'node_modules'));
94
97
  if (resolved !== null) {
@@ -98,28 +101,6 @@ function resolveFromNpxCache() {
98
101
  }
99
102
  return null;
100
103
  }
101
- /**
102
- * `<npm cache>/_npx` candidates: the per-user defaults, and nothing else.
103
- *
104
- * `npm_config_cache` / `NPM_CONFIG_CACHE` used to be taken first "when
105
- * configured". They are not configuration this module may trust: under
106
- * `npm run`, npm exports the value a repo's own `.npmrc` chose, so a committed
107
- * `.npmrc` plus a committed `_npx`-shaped tree selected the package that
108
- * `import()` then executed (security review S2, reproduced). The default roots
109
- * below are where npm actually puts an `npx` cache.
110
- */
111
- function npxCacheRoots() {
112
- const roots = [join(homedir(), '.npm', '_npx')];
113
- if (process.platform === 'win32') {
114
- for (const key of ['LOCALAPPDATA', 'APPDATA']) {
115
- const base = process.env[key];
116
- if (base !== undefined && base.length > 0) {
117
- roots.push(join(base, 'npm-cache', '_npx'));
118
- }
119
- }
120
- }
121
- return roots;
122
- }
123
104
  /**
124
105
  * Resolve `playwright` as if from `moduleRoot`, and admit it only when it
125
106
  * passes `verifyPinnedPackage` against that same root. The anchor is what