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
|
@@ -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
|
+
}
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
* 8 supported languages to the corresponding `ocr review` filter.
|
|
4
4
|
*/
|
|
5
5
|
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { resolveNpxInvocation } from './npx-resolver.js';
|
|
6
7
|
export const OCR_18_PACKAGE = '@alibaba-group/open-code-review@1.8.9';
|
|
7
8
|
export const OCR_18_LANGUAGES = [
|
|
8
9
|
'python',
|
|
@@ -75,10 +76,18 @@ export function runOcr18(options) {
|
|
|
75
76
|
const spawnOptions = {
|
|
76
77
|
cwd: options.cwd,
|
|
77
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,
|
|
78
82
|
timeout: options.timeoutMs ?? 60_000,
|
|
79
83
|
maxBuffer: 32 * 1024 * 1024
|
|
80
84
|
};
|
|
81
|
-
|
|
85
|
+
// 2026-09-10: bare `spawnSync('npx', …)` cannot launch the Windows `npx.cmd`
|
|
86
|
+
// shim (ENOENT, no shell) — same fix and same helper as `detect-ocr-18.ts` /
|
|
87
|
+
// `detect-eslint.ts`. `shell: true` is NOT an option: it would concatenate the
|
|
88
|
+
// argv unescaped and split the `--package` flag.
|
|
89
|
+
const { command, args: npxArgs, baseEnv } = resolveNpxInvocation(args);
|
|
90
|
+
const result = spawnSync(command, npxArgs, { ...spawnOptions, env: baseEnv });
|
|
82
91
|
const stdout = typeof result.stdout === 'string' ? result.stdout : '';
|
|
83
92
|
const stderr = typeof result.stderr === 'string' ? result.stderr : '';
|
|
84
93
|
if (result.error !== undefined && result.error !== null) {
|
|
@@ -87,7 +96,8 @@ export function runOcr18(options) {
|
|
|
87
96
|
findings: [],
|
|
88
97
|
summary: null,
|
|
89
98
|
durationMs: Date.now() - start,
|
|
90
|
-
|
|
99
|
+
// Never discard WHY the launch failed — stdout/stderr are both empty here.
|
|
100
|
+
rawOutput: stderr || stdout || result.error.message
|
|
91
101
|
};
|
|
92
102
|
}
|
|
93
103
|
if (result.status !== 0) {
|
|
@@ -95,4 +95,45 @@ export declare function resolveLegacySentinels(ide: IdeId): ReadonlyArray<string
|
|
|
95
95
|
export declare const SUPERPOWERS_DENIED_SKILLS: ReadonlyArray<string>;
|
|
96
96
|
export declare function formatSuperpowersDenyEntry(skillId: string): string;
|
|
97
97
|
export declare const SUPERPOWERS_DENY_SENTINELS: ReadonlySet<string>;
|
|
98
|
+
/**
|
|
99
|
+
* Adapter table: a Peaks *concept* → the settings value an EXTERNAL,
|
|
100
|
+
* non-Peaks PreToolUse gate must read to honour it.
|
|
101
|
+
*
|
|
102
|
+
* The concept Peaks holds is "the `.peaks/**` workspace tree is not project
|
|
103
|
+
* source, so 'who imports this / what schema' carries no signal there".
|
|
104
|
+
* Peaks already enforces it for its own hooks: slice 2.0.1-bug3 materializes
|
|
105
|
+
* a `Write|Edit|MultiEdit` bypass in `.claude/settings.local.json` precisely
|
|
106
|
+
* so the first workspace write is never fact-gated. This table is that same
|
|
107
|
+
* intent declared to a gate Peaks does not own.
|
|
108
|
+
*
|
|
109
|
+
* The mapped key is read by the ECC plugin's `gateguard-fact-force` hook: a
|
|
110
|
+
* comma-separated glob list matched against the normalized (forward-slash,
|
|
111
|
+
* lowercased) path, where a match skips first-touch fact-forcing. The row is
|
|
112
|
+
* INERT when that plugin is absent — an env var nothing reads.
|
|
113
|
+
*
|
|
114
|
+
* The key below is the ONLY occurrence of that third-party name in the source
|
|
115
|
+
* tree (a test pins the count). Vendor-specific translation belongs in the
|
|
116
|
+
* adapter layer, next to `HOOK_COMMAND_BY_IDE` and `resolveHookShell` — never
|
|
117
|
+
* scattered through the installer. And it is emitted into a MACHINE-LOCAL
|
|
118
|
+
* settings file only (see `resolveHookTargets`): a third-party variable name in
|
|
119
|
+
* the COMMITTED shared settings would be pushed to every consumer of this repo.
|
|
120
|
+
*/
|
|
121
|
+
export declare const EXTERNAL_GATE_EXEMPT_ENV: Readonly<Record<string, string>>;
|
|
122
|
+
/** True when every `EXTERNAL_GATE_EXEMPT_ENV` glob is already declared in `settings.env`. */
|
|
123
|
+
export declare function hasExternalGateExemptions(settings: Record<string, unknown>): boolean;
|
|
124
|
+
/**
|
|
125
|
+
* Union every `EXTERNAL_GATE_EXEMPT_ENV` row into `settings.env`. An existing
|
|
126
|
+
* value is EXTENDED, never replaced, so a user who exempted other trees keeps
|
|
127
|
+
* them; unrelated `env` keys are untouched. Rows already carrying our glob are
|
|
128
|
+
* left byte-identical (so a re-run cannot churn the file), and the input object
|
|
129
|
+
* is returned unchanged when there is nothing to add. Pure.
|
|
130
|
+
*/
|
|
131
|
+
export declare function withExternalGateExemptions(settings: Record<string, unknown>): Record<string, unknown>;
|
|
132
|
+
/**
|
|
133
|
+
* Inverse of `withExternalGateExemptions`: remove exactly the globs this repo
|
|
134
|
+
* added, keeping any the user wrote. The key is deleted once it holds nothing
|
|
135
|
+
* of ours, and `env` itself is dropped when it becomes empty — so uninstall
|
|
136
|
+
* leaves no orphan field behind. Pure.
|
|
137
|
+
*/
|
|
138
|
+
export declare function withoutExternalGateExemptions(settings: Record<string, unknown>): Record<string, unknown>;
|
|
98
139
|
export {};
|
|
@@ -233,3 +233,98 @@ export function formatSuperpowersDenyEntry(skillId) {
|
|
|
233
233
|
return `UseSkill(${skillId})`;
|
|
234
234
|
}
|
|
235
235
|
export const SUPERPOWERS_DENY_SENTINELS = new Set(SUPERPOWERS_DENIED_SKILLS.map(formatSuperpowersDenyEntry));
|
|
236
|
+
// --- External (third-party) PreToolUse gate exemptions ---------------------
|
|
237
|
+
/**
|
|
238
|
+
* Adapter table: a Peaks *concept* → the settings value an EXTERNAL,
|
|
239
|
+
* non-Peaks PreToolUse gate must read to honour it.
|
|
240
|
+
*
|
|
241
|
+
* The concept Peaks holds is "the `.peaks/**` workspace tree is not project
|
|
242
|
+
* source, so 'who imports this / what schema' carries no signal there".
|
|
243
|
+
* Peaks already enforces it for its own hooks: slice 2.0.1-bug3 materializes
|
|
244
|
+
* a `Write|Edit|MultiEdit` bypass in `.claude/settings.local.json` precisely
|
|
245
|
+
* so the first workspace write is never fact-gated. This table is that same
|
|
246
|
+
* intent declared to a gate Peaks does not own.
|
|
247
|
+
*
|
|
248
|
+
* The mapped key is read by the ECC plugin's `gateguard-fact-force` hook: a
|
|
249
|
+
* comma-separated glob list matched against the normalized (forward-slash,
|
|
250
|
+
* lowercased) path, where a match skips first-touch fact-forcing. The row is
|
|
251
|
+
* INERT when that plugin is absent — an env var nothing reads.
|
|
252
|
+
*
|
|
253
|
+
* The key below is the ONLY occurrence of that third-party name in the source
|
|
254
|
+
* tree (a test pins the count). Vendor-specific translation belongs in the
|
|
255
|
+
* adapter layer, next to `HOOK_COMMAND_BY_IDE` and `resolveHookShell` — never
|
|
256
|
+
* scattered through the installer. And it is emitted into a MACHINE-LOCAL
|
|
257
|
+
* settings file only (see `resolveHookTargets`): a third-party variable name in
|
|
258
|
+
* the COMMITTED shared settings would be pushed to every consumer of this repo.
|
|
259
|
+
*/
|
|
260
|
+
export const EXTERNAL_GATE_EXEMPT_ENV = Object.freeze({
|
|
261
|
+
// peaks' `.peaks/**` workspace tree is not project source → skip fact-forcing
|
|
262
|
+
GATEGUARD_EXEMPT_GLOBS: '.peaks/**'
|
|
263
|
+
});
|
|
264
|
+
function isPlainObject(value) {
|
|
265
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
266
|
+
}
|
|
267
|
+
/** Split a comma-separated glob list, dropping blanks and surrounding space. */
|
|
268
|
+
function splitGlobList(value) {
|
|
269
|
+
return value.split(',').map((glob) => glob.trim()).filter((glob) => glob.length > 0);
|
|
270
|
+
}
|
|
271
|
+
/** True when every `EXTERNAL_GATE_EXEMPT_ENV` glob is already declared in `settings.env`. */
|
|
272
|
+
export function hasExternalGateExemptions(settings) {
|
|
273
|
+
const env = isPlainObject(settings.env) ? settings.env : {};
|
|
274
|
+
return Object.entries(EXTERNAL_GATE_EXEMPT_ENV).every(([key, glob]) => {
|
|
275
|
+
const current = env[key];
|
|
276
|
+
return typeof current === 'string' && splitGlobList(current).includes(glob);
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* Union every `EXTERNAL_GATE_EXEMPT_ENV` row into `settings.env`. An existing
|
|
281
|
+
* value is EXTENDED, never replaced, so a user who exempted other trees keeps
|
|
282
|
+
* them; unrelated `env` keys are untouched. Rows already carrying our glob are
|
|
283
|
+
* left byte-identical (so a re-run cannot churn the file), and the input object
|
|
284
|
+
* is returned unchanged when there is nothing to add. Pure.
|
|
285
|
+
*/
|
|
286
|
+
export function withExternalGateExemptions(settings) {
|
|
287
|
+
const env = isPlainObject(settings.env) ? { ...settings.env } : {};
|
|
288
|
+
let changed = false;
|
|
289
|
+
for (const [key, glob] of Object.entries(EXTERNAL_GATE_EXEMPT_ENV)) {
|
|
290
|
+
const current = typeof env[key] === 'string' ? env[key] : '';
|
|
291
|
+
const existing = splitGlobList(current);
|
|
292
|
+
if (existing.includes(glob))
|
|
293
|
+
continue;
|
|
294
|
+
env[key] = [...existing, glob].join(',');
|
|
295
|
+
changed = true;
|
|
296
|
+
}
|
|
297
|
+
return changed ? { ...settings, env } : settings;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Inverse of `withExternalGateExemptions`: remove exactly the globs this repo
|
|
301
|
+
* added, keeping any the user wrote. The key is deleted once it holds nothing
|
|
302
|
+
* of ours, and `env` itself is dropped when it becomes empty — so uninstall
|
|
303
|
+
* leaves no orphan field behind. Pure.
|
|
304
|
+
*/
|
|
305
|
+
export function withoutExternalGateExemptions(settings) {
|
|
306
|
+
if (!isPlainObject(settings.env))
|
|
307
|
+
return settings;
|
|
308
|
+
const env = { ...settings.env };
|
|
309
|
+
let changed = false;
|
|
310
|
+
for (const [key, glob] of Object.entries(EXTERNAL_GATE_EXEMPT_ENV)) {
|
|
311
|
+
const current = env[key];
|
|
312
|
+
if (typeof current !== 'string')
|
|
313
|
+
continue;
|
|
314
|
+
const kept = splitGlobList(current).filter((entry) => entry !== glob);
|
|
315
|
+
changed = true;
|
|
316
|
+
if (kept.length > 0) {
|
|
317
|
+
env[key] = kept.join(',');
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
delete env[key];
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
if (!changed)
|
|
324
|
+
return settings;
|
|
325
|
+
if (Object.keys(env).length === 0) {
|
|
326
|
+
const { env: _omit, ...rest } = settings;
|
|
327
|
+
return rest;
|
|
328
|
+
}
|
|
329
|
+
return { ...settings, env };
|
|
330
|
+
}
|
|
@@ -68,6 +68,20 @@ export type HookInstallOptions = {
|
|
|
68
68
|
};
|
|
69
69
|
/** Default (claude-code) hook command — kept as a stable export for tests. */
|
|
70
70
|
export declare const HOOK_ENFORCE_COMMAND = "peaks gate enforce --project \"${CLAUDE_PROJECT_DIR}\" --json";
|
|
71
|
+
/**
|
|
72
|
+
* One peaks-managed entry paired with the settings file it is (or will be)
|
|
73
|
+
* written to.
|
|
74
|
+
*
|
|
75
|
+
* `entries` is a flat list of the same matcher/sentinel pairs, which reads as
|
|
76
|
+
* "written to `settingsPath`" even when the entry is routed to the other file
|
|
77
|
+
* (see `resolveHookTargets`). This carries the routing explicitly so the
|
|
78
|
+
* dry-run can name the real target without a real run.
|
|
79
|
+
*/
|
|
80
|
+
export type HookEntryTarget = {
|
|
81
|
+
matcher: string;
|
|
82
|
+
sentinel: string;
|
|
83
|
+
settingsPath: string;
|
|
84
|
+
};
|
|
71
85
|
export type HookInstallPlan = {
|
|
72
86
|
scope: HookScope;
|
|
73
87
|
settingsPath: string;
|
|
@@ -83,6 +97,8 @@ export type HookInstallPlan = {
|
|
|
83
97
|
* machine-local). See `resolveHookTargets`.
|
|
84
98
|
*/
|
|
85
99
|
localSettingsPath?: string;
|
|
100
|
+
/** Every entry the install writes, paired with its target file. */
|
|
101
|
+
entryTargets: ReadonlyArray<HookEntryTarget>;
|
|
86
102
|
};
|
|
87
103
|
export type HookInstallResult = HookInstallPlan & {
|
|
88
104
|
applied: boolean;
|