peaks-loop 4.0.37 → 4.0.39

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 (55) hide show
  1. package/CHANGELOG.md +37 -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/cli/commands/hooks-commands.js +31 -6
  10. package/dist/services/code/auto-compact-orchestrator.js +2 -2
  11. package/dist/services/context/auto-compact-dispatcher.js +3 -1
  12. package/dist/services/context/build-dispatch-system-prompt.d.ts +35 -1
  13. package/dist/services/context/build-dispatch-system-prompt.js +55 -3
  14. package/dist/services/hooks/auto-compact-hook-install.d.ts +14 -1
  15. package/dist/services/hooks/auto-compact-hook-install.js +39 -15
  16. package/dist/services/lint/detect-ocr-18.d.ts +9 -1
  17. package/dist/services/lint/detect-ocr-18.js +114 -10
  18. package/dist/services/lint/ocr-18-acquire.d.ts +113 -0
  19. package/dist/services/lint/ocr-18-acquire.js +350 -0
  20. package/dist/services/lint/ocr-multilang-adapter.js +12 -2
  21. package/dist/services/skills/hooks-codegate-superpowers.d.ts +41 -0
  22. package/dist/services/skills/hooks-codegate-superpowers.js +95 -0
  23. package/dist/services/skills/hooks-settings-service.d.ts +16 -0
  24. package/dist/services/skills/hooks-settings-service.js +46 -13
  25. package/dist/services/web/playwright-loader.js +5 -24
  26. package/dist/services/workflow/provision-dispatch-node.d.ts +42 -0
  27. package/dist/services/workflow/provision-dispatch-node.js +66 -0
  28. package/dist/services/workspace/claude-settings-template.d.ts +19 -3
  29. package/dist/services/workspace/claude-settings-template.js +25 -5
  30. package/dist/services/workspace/workspace-claude-settings-materializer.js +111 -29
  31. package/dist/shared/npm-cache.d.ts +2 -0
  32. package/dist/shared/npm-cache.js +31 -0
  33. package/package.json +5 -5
  34. package/skills/bee/peaks-perf-audit/SKILL.md +24 -0
  35. package/skills/bee/peaks-prd/SKILL.md +24 -0
  36. package/skills/bee/peaks-qa/SKILL.md +24 -0
  37. package/skills/bee/peaks-rd/SKILL.md +24 -0
  38. package/skills/bee/peaks-reviewer/SKILL.md +24 -0
  39. package/skills/bee/peaks-sc/SKILL.md +24 -0
  40. package/skills/bee/peaks-security-audit/SKILL.md +24 -0
  41. package/skills/bee/peaks-txt/SKILL.md +24 -0
  42. package/skills/bee/peaks-ui/SKILL.md +24 -0
  43. package/skills/peaks-audit/SKILL.md +24 -0
  44. package/skills/peaks-code/SKILL.md +24 -0
  45. package/skills/peaks-content/SKILL.md +24 -0
  46. package/skills/peaks-doctor/SKILL.md +24 -0
  47. package/skills/peaks-final-review/SKILL.md +24 -0
  48. package/skills/peaks-ide/SKILL.md +24 -0
  49. package/skills/peaks-issue-fix-orchestrator/SKILL.md +24 -0
  50. package/skills/peaks-resume/SKILL.md +24 -0
  51. package/skills/peaks-slice-decompose/SKILL.md +24 -0
  52. package/skills/peaks-solo/SKILL.md +24 -0
  53. package/skills/peaks-sop/SKILL.md +24 -0
  54. package/skills/peaks-status/SKILL.md +24 -0
  55. package/skills/peaks-test/SKILL.md +24 -0
@@ -93,6 +93,47 @@ export const REPORT_CAP_BLOCK = `## Final report cap (mandatory)
93
93
 
94
94
  Your FINAL report to the parent MUST be ≤ 40 lines and ≤ 2 KB. Write any longer detail into the artifact file you already own — the parent can \`Read\` that file for the full detail, so nothing is lost. The report itself MUST still carry: changed files (one line each), the exact commands you ran, pass/fail counts, tsc status, and any blocker. Do NOT paste file contents, full tool output, or logs into the report.
95
95
  `;
