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.
- package/CHANGELOG.md +37 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/dist/cli/commands/code-review-commands.js +43 -1
- package/dist/cli/commands/code-runtime-commands.js +16 -4
- package/dist/cli/commands/core/skill-command.d.ts +44 -0
- package/dist/cli/commands/core/skill-command.js +67 -3
- package/dist/cli/commands/dispatch-commands.js +42 -12
- package/dist/cli/commands/hooks-commands.js +31 -6
- package/dist/services/code/auto-compact-orchestrator.js +2 -2
- package/dist/services/context/auto-compact-dispatcher.js +3 -1
- package/dist/services/context/build-dispatch-system-prompt.d.ts +35 -1
- package/dist/services/context/build-dispatch-system-prompt.js +55 -3
- package/dist/services/hooks/auto-compact-hook-install.d.ts +14 -1
- package/dist/services/hooks/auto-compact-hook-install.js +39 -15
- package/dist/services/lint/detect-ocr-18.d.ts +9 -1
- package/dist/services/lint/detect-ocr-18.js +114 -10
- package/dist/services/lint/ocr-18-acquire.d.ts +113 -0
- package/dist/services/lint/ocr-18-acquire.js +350 -0
- package/dist/services/lint/ocr-multilang-adapter.js +12 -2
- package/dist/services/skills/hooks-codegate-superpowers.d.ts +41 -0
- package/dist/services/skills/hooks-codegate-superpowers.js +95 -0
- package/dist/services/skills/hooks-settings-service.d.ts +16 -0
- package/dist/services/skills/hooks-settings-service.js +46 -13
- package/dist/services/web/playwright-loader.js +5 -24
- package/dist/services/workflow/provision-dispatch-node.d.ts +42 -0
- package/dist/services/workflow/provision-dispatch-node.js +66 -0
- package/dist/services/workspace/claude-settings-template.d.ts +19 -3
- package/dist/services/workspace/claude-settings-template.js +25 -5
- package/dist/services/workspace/workspace-claude-settings-materializer.js +111 -29
- package/dist/shared/npm-cache.d.ts +2 -0
- package/dist/shared/npm-cache.js +31 -0
- package/package.json +5 -5
- package/skills/bee/peaks-perf-audit/SKILL.md +24 -0
- package/skills/bee/peaks-prd/SKILL.md +24 -0
- package/skills/bee/peaks-qa/SKILL.md +24 -0
- package/skills/bee/peaks-rd/SKILL.md +24 -0
- package/skills/bee/peaks-reviewer/SKILL.md +24 -0
- package/skills/bee/peaks-sc/SKILL.md +24 -0
- package/skills/bee/peaks-security-audit/SKILL.md +24 -0
- package/skills/bee/peaks-txt/SKILL.md +24 -0
- package/skills/bee/peaks-ui/SKILL.md +24 -0
- package/skills/peaks-audit/SKILL.md +24 -0
- package/skills/peaks-code/SKILL.md +24 -0
- package/skills/peaks-content/SKILL.md +24 -0
- package/skills/peaks-doctor/SKILL.md +24 -0
- package/skills/peaks-final-review/SKILL.md +24 -0
- package/skills/peaks-ide/SKILL.md +24 -0
- package/skills/peaks-issue-fix-orchestrator/SKILL.md +24 -0
- package/skills/peaks-resume/SKILL.md +24 -0
- package/skills/peaks-slice-decompose/SKILL.md +24 -0
- package/skills/peaks-solo/SKILL.md +24 -0
- package/skills/peaks-sop/SKILL.md +24 -0
- package/skills/peaks-status/SKILL.md +24 -0
- 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
|
-
|
|
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
|
|
8
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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>;
|