peaks-loop 4.0.36 → 4.0.37
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 +24 -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 +10 -1
- 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/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/npx-resolver.d.ts +6 -0
- package/dist/services/lint/npx-resolver.js +38 -14
- 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 +33 -0
- package/dist/services/skills/hooks-codegate-superpowers.js +34 -3
- package/dist/services/skills/hooks-settings-service.d.ts +10 -0
- package/dist/services/skills/hooks-settings-service.js +152 -61
- 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 +41 -5
- package/dist/services/workspace/claude-settings-template.js +116 -64
- package/dist/services/workspace/workspace-claude-settings-materializer.js +5 -1
- 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
|
@@ -2,20 +2,25 @@
|
|
|
2
2
|
* Canonical session-directory resolver.
|
|
3
3
|
*
|
|
4
4
|
* As of slice 2026-06-05-peaks-runtime-layer the per-session workspace
|
|
5
|
-
* lives at `<root>/.peaks/_runtime/<sessionId
|
|
6
|
-
* `<root>/.peaks
|
|
7
|
-
* through this helper. The legacy top-level path
|
|
8
|
-
* back-compat **read** fallback
|
|
9
|
-
* `src/services/artifacts/
|
|
5
|
+
* lives at `<root>/.peaks/_runtime/<sessionId>/`, NOT at the legacy
|
|
6
|
+
* `<root>/.peaks/<sessionId>/` location. All **write** paths MUST route
|
|
7
|
+
* through this helper. The legacy top-level path survives only as a
|
|
8
|
+
* back-compat **read** fallback (see `legacySessionRoot` in
|
|
9
|
+
* `src/services/artifacts/artifact-prerequisites.ts` and `sessionOwnsSlice`
|
|
10
|
+
* in `src/services/sc/sc-service.ts`).
|
|
10
11
|
*
|
|
11
|
-
* The corresponding test in
|
|
12
12
|
* `tests/unit/services/session/session-dir-canonical.test.ts` enforces
|
|
13
|
-
*
|
|
13
|
+
* three invariants:
|
|
14
14
|
*
|
|
15
15
|
* (a) `getSessionDir(root, sid)` returns `<root>/.peaks/_runtime/<sid>`.
|
|
16
|
-
* (b) A static scan of `src
|
|
17
|
-
* `
|
|
18
|
-
* back-compat **read** sites
|
|
16
|
+
* (b) A static scan of `src/**` flags any `join()` chain that names
|
|
17
|
+
* `.peaks` plus a session id without routing through this resolver,
|
|
18
|
+
* wherever the id sits in the chain. The back-compat **read** sites
|
|
19
|
+
* are exempted by an allow-list whose entries are asserted to be
|
|
20
|
+
* load-bearing (an inert entry fails the scan).
|
|
21
|
+
* (c) A static scan of every markdown file under `skills/` flags a
|
|
22
|
+
* legacy `.peaks/<sid>/...` artifact path that a sub-agent would
|
|
23
|
+
* follow verbatim.
|
|
19
24
|
*
|
|
20
25
|
* @param projectRoot - Absolute path to the project root.
|
|
21
26
|
* @param sessionId - The session identifier (e.g. `2026-06-06-session-5b1095`).
|
|
@@ -15,7 +15,32 @@ interface ResolvedHookSpec {
|
|
|
15
15
|
readonly hookEnforceSentinel: string;
|
|
16
16
|
readonly hookEnforceMatcher: string;
|
|
17
17
|
readonly hookEnforceEvent: string;
|
|
18
|
+
/**
|
|
19
|
+
* True when the gate-enforce entry must be materialized into the IDE's
|
|
20
|
+
* MACHINE-LOCAL settings file rather than the shared one (see
|
|
21
|
+
* `hookEnforceShell` below). Set for Claude Code, whose project-scope
|
|
22
|
+
* hooks have a gitignored per-machine sibling file.
|
|
23
|
+
*/
|
|
24
|
+
readonly hookEnforceMachineLocal: boolean;
|
|
25
|
+
/**
|
|
26
|
+
* The `shell` field for the gate-enforce handler, or `undefined` to omit
|
|
27
|
+
* the key entirely and let the IDE use its documented default.
|
|
28
|
+
*
|
|
29
|
+
* Claude Code only. See `resolveHookShell` for why Windows needs this.
|
|
30
|
+
*/
|
|
31
|
+
readonly hookEnforceShell: string | undefined;
|
|
18
32
|
}
|
|
33
|
+
/**
|
|
34
|
+
* Claude Code runs a shell-form hook command through a shell that defaults
|
|
35
|
+
* to bash — which on Windows means Git Bash, and MSYS2's bash
|
|
36
|
+
* force-allocates its own console window. The result is a visible window on
|
|
37
|
+
* EVERY Bash tool call. The window is created by the spawner (Claude Code)
|
|
38
|
+
* before any peaks code runs, so `windowsHide` and any in-process hiding
|
|
39
|
+
* cannot help; pinning the hook's `shell` is the only lever the hook schema
|
|
40
|
+
* offers. The platform-neutral default (`undefined` → omit the key) is kept
|
|
41
|
+
* everywhere else.
|
|
42
|
+
*/
|
|
43
|
+
export declare function resolveHookShell(platform?: NodeJS.Platform): string | undefined;
|
|
19
44
|
export declare function resolveHookSpec(ide: IdeId): ResolvedHookSpec;
|
|
20
45
|
/** A typed descriptor for a single peaks-managed hook entry. */
|
|
21
46
|
export type PeaksHookEntry = {
|
|
@@ -23,6 +48,14 @@ export type PeaksHookEntry = {
|
|
|
23
48
|
matcher: string;
|
|
24
49
|
command: string;
|
|
25
50
|
event: string;
|
|
51
|
+
/**
|
|
52
|
+
* When true the entry is written to the machine-local, gitignored settings
|
|
53
|
+
* file (`.claude/settings.local.json`) instead of the shared one, because
|
|
54
|
+
* the entry carries a machine-specific value (the `shell` field).
|
|
55
|
+
*/
|
|
56
|
+
machineLocal?: boolean;
|
|
57
|
+
/** Optional `shell` field for the emitted handler. */
|
|
58
|
+
shell?: string;
|
|
26
59
|
};
|
|
27
60
|
/**
|
|
28
61
|
* Slice 2026-08-06-codegate-vendor-neutral — code-gate hook entry.
|
|
@@ -11,6 +11,19 @@ import { getAdapter } from '../ide/ide-registry.js';
|
|
|
11
11
|
import { HOOK_OUTER_CACHE_COMMAND, HOOK_OUTER_CACHE_EVENT, HOOK_OUTER_CACHE_SENTINEL, HOOK_WORKSPACE_INIT_COMMAND, HOOK_WORKSPACE_INIT_EVENT, HOOK_WORKSPACE_INIT_SENTINEL } from './session-start-hook-constants.js';
|
|
12
12
|
/** Sentinel substring identifying a Claude-Code gate-enforce hook entry. */
|
|
13
13
|
export const HOOK_ENFORCE_SENTINEL = 'peaks gate enforce';
|
|
14
|
+
/**
|
|
15
|
+
* Claude Code runs a shell-form hook command through a shell that defaults
|
|
16
|
+
* to bash — which on Windows means Git Bash, and MSYS2's bash
|
|
17
|
+
* force-allocates its own console window. The result is a visible window on
|
|
18
|
+
* EVERY Bash tool call. The window is created by the spawner (Claude Code)
|
|
19
|
+
* before any peaks code runs, so `windowsHide` and any in-process hiding
|
|
20
|
+
* cannot help; pinning the hook's `shell` is the only lever the hook schema
|
|
21
|
+
* offers. The platform-neutral default (`undefined` → omit the key) is kept
|
|
22
|
+
* everywhere else.
|
|
23
|
+
*/
|
|
24
|
+
export function resolveHookShell(platform = process.platform) {
|
|
25
|
+
return platform === 'win32' ? 'powershell' : undefined;
|
|
26
|
+
}
|
|
14
27
|
/**
|
|
15
28
|
* Per-IDE hook command + sentinel. The default (Claude Code) uses the
|
|
16
29
|
* legacy `peaks gate enforce` surface; Trae / Cursor / Codex (Cursor-style
|
|
@@ -42,11 +55,22 @@ export function resolveHookSpec(ide) {
|
|
|
42
55
|
// silently writing a Claude-shaped entry to a non-Claude settings.json.
|
|
43
56
|
throw new Error(`peaks hooks install: unsupported IDE '${ide}' (no HOOK_COMMAND_BY_IDE entry; add one to hooks-settings-service.ts)`);
|
|
44
57
|
}
|
|
58
|
+
const isClaudeCode = ide === 'claude-code';
|
|
59
|
+
// Claude Code's gate hook must emit its structured decision as JSON:
|
|
60
|
+
// without `--json` the hook validator rejects the plain `{}` stdout with
|
|
61
|
+
// "Hook JSON output validation failed". See
|
|
62
|
+
// .peaks/memory/bash-pretooluse-hook-json-error-fix.md.
|
|
63
|
+
const jsonFlag = isClaudeCode ? ' --json' : '';
|
|
45
64
|
return {
|
|
46
|
-
hookEnforceCommand: `${spec.command} --project "\${${adapter.envVar}}"`,
|
|
65
|
+
hookEnforceCommand: `${spec.command} --project "\${${adapter.envVar}}"${jsonFlag}`,
|
|
47
66
|
hookEnforceSentinel: spec.sentinel,
|
|
48
67
|
hookEnforceMatcher: adapter.toolMatcher,
|
|
49
|
-
hookEnforceEvent: adapter.hookEvent
|
|
68
|
+
hookEnforceEvent: adapter.hookEvent,
|
|
69
|
+
// Only Claude Code has the machine-local sibling settings file the
|
|
70
|
+
// routing depends on, and only Claude Code's hook schema accepts a
|
|
71
|
+
// `shell` key.
|
|
72
|
+
hookEnforceMachineLocal: isClaudeCode,
|
|
73
|
+
hookEnforceShell: isClaudeCode ? resolveHookShell() : undefined
|
|
50
74
|
};
|
|
51
75
|
}
|
|
52
76
|
/**
|
|
@@ -64,7 +88,14 @@ export const HOOK_CODE_GATE_COMMAND = `peaks code-gate --json`;
|
|
|
64
88
|
export function resolveHookEntries(ide, _skipProgress = false) {
|
|
65
89
|
const spec = resolveHookSpec(ide);
|
|
66
90
|
const entries = [
|
|
67
|
-
{
|
|
91
|
+
{
|
|
92
|
+
sentinel: spec.hookEnforceSentinel,
|
|
93
|
+
matcher: spec.hookEnforceMatcher,
|
|
94
|
+
command: spec.hookEnforceCommand,
|
|
95
|
+
event: spec.hookEnforceEvent,
|
|
96
|
+
machineLocal: spec.hookEnforceMachineLocal,
|
|
97
|
+
...(spec.hookEnforceShell !== undefined ? { shell: spec.hookEnforceShell } : {})
|
|
98
|
+
}
|
|
68
99
|
];
|
|
69
100
|
if (ide === 'claude-code') {
|
|
70
101
|
entries.push({
|
|
@@ -76,6 +76,13 @@ export type HookInstallPlan = {
|
|
|
76
76
|
desiredCommand: string;
|
|
77
77
|
sentinel: string;
|
|
78
78
|
matcher: string;
|
|
79
|
+
/**
|
|
80
|
+
* Machine-local, gitignored settings file that the gate-enforce entry is
|
|
81
|
+
* written to instead of `settingsPath`. Undefined when the IDE has no such
|
|
82
|
+
* file (or the scope is global, where the settings file is already
|
|
83
|
+
* machine-local). See `resolveHookTargets`.
|
|
84
|
+
*/
|
|
85
|
+
localSettingsPath?: string;
|
|
79
86
|
};
|
|
80
87
|
export type HookInstallResult = HookInstallPlan & {
|
|
81
88
|
applied: boolean;
|
|
@@ -83,12 +90,15 @@ export type HookInstallResult = HookInstallPlan & {
|
|
|
83
90
|
export type HookRemoveResult = {
|
|
84
91
|
scope: HookScope;
|
|
85
92
|
settingsPath: string;
|
|
93
|
+
localSettingsPath?: string;
|
|
86
94
|
removed: boolean;
|
|
87
95
|
};
|
|
88
96
|
export type HookStatus = {
|
|
89
97
|
scope: HookScope;
|
|
90
98
|
settingsPath: string;
|
|
91
99
|
exists: boolean;
|
|
100
|
+
localSettingsPath?: string;
|
|
101
|
+
localExists?: boolean;
|
|
92
102
|
installed: boolean;
|
|
93
103
|
};
|
|
94
104
|
/**
|
|
@@ -1,6 +1,6 @@
|
|
|
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';
|
|
@@ -44,6 +44,43 @@ 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 shared = { settingsPath: sharedPath, entries: [] };
|
|
71
|
+
if (localPath === undefined || localPath === sharedPath) {
|
|
72
|
+
return [{ settingsPath: sharedPath, entries: [...resolveHookEntries(ide)] }];
|
|
73
|
+
}
|
|
74
|
+
const local = { settingsPath: localPath, entries: [] };
|
|
75
|
+
for (const entry of resolveHookEntries(ide)) {
|
|
76
|
+
(entry.machineLocal === true ? local : shared).entries.push(entry);
|
|
77
|
+
}
|
|
78
|
+
return [shared, local];
|
|
79
|
+
}
|
|
80
|
+
/** Read a settings file as an object, or `{}` when it does not exist yet. */
|
|
81
|
+
function readSettingsFile(settingsPath) {
|
|
82
|
+
return existsSync(settingsPath) ? readJsonObjectFile(settingsPath) : {};
|
|
83
|
+
}
|
|
47
84
|
/** Read the existing hook array entries for the adapter's hookEvent (tolerant of any prior shape). */
|
|
48
85
|
function readHookEventEntries(settings, eventKey) {
|
|
49
86
|
const hooks = settings.hooks;
|
|
@@ -209,8 +246,7 @@ export const PEAKS_HOOK_ENTRIES = (() => {
|
|
|
209
246
|
{ sentinel: spec.hookEnforceSentinel, matcher: spec.hookEnforceMatcher, command: spec.hookEnforceCommand, event: spec.hookEnforceEvent }
|
|
210
247
|
];
|
|
211
248
|
})();
|
|
212
|
-
function
|
|
213
|
-
const entries = resolveHookEntries(ide);
|
|
249
|
+
function isInstalledForEntries(settings, entries) {
|
|
214
250
|
const sentinels = entries.map((e) => e.sentinel);
|
|
215
251
|
// Check every distinct event key our entries could be on.
|
|
216
252
|
const eventKeys = new Set(entries.map((e) => e.event));
|
|
@@ -230,12 +266,14 @@ function isInstalledForIde(settings, ide) {
|
|
|
230
266
|
* fully reflected on disk: gate-enforce present AND no legacy
|
|
231
267
|
* progress-start present.
|
|
232
268
|
*/
|
|
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));
|
|
269
|
+
function shapeMatchesDesired(settings, entries, allPeaksSentinels) {
|
|
270
|
+
const eventKeys = new Set(entries.map((e) => e.event));
|
|
238
271
|
for (const eventKey of eventKeys) {
|
|
272
|
+
// Only the entries that belong to THIS event key can be expected on it —
|
|
273
|
+
// the desired set must be per-event, or a target whose entries span two
|
|
274
|
+
// event keys (claude-code: SessionStart + PreToolUse) would never look
|
|
275
|
+
// installed and every `peaks hooks install` would rewrite the files.
|
|
276
|
+
const desiredSentinels = new Set(entries.filter((e) => e.event === eventKey).map((e) => e.sentinel));
|
|
239
277
|
const present = readHookEventEntries(settings, eventKey);
|
|
240
278
|
const peaksPresent = present.filter((e) => entryIsPeaksManaged(e, allPeaksSentinels));
|
|
241
279
|
// (a) every peaks-managed entry currently on disk must match the
|
|
@@ -261,29 +299,36 @@ export function planHookInstall(scope, projectRoot, options) {
|
|
|
261
299
|
const root = resolveSettingsRoot(scope, projectRoot);
|
|
262
300
|
const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
|
|
263
301
|
assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
|
|
302
|
+
const targets = resolveHookTargets(scope, ide, projectRoot);
|
|
264
303
|
const exists = existsSync(settingsPath);
|
|
265
|
-
const settings = exists ? readJsonObjectFile(settingsPath) : {};
|
|
266
304
|
const spec = resolveHookSpec(ide);
|
|
305
|
+
const localTarget = targets.find((t) => t.settingsPath !== settingsPath);
|
|
267
306
|
return {
|
|
268
307
|
scope,
|
|
269
308
|
settingsPath,
|
|
270
309
|
exists,
|
|
271
|
-
alreadyInstalled:
|
|
310
|
+
alreadyInstalled: targets.every((t) => isInstalledForEntries(readSettingsFile(t.settingsPath), t.entries)),
|
|
272
311
|
desiredCommand: spec.hookEnforceCommand,
|
|
273
312
|
sentinel: spec.hookEnforceSentinel,
|
|
274
|
-
matcher: spec.hookEnforceMatcher
|
|
313
|
+
matcher: spec.hookEnforceMatcher,
|
|
314
|
+
...(localTarget !== undefined ? { localSettingsPath: localTarget.settingsPath } : {})
|
|
275
315
|
};
|
|
276
316
|
}
|
|
277
|
-
/**
|
|
278
|
-
|
|
317
|
+
/**
|
|
318
|
+
* Merge a target's peaks-managed hook entries into `settings`, preserving
|
|
319
|
+
* all other keys and hooks. Entries routed to a DIFFERENT settings file are
|
|
320
|
+
* not part of `entries`, so the legacy-sentinel filter below strips them
|
|
321
|
+
* from this file (that is what migrates a gate-enforce entry that an older
|
|
322
|
+
* peaks release wrote into the shared file).
|
|
323
|
+
*/
|
|
324
|
+
function withHooksInstalled(settings, entries, allSentinels) {
|
|
279
325
|
const existingHooks = (settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks))
|
|
280
326
|
? settings.hooks
|
|
281
327
|
: {};
|
|
282
|
-
//
|
|
283
|
-
//
|
|
284
|
-
// so each event array is independently merged.
|
|
328
|
+
// Our entries may sit on more than one event key. Group by event so each
|
|
329
|
+
// event array is independently merged.
|
|
285
330
|
const ourByEvent = new Map();
|
|
286
|
-
for (const spec of
|
|
331
|
+
for (const spec of entries) {
|
|
287
332
|
const list = ourByEvent.get(spec.event) ?? [];
|
|
288
333
|
list.push(spec);
|
|
289
334
|
ourByEvent.set(spec.event, list);
|
|
@@ -293,14 +338,16 @@ function withHooksInstalledForIde(settings, ide, _skipProgress = false) {
|
|
|
293
338
|
// stripped by the filter (the file converges on the new
|
|
294
339
|
// gate-enforce-only shape, idempotently). The desired set (passed
|
|
295
340
|
// in below) only contains the gate-enforce sentinel.
|
|
296
|
-
const allSentinels = resolveLegacySentinels(ide);
|
|
297
341
|
const nextHooks = { ...existingHooks };
|
|
298
|
-
|
|
342
|
+
// An event key this file used to carry but whose entries all moved to the
|
|
343
|
+
// other file still needs its stale peaks entries stripped.
|
|
344
|
+
const eventKeys = new Set([...ourByEvent.keys(), ...Object.keys(existingHooks)]);
|
|
345
|
+
for (const eventKey of eventKeys) {
|
|
299
346
|
const existing = readHookEntriesFromHooks(nextHooks, eventKey);
|
|
300
347
|
const nonPeaks = existing.filter((entry) => !entryIsPeaksManaged(entry, allSentinels));
|
|
301
|
-
const ourFormatted =
|
|
348
|
+
const ourFormatted = (ourByEvent.get(eventKey) ?? []).map((spec) => ({
|
|
302
349
|
matcher: spec.matcher,
|
|
303
|
-
hooks: [{ type: 'command', command: spec.command }]
|
|
350
|
+
hooks: [{ type: 'command', command: spec.command, ...(spec.shell !== undefined ? { shell: spec.shell } : {}) }]
|
|
304
351
|
}));
|
|
305
352
|
const merged = [...nonPeaks, ...ourFormatted];
|
|
306
353
|
if (merged.length > 0) {
|
|
@@ -322,15 +369,17 @@ export function applyHookInstall(scope, projectRoot, options) {
|
|
|
322
369
|
const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
|
|
323
370
|
assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
|
|
324
371
|
const exists = existsSync(settingsPath);
|
|
325
|
-
const settings = exists ? readJsonObjectFile(settingsPath) : {};
|
|
326
372
|
const spec = resolveHookSpec(ide);
|
|
373
|
+
const targets = resolveHookTargets(scope, ide, projectRoot);
|
|
374
|
+
const allSentinels = resolveLegacySentinels(ide);
|
|
375
|
+
const localTarget = targets.find((t) => t.settingsPath !== settingsPath);
|
|
327
376
|
// Slice #014: `alreadyInstalled` reflects the FULL desired shape
|
|
328
377
|
// (gate-enforce-only + no stale progress-start entry). Pre-#014
|
|
329
378
|
// installs that left a progress-start entry behind will be treated
|
|
330
379
|
// as not-yet-installed, so the merge strips the stale entry on the
|
|
331
380
|
// next install call. This is the only path that converges the file
|
|
332
381
|
// on the new shape; pure presence-checks are insufficient.
|
|
333
|
-
const alreadyInstalled = shapeMatchesDesired(
|
|
382
|
+
const alreadyInstalled = targets.every((t) => shapeMatchesDesired(readSettingsFile(t.settingsPath), t.entries, allSentinels));
|
|
334
383
|
const baseResult = {
|
|
335
384
|
scope,
|
|
336
385
|
settingsPath,
|
|
@@ -338,7 +387,8 @@ export function applyHookInstall(scope, projectRoot, options) {
|
|
|
338
387
|
alreadyInstalled,
|
|
339
388
|
desiredCommand: spec.hookEnforceCommand,
|
|
340
389
|
sentinel: spec.hookEnforceSentinel,
|
|
341
|
-
matcher: spec.hookEnforceMatcher
|
|
390
|
+
matcher: spec.hookEnforceMatcher,
|
|
391
|
+
...(localTarget !== undefined ? { localSettingsPath: localTarget.settingsPath } : {})
|
|
342
392
|
};
|
|
343
393
|
if (baseResult.alreadyInstalled) {
|
|
344
394
|
return { ...baseResult, applied: false };
|
|
@@ -356,8 +406,17 @@ export function applyHookInstall(scope, projectRoot, options) {
|
|
|
356
406
|
// entry. The chain cannot run raw `git worktree add` (or
|
|
357
407
|
// `podman run`) without an explicit `peaks hooks uninstall`
|
|
358
408
|
// first. Both helpers are pure + idempotent.
|
|
359
|
-
|
|
360
|
-
|
|
409
|
+
//
|
|
410
|
+
// The `permissions.deny` block lives in the adapter's settings file
|
|
411
|
+
// only (as it always has) — it is committed, so the deny list is
|
|
412
|
+
// shared; the machine-local file carries hook entries only.
|
|
413
|
+
for (const target of targets) {
|
|
414
|
+
const next = withHooksInstalled(readSettingsFile(target.settingsPath), target.entries, allSentinels);
|
|
415
|
+
const merged = target.settingsPath === settingsPath
|
|
416
|
+
? withTriggeredDenyList(withSuperpowersSkillDenylist(next))
|
|
417
|
+
: next;
|
|
418
|
+
atomicWriteJson(target.settingsPath, merged);
|
|
419
|
+
}
|
|
361
420
|
return { ...baseResult, alreadyInstalled: false, applied: true };
|
|
362
421
|
}
|
|
363
422
|
export function removeHookInstall(scope, projectRoot, options) {
|
|
@@ -365,60 +424,92 @@ export function removeHookInstall(scope, projectRoot, options) {
|
|
|
365
424
|
const root = resolveSettingsRoot(scope, projectRoot);
|
|
366
425
|
const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
|
|
367
426
|
assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
|
|
368
|
-
|
|
369
|
-
|
|
427
|
+
const targets = resolveHookTargets(scope, ide, projectRoot);
|
|
428
|
+
const localTarget = targets.find((t) => t.settingsPath !== settingsPath);
|
|
429
|
+
const present = targets.filter((t) => existsSync(t.settingsPath));
|
|
430
|
+
if (present.length === 0) {
|
|
431
|
+
return {
|
|
432
|
+
scope,
|
|
433
|
+
settingsPath,
|
|
434
|
+
removed: false,
|
|
435
|
+
...(localTarget !== undefined ? { localSettingsPath: localTarget.settingsPath } : {})
|
|
436
|
+
};
|
|
370
437
|
}
|
|
371
|
-
const settings = readJsonObjectFile(settingsPath);
|
|
372
|
-
const existingHooks = settings.hooks ?? {};
|
|
373
438
|
// Slice #014: uninstall must remove the gate-enforce entry AND any
|
|
374
439
|
// legacy progress-start entry that a pre-#014 install left behind.
|
|
375
440
|
// The legacy sentinel set covers both shapes so the uninstall
|
|
376
441
|
// converges the file on "no peaks-managed entries", regardless of
|
|
377
442
|
// what shape the file was in when the user ran uninstall.
|
|
378
443
|
const sentinels = resolveLegacySentinels(ide);
|
|
379
|
-
const eventKeys = new Set(resolveHookEntries(ide).map((e) => e.event));
|
|
380
444
|
let removedAny = false;
|
|
381
|
-
const
|
|
382
|
-
|
|
383
|
-
const
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
445
|
+
for (const target of present) {
|
|
446
|
+
const settings = readJsonObjectFile(target.settingsPath);
|
|
447
|
+
const existingHooks = settings.hooks ?? {};
|
|
448
|
+
// Scan every event key on disk as well as the ones we install on:
|
|
449
|
+
// entries may have been routed to the other settings file by a
|
|
450
|
+
// previous install.
|
|
451
|
+
const eventKeys = new Set([...target.entries.map((e) => e.event), ...Object.keys(existingHooks)]);
|
|
452
|
+
const nextHooks = { ...existingHooks };
|
|
453
|
+
for (const eventKey of eventKeys) {
|
|
454
|
+
const entries = readHookEntriesFromHooks(nextHooks, eventKey);
|
|
455
|
+
const kept = entries.filter((entry) => !entryIsPeaksManaged(entry, sentinels));
|
|
456
|
+
if (kept.length !== entries.length)
|
|
457
|
+
removedAny = true;
|
|
458
|
+
if (kept.length > 0) {
|
|
459
|
+
nextHooks[eventKey] = kept;
|
|
460
|
+
}
|
|
461
|
+
else {
|
|
462
|
+
delete nextHooks[eventKey];
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const nextSettings = { ...settings };
|
|
466
|
+
if (Object.keys(nextHooks).length > 0) {
|
|
467
|
+
nextSettings.hooks = nextHooks;
|
|
389
468
|
}
|
|
390
469
|
else {
|
|
391
|
-
delete
|
|
470
|
+
delete nextSettings.hooks;
|
|
392
471
|
}
|
|
472
|
+
// Slice 2026-07-29-worktree-layer3-deny: Layer 3 — symmetric uninstall.
|
|
473
|
+
// Strips the peaks-managed `UseSkill(...)` deny entries alongside the
|
|
474
|
+
// hook entries; user-written entries are untouched. The helper self-
|
|
475
|
+
// decomposes an empty `permissions` object.
|
|
476
|
+
//
|
|
477
|
+
// Slice 2026-07-29-worktree-l2-extended Part 29: also strip the
|
|
478
|
+
// trigger-style deny entries via withoutTriggeredDenyList. The
|
|
479
|
+
// chain of helpers is order-independent (each is idempotent and
|
|
480
|
+
// additive over the same set of peaks-managed entries).
|
|
481
|
+
const finalSettings = target.settingsPath === settingsPath
|
|
482
|
+
? withoutTriggeredDenyList(withoutSuperpowersSkillDenylist(nextSettings))
|
|
483
|
+
: nextSettings;
|
|
484
|
+
atomicWriteJson(target.settingsPath, finalSettings);
|
|
393
485
|
}
|
|
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 };
|
|
486
|
+
return {
|
|
487
|
+
scope,
|
|
488
|
+
settingsPath,
|
|
489
|
+
removed: removedAny,
|
|
490
|
+
...(localTarget !== undefined ? { localSettingsPath: localTarget.settingsPath } : {})
|
|
491
|
+
};
|
|
413
492
|
}
|
|
414
493
|
export function readHookStatus(scope, projectRoot, options) {
|
|
415
494
|
const ide = resolveIde(options);
|
|
416
495
|
const root = resolveSettingsRoot(scope, projectRoot);
|
|
417
496
|
const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
|
|
418
497
|
assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
|
|
498
|
+
const targets = resolveHookTargets(scope, ide, projectRoot);
|
|
419
499
|
const exists = existsSync(settingsPath);
|
|
420
|
-
const
|
|
421
|
-
return {
|
|
500
|
+
const localTarget = targets.find((t) => t.settingsPath !== settingsPath);
|
|
501
|
+
return {
|
|
502
|
+
scope,
|
|
503
|
+
settingsPath,
|
|
504
|
+
exists,
|
|
505
|
+
...(localTarget !== undefined
|
|
506
|
+
? { localSettingsPath: localTarget.settingsPath, localExists: existsSync(localTarget.settingsPath) }
|
|
507
|
+
: {}),
|
|
508
|
+
installed: targets.some((t) => {
|
|
509
|
+
const settings = readSettingsFile(t.settingsPath);
|
|
510
|
+
return Object.keys(settings).length > 0 && isInstalledForEntries(settings, t.entries);
|
|
511
|
+
})
|
|
512
|
+
};
|
|
422
513
|
}
|
|
423
514
|
/**
|
|
424
515
|
* 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>;
|