96
+ /**
97
+ * Slice 2026-09-10-fact-force-gate-adaptation: stand IN FRONT of an external
98
+ * `PreToolUse` gate instead of explaining its denial after the fact.
99
+ *
100
+ * ECC (a third-party plugin under `~/.claude/plugins/`) registers
101
+ * `gateguard-fact-force.js` on `Edit|Write|MultiEdit`. It denies the first
102
+ * edit of a file whose facts the agent has not established, and its four-item
103
+ * message never says two things the agent needs: that the file must be READ
104
+ * first, and that the edit was NOT applied. Measured failure mode
105
+ * (session 2026-09-10-session-528a63): the sub-agent reads the denial as
106
+ * "the tool is broken" and abandons the edit.
107
+ *
108
+ * So the block below leads with the STANDING RULE (read before you edit) and
109
+ * names the paths it covers, and mentions the denial only as the consequence
110
+ * of skipping that rule. A sub-agent that has never seen the gate can read
111
+ * this once and never trip it.
112
+ *
113
+ * The gate itself is untouched: peaks-loop adapts to it, and does NOT disable,
114
+ * bypass, or re-implement it. Nor is `ECC_GATEGUARD=off` part of this.
115
+ *
116
+ * Always rendered — no opt-out flag and no role split. Every role edits files
117
+ * outside `.peaks/**`, and only sub-agent #1 of a session sees a denial (the
118
+ * gate fires once per file), so a role-scoped or opt-in block would leave the
119
+ * rest of the fleet untold. It joins the stable boilerplate prefix: constant
120
+ * bytes for every dispatch, prompt-cache friendly.
121
+ *
122
+ * The `.peaks/**` exemption is real and pre-dates this slice — peaks-loop
123
+ * materialises `.claude/settings.local.json` so the gate skips `.peaks/**`
124
+ * (slice 2.0.1-bug3-fact-forcing-bypass; see
125
+ * `src/cli/commands/workspace/init-command.ts`).
126
+ */
127
+ export const FACT_FORCE_GATE_BLOCK = `## Read before you edit (Fact-Forcing Gate)
128
+
129
+ Read a file BEFORE your first \`Edit\` / \`Write\` / \`MultiEdit\` on it — the normal way to work here, not an optional step. It applies to every path OUTSIDE \`.peaks/**\` (source, tests, docs, config); \`.peaks/**\` writes are exempt.
130
+
131
+ Skipping that read trips a \`PreToolUse\` plugin gate (ECC's "Fact-Forcing Gate"), which denies the edit. A denial is NOT a failure and the tool is NOT broken — your edit was NOT applied. Read the file, state the four facts the gate asks for (importers, affected API, data schemas if any, the user's verbatim instruction), then retry the same operation. Do not switch tools, do not give up, do not re-attempt blindly.
132
+ `;
133
+ /** Always-on renderer for {@link FACT_FORCE_GATE_BLOCK}. */
134
+ export function renderFactForceGateBlock() {
135
+ return `${FACT_FORCE_GATE_BLOCK}\n`;
136
+ }
96
137
  /**
97
138
  * Compose the system-prompt body for a sub-agent dispatch.
98
139
  *
@@ -104,7 +145,7 @@ Your FINAL report to the parent MUST be ≤ 40 lines and ≤ 2 KB. Write any lon
104
145
  * Byte-identical degradation contract (slice 2026-07-22-orchestrator-memory-preflight
105
146
  * controller brief): when the memory block is unavailable, the composed body is
106
147
  * exactly `formatTestToolDetection() + "\n\n" + L1 + "\n" + LIFECYCLE +
107
- * "\n" + REPORT_CAP + "\n" + contextBlock + taskBody`, so the unavailable
148
+ * "\n" + REPORT_CAP + "\n" + FACT_FORCE_GATE + "\n" + contextBlock + taskBody`, so the unavailable
108
149
  * branch MUST return `taskBody` unwrapped (NOT a `# title\n\n` wrap).
109
150
  * (REPORT_CAP joined the stable prefix in slice
110
151
  * 2026-09-10-context-audit-and-discipline, Slice C.)
@@ -128,15 +169,17 @@ export function buildDispatchSystemPrompt(input) {
128
169
  // for every role — the composer owns the injection so callers MUST NOT
129
170
  // prepend `formatTestToolDetection()` themselves (double injection).
130
171
  const testToolText = `${formatTestToolDetection()}\n\n`;
172
+ // 2026-09-10-fact-force-gate-adaptation: always-on, both branches.
173
+ const factForceGateText = renderFactForceGateBlock();
131
174
  const contextBlock = renderContextBlock(contextProbe ?? null);
132
175
  const codegraphText = renderCodegraphBlock(codegraphBlock);
133
176
  const projectStackText = renderProjectStackBlock(projectStackBlock);
134
177
  const freshContextText = renderFreshContextBlock(freshContextBlock);
135
178
  const capsuleText = renderCapsulePointer(capsule);
136
179
  if (memoryBlock.available === true && typeof memoryBlock.block === 'string') {
137
- return `${testToolText}${L1_WORKTREE_GOVERNANCE_BLOCK}\n${LIFECYCLE_RULES}\n${REPORT_CAP_BLOCK}\n${contextBlock}${codegraphText}${projectStackText}${freshContextText}${capsuleText}${memoryBlock.block}\n## Task\n${taskBody}`;
180
+ return `${testToolText}${L1_WORKTREE_GOVERNANCE_BLOCK}\n${LIFECYCLE_RULES}\n${REPORT_CAP_BLOCK}\n${factForceGateText}${contextBlock}${codegraphText}${projectStackText}${freshContextText}${capsuleText}${memoryBlock.block}\n## Task\n${taskBody}`;
138
181
  }
139
- return `${testToolText}${L1_WORKTREE_GOVERNANCE_BLOCK}\n${LIFECYCLE_RULES}\n${REPORT_CAP_BLOCK}\n${contextBlock}${codegraphText}${projectStackText}${freshContextText}${capsuleText}${taskBody}`;
182
+ return `${testToolText}${L1_WORKTREE_GOVERNANCE_BLOCK}\n${LIFECYCLE_RULES}\n${REPORT_CAP_BLOCK}\n${factForceGateText}${contextBlock}${codegraphText}${projectStackText}${freshContextText}${capsuleText}${taskBody}`;
140
183
  }
141
184
  /**
142
185
  * Slice 2026-09-10-dispatch-token-and-swarm §4 — session capsule pointer.
@@ -294,6 +337,15 @@ export const BINDING_RULE_TOKENS = [
294
337
  'changed files (one line each)',
295
338
  'pass/fail counts',
296
339
  'tsc status',
340
+ // fact-forcing gate (slice 2026-09-10-fact-force-gate-adaptation)
341
+ '## Read before you edit (Fact-Forcing Gate)',
342
+ 'Read a file BEFORE your first `Edit` / `Write` / `MultiEdit` on it',
343
+ 'every path OUTSIDE `.peaks/**`',
344
+ '`PreToolUse` plugin gate',
345
+ 'A denial is NOT a failure and the tool is NOT broken',
346
+ 'your edit was NOT applied',
347
+ 'retry the same operation',
348
+ 'do not re-attempt blindly',
297
349
  // test scope — ONE unified block, byte-identical for EVERY role
298
350
  '## Test Tool Detection (mandatory)',
299
351
  '`package.json#scripts.test`',
@@ -41,12 +41,22 @@ export declare const AUTO_COMPACT_HOOK_MATCHER = "Bash|Task";
41
41
  * heavy lifting (ratio probe + in-band `claude --compact` spawn)
42
42
  * lives in the CLI surface, not inlined into the hook.
43
43
  *
44
+ * `--project .` is LOAD-BEARING, not decoration. `--project` is a
45
+ * `.requiredOption` on `peaks code auto-compact`, and a hook has no way
46
+ * to prompt for a missing required option. Without this argument the
47
+ * hook exited with `error: required option '--project <path>' not
48
+ * specified` on EVERY matching tool call — a non-blocking failure, so
49
+ * the hook looked installed while the zero-human-intervention
50
+ * auto-compact contract never once fired. `.` is promoted to the git
51
+ * root by `resolveCanonicalProjectRoot`, the same convention the
52
+ * `post-compact-detect` hook already documents in its own option help.
53
+ *
44
54
  * `npx peaks` is deliberately omitted: the project-local install
45
55
  * puts `peaks` on PATH inside the consumer repo (postinstall step),
46
56
  * so the unqualified command works in the same shell the runner
47
57
  * is using.
48
58
  */
