peaks-loop 4.0.36 → 4.0.38
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 +42 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/bin/peaks.js +71 -1
- package/dist/cli/cli-helpers.js +7 -0
- package/dist/cli/commands/_register.js +2 -0
- package/dist/cli/commands/best-practice-scan-command.d.ts +14 -1
- package/dist/cli/commands/best-practice-scan-command.js +67 -9
- package/dist/cli/commands/code-runtime-commands.js +21 -5
- package/dist/cli/commands/hooks-commands.js +41 -7
- package/dist/cli/commands/job-commands.js +107 -25
- package/dist/cli/commands/scan-commands.js +1 -1
- package/dist/cli/commands/web-commands.d.ts +28 -0
- package/dist/cli/commands/web-commands.js +327 -0
- package/dist/cli/commands/web-lifecycle-commands.d.ts +49 -0
- package/dist/cli/commands/web-lifecycle-commands.js +321 -0
- package/dist/services/best-practice/scan-orchestrator.d.ts +22 -0
- package/dist/services/best-practice/scan-orchestrator.js +14 -5
- package/dist/services/code/orchestrator-can-do.js +27 -4
- 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/context/context-audit-hint.d.ts +79 -0
- package/dist/services/context/context-audit-hint.js +150 -0
- package/dist/services/hooks/auto-compact-hook-install.js +10 -1
- package/dist/services/hooks/write-gate.js +88 -0
- package/dist/services/lint/detect-eslint.d.ts +2 -0
- package/dist/services/lint/detect-eslint.js +23 -9
- package/dist/services/lint/detect-ocr-18.d.ts +2 -0
- package/dist/services/lint/detect-ocr-18.js +36 -5
- package/dist/services/lint/npx-resolver.d.ts +6 -0
- package/dist/services/lint/npx-resolver.js +38 -14
- package/dist/services/lint/ocr-multilang-adapter.js +9 -2
- package/dist/services/release/version-precheck-service.js +9 -2
- package/dist/services/scan/file-size-scan.d.ts +29 -0
- package/dist/services/scan/file-size-scan.js +63 -0
- package/dist/services/session/caller-binding-service.d.ts +24 -0
- package/dist/services/session/caller-binding-service.js +34 -0
- package/dist/services/session/getSessionDir.js +15 -10
- package/dist/services/skills/hooks-codegate-superpowers.d.ts +74 -0
- package/dist/services/skills/hooks-codegate-superpowers.js +129 -3
- package/dist/services/skills/hooks-settings-service.d.ts +26 -0
- package/dist/services/skills/hooks-settings-service.js +186 -62
- package/dist/services/slice/slice-check-service.d.ts +14 -0
- package/dist/services/slice/slice-check-service.js +110 -50
- package/dist/services/slice/slice-check-types.d.ts +12 -7
- package/dist/services/slice/slice-check-types.js +8 -3
- package/dist/services/slice/slice-decompose-runners.js +24 -21
- package/dist/services/sop/sop-check-service.js +12 -1
- package/dist/services/web/bounded-output.d.ts +34 -0
- package/dist/services/web/bounded-output.js +68 -0
- package/dist/services/web/browser-acquire.d.ts +14 -0
- package/dist/services/web/browser-acquire.js +84 -0
- package/dist/services/web/browser-session-manager.d.ts +111 -0
- package/dist/services/web/browser-session-manager.js +413 -0
- package/dist/services/web/daemon-entry.d.ts +1 -0
- package/dist/services/web/daemon-entry.js +65 -0
- package/dist/services/web/daemon-registry.d.ts +42 -0
- package/dist/services/web/daemon-registry.js +164 -0
- package/dist/services/web/daemon-supervisor.d.ts +144 -0
- package/dist/services/web/daemon-supervisor.js +455 -0
- package/dist/services/web/playwright-loader.d.ts +89 -0
- package/dist/services/web/playwright-loader.js +253 -0
- package/dist/services/web/snapshot-pruner.d.ts +48 -0
- package/dist/services/web/snapshot-pruner.js +241 -0
- package/dist/services/web/untrusted-envelope.d.ts +27 -0
- package/dist/services/web/untrusted-envelope.js +44 -0
- package/dist/services/web/web-artifact-paths.d.ts +79 -0
- package/dist/services/web/web-artifact-paths.js +163 -0
- package/dist/services/web/web-client.d.ts +19 -0
- package/dist/services/web/web-client.js +55 -0
- package/dist/services/web/web-daemon-service.d.ts +38 -0
- package/dist/services/web/web-daemon-service.js +416 -0
- package/dist/services/web/web-fallback.d.ts +70 -0
- package/dist/services/web/web-fallback.js +121 -0
- package/dist/services/web/web-install-service.d.ts +91 -0
- package/dist/services/web/web-install-service.js +346 -0
- package/dist/services/web/web-login-profile.d.ts +89 -0
- package/dist/services/web/web-login-profile.js +612 -0
- package/dist/services/web/web-login-staging.d.ts +27 -0
- package/dist/services/web/web-login-staging.js +173 -0
- package/dist/services/web/web-protocol.d.ts +58 -0
- package/dist/services/web/web-protocol.js +58 -0
- package/dist/services/web/web-status-report.d.ts +33 -0
- package/dist/services/web/web-status-report.js +47 -0
- package/dist/services/workspace/claude-settings-template.d.ts +59 -7
- package/dist/services/workspace/claude-settings-template.js +139 -67
- package/dist/services/workspace/workspace-claude-settings-materializer.js +46 -19
- package/dist/services/workspace/workspace-service.js +33 -0
- package/package.json +5 -5
- package/scripts/copy-templates.mjs +12 -0
- package/scripts/sync-version.mjs +20 -0
- package/skills/peaks-code/SKILL.md +10 -0
- package/skills/peaks-code/references/browser-workflow.md +10 -1
|
@@ -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;
|
|
@@ -76,6 +90,15 @@ export type HookInstallPlan = {
|
|
|
76
90
|
desiredCommand: string;
|
|
77
91
|
sentinel: string;
|
|
78
92
|
matcher: string;
|
|
93
|
+
/**
|
|
94
|
+
* Machine-local, gitignored settings file that the gate-enforce entry is
|
|
95
|
+
* written to instead of `settingsPath`. Undefined when the IDE has no such
|
|
96
|
+
* file (or the scope is global, where the settings file is already
|
|
97
|
+
* machine-local). See `resolveHookTargets`.
|
|
98
|
+
*/
|
|
99
|
+
localSettingsPath?: string;
|
|
100
|
+
/** Every entry the install writes, paired with its target file. */
|
|
101
|
+
entryTargets: ReadonlyArray<HookEntryTarget>;
|
|
79
102
|
};
|
|
80
103
|
export type HookInstallResult = HookInstallPlan & {
|
|
81
104
|
applied: boolean;
|
|
@@ -83,12 +106,15 @@ export type HookInstallResult = HookInstallPlan & {
|
|
|
83
106
|
export type HookRemoveResult = {
|
|
84
107
|
scope: HookScope;
|
|
85
108
|
settingsPath: string;
|
|
109
|
+
localSettingsPath?: string;
|
|
86
110
|
removed: boolean;
|
|
87
111
|
};
|
|
88
112
|
export type HookStatus = {
|
|
89
113
|
scope: HookScope;
|
|
90
114
|
settingsPath: string;
|
|
91
115
|
exists: boolean;
|
|
116
|
+
localSettingsPath?: string;
|
|
117
|
+
localExists?: boolean;
|
|
92
118
|
installed: boolean;
|
|
93
119
|
};
|
|
94
120
|
/**
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { existsSync } from 'node:fs';
|
|
2
2
|
import { homedir } from 'node:os';
|
|
3
|
-
import { resolve } from 'node:path';
|
|
3
|
+
import { join, resolve } from 'node:path';
|
|
4
4
|
import { assertSafeSettingsFile } from '../ide/shared/safe-path.js';
|
|
5
5
|
import { atomicWriteJson, readJsonObjectFile } from '../ide/shared/atomic-json.js';
|
|
6
6
|
import { getAdapter } from '../ide/ide-registry.js';
|
|
7
|
-
import { resolveHookSpec, resolveHookEntries, resolveLegacySentinels, SUPERPOWERS_DENIED_SKILLS, formatSuperpowersDenyEntry, SUPERPOWERS_DENY_SENTINELS } from './hooks-codegate-superpowers.js';
|
|
7
|
+
import { resolveHookSpec, resolveHookEntries, resolveLegacySentinels, SUPERPOWERS_DENIED_SKILLS, formatSuperpowersDenyEntry, SUPERPOWERS_DENY_SENTINELS, hasExternalGateExemptions, withExternalGateExemptions, withoutExternalGateExemptions } from './hooks-codegate-superpowers.js';
|
|
8
8
|
export { HOOK_ENFORCE_SENTINEL, HOOK_CODE_GATE_SENTINEL, HOOK_CODE_GATE_MATCHER, HOOK_CODE_GATE_EVENT, HOOK_CODE_GATE_COMMAND, SUPERPOWERS_DENIED_SKILLS } from './hooks-codegate-superpowers.js';
|
|
9
9
|
// --- Module-level defaults (claude-code) -----------------------------------
|
|
10
10
|
// These exports remain for backward compat — tests and downstream callers
|
|
@@ -44,6 +44,57 @@ function assertSafeSettingsPathCompat(scope, ide, root, settingsPath) {
|
|
|
44
44
|
throw new Error(`settings path drift: ${expected} vs ${settingsPath}`);
|
|
45
45
|
}
|
|
46
46
|
}
|
|
47
|
+
/** Machine-local sibling of the adapter's settings file (Claude Code convention). */
|
|
48
|
+
const LOCAL_SETTINGS_FILE_NAME = 'settings.local.json';
|
|
49
|
+
/** IDEs whose hook settings have a machine-local, gitignored sibling file. */
|
|
50
|
+
const IDES_WITH_LOCAL_SETTINGS = new Set(['claude-code']);
|
|
51
|
+
/**
|
|
52
|
+
* Resolve the machine-local settings file an IDE may carry (e.g. Claude
|
|
53
|
+
* Code's `.claude/settings.local.json`), or `undefined` when there is none.
|
|
54
|
+
*
|
|
55
|
+
* Global scope resolves to `undefined`: the global settings file
|
|
56
|
+
* (`~/.claude/settings.json`) is already machine-local, so nothing needs
|
|
57
|
+
* relocating there.
|
|
58
|
+
*/
|
|
59
|
+
function resolveLocalSettingsPath(scope, ide, projectRoot) {
|
|
60
|
+
if (scope === 'global' || !IDES_WITH_LOCAL_SETTINGS.has(ide))
|
|
61
|
+
return undefined;
|
|
62
|
+
const root = resolveSettingsRoot(scope, projectRoot);
|
|
63
|
+
const adapter = getAdapter(ide);
|
|
64
|
+
assertSafeSettingsFile(scope, root, adapter.settings.dirName, LOCAL_SETTINGS_FILE_NAME);
|
|
65
|
+
return join(root, adapter.settings.dirName, LOCAL_SETTINGS_FILE_NAME);
|
|
66
|
+
}
|
|
67
|
+
function resolveHookTargets(scope, ide, projectRoot) {
|
|
68
|
+
const sharedPath = resolveSettingsPath(scope, ide, projectRoot);
|
|
69
|
+
const localPath = resolveLocalSettingsPath(scope, ide, projectRoot);
|
|
70
|
+
const wantsEnvExemptions = ide === 'claude-code';
|
|
71
|
+
if (localPath === undefined || localPath === sharedPath) {
|
|
72
|
+
// Global scope lands here: the user-level file is already machine-local,
|
|
73
|
+
// so it is the safe target (there is no sibling to prefer).
|
|
74
|
+
return [{ settingsPath: sharedPath, entries: [...resolveHookEntries(ide)], envExemptions: wantsEnvExemptions }];
|
|
75
|
+
}
|
|
76
|
+
const shared = { settingsPath: sharedPath, entries: [] };
|
|
77
|
+
const local = { settingsPath: localPath, entries: [], envExemptions: wantsEnvExemptions };
|
|
78
|
+
for (const entry of resolveHookEntries(ide)) {
|
|
79
|
+
(entry.machineLocal === true ? local : shared).entries.push(entry);
|
|
80
|
+
}
|
|
81
|
+
return [shared, local];
|
|
82
|
+
}
|
|
83
|
+
/** True when `target` already holds everything the install would write to it. */
|
|
84
|
+
function targetIsSatisfied(target, allSentinels) {
|
|
85
|
+
const settings = readSettingsFile(target.settingsPath);
|
|
86
|
+
if (!shapeMatchesDesired(settings, target.entries, allSentinels))
|
|
87
|
+
return false;
|
|
88
|
+
return target.envExemptions !== true || hasExternalGateExemptions(settings);
|
|
89
|
+
}
|
|
90
|
+
/** Flatten the resolved targets into one `{ matcher, sentinel, settingsPath }` row per entry. */
|
|
91
|
+
function describeEntryTargets(targets) {
|
|
92
|
+
return targets.flatMap((target) => target.entries.map((entry) => ({ matcher: entry.matcher, sentinel: entry.sentinel, settingsPath: target.settingsPath })));
|
|
93
|
+
}
|
|
94
|
+
/** Read a settings file as an object, or `{}` when it does not exist yet. */
|
|
95
|
+
function readSettingsFile(settingsPath) {
|
|
96
|
+
return existsSync(settingsPath) ? readJsonObjectFile(settingsPath) : {};
|
|
97
|
+
}
|
|
47
98
|
/** Read the existing hook array entries for the adapter's hookEvent (tolerant of any prior shape). */
|
|
48
99
|
function readHookEventEntries(settings, eventKey) {
|
|
49
100
|
const hooks = settings.hooks;
|
|
@@ -209,8 +260,7 @@ export const PEAKS_HOOK_ENTRIES = (() => {
|
|
|
209
260
|
{ sentinel: spec.hookEnforceSentinel, matcher: spec.hookEnforceMatcher, command: spec.hookEnforceCommand, event: spec.hookEnforceEvent }
|
|
210
261
|
];
|
|
211
262
|
})();
|
|
212
|
-
function
|
|
213
|
-
const entries = resolveHookEntries(ide);
|
|
263
|
+
function isInstalledForEntries(settings, entries) {
|
|
214
264
|
const sentinels = entries.map((e) => e.sentinel);
|
|
215
265
|
// Check every distinct event key our entries could be on.
|
|
216
266
|
const eventKeys = new Set(entries.map((e) => e.event));
|
|
@@ -230,12 +280,14 @@ function isInstalledForIde(settings, ide) {
|
|
|
230
280
|
* fully reflected on disk: gate-enforce present AND no legacy
|
|
231
281
|
* progress-start present.
|
|
232
282
|
*/
|
|
233
|
-
function shapeMatchesDesired(settings,
|
|
234
|
-
const
|
|
235
|
-
const desiredSentinels = new Set(desiredEntries.map((e) => e.sentinel));
|
|
236
|
-
const allPeaksSentinels = resolveLegacySentinels(ide);
|
|
237
|
-
const eventKeys = new Set(resolveHookEntries(ide).map((e) => e.event));
|
|
283
|
+
function shapeMatchesDesired(settings, entries, allPeaksSentinels) {
|
|
284
|
+
const eventKeys = new Set(entries.map((e) => e.event));
|
|
238
285
|
for (const eventKey of eventKeys) {
|
|
286
|
+
// Only the entries that belong to THIS event key can be expected on it —
|
|
287
|
+
// the desired set must be per-event, or a target whose entries span two
|
|
288
|
+
// event keys (claude-code: SessionStart + PreToolUse) would never look
|
|
289
|
+
// installed and every `peaks hooks install` would rewrite the files.
|
|
290
|
+
const desiredSentinels = new Set(entries.filter((e) => e.event === eventKey).map((e) => e.sentinel));
|
|
239
291
|
const present = readHookEventEntries(settings, eventKey);
|
|
240
292
|
const peaksPresent = present.filter((e) => entryIsPeaksManaged(e, allPeaksSentinels));
|
|
241
293
|
// (a) every peaks-managed entry currently on disk must match the
|
|
@@ -261,29 +313,42 @@ export function planHookInstall(scope, projectRoot, options) {
|
|
|
261
313
|
const root = resolveSettingsRoot(scope, projectRoot);
|
|
262
314
|
const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
|
|
263
315
|
assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
|
|
316
|
+
const targets = resolveHookTargets(scope, ide, projectRoot);
|
|
264
317
|
const exists = existsSync(settingsPath);
|
|
265
|
-
const settings = exists ? readJsonObjectFile(settingsPath) : {};
|
|
266
318
|
const spec = resolveHookSpec(ide);
|
|
319
|
+
const localTarget = targets.find((t) => t.settingsPath !== settingsPath);
|
|
267
320
|
return {
|
|
268
321
|
scope,
|
|
269
322
|
settingsPath,
|
|
270
323
|
exists,
|
|
271
|
-
|
|
324
|
+
// The env clause mirrors `applyHookInstall`'s: without it the dry-run would
|
|
325
|
+
// claim "nothing to do" about a file the install is going to write.
|
|
326
|
+
alreadyInstalled: targets.every((t) => {
|
|
327
|
+
const settings = readSettingsFile(t.settingsPath);
|
|
328
|
+
return isInstalledForEntries(settings, t.entries) && (t.envExemptions !== true || hasExternalGateExemptions(settings));
|
|
329
|
+
}),
|
|
272
330
|
desiredCommand: spec.hookEnforceCommand,
|
|
273
331
|
sentinel: spec.hookEnforceSentinel,
|
|
274
|
-
matcher: spec.hookEnforceMatcher
|
|
332
|
+
matcher: spec.hookEnforceMatcher,
|
|
333
|
+
...(localTarget !== undefined ? { localSettingsPath: localTarget.settingsPath } : {}),
|
|
334
|
+
entryTargets: describeEntryTargets(targets)
|
|
275
335
|
};
|
|
276
336
|
}
|
|
277
|
-
/**
|
|
278
|
-
|
|
337
|
+
/**
|
|
338
|
+
* Merge a target's peaks-managed hook entries into `settings`, preserving
|
|
339
|
+
* all other keys and hooks. Entries routed to a DIFFERENT settings file are
|
|
340
|
+
* not part of `entries`, so the legacy-sentinel filter below strips them
|
|
341
|
+
* from this file (that is what migrates a gate-enforce entry that an older
|
|
342
|
+
* peaks release wrote into the shared file).
|
|
343
|
+
*/
|
|
344
|
+
function withHooksInstalled(settings, entries, allSentinels) {
|
|
279
345
|
const existingHooks = (settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks))
|
|
280
346
|
? settings.hooks
|
|
281
347
|
: {};
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
// so each event array is independently merged.
|
|
348
|
+
// Our entries may sit on more than one event key. Group by event so each
|
|
349
|
+
// event array is independently merged.
|
|
285
350
|
const ourByEvent = new Map();
|
|
286
|
-
for (const spec of
|
|
351
|
+
for (const spec of entries) {
|
|
287
352
|
const list = ourByEvent.get(spec.event) ?? [];
|
|
288
353
|
list.push(spec);
|
|
289
354
|
ourByEvent.set(spec.event, list);
|
|
@@ -293,14 +358,16 @@ function withHooksInstalledForIde(settings, ide, _skipProgress = false) {
|
|
|
293
358
|
// stripped by the filter (the file converges on the new
|
|
294
359
|
// gate-enforce-only shape, idempotently). The desired set (passed
|
|
295
360
|
// in below) only contains the gate-enforce sentinel.
|
|
296
|
-
const allSentinels = resolveLegacySentinels(ide);
|
|
297
361
|
const nextHooks = { ...existingHooks };
|
|
298
|
-
|
|
362
|
+
// An event key this file used to carry but whose entries all moved to the
|
|
363
|
+
// other file still needs its stale peaks entries stripped.
|
|
364
|
+
const eventKeys = new Set([...ourByEvent.keys(), ...Object.keys(existingHooks)]);
|
|
365
|
+
for (const eventKey of eventKeys) {
|
|
299
366
|
const existing = readHookEntriesFromHooks(nextHooks, eventKey);
|
|
300
367
|
const nonPeaks = existing.filter((entry) => !entryIsPeaksManaged(entry, allSentinels));
|
|
301
|
-
const ourFormatted =
|
|
368
|
+
const ourFormatted = (ourByEvent.get(eventKey) ?? []).map((spec) => ({
|
|
302
369
|
matcher: spec.matcher,
|
|
303
|
-
hooks: [{ type: 'command', command: spec.command }]
|
|
370
|
+
hooks: [{ type: 'command', command: spec.command, ...(spec.shell !== undefined ? { shell: spec.shell } : {}) }]
|
|
304
371
|
}));
|
|
305
372
|
const merged = [...nonPeaks, ...ourFormatted];
|
|
306
373
|
if (merged.length > 0) {
|
|
@@ -322,15 +389,20 @@ export function applyHookInstall(scope, projectRoot, options) {
|
|
|
322
389
|
const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
|
|
323
390
|
assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
|
|
324
391
|
const exists = existsSync(settingsPath);
|
|
325
|
-
const settings = exists ? readJsonObjectFile(settingsPath) : {};
|
|
326
392
|
const spec = resolveHookSpec(ide);
|
|
393
|
+
const targets = resolveHookTargets(scope, ide, projectRoot);
|
|
394
|
+
const allSentinels = resolveLegacySentinels(ide);
|
|
395
|
+
const localTarget = targets.find((t) => t.settingsPath !== settingsPath);
|
|
327
396
|
// Slice #014: `alreadyInstalled` reflects the FULL desired shape
|
|
328
397
|
// (gate-enforce-only + no stale progress-start entry). Pre-#014
|
|
329
398
|
// installs that left a progress-start entry behind will be treated
|
|
330
399
|
// as not-yet-installed, so the merge strips the stale entry on the
|
|
331
400
|
// next install call. This is the only path that converges the file
|
|
332
401
|
// on the new shape; pure presence-checks are insufficient.
|
|
333
|
-
|
|
402
|
+
//
|
|
403
|
+
// The external-gate exemption is part of the desired shape too, so a project
|
|
404
|
+
// installed by a release that predates it still converges on upgrade.
|
|
405
|
+
const alreadyInstalled = targets.every((t) => targetIsSatisfied(t, allSentinels));
|
|
334
406
|
const baseResult = {
|
|
335
407
|
scope,
|
|
336
408
|
settingsPath,
|
|
@@ -338,7 +410,9 @@ export function applyHookInstall(scope, projectRoot, options) {
|
|
|
338
410
|
alreadyInstalled,
|
|
339
411
|
desiredCommand: spec.hookEnforceCommand,
|
|
340
412
|
sentinel: spec.hookEnforceSentinel,
|
|
341
|
-
matcher: spec.hookEnforceMatcher
|
|
413
|
+
matcher: spec.hookEnforceMatcher,
|
|
414
|
+
...(localTarget !== undefined ? { localSettingsPath: localTarget.settingsPath } : {}),
|
|
415
|
+
entryTargets: describeEntryTargets(targets)
|
|
342
416
|
};
|
|
343
417
|
if (baseResult.alreadyInstalled) {
|
|
344
418
|
return { ...baseResult, applied: false };
|
|
@@ -356,8 +430,22 @@ export function applyHookInstall(scope, projectRoot, options) {
|
|
|
356
430
|
// entry. The chain cannot run raw `git worktree add` (or
|
|
357
431
|
// `podman run`) without an explicit `peaks hooks uninstall`
|
|
358
432
|
// first. Both helpers are pure + idempotent.
|
|
359
|
-
|
|
360
|
-
|
|
433
|
+
//
|
|
434
|
+
// The `permissions.deny` block lives in the adapter's settings file
|
|
435
|
+
// only (as it always has) — it is committed, so the deny list is
|
|
436
|
+
// shared; the machine-local file carries hook entries plus, for Claude
|
|
437
|
+
// Code, the third-party gate exemptions (machine-local by the same
|
|
438
|
+
// argument as the hook `shell` pin).
|
|
439
|
+
for (const target of targets) {
|
|
440
|
+
let next = withHooksInstalled(readSettingsFile(target.settingsPath), target.entries, allSentinels);
|
|
441
|
+
if (target.envExemptions === true) {
|
|
442
|
+
next = withExternalGateExemptions(next);
|
|
443
|
+
}
|
|
444
|
+
const merged = target.settingsPath === settingsPath
|
|
445
|
+
? withTriggeredDenyList(withSuperpowersSkillDenylist(next))
|
|
446
|
+
: next;
|
|
447
|
+
atomicWriteJson(target.settingsPath, merged);
|
|
448
|
+
}
|
|
361
449
|
return { ...baseResult, alreadyInstalled: false, applied: true };
|
|
362
450
|
}
|
|
363
451
|
export function removeHookInstall(scope, projectRoot, options) {
|
|
@@ -365,60 +453,96 @@ export function removeHookInstall(scope, projectRoot, options) {
|
|
|
365
453
|
const root = resolveSettingsRoot(scope, projectRoot);
|
|
366
454
|
const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
|
|
367
455
|
assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
|
|
368
|
-
|
|
369
|
-
|
|
456
|
+
const targets = resolveHookTargets(scope, ide, projectRoot);
|
|
457
|
+
const localTarget = targets.find((t) => t.settingsPath !== settingsPath);
|
|
458
|
+
const present = targets.filter((t) => existsSync(t.settingsPath));
|
|
459
|
+
if (present.length === 0) {
|
|
460
|
+
return {
|
|
461
|
+
scope,
|
|
462
|
+
settingsPath,
|
|
463
|
+
removed: false,
|
|
464
|
+
...(localTarget !== undefined ? { localSettingsPath: localTarget.settingsPath } : {})
|
|
465
|
+
};
|
|
370
466
|
}
|
|
371
|
-
const settings = readJsonObjectFile(settingsPath);
|
|
372
|
-
const existingHooks = settings.hooks ?? {};
|
|
373
467
|
// Slice #014: uninstall must remove the gate-enforce entry AND any
|
|
374
468
|
// legacy progress-start entry that a pre-#014 install left behind.
|
|
375
469
|
// The legacy sentinel set covers both shapes so the uninstall
|
|
376
470
|
// converges the file on "no peaks-managed entries", regardless of
|
|
377
471
|
// what shape the file was in when the user ran uninstall.
|
|
378
472
|
const sentinels = resolveLegacySentinels(ide);
|
|
379
|
-
const eventKeys = new Set(resolveHookEntries(ide).map((e) => e.event));
|
|
380
473
|
let removedAny = false;
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
474
|
+
for (const target of present) {
|
|
475
|
+
const settings = readJsonObjectFile(target.settingsPath);
|
|
476
|
+
const existingHooks = settings.hooks ?? {};
|
|
477
|
+
// Scan every event key on disk as well as the ones we install on:
|
|
478
|
+
// entries may have been routed to the other settings file by a
|
|
479
|
+
// previous install.
|
|
480
|
+
const eventKeys = new Set([...target.entries.map((e) => e.event), ...Object.keys(existingHooks)]);
|
|
481
|
+
const nextHooks = { ...existingHooks };
|
|
482
|
+
for (const eventKey of eventKeys) {
|
|
483
|
+
const entries = readHookEntriesFromHooks(nextHooks, eventKey);
|
|
484
|
+
const kept = entries.filter((entry) => !entryIsPeaksManaged(entry, sentinels));
|
|
485
|
+
if (kept.length !== entries.length)
|
|
486
|
+
removedAny = true;
|
|
487
|
+
if (kept.length > 0) {
|
|
488
|
+
nextHooks[eventKey] = kept;
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
delete nextHooks[eventKey];
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
const nextSettings = { ...settings };
|
|
495
|
+
if (Object.keys(nextHooks).length > 0) {
|
|
496
|
+
nextSettings.hooks = nextHooks;
|
|
389
497
|
}
|
|
390
498
|
else {
|
|
391
|
-
delete
|
|
499
|
+
delete nextSettings.hooks;
|
|
392
500
|
}
|
|
501
|
+
// Slice 2026-07-29-worktree-layer3-deny: Layer 3 — symmetric uninstall.
|
|
502
|
+
// Strips the peaks-managed `UseSkill(...)` deny entries alongside the
|
|
503
|
+
// hook entries; user-written entries are untouched. The helper self-
|
|
504
|
+
// decomposes an empty `permissions` object.
|
|
505
|
+
//
|
|
506
|
+
// Slice 2026-07-29-worktree-l2-extended Part 29: also strip the
|
|
507
|
+
// trigger-style deny entries via withoutTriggeredDenyList. The
|
|
508
|
+
// chain of helpers is order-independent (each is idempotent and
|
|
509
|
+
// additive over the same set of peaks-managed entries).
|
|
510
|
+
let finalSettings = nextSettings;
|
|
511
|
+
if (target.envExemptions === true) {
|
|
512
|
+
finalSettings = withoutExternalGateExemptions(finalSettings);
|
|
513
|
+
}
|
|
514
|
+
if (target.settingsPath === settingsPath) {
|
|
515
|
+
finalSettings = withoutTriggeredDenyList(withoutSuperpowersSkillDenylist(finalSettings));
|
|
516
|
+
}
|
|
517
|
+
atomicWriteJson(target.settingsPath, finalSettings);
|
|
393
518
|
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
}
|
|
401
|
-
// Slice 2026-07-29-worktree-layer3-deny: Layer 3 — symmetric uninstall.
|
|
402
|
-
// Strips the peaks-managed `UseSkill(...)` deny entries alongside the
|
|
403
|
-
// hook entries; user-written entries are untouched. The helper self-
|
|
404
|
-
// decomposes an empty `permissions` object.
|
|
405
|
-
//
|
|
406
|
-
// Slice 2026-07-29-worktree-l2-extended Part 29: also strip the
|
|
407
|
-
// trigger-style deny entries via withoutTriggeredDenyList. The
|
|
408
|
-
// chain of helpers is order-independent (each is idempotent and
|
|
409
|
-
// additive over the same set of peaks-managed entries).
|
|
410
|
-
const finalSettings = withoutTriggeredDenyList(withoutSuperpowersSkillDenylist(nextSettings));
|
|
411
|
-
atomicWriteJson(settingsPath, finalSettings);
|
|
412
|
-
return { scope, settingsPath, removed: removedAny };
|
|
519
|
+
return {
|
|
520
|
+
scope,
|
|
521
|
+
settingsPath,
|
|
522
|
+
removed: removedAny,
|
|
523
|
+
...(localTarget !== undefined ? { localSettingsPath: localTarget.settingsPath } : {})
|
|
524
|
+
};
|
|
413
525
|
}
|
|
414
526
|
export function readHookStatus(scope, projectRoot, options) {
|
|
415
527
|
const ide = resolveIde(options);
|
|
416
528
|
const root = resolveSettingsRoot(scope, projectRoot);
|
|
417
529
|
const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
|
|
418
530
|
assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
|
|
531
|
+
const targets = resolveHookTargets(scope, ide, projectRoot);
|
|
419
532
|
const exists = existsSync(settingsPath);
|
|
420
|
-
const
|
|
421
|
-
return {
|
|
533
|
+
const localTarget = targets.find((t) => t.settingsPath !== settingsPath);
|
|
534
|
+
return {
|
|
535
|
+
scope,
|
|
536
|
+
settingsPath,
|
|
537
|
+
exists,
|
|
538
|
+
...(localTarget !== undefined
|
|
539
|
+
? { localSettingsPath: localTarget.settingsPath, localExists: existsSync(localTarget.settingsPath) }
|
|
540
|
+
: {}),
|
|
541
|
+
installed: targets.some((t) => {
|
|
542
|
+
const settings = readSettingsFile(t.settingsPath);
|
|
543
|
+
return Object.keys(settings).length > 0 && isInstalledForEntries(settings, t.entries);
|
|
544
|
+
})
|
|
545
|
+
};
|
|
422
546
|
}
|
|
423
547
|
/**
|
|
424
548
|
* Slice 2026-07-29-worktree-l2-extended Part 27 — trigger-style deny.
|
|
@@ -1,2 +1,16 @@
|
|
|
1
1
|
import type { SliceCheckOptions, SliceCheckResult } from './slice-check-types.js';
|
|
2
|
+
/**
|
|
3
|
+
* 2026-09-10 D1-follow-up (G5): `tsconfig.build.json` is the type contract the
|
|
4
|
+
* package actually ships (`src/**` only) and it is clean, so the typecheck
|
|
5
|
+
* stage GATES on it. The wider `tsconfig.json` adds `tests/**`, which still
|
|
6
|
+
* carries errors this slice did not cause — 142 as measured on 2026-09-10
|
|
7
|
+
* (`pnpm exec tsc -p tsconfig.json --noEmit 2>&1 | grep -c "error TS"`), all
|
|
8
|
+
* of them under `tests/`, none under `src/`. That count is therefore a
|
|
9
|
+
* baseline, not a gate: the stage fails only when it GROWS, which is the
|
|
10
|
+
* question "did this slice add a type error anywhere?". Re-measure and update
|
|
11
|
+
* this constant when the residue is paid down.
|
|
12
|
+
*/
|
|
13
|
+
export declare const TYPECHECK_PREEXISTING_BASELINE = 142;
|
|
14
|
+
/** Named code for "the CLI this stage needs is not installed". */
|
|
15
|
+
export declare const BINARY_UNRESOLVED_CODE = "SLICE_CHECK_BINARY_UNRESOLVED";
|
|
2
16
|
export declare function sliceCheck(options: SliceCheckOptions): Promise<SliceCheckResult>;
|
|
@@ -28,49 +28,75 @@ const HEAD_LINES_IN_TAIL = 3;
|
|
|
28
28
|
const AUDIT_MIN_RED_LINES = 60;
|
|
29
29
|
const REVIEW_FILE_MIN_BYTES = 20;
|
|
30
30
|
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
* `
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
31
|
+
* 2026-09-10 D1-follow-up (G5): `tsconfig.build.json` is the type contract the
|
|
32
|
+
* package actually ships (`src/**` only) and it is clean, so the typecheck
|
|
33
|
+
* stage GATES on it. The wider `tsconfig.json` adds `tests/**`, which still
|
|
34
|
+
* carries errors this slice did not cause — 142 as measured on 2026-09-10
|
|
35
|
+
* (`pnpm exec tsc -p tsconfig.json --noEmit 2>&1 | grep -c "error TS"`), all
|
|
36
|
+
* of them under `tests/`, none under `src/`. That count is therefore a
|
|
37
|
+
* baseline, not a gate: the stage fails only when it GROWS, which is the
|
|
38
|
+
* question "did this slice add a type error anywhere?". Re-measure and update
|
|
39
|
+
* this constant when the residue is paid down.
|
|
40
|
+
*/
|
|
41
|
+
export const TYPECHECK_PREEXISTING_BASELINE = 142;
|
|
42
|
+
/**
|
|
43
|
+
* 2026-09-10 D1-follow-up (C6): each name resolves to the JS entry its package
|
|
44
|
+
* declares, as a path relative to `node_modules/` — the same files
|
|
45
|
+
* `node_modules/.bin/<name>` shims point at.
|
|
42
46
|
*
|
|
43
|
-
*
|
|
44
|
-
* `
|
|
47
|
+
* The `.bin` shim these used to spawn is a `.cmd` on Windows, and a `.cmd`
|
|
48
|
+
* cannot be spawned without `shell: true` (EINVAL, Node >= 20). `shell: true`
|
|
49
|
+
* re-parses the command line, so a project path containing a space was split
|
|
50
|
+
* at the space and the stage reported a phantom typecheck/test failure.
|
|
51
|
+
* Running the JS entry through `process.execPath` needs no shell — a spaced
|
|
52
|
+
* path is just an argument — and is the shape the D1 fix uses for `peaks`
|
|
53
|
+
* itself (`orchestrator-can-do.ts`).
|
|
54
|
+
*/
|
|
55
|
+
const LOCAL_CLI_ENTRIES = {
|
|
56
|
+
tsc: ['typescript', 'bin', 'tsc'],
|
|
57
|
+
vitest: ['vitest', 'vitest.mjs']
|
|
58
|
+
};
|
|
59
|
+
/** Named code for "the CLI this stage needs is not installed". */
|
|
60
|
+
export const BINARY_UNRESOLVED_CODE = 'SLICE_CHECK_BINARY_UNRESOLVED';
|
|
61
|
+
function localCliPath(projectRoot, name) {
|
|
62
|
+
return join(projectRoot, 'node_modules', ...LOCAL_CLI_ENTRIES[name]);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Resolve a CLI to the interpreter + JS entry `runCommand` needs, or `null`
|
|
66
|
+
* when that entry is not on disk. Never returns a shell: the returned command
|
|
67
|
+
* is always `process.execPath`.
|
|
45
68
|
*/
|
|
46
69
|
function resolveLocalBinary(projectRoot, name) {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const isWin = process.platform === 'win32';
|
|
52
|
-
const candidateNames = isWin ? [`${name}.cmd`, `${name}.ps1`, `${name}`] : [name];
|
|
53
|
-
for (const candidate of candidateNames) {
|
|
54
|
-
const cmdPath = join(projectRoot, 'node_modules', '.bin', candidate);
|
|
55
|
-
if (existsSync(cmdPath)) {
|
|
56
|
-
return { command: cmdPath, args: [], shell: isWin };
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
// Fallback: system npx. On Windows this still has the ENOENT
|
|
60
|
-
// issue, but the fallback is at least informative when it
|
|
61
|
-
// fires (the user can see "npx not found" instead of a
|
|
62
|
-
// silent exit 1).
|
|
63
|
-
return { command: 'npx', args: [name], shell: false };
|
|
70
|
+
const entry = localCliPath(projectRoot, name);
|
|
71
|
+
if (!existsSync(entry))
|
|
72
|
+
return null;
|
|
73
|
+
return { command: process.execPath, args: [entry] };
|
|
64
74
|
}
|
|
65
|
-
|
|
75
|
+
/**
|
|
76
|
+
* Failed stage for an unresolvable CLI. The previous fallback spawned `npx`
|
|
77
|
+
* and reported whatever came back — an ENOENT-shaped "exited 1" that reads
|
|
78
|
+
* exactly like a genuine typecheck/test failure. This names the code and the
|
|
79
|
+
* path that was searched instead.
|
|
80
|
+
*/
|
|
81
|
+
function unresolvedBinaryStage(stage, cli, projectRoot, durationMs) {
|
|
82
|
+
const expected = localCliPath(projectRoot, cli);
|
|
83
|
+
return {
|
|
84
|
+
name: stage,
|
|
85
|
+
description: `${cli}: binary unresolved (${BINARY_UNRESOLVED_CODE})`,
|
|
86
|
+
status: 'fail',
|
|
87
|
+
durationMs,
|
|
88
|
+
detail: `${BINARY_UNRESOLVED_CODE}: ${cli} not found at ${expected}. Install dependencies (pnpm install) so that entry exists.`,
|
|
89
|
+
data: { code: BINARY_UNRESOLVED_CODE, cli, expected }
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
function runCommand(command, args, cwd, timeoutMs) {
|
|
66
93
|
const start = Date.now();
|
|
67
94
|
try {
|
|
68
95
|
const stdout = execFileSync(command, args, {
|
|
69
96
|
cwd,
|
|
70
97
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
71
98
|
timeout: timeoutMs,
|
|
72
|
-
maxBuffer: EXEC_MAX_BUFFER_BYTES
|
|
73
|
-
shell
|
|
99
|
+
maxBuffer: EXEC_MAX_BUFFER_BYTES
|
|
74
100
|
}).toString('utf8');
|
|
75
101
|
return {
|
|
76
102
|
status: 'pass',
|
|
@@ -105,22 +131,52 @@ function tailLines(text, max) {
|
|
|
105
131
|
async function runTypecheck(projectRoot) {
|
|
106
132
|
const start = Date.now();
|
|
107
133
|
// Per Windows npx ENOENT (observations 2317+2792 from
|
|
108
|
-
// 2026-06-09), prefer the project-local
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
134
|
+
// 2026-06-09), prefer the project-local tsc; it is installed by
|
|
135
|
+
// pnpm at workspace-install time and avoids the npx PATH-lookup
|
|
136
|
+
// issue. 2026-09-10 C6: that local binary is now the package's
|
|
137
|
+
// JS entry run through `process.execPath`, not the `.bin` shim.
|
|
112
138
|
const tsc = resolveLocalBinary(projectRoot, 'tsc');
|
|
113
|
-
|
|
114
|
-
|
|
139
|
+
if (tsc === null) {
|
|
140
|
+
return unresolvedBinaryStage('typecheck', 'tsc', projectRoot, Date.now() - start);
|
|
141
|
+
}
|
|
142
|
+
// 2026-09-10 G5: this stage used to run a bare `tsc --noEmit`, which picked
|
|
143
|
+
// up the DEFAULT tsconfig.json (src/** + tests/**) and its 142 pre-existing
|
|
144
|
+
// test-side errors — so the stage was permanently red and its one real
|
|
145
|
+
// signal was buried. The gate is now the build tsconfig, which covers
|
|
146
|
+
// exactly what ships.
|
|
147
|
+
const buildConfig = 'tsconfig.build.json';
|
|
148
|
+
const hasBuildConfig = existsSync(join(projectRoot, buildConfig));
|
|
149
|
+
const gate = runCommand(tsc.command, [...tsc.args, '-p', hasBuildConfig ? buildConfig : 'tsconfig.json', '--noEmit'], projectRoot, TYPECHECK_TIMEOUT_MS);
|
|
150
|
+
// Measure the wider count so the residue is surfaced rather than hidden.
|
|
151
|
+
// The baseline comparison only applies when a clean build tsconfig gives it
|
|
152
|
+
// a meaning; without one the gate above already IS this run.
|
|
153
|
+
const wide = hasBuildConfig
|
|
154
|
+
? runCommand(tsc.command, [...tsc.args, '-p', 'tsconfig.json', '--noEmit'], projectRoot, TYPECHECK_TIMEOUT_MS)
|
|
155
|
+
: gate;
|
|
156
|
+
const wideErrors = (wide.stdout + wide.stderr).match(/error TS\d+/g)?.length ?? 0;
|
|
157
|
+
const wideLabel = hasBuildConfig ? 'tsconfig.json (src/** + tests/**)' : 'tsconfig.json';
|
|
158
|
+
const regressed = hasBuildConfig && gate.status === 'pass' && wideErrors > TYPECHECK_PREEXISTING_BASELINE;
|
|
159
|
+
const reason = gate.status !== 'pass'
|
|
160
|
+
? `tsc -p ${hasBuildConfig ? buildConfig : 'tsconfig.json'} --noEmit exited ${gate.exitCode}`
|
|
161
|
+
: regressed
|
|
162
|
+
? `typecheck errors grew to ${wideErrors} (pre-existing baseline ${TYPECHECK_PREEXISTING_BASELINE})`
|
|
163
|
+
: '';
|
|
115
164
|
return {
|
|
116
165
|
name: 'typecheck',
|
|
117
|
-
description:
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
166
|
+
description: hasBuildConfig
|
|
167
|
+
? `tsc -p ${buildConfig} --noEmit (gate) + -p tsconfig.json (baseline report)`
|
|
168
|
+
: `tsc --noEmit (no JS emit, type-only check)`,
|
|
169
|
+
status: reason.length === 0 ? 'pass' : 'fail',
|
|
170
|
+
durationMs: Date.now() - start,
|
|
171
|
+
detail: reason.length === 0
|
|
172
|
+
? `${hasBuildConfig ? `${buildConfig} (src/**) clean; ` : ''}${wideLabel}: ${wideErrors} pre-existing error(s), baseline ${TYPECHECK_PREEXISTING_BASELINE} — reported, not hidden.`
|
|
173
|
+
: `${reason}. ${tailLines(gate.status !== 'pass' ? gate.stdout + gate.stderr : wide.stdout + wide.stderr, TYPECHECK_TAIL_LINES)}`,
|
|
174
|
+
data: {
|
|
175
|
+
exitCode: gate.exitCode,
|
|
176
|
+
wideTsconfigErrors: wideErrors,
|
|
177
|
+
preExistingBaseline: hasBuildConfig ? TYPECHECK_PREEXISTING_BASELINE : null,
|
|
178
|
+
...(regressed ? { regression: wideErrors - TYPECHECK_PREEXISTING_BASELINE } : {})
|
|
179
|
+
}
|
|
124
180
|
};
|
|
125
181
|
}
|
|
126
182
|
function parseVitestSummary(stdout, fallbackDuration) {
|
|
@@ -147,15 +203,19 @@ async function runUnitTests(projectRoot, runTests) {
|
|
|
147
203
|
// `tests/unit/slice-check-service.test.ts` for the regression net.
|
|
148
204
|
// Per Windows npx ENOENT (observations 2317+2792), resolve
|
|
149
205
|
// the project-local vitest binary instead of shelling out
|
|
150
|
-
// through npx.
|
|
206
|
+
// through npx. 2026-09-10 C6: that binary is the package's JS
|
|
207
|
+
// entry run through `process.execPath`, not the `.bin` shim.
|
|
151
208
|
const vitest = resolveLocalBinary(projectRoot, 'vitest');
|
|
209
|
+
if (vitest === null) {
|
|
210
|
+
return unresolvedBinaryStage('unit-tests', 'vitest', projectRoot, Date.now() - start);
|
|
211
|
+
}
|
|
152
212
|
const vitestArgs = runTests
|
|
153
213
|
? ['run', '--reporter=default', '--coverage=false']
|
|
154
214
|
: ['run', '--changed', '--reporter=default', '--coverage=false'];
|
|
155
215
|
const description = runTests
|
|
156
|
-
?
|
|
157
|
-
:
|
|
158
|
-
const result = runCommand(vitest.command, [...vitest.args, ...vitestArgs], projectRoot, UNIT_TESTS_TIMEOUT_MS
|
|
216
|
+
? `vitest run (full test suite, coverage off)`
|
|
217
|
+
: `vitest run --changed (tests for git-changed files only, coverage off)`;
|
|
218
|
+
const result = runCommand(vitest.command, [...vitest.args, ...vitestArgs], projectRoot, UNIT_TESTS_TIMEOUT_MS);
|
|
159
219
|
const summary = parseVitestSummary(result.stdout, result.durationMs);
|
|
160
220
|
// Vitest doesn't always print the per-bucket counts cleanly; infer "passed"
|
|
161
221
|
// as total - failed - skipped when failed/skipped buckets are present.
|