49
- export declare const AUTO_COMPACT_HOOK_COMMAND = "peaks code auto-compact";
59
+ export declare const AUTO_COMPACT_HOOK_COMMAND = "peaks code auto-compact --project .";
50
60
  /**
51
61
  * Magic comment marker that fences the auto-compatchook block.
52
62
  * Both install (writes the marker) and remove (matches on it) use
@@ -68,6 +78,9 @@ export type AutoCompactHookInstallResult = {
68
78
  } | {
69
79
  readonly action: 'already-installed';
70
80
  readonly settingsPath: string;
81
+ } | {
82
+ readonly action: 'updated';
83
+ readonly settingsPath: string;
71
84
  };
72
85
  /**
73
86
  * Result envelope for `removeAutoCompactHook`.
@@ -44,12 +44,22 @@ export const AUTO_COMPACT_HOOK_MATCHER = 'Bash|Task';
44
44
  * heavy lifting (ratio probe + in-band `claude --compact` spawn)
45
45
  * lives in the CLI surface, not inlined into the hook.
46
46
  *
47
+ * `--project .` is LOAD-BEARING, not decoration. `--project` is a
48
+ * `.requiredOption` on `peaks code auto-compact`, and a hook has no way
49
+ * to prompt for a missing required option. Without this argument the
50
+ * hook exited with `error: required option '--project <path>' not
51
+ * specified` on EVERY matching tool call — a non-blocking failure, so
52
+ * the hook looked installed while the zero-human-intervention
53
+ * auto-compact contract never once fired. `.` is promoted to the git
54
+ * root by `resolveCanonicalProjectRoot`, the same convention the
55
+ * `post-compact-detect` hook already documents in its own option help.
56
+ *
47
57
  * `npx peaks` is deliberately omitted: the project-local install
48
58
  * puts `peaks` on PATH inside the consumer repo (postinstall step),
49
59
  * so the unqualified command works in the same shell the runner
50
60
  * is using.
51
61
  */
52
- export const AUTO_COMPACT_HOOK_COMMAND = 'peaks code auto-compact';
62
+ export const AUTO_COMPACT_HOOK_COMMAND = 'peaks code auto-compact --project .';
53
63
  /**
54
64
  * Magic comment marker that fences the auto-compatchook block.
55
65
  * Both install (writes the marker) and remove (matches on it) use
@@ -102,10 +112,6 @@ export function installAutoCompactHook(input) {
102
112
  const settings = readSettings(settingsPath);
103
113
  const hooks = settings.hooks ?? {};
104
114
  const preToolUse = hooks.PreToolUse ?? [];
105
- const alreadyInstalled = preToolUse.some(isAutoCompactEntry);
106
- if (alreadyInstalled) {
107
- return { action: 'already-installed', settingsPath };
108
- }
109
115
  // The matcher is `Bash|Task`, so on Windows this runs on the same
110
116
  // Git-Bash / MSYS2 shell-form path as the other peaks Bash hooks, which
111
117
  // force-allocates a console window on every matching tool call. The
@@ -113,18 +119,36 @@ export function installAutoCompactHook(input) {
113
119
  // `.claude/settings.local.json`, so a machine-specific `shell` cannot
114
120
  // leak into a shared file. `undefined` on POSIX omits the key entirely.
115
121
  const shell = resolveHookShell();
122
+ const desiredHook = {
123
+ type: 'command',
124
+ command: AUTO_COMPACT_HOOK_COMMAND,
125
+ ...(shell !== undefined ? { shell } : {})
126
+ };
127
+ const existing = preToolUse.find(isAutoCompactEntry);
128
+ // "Already installed" must mean installed CORRECTLY.
129
+ //
130
+ // Keying only off the matcher is how a corrected command never reaches
131
+ // an existing install: the entry is present, so the installer returns
132
+ // early and the stale string stays in the settings file forever. That
133
+ // is not hypothetical — it is exactly how the missing `--project`
134
+ // survived on every machine that had already installed the hook, and
135
+ // re-running the installer could not repair any of them.
136
+ if (existing !== undefined) {
137
+ if (existing.hooks?.[0]?.command === AUTO_COMPACT_HOOK_COMMAND) {
138
+ return { action: 'already-installed', settingsPath };
139
+ }
140
+ const migrated = preToolUse.map((entry) => isAutoCompactEntry(entry)
141
+ ? { ...entry, matcher: AUTO_COMPACT_HOOK_MATCHER, hooks: [desiredHook] }
142
+ : entry);
143
+ writeSettings(settingsPath, {
144
+ ...settings,
145
+ hooks: { ...hooks, PreToolUse: migrated }
146
+ });
147
+ return { action: 'updated', settingsPath };
148
+ }
116
149
  const nextPreToolUse = [
117
150
  ...preToolUse,
118
- {
119
- matcher: AUTO_COMPACT_HOOK_MATCHER,
120
- hooks: [
121
- {
122
- type: 'command',
123
- command: AUTO_COMPACT_HOOK_COMMAND,
124
- ...(shell !== undefined ? { shell } : {})
125
- }
126
- ]
127
- }
151
+ { matcher: AUTO_COMPACT_HOOK_MATCHER, hooks: [desiredHook] }
128
152
  ];
129
153
  writeSettings(settingsPath, {
130
154
  ...settings,
@@ -7,4 +7,12 @@ export type Ocr18DetectResult = {
7
7
  readonly warnings: readonly string[];
8
8
  readonly nextActions: readonly string[];
9
9
  };
10
- export declare function detectOcr18(): Ocr18DetectResult;
10
+ /** Named code for "npx itself could not be launched" — distinct from "npx is absent". */
11
+ export declare const NPX_PROBE_UNRESOLVED_CODE = "NPX_PROBE_UNRESOLVED";
12
+ /**
13
+ * `cwd` is where the local npm tree is looked for (defaults to the process
14
+ * cwd, like `runOcr18`'s own `cwd`); the npx cache roots are the machine's.
15
+ */
16
+ export declare function detectOcr18(options?: {
17
+ readonly cwd?: string;
18
+ }): Ocr18DetectResult;
@@ -2,32 +2,136 @@
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';
7
+ import { resolveNpxInvocation } from './npx-resolver.js';
5
8
  import { OCR_18_PACKAGE } from './ocr-multilang-adapter.js';
9
+ import { npmExecCacheRoots } from '../../shared/npm-cache.js';
10
+ /** Named code for "npx itself could not be launched" — distinct from "npx is absent". */
11
+ export const NPX_PROBE_UNRESOLVED_CODE = 'NPX_PROBE_UNRESOLVED';
12
+ // 2026-09-10: `npx` on Windows is an `npx.cmd` shim, which Node refuses to spawn
13
+ // without `shell: true` — a bare `spawnSync('npx', …)` failed with ENOENT and was
14
+ // then reported as "npx is not on PATH" on machines where `npx --version` exits 0.
15
+ // The shim is bypassed through `resolveNpxInvocation` (same helper as
16
+ // `detect-eslint.ts`), and a launch failure is reported as its OWN reason rather
17
+ // than being collapsed into "absent".
6
18
  function probeNpx() {
7
- const probe = spawnSync('npx', ['--version'], { encoding: 'utf8' });
8
- return probe.status === 0;
19
+ const { command, args, baseEnv } = resolveNpxInvocation(['--version']);
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 });
23
+ if (probe.status === 0)
24
+ return { available: true };
25
+ const error = probe.error;
26
+ if (error !== undefined && error !== null) {
27
+ // `command !== 'npx'` ⇒ the resolver located a real npx CLI entry and it STILL
28
+ // could not be launched — a different failure from "npx is not on PATH".
29
+ return command !== 'npx'
30
+ ? { available: false, reason: 'not-launchable', detail: error.message }
31
+ : { available: false, reason: 'not-on-path', detail: error.message };
32
+ }
33
+ return { available: false, reason: 'probe-failed', detail: `npx --version exited ${probe.status ?? 'null'}` };
9
34
  }
10
- function probeOcr18() {
11
- const result = spawnSync('npx', ['--package', OCR_18_PACKAGE, '--', 'ocr', 'version'], { encoding: 'utf8' });
12
- 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) };
13
39
  }
14
- export function detectOcr18() {
15
- if (!probeNpx()) {
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();
106
+ const probe = probeNpx();
107
+ if (!probe.available) {
108
+ if (probe.reason === 'not-launchable') {
109
+ return {
110
+ state: 'detection-failed',
111
+ npxAvailable: false,
112
+ package: OCR_18_PACKAGE,
113
+ warnings: [`${NPX_PROBE_UNRESOLVED_CODE}: could not launch npx to probe (${probe.detail}).`],
114
+ nextActions: ['Ensure Node.js >= 20 with its bundled npm is installed; `npx --version` must succeed.']
115
+ };
116
+ }
16
117
  return {
17
118
  state: 'ocr18-missing',
18
119
  npxAvailable: false,
19
120
  package: OCR_18_PACKAGE,
20
- warnings: ['npx is not on PATH'],
121
+ warnings: [probe.reason === 'not-on-path' ? 'npx is not on PATH' : probe.detail],
21
122
  nextActions: ['Install Node.js ≥ 20 with npm to enable `npx --package`.']
22
123
  };
23
124
  }
24
- if (!probeOcr18()) {
125
+ if (!probeOcr18(cwd)) {
25
126
  return {
26
127
  state: 'ocr18-missing',
27
128
  npxAvailable: true,
28
129
  package: OCR_18_PACKAGE,
29
130
  warnings: [`could not resolve ${OCR_18_PACKAGE}`],
30
- 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.']
31
135
  };
32
136
  }
33
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>;