peaks-cli 1.3.2 → 1.3.4
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/README.md +6 -2
- package/dist/src/cli/commands/core-artifact-commands.js +6 -3
- package/dist/src/cli/commands/gate-commands.js +28 -19
- package/dist/src/cli/commands/hook-handle.d.ts +17 -0
- package/dist/src/cli/commands/hook-handle.js +111 -0
- package/dist/src/cli/commands/hooks-commands.js +72 -21
- package/dist/src/cli/commands/progress-commands.js +9 -2
- package/dist/src/cli/commands/progress-start-spawn.js +30 -4
- package/dist/src/cli/commands/project-commands.js +8 -4
- package/dist/src/cli/commands/statusline-commands.js +75 -17
- package/dist/src/cli/commands/sub-agent-commands.d.ts +5 -0
- package/dist/src/cli/commands/sub-agent-commands.js +488 -0
- package/dist/src/cli/commands/sub-agent-dispatch-guard.d.ts +55 -0
- package/dist/src/cli/commands/sub-agent-dispatch-guard.js +57 -0
- package/dist/src/cli/commands/workflow-commands.js +2 -1
- package/dist/src/cli/commands/workspace-commands.js +3 -0
- package/dist/src/cli/program.js +9 -0
- package/dist/src/hooks/pre-tool-use-sub-agent.d.ts +28 -0
- package/dist/src/hooks/pre-tool-use-sub-agent.js +105 -0
- package/dist/src/services/config/config-types.d.ts +1 -1
- package/dist/src/services/context/artifact-meta.d.ts +72 -0
- package/dist/src/services/context/artifact-meta.js +105 -0
- package/dist/src/services/context/context-guard.d.ts +49 -0
- package/dist/src/services/context/context-guard.js +91 -0
- package/dist/src/services/context/dispatch-context-guard.d.ts +27 -0
- package/dist/src/services/context/dispatch-context-guard.js +192 -0
- package/dist/src/services/context/headroom-client.d.ts +34 -0
- package/dist/src/services/context/headroom-client.js +117 -0
- package/dist/src/services/context/shared-channel.d.ts +92 -0
- package/dist/src/services/context/shared-channel.js +285 -0
- package/dist/src/services/context/threshold.d.ts +35 -0
- package/dist/src/services/context/threshold.js +76 -0
- package/dist/src/services/dashboard/project-dashboard-service.d.ts +23 -0
- package/dist/src/services/dashboard/project-dashboard-service.js +21 -0
- package/dist/src/services/dispatch/batch-counter.d.ts +27 -0
- package/dist/src/services/dispatch/batch-counter.js +85 -0
- package/dist/src/services/dispatch/dispatch-record-writer.d.ts +93 -0
- package/dist/src/services/dispatch/dispatch-record-writer.js +261 -0
- package/dist/src/services/dispatch/heartbeat-truncator.d.ts +26 -0
- package/dist/src/services/dispatch/heartbeat-truncator.js +13 -0
- package/dist/src/services/dispatch/leak-detector.d.ts +11 -0
- package/dist/src/services/dispatch/leak-detector.js +72 -0
- package/dist/src/services/dispatch/sub-agent-dispatcher.d.ts +127 -0
- package/dist/src/services/dispatch/sub-agent-dispatcher.js +98 -0
- package/dist/src/services/ide/adapters/claude-code-adapter.d.ts +18 -0
- package/dist/src/services/ide/adapters/claude-code-adapter.js +80 -0
- package/dist/src/services/ide/adapters/trae-adapter.d.ts +42 -0
- package/dist/src/services/ide/adapters/trae-adapter.js +98 -0
- package/dist/src/services/ide/hook-protocol.d.ts +47 -0
- package/dist/src/services/ide/hook-protocol.js +74 -0
- package/dist/src/services/ide/hook-translator.d.ts +72 -0
- package/dist/src/services/ide/hook-translator.js +128 -0
- package/dist/src/services/ide/ide-detector.d.ts +10 -0
- package/dist/src/services/ide/ide-detector.js +19 -0
- package/dist/src/services/ide/ide-registry.d.ts +14 -0
- package/dist/src/services/ide/ide-registry.js +45 -0
- package/dist/src/services/ide/ide-types.d.ts +180 -0
- package/dist/src/services/ide/ide-types.js +2 -0
- package/dist/src/services/ide/resource-profile.d.ts +52 -0
- package/dist/src/services/ide/resource-profile.js +33 -0
- package/dist/src/services/ide/shared/atomic-json.d.ts +15 -0
- package/dist/src/services/ide/shared/atomic-json.js +58 -0
- package/dist/src/services/ide/shared/safe-path.d.ts +11 -0
- package/dist/src/services/ide/shared/safe-path.js +29 -0
- package/dist/src/services/memory/project-context-service.js +2 -1
- package/dist/src/services/memory/project-memory-service.js +4 -3
- package/dist/src/services/perf/perf-baseline-service.js +2 -1
- package/dist/src/services/progress/progress-service.d.ts +1 -1
- package/dist/src/services/progress/progress-service.js +18 -14
- package/dist/src/services/security/safe-settings-path.d.ts +12 -0
- package/dist/src/services/security/safe-settings-path.js +104 -0
- package/dist/src/services/session/getSessionDir.d.ts +1 -0
- package/dist/src/services/session/getSessionDir.js +27 -0
- package/dist/src/services/session/index.d.ts +1 -0
- package/dist/src/services/session/index.js +1 -0
- package/dist/src/services/signal/cancel-handler.d.ts +14 -0
- package/dist/src/services/signal/cancel-handler.js +76 -0
- package/dist/src/services/skill/resume-detector.d.ts +54 -0
- package/dist/src/services/skill/resume-detector.js +334 -0
- package/dist/src/services/skill/skill-scheduler.d.ts +40 -0
- package/dist/src/services/skill/skill-scheduler.js +53 -0
- package/dist/src/services/skills/hooks-settings-service.d.ts +47 -29
- package/dist/src/services/skills/hooks-settings-service.js +190 -144
- package/dist/src/services/skills/statusline-settings-service.d.ts +33 -6
- package/dist/src/services/skills/statusline-settings-service.js +31 -34
- package/dist/src/services/slice/slice-archive-service.d.ts +20 -0
- package/dist/src/services/slice/slice-archive-service.js +111 -0
- package/dist/src/services/solo/batch-heartbeat-poller.d.ts +51 -0
- package/dist/src/services/solo/batch-heartbeat-poller.js +88 -0
- package/dist/src/services/solo/status-line-renderer.d.ts +34 -0
- package/dist/src/services/solo/status-line-renderer.js +55 -0
- package/dist/src/services/standards/ide-aware-standards-service.d.ts +94 -0
- package/dist/src/services/standards/ide-aware-standards-service.js +89 -0
- package/dist/src/services/standards/project-standards-service.d.ts +1 -2
- package/dist/src/services/workspace/reconcile-service.d.ts +36 -0
- package/dist/src/services/workspace/reconcile-service.js +107 -6
- package/dist/src/services/workspace/reconcile-types.d.ts +12 -0
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +2 -1
- package/scripts/install-skills.mjs +112 -2
- package/skills/peaks-ide/SKILL.md +159 -0
- package/skills/peaks-ide/references/audit-log-helper.md +52 -0
- package/skills/peaks-qa/SKILL.md +153 -55
- package/skills/peaks-qa/references/qa-fanout-contract.md +150 -0
- package/skills/peaks-rd/SKILL.md +134 -62
- package/skills/peaks-solo/SKILL.md +124 -37
- package/skills/peaks-solo/references/browser-workflow.md +22 -20
- package/skills/peaks-solo/references/context-governance.md +144 -0
- package/skills/peaks-solo/references/headroom-integration.md +107 -0
- package/skills/peaks-solo/references/runbook.md +3 -3
- package/skills/peaks-solo/references/sub-agent-dispatch.md +261 -0
- package/skills/peaks-solo/references/swarm-dispatch-contract.md +3 -37
- package/skills/peaks-txt/SKILL.md +17 -0
- package/skills/peaks-ui/SKILL.md +45 -10
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G6 — skill-level heartbeat scheduler config.
|
|
3
|
+
*
|
|
4
|
+
* Slice 2026-06-07-sub-agent-dispatch-decouple (G6): the SKILL.md front
|
|
5
|
+
* matter for a Dispatcher (peaks-solo / peaks-rd / peaks-qa) can opt
|
|
6
|
+
* into a non-default heartbeat interval by including a line like:
|
|
7
|
+
*
|
|
8
|
+
* heartbeatIntervalSec: 15
|
|
9
|
+
*
|
|
10
|
+
* The default is 30 s (RL-13 empirical sweet spot). The poller
|
|
11
|
+
* cadence is fixed at 10 s (sub-agent 30 s / poller 10 s is the
|
|
12
|
+
* jitter-resistant offset).
|
|
13
|
+
*
|
|
14
|
+
* This module is a pure-key parser — it takes a SKILL.md body and
|
|
15
|
+
* returns the effective config. The Dispatcher's prompt template
|
|
16
|
+
* for sub-agents is then responsible for inlining the chosen value
|
|
17
|
+
* into the sub-agent prompt so that the LLM knows how often to
|
|
18
|
+
* call `peaks sub-agent heartbeat`.
|
|
19
|
+
*
|
|
20
|
+
* Note: the heartbeat *cadence* the LLM uses is enforced socially
|
|
21
|
+
* (via the prompt), not via any hook. R-1 / R-8 boundary — LLM
|
|
22
|
+
* behaviour is not observable. The user has been explicit about
|
|
23
|
+
* this: "心跳是 sub-agent 主动写, peaks CLI 不观测 LLM 行为".
|
|
24
|
+
*/
|
|
25
|
+
export const DEFAULT_HEARTBEAT_INTERVAL_SEC = 30;
|
|
26
|
+
export const MIN_HEARTBEAT_INTERVAL_SEC = 5;
|
|
27
|
+
export const MAX_HEARTBEAT_INTERVAL_SEC = 600;
|
|
28
|
+
/** Parse a SKILL.md body for a `heartbeatIntervalSec: <N>` line. */
|
|
29
|
+
export function parseHeartbeatConfig(skillBody) {
|
|
30
|
+
const match = skillBody.match(/^\s*heartbeatIntervalSec\s*:\s*(\d+)\s*$/m);
|
|
31
|
+
if (!match) {
|
|
32
|
+
return { intervalSec: DEFAULT_HEARTBEAT_INTERVAL_SEC, source: 'default' };
|
|
33
|
+
}
|
|
34
|
+
const value = Number.parseInt(match[1], 10);
|
|
35
|
+
if (!Number.isInteger(value) || value < MIN_HEARTBEAT_INTERVAL_SEC || value > MAX_HEARTBEAT_INTERVAL_SEC) {
|
|
36
|
+
return { intervalSec: DEFAULT_HEARTBEAT_INTERVAL_SEC, source: 'default' };
|
|
37
|
+
}
|
|
38
|
+
return { intervalSec: value, source: 'skill-frontmatter' };
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Build the heartbeat-instruction paragraph to inline in a sub-agent
|
|
42
|
+
* prompt. The LLM reads this and adjusts its `peaks sub-agent
|
|
43
|
+
* heartbeat` cadence accordingly.
|
|
44
|
+
*/
|
|
45
|
+
export function heartbeatInstructionParagraph(config) {
|
|
46
|
+
return (`While running, call ` +
|
|
47
|
+
`\`peaks sub-agent heartbeat --record <dispatchRecordPath> --status <state> --progress <pct> --note "<text>"\` ` +
|
|
48
|
+
`at least every ${config.intervalSec} seconds (the Dispatcher expects ` +
|
|
49
|
+
`${config.intervalSec}s cadence; default 30s, your SKILL.md overrides to ${config.intervalSec}s). ` +
|
|
50
|
+
`On completion, call \`--status done --progress 100 --note "completed"\`. ` +
|
|
51
|
+
`On failure, \`--status failed\`. Do not skip heartbeats; the parent ` +
|
|
52
|
+
`Dispatcher uses them to keep the user informed during the wait.`);
|
|
53
|
+
}
|
|
@@ -1,43 +1,59 @@
|
|
|
1
|
+
import type { IdeId } from '../ide/ide-types.js';
|
|
2
|
+
import type { HookScope } from '../ide/shared/safe-path.js';
|
|
1
3
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Install (and remove) the Peaks-managed hooks in an IDE's settings.json.
|
|
5
|
+
*
|
|
6
|
+
* The hook runs `peaks gate enforce` (Claude) or `peaks hook handle` (Trae /
|
|
7
|
+
* other future adapters) before every relevant tool call; when a SOP guard's
|
|
8
|
+
* gates fail it returns the adapter-specific deny shape, which blocks the
|
|
9
|
+
* tool call BEFORE the IDE's permission checks — making the gate
|
|
10
|
+
* un-bypassable by the agent.
|
|
11
|
+
*
|
|
12
|
+
* Slice #1 refactor: this service delegates to the `IdeAdapter` for
|
|
13
|
+
* `claude-code`. Slice #2 added Trae. Adapter provides `dirName` /
|
|
14
|
+
* `settingsFileName` / `envVar` / `hookEvent` / `toolMatcher`. The Claude
|
|
15
|
+
* install path is byte-level-compat with slice #0 (AC-1).
|
|
16
|
+
*
|
|
17
|
+
* Slice #3 refactor (this commit): the service is now per-IDE aware via an
|
|
18
|
+
* optional `options.ide` parameter. The CLI command is responsible for
|
|
19
|
+
* resolving the IDE (env → stdin shape → cwd → fallback to 'claude-code')
|
|
20
|
+
* via `detectIdeFromContext` and passing the result here. When `ide` is
|
|
21
|
+
* omitted, the service defaults to `'claude-code'` so existing tests and
|
|
22
|
+
* downstream callers continue to work without modification.
|
|
7
23
|
*
|
|
8
24
|
* Installation is an EXPLICIT user command (never postinstall): skills describe,
|
|
9
|
-
* the CLI performs side effects. Writes preserve all other settings keys and
|
|
10
|
-
* other hooks, reject symlinked targets, and use an atomic rename so a
|
|
11
|
-
* write can never corrupt the settings file. Our entry is merged into
|
|
12
|
-
* replacing) the existing `hooks
|
|
25
|
+
* the CLI performs side effects. Writes preserve all other settings keys and
|
|
26
|
+
* any other hooks, reject symlinked targets, and use an atomic rename so a
|
|
27
|
+
* partial write can never corrupt the settings file. Our entry is merged into
|
|
28
|
+
* (not replacing) the existing `hooks.<event>` array and is identified by a
|
|
13
29
|
* sentinel substring in its command, so install is idempotent and uninstall
|
|
14
30
|
* removes only our own entry.
|
|
15
31
|
*/
|
|
16
|
-
export type HookScope
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*/
|
|
27
|
-
export declare const HOOK_PROGRESS_COMMAND = "peaks progress start --project \"${CLAUDE_PROJECT_DIR}\" --reason \"auto-spawn for sub-agent Task\" --quiet";
|
|
28
|
-
/** Substring that identifies a Peaks-managed PreToolUse gate-enforce hook entry. */
|
|
32
|
+
export type { HookScope } from '../ide/shared/safe-path.js';
|
|
33
|
+
export type HookInstallOptions = {
|
|
34
|
+
/**
|
|
35
|
+
* Which IDE's adapter to install for. Defaults to `'claude-code'` for
|
|
36
|
+
* backward compatibility. The CLI command should resolve this from
|
|
37
|
+
* `detectIdeFromContext({ env, cwd, parsedStdin })` and pass the result.
|
|
38
|
+
* Throws if the IDE is not registered in the adapter registry.
|
|
39
|
+
*/
|
|
40
|
+
readonly ide?: IdeId;
|
|
41
|
+
};
|
|
42
|
+
/** Sentinel substring identifying a Claude-Code gate-enforce hook entry. */
|
|
29
43
|
export declare const HOOK_ENFORCE_SENTINEL = "peaks gate enforce";
|
|
30
|
-
/**
|
|
44
|
+
/** Sentinel substring identifying a peaks-managed sub-agent-progress hook entry. */
|
|
31
45
|
export declare const HOOK_PROGRESS_SENTINEL = "peaks progress start";
|
|
46
|
+
/** Default (claude-code) hook command — kept as a stable export for tests. */
|
|
47
|
+
export declare const HOOK_ENFORCE_COMMAND = "peaks gate enforce --project \"${CLAUDE_PROJECT_DIR}\"";
|
|
48
|
+
/** Default (claude-code) progress command — kept as a stable export for tests. */
|
|
49
|
+
export declare const HOOK_PROGRESS_COMMAND = "peaks progress start --project \"${CLAUDE_PROJECT_DIR}\" --reason \"auto-spawn for sub-agent Task\" --quiet";
|
|
32
50
|
export type HookInstallPlan = {
|
|
33
51
|
scope: HookScope;
|
|
34
52
|
settingsPath: string;
|
|
35
53
|
exists: boolean;
|
|
36
54
|
alreadyInstalled: boolean;
|
|
37
55
|
desiredCommand: string;
|
|
38
|
-
/** Substring sentinel used to detect the entry. */
|
|
39
56
|
sentinel: string;
|
|
40
|
-
/** Tool name (Bash | Task) the PreToolUse hook is keyed on. */
|
|
41
57
|
matcher: string;
|
|
42
58
|
};
|
|
43
59
|
export type HookInstallResult = HookInstallPlan & {
|
|
@@ -59,9 +75,11 @@ export type PeaksHookEntry = {
|
|
|
59
75
|
sentinel: string;
|
|
60
76
|
matcher: string;
|
|
61
77
|
command: string;
|
|
78
|
+
event: string;
|
|
62
79
|
};
|
|
80
|
+
/** Default (claude-code) peaks-managed hook entries — kept as a stable export for tests. */
|
|
63
81
|
export declare const PEAKS_HOOK_ENTRIES: ReadonlyArray<PeaksHookEntry>;
|
|
64
|
-
export declare function planHookInstall(scope: HookScope, projectRoot?: string): HookInstallPlan;
|
|
65
|
-
export declare function applyHookInstall(scope: HookScope, projectRoot?: string): HookInstallResult;
|
|
66
|
-
export declare function removeHookInstall(scope: HookScope, projectRoot?: string): HookRemoveResult;
|
|
67
|
-
export declare function readHookStatus(scope: HookScope, projectRoot?: string): HookStatus;
|
|
82
|
+
export declare function planHookInstall(scope: HookScope, projectRoot?: string, options?: HookInstallOptions): HookInstallPlan;
|
|
83
|
+
export declare function applyHookInstall(scope: HookScope, projectRoot?: string, options?: HookInstallOptions): HookInstallResult;
|
|
84
|
+
export declare function removeHookInstall(scope: HookScope, projectRoot?: string, options?: HookInstallOptions): HookRemoveResult;
|
|
85
|
+
export declare function readHookStatus(scope: HookScope, projectRoot?: string, options?: HookInstallOptions): HookStatus;
|
|
@@ -1,38 +1,57 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
4
2
|
import { homedir } from 'node:os';
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
*/
|
|
15
|
-
export const HOOK_PROGRESS_COMMAND = 'peaks progress start --project "${CLAUDE_PROJECT_DIR}" --reason "auto-spawn for sub-agent Task" --quiet';
|
|
16
|
-
/** Substring that identifies a Peaks-managed PreToolUse gate-enforce hook entry. */
|
|
3
|
+
import { resolve } from 'node:path';
|
|
4
|
+
import { assertSafeSettingsFile } from '../ide/shared/safe-path.js';
|
|
5
|
+
import { atomicWriteJson, readJsonObjectFile } from '../ide/shared/atomic-json.js';
|
|
6
|
+
import { getAdapter } from '../ide/ide-registry.js';
|
|
7
|
+
// --- Module-level defaults (claude-code) -----------------------------------
|
|
8
|
+
// These exports remain for backward compat — tests and downstream callers
|
|
9
|
+
// that only care about Claude Code can keep importing them. The per-IDE
|
|
10
|
+
// values are computed lazily inside each public function call.
|
|
11
|
+
/** Sentinel substring identifying a Claude-Code gate-enforce hook entry. */
|
|
17
12
|
export const HOOK_ENFORCE_SENTINEL = 'peaks gate enforce';
|
|
18
|
-
/**
|
|
13
|
+
/** Sentinel substring identifying a peaks-managed sub-agent-progress hook entry. */
|
|
19
14
|
export const HOOK_PROGRESS_SENTINEL = 'peaks progress start';
|
|
20
|
-
|
|
21
|
-
const
|
|
22
|
-
/**
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
15
|
+
/** Default (claude-code) hook command — kept as a stable export for tests. */
|
|
16
|
+
export const HOOK_ENFORCE_COMMAND = `peaks gate enforce --project "\${CLAUDE_PROJECT_DIR}"`;
|
|
17
|
+
/** Default (claude-code) progress command — kept as a stable export for tests. */
|
|
18
|
+
export const HOOK_PROGRESS_COMMAND = `peaks progress start --project "\${CLAUDE_PROJECT_DIR}" --reason "auto-spawn for sub-agent Task" --quiet`;
|
|
19
|
+
function resolveHookSpec(ide) {
|
|
20
|
+
const adapter = getAdapter(ide);
|
|
21
|
+
if (ide === 'claude-code') {
|
|
22
|
+
return {
|
|
23
|
+
hookEnforceCommand: `peaks gate enforce --project "\${${adapter.envVar}}"`,
|
|
24
|
+
hookProgressCommand: `peaks progress start --project "\${${adapter.envVar}}" --reason "auto-spawn for sub-agent ${adapter.subAgentToolMatcher}" --quiet`,
|
|
25
|
+
hookEnforceSentinel: HOOK_ENFORCE_SENTINEL,
|
|
26
|
+
hookProgressSentinel: HOOK_PROGRESS_SENTINEL,
|
|
27
|
+
hookEnforceMatcher: adapter.toolMatcher, // 'Bash'
|
|
28
|
+
hookProgressMatcher: adapter.subAgentToolMatcher, // 'Task' (slice 2026-06-06-sub-agent-spawn-bug-and-decouple — adapter now self-reports sub-agent tool name)
|
|
29
|
+
hookEnforceEvent: adapter.hookEvent, // 'PreToolUse'
|
|
30
|
+
hookProgressEvent: adapter.hookEvent // 'PreToolUse' for Claude
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
if (ide === 'trae') {
|
|
34
|
+
return {
|
|
35
|
+
hookEnforceCommand: `peaks hook handle --project "\${${adapter.envVar}}"`,
|
|
36
|
+
hookProgressCommand: `peaks progress start --project "\${${adapter.envVar}}" --reason "auto-spawn for sub-agent ${adapter.subAgentToolMatcher}" --quiet`,
|
|
37
|
+
hookEnforceSentinel: 'peaks hook handle',
|
|
38
|
+
hookProgressSentinel: HOOK_PROGRESS_SENTINEL,
|
|
39
|
+
hookEnforceMatcher: adapter.toolMatcher, // 'terminal'
|
|
40
|
+
hookProgressMatcher: adapter.subAgentToolMatcher, // 'Task' (UNVERIFIED for Trae; matches prior hardcoded literal so byte-level install output is unchanged)
|
|
41
|
+
hookEnforceEvent: adapter.hookEvent, // 'beforeToolCall'
|
|
42
|
+
hookProgressEvent: adapter.hookEvent // 'beforeToolCall' (no separate progress event yet for Trae)
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
// Future adapters (codex, cursor, qoder, tongyi-lingma) — not yet registered.
|
|
46
|
+
// When a slice adds them, branch here. Until then, throw a clear error so
|
|
47
|
+
// the CLI surfaces "unsupported IDE" instead of writing a Claude-shaped
|
|
48
|
+
// entry to a non-Claude settings.json.
|
|
49
|
+
throw new Error(`peaks hooks install: unsupported IDE '${ide}' (not registered in adapter registry; future slice will add support)`);
|
|
35
50
|
}
|
|
51
|
+
function resolveIde(options) {
|
|
52
|
+
return options?.ide ?? 'claude-code';
|
|
53
|
+
}
|
|
54
|
+
/** Resolve settings root dir for a scope. */
|
|
36
55
|
function resolveSettingsRoot(scope, projectRoot) {
|
|
37
56
|
if (scope === 'global')
|
|
38
57
|
return resolve(homedir());
|
|
@@ -41,76 +60,37 @@ function resolveSettingsRoot(scope, projectRoot) {
|
|
|
41
60
|
}
|
|
42
61
|
return resolve(projectRoot);
|
|
43
62
|
}
|
|
44
|
-
function resolveSettingsPath(scope, projectRoot) {
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const claudeDir = join(root, '.claude');
|
|
49
|
-
if (existsSync(claudeDir) && lstatSync(claudeDir).isSymbolicLink()) {
|
|
50
|
-
throw new Error('.claude directory must not be a symlink');
|
|
51
|
-
}
|
|
52
|
-
if (existsSync(settingsPath)) {
|
|
53
|
-
if (lstatSync(settingsPath).isSymbolicLink()) {
|
|
54
|
-
throw new Error('settings.json must not be a symlink');
|
|
55
|
-
}
|
|
56
|
-
const realRoot = realpathSync(root);
|
|
57
|
-
if (!isInsidePath(realpathSync(settingsPath), realRoot)) {
|
|
58
|
-
throw new Error(`settings.json must stay inside the ${scope} root`);
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
function readSettings(settingsPath) {
|
|
63
|
-
if (!existsSync(settingsPath))
|
|
64
|
-
return {};
|
|
65
|
-
const fd = openSync(settingsPath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
66
|
-
try {
|
|
67
|
-
const raw = readFileSync(fd, 'utf8').trim();
|
|
68
|
-
if (raw.length === 0)
|
|
69
|
-
return {};
|
|
70
|
-
const parsed = JSON.parse(raw);
|
|
71
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
72
|
-
throw new Error('settings.json must contain a JSON object');
|
|
73
|
-
}
|
|
74
|
-
return parsed;
|
|
75
|
-
}
|
|
76
|
-
finally {
|
|
77
|
-
closeSync(fd);
|
|
78
|
-
}
|
|
63
|
+
function resolveSettingsPath(scope, ide, projectRoot) {
|
|
64
|
+
const root = resolveSettingsRoot(scope, projectRoot);
|
|
65
|
+
const adapter = getAdapter(ide);
|
|
66
|
+
return adapter.settings.resolveSettingsFile(scope, scope === 'global' ? homedir() : projectRoot);
|
|
79
67
|
}
|
|
80
|
-
function
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
closeSync(fd);
|
|
90
|
-
}
|
|
91
|
-
try {
|
|
92
|
-
renameSync(tempPath, settingsPath);
|
|
93
|
-
}
|
|
94
|
-
catch (error) {
|
|
95
|
-
try {
|
|
96
|
-
unlinkSync(tempPath);
|
|
97
|
-
}
|
|
98
|
-
catch {
|
|
99
|
-
// best effort cleanup
|
|
100
|
-
}
|
|
101
|
-
throw error;
|
|
68
|
+
function assertSafeSettingsPathCompat(scope, ide, root, settingsPath) {
|
|
69
|
+
const adapter = getAdapter(ide);
|
|
70
|
+
assertSafeSettingsFile(scope, root, adapter.settings.dirName, adapter.settings.settingsFileName);
|
|
71
|
+
// The compat path receives the already-computed settingsPath; double-check
|
|
72
|
+
// that the computed path matches what assertSafeSettingsFile would have
|
|
73
|
+
// produced. This guards against drift between the two resolvers.
|
|
74
|
+
const expected = adapter.settings.resolveSettingsFile(scope, scope === 'global' ? homedir() : root);
|
|
75
|
+
if (expected !== settingsPath) {
|
|
76
|
+
throw new Error(`settings path drift: ${expected} vs ${settingsPath}`);
|
|
102
77
|
}
|
|
103
78
|
}
|
|
104
|
-
/** Read the existing
|
|
105
|
-
function
|
|
79
|
+
/** Read the existing hook array entries for the adapter's hookEvent (tolerant of any prior shape). */
|
|
80
|
+
function readHookEventEntries(settings, eventKey) {
|
|
106
81
|
const hooks = settings.hooks;
|
|
107
82
|
if (!hooks || typeof hooks !== 'object' || Array.isArray(hooks))
|
|
108
83
|
return [];
|
|
109
|
-
const
|
|
110
|
-
return Array.isArray(
|
|
84
|
+
const arr = hooks[eventKey];
|
|
85
|
+
return Array.isArray(arr) ? arr : [];
|
|
111
86
|
}
|
|
112
|
-
/**
|
|
113
|
-
function
|
|
87
|
+
/** Read the existing hook array entries from a `settings.hooks` object (already extracted). */
|
|
88
|
+
function readHookEntriesFromHooks(hooks, eventKey) {
|
|
89
|
+
const arr = hooks[eventKey];
|
|
90
|
+
return Array.isArray(arr) ? arr : [];
|
|
91
|
+
}
|
|
92
|
+
/** True when every command handler in the entry matches a known peaks sentinel for the given IDE. */
|
|
93
|
+
function entryIsPeaksManaged(entry, sentinels) {
|
|
114
94
|
const handlers = Array.isArray(entry?.hooks) ? entry.hooks : [];
|
|
115
95
|
if (handlers.length === 0)
|
|
116
96
|
return false;
|
|
@@ -118,78 +98,143 @@ function entryIsPeaksManaged(entry) {
|
|
|
118
98
|
if (typeof h?.command !== 'string')
|
|
119
99
|
return false;
|
|
120
100
|
const cmd = h.command;
|
|
121
|
-
return
|
|
101
|
+
return sentinels.some((sentinel) => cmd.includes(sentinel));
|
|
122
102
|
});
|
|
123
103
|
}
|
|
124
|
-
|
|
125
|
-
|
|
104
|
+
/**
|
|
105
|
+
* Compute the per-IDE peaks hook entries to merge into the settings file.
|
|
106
|
+
* Replaces the slice #1 hardcoded `PEAKS_HOOK_ENTRIES` constant; the constant
|
|
107
|
+
* remains exported (computed for claude-code) for backward compat.
|
|
108
|
+
*/
|
|
109
|
+
function resolveHookEntries(ide) {
|
|
110
|
+
const spec = resolveHookSpec(ide);
|
|
111
|
+
return [
|
|
112
|
+
{ sentinel: spec.hookEnforceSentinel, matcher: spec.hookEnforceMatcher, command: spec.hookEnforceCommand, event: spec.hookEnforceEvent },
|
|
113
|
+
{ sentinel: spec.hookProgressSentinel, matcher: spec.hookProgressMatcher, command: spec.hookProgressCommand, event: spec.hookProgressEvent }
|
|
114
|
+
];
|
|
115
|
+
}
|
|
116
|
+
/** Default (claude-code) peaks-managed hook entries — kept as a stable export for tests. */
|
|
117
|
+
export const PEAKS_HOOK_ENTRIES = (() => {
|
|
118
|
+
const spec = resolveHookSpec('claude-code');
|
|
119
|
+
return [
|
|
120
|
+
{ sentinel: spec.hookEnforceSentinel, matcher: spec.hookEnforceMatcher, command: spec.hookEnforceCommand, event: spec.hookEnforceEvent },
|
|
121
|
+
{ sentinel: spec.hookProgressSentinel, matcher: spec.hookProgressMatcher, command: spec.hookProgressCommand, event: spec.hookProgressEvent }
|
|
122
|
+
];
|
|
123
|
+
})();
|
|
124
|
+
function isInstalledForIde(settings, ide) {
|
|
125
|
+
const entries = resolveHookEntries(ide);
|
|
126
|
+
const sentinels = entries.map((e) => e.sentinel);
|
|
127
|
+
// Check every distinct event key our entries could be on.
|
|
128
|
+
const eventKeys = new Set(entries.map((e) => e.event));
|
|
129
|
+
for (const eventKey of eventKeys) {
|
|
130
|
+
if (readHookEventEntries(settings, eventKey).some((e) => entryIsPeaksManaged(e, sentinels))) {
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return false;
|
|
126
135
|
}
|
|
127
|
-
export function planHookInstall(scope, projectRoot) {
|
|
136
|
+
export function planHookInstall(scope, projectRoot, options) {
|
|
137
|
+
const ide = resolveIde(options);
|
|
128
138
|
const root = resolveSettingsRoot(scope, projectRoot);
|
|
129
|
-
const settingsPath = resolveSettingsPath(scope, projectRoot);
|
|
130
|
-
|
|
139
|
+
const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
|
|
140
|
+
assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
|
|
131
141
|
const exists = existsSync(settingsPath);
|
|
132
|
-
const settings =
|
|
142
|
+
const settings = exists ? readJsonObjectFile(settingsPath) : {};
|
|
143
|
+
const spec = resolveHookSpec(ide);
|
|
133
144
|
return {
|
|
134
145
|
scope,
|
|
135
146
|
settingsPath,
|
|
136
147
|
exists,
|
|
137
|
-
alreadyInstalled:
|
|
138
|
-
desiredCommand:
|
|
139
|
-
sentinel:
|
|
140
|
-
matcher:
|
|
148
|
+
alreadyInstalled: isInstalledForIde(settings, ide),
|
|
149
|
+
desiredCommand: spec.hookEnforceCommand,
|
|
150
|
+
sentinel: spec.hookEnforceSentinel,
|
|
151
|
+
matcher: spec.hookEnforceMatcher
|
|
141
152
|
};
|
|
142
153
|
}
|
|
143
|
-
/** Merge all peaks-managed
|
|
144
|
-
function
|
|
154
|
+
/** Merge all peaks-managed hook entries into settings, preserving all other keys and hooks. */
|
|
155
|
+
function withHooksInstalledForIde(settings, ide) {
|
|
145
156
|
const existingHooks = (settings.hooks && typeof settings.hooks === 'object' && !Array.isArray(settings.hooks))
|
|
146
157
|
? settings.hooks
|
|
147
158
|
: {};
|
|
148
|
-
|
|
149
|
-
//
|
|
150
|
-
//
|
|
151
|
-
const
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
159
|
+
// Per-IDE entries may map to different events (Trae: both on beforeToolCall;
|
|
160
|
+
// Claude: both on PreToolUse). Group by event so each event array is
|
|
161
|
+
// independently merged.
|
|
162
|
+
const ourByEvent = new Map();
|
|
163
|
+
for (const spec of resolveHookEntries(ide)) {
|
|
164
|
+
const list = ourByEvent.get(spec.event) ?? [];
|
|
165
|
+
list.push(spec);
|
|
166
|
+
ourByEvent.set(spec.event, list);
|
|
167
|
+
}
|
|
168
|
+
const sentinels = resolveHookEntries(ide).map((e) => e.sentinel);
|
|
169
|
+
const nextHooks = { ...existingHooks };
|
|
170
|
+
for (const [eventKey, ourEntries] of ourByEvent) {
|
|
171
|
+
const existing = readHookEntriesFromHooks(nextHooks, eventKey);
|
|
172
|
+
const nonPeaks = existing.filter((entry) => !entryIsPeaksManaged(entry, sentinels));
|
|
173
|
+
const ourFormatted = ourEntries.map((spec) => ({
|
|
174
|
+
matcher: spec.matcher,
|
|
175
|
+
hooks: [{ type: 'command', command: spec.command }]
|
|
176
|
+
}));
|
|
177
|
+
const merged = [...nonPeaks, ...ourFormatted];
|
|
178
|
+
if (merged.length > 0) {
|
|
179
|
+
nextHooks[eventKey] = merged;
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
delete nextHooks[eventKey];
|
|
183
|
+
}
|
|
184
|
+
}
|
|
156
185
|
return {
|
|
157
186
|
...settings,
|
|
158
|
-
hooks:
|
|
187
|
+
hooks: nextHooks
|
|
159
188
|
};
|
|
160
189
|
}
|
|
161
|
-
export function applyHookInstall(scope, projectRoot) {
|
|
190
|
+
export function applyHookInstall(scope, projectRoot, options) {
|
|
191
|
+
const ide = resolveIde(options);
|
|
162
192
|
const root = resolveSettingsRoot(scope, projectRoot);
|
|
163
|
-
const settingsPath = resolveSettingsPath(scope, projectRoot);
|
|
164
|
-
|
|
193
|
+
const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
|
|
194
|
+
assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
|
|
165
195
|
const exists = existsSync(settingsPath);
|
|
166
|
-
const settings =
|
|
167
|
-
|
|
168
|
-
|
|
196
|
+
const settings = exists ? readJsonObjectFile(settingsPath) : {};
|
|
197
|
+
const spec = resolveHookSpec(ide);
|
|
198
|
+
const baseResult = {
|
|
199
|
+
scope,
|
|
200
|
+
settingsPath,
|
|
201
|
+
exists,
|
|
202
|
+
alreadyInstalled: isInstalledForIde(settings, ide),
|
|
203
|
+
desiredCommand: spec.hookEnforceCommand,
|
|
204
|
+
sentinel: spec.hookEnforceSentinel,
|
|
205
|
+
matcher: spec.hookEnforceMatcher
|
|
206
|
+
};
|
|
207
|
+
if (baseResult.alreadyInstalled) {
|
|
208
|
+
return { ...baseResult, applied: false };
|
|
169
209
|
}
|
|
170
|
-
atomicWriteJson(settingsPath,
|
|
171
|
-
return {
|
|
210
|
+
atomicWriteJson(settingsPath, withHooksInstalledForIde(settings, ide));
|
|
211
|
+
return { ...baseResult, alreadyInstalled: false, applied: true };
|
|
172
212
|
}
|
|
173
|
-
export function removeHookInstall(scope, projectRoot) {
|
|
213
|
+
export function removeHookInstall(scope, projectRoot, options) {
|
|
214
|
+
const ide = resolveIde(options);
|
|
174
215
|
const root = resolveSettingsRoot(scope, projectRoot);
|
|
175
|
-
const settingsPath = resolveSettingsPath(scope, projectRoot);
|
|
176
|
-
|
|
216
|
+
const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
|
|
217
|
+
assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
|
|
177
218
|
if (!existsSync(settingsPath)) {
|
|
178
219
|
return { scope, settingsPath, removed: false };
|
|
179
220
|
}
|
|
180
|
-
const settings =
|
|
181
|
-
const preToolUse = readPreToolUse(settings);
|
|
182
|
-
const kept = preToolUse.filter((entry) => !entryIsPeaksManaged(entry));
|
|
183
|
-
if (kept.length === preToolUse.length) {
|
|
184
|
-
return { scope, settingsPath, removed: false };
|
|
185
|
-
}
|
|
221
|
+
const settings = readJsonObjectFile(settingsPath);
|
|
186
222
|
const existingHooks = settings.hooks ?? {};
|
|
223
|
+
const sentinels = resolveHookEntries(ide).map((e) => e.sentinel);
|
|
224
|
+
const eventKeys = new Set(resolveHookEntries(ide).map((e) => e.event));
|
|
225
|
+
let removedAny = false;
|
|
187
226
|
const nextHooks = { ...existingHooks };
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
227
|
+
for (const eventKey of eventKeys) {
|
|
228
|
+
const entries = readHookEntriesFromHooks(nextHooks, eventKey);
|
|
229
|
+
const kept = entries.filter((entry) => !entryIsPeaksManaged(entry, sentinels));
|
|
230
|
+
if (kept.length !== entries.length)
|
|
231
|
+
removedAny = true;
|
|
232
|
+
if (kept.length > 0) {
|
|
233
|
+
nextHooks[eventKey] = kept;
|
|
234
|
+
}
|
|
235
|
+
else {
|
|
236
|
+
delete nextHooks[eventKey];
|
|
237
|
+
}
|
|
193
238
|
}
|
|
194
239
|
const nextSettings = { ...settings };
|
|
195
240
|
if (Object.keys(nextHooks).length > 0) {
|
|
@@ -199,13 +244,14 @@ export function removeHookInstall(scope, projectRoot) {
|
|
|
199
244
|
delete nextSettings.hooks;
|
|
200
245
|
}
|
|
201
246
|
atomicWriteJson(settingsPath, nextSettings);
|
|
202
|
-
return { scope, settingsPath, removed:
|
|
247
|
+
return { scope, settingsPath, removed: removedAny };
|
|
203
248
|
}
|
|
204
|
-
export function readHookStatus(scope, projectRoot) {
|
|
249
|
+
export function readHookStatus(scope, projectRoot, options) {
|
|
250
|
+
const ide = resolveIde(options);
|
|
205
251
|
const root = resolveSettingsRoot(scope, projectRoot);
|
|
206
|
-
const settingsPath = resolveSettingsPath(scope, projectRoot);
|
|
207
|
-
|
|
252
|
+
const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
|
|
253
|
+
assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
|
|
208
254
|
const exists = existsSync(settingsPath);
|
|
209
|
-
const settings = exists ?
|
|
210
|
-
return { scope, settingsPath, exists, installed:
|
|
255
|
+
const settings = exists ? readJsonObjectFile(settingsPath) : {};
|
|
256
|
+
return { scope, settingsPath, exists, installed: isInstalledForIde(settings, ide) };
|
|
211
257
|
}
|
|
@@ -1,13 +1,38 @@
|
|
|
1
|
+
import { isInsidePath } from '../ide/shared/safe-path.js';
|
|
2
|
+
import type { IdeId } from '../ide/ide-types.js';
|
|
1
3
|
/**
|
|
2
|
-
* Installs (and removes) the Peaks statusLine entry in
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
4
|
+
* Installs (and removes) the Peaks statusLine entry in an IDE's settings
|
|
5
|
+
* file. The settings file location is adapter-driven
|
|
6
|
+
* (`getAdapter(ide).settings.dirName` + `settingsFileName`) so a future slice
|
|
7
|
+
* adding a Trae / Cursor / Codex adapter does not need to touch this file.
|
|
8
|
+
*
|
|
9
|
+
* Slice #1 only registers claude-code, so the resolved path is still
|
|
10
|
+
* `<root>/.claude/settings.json` — the same as before the refactor. The
|
|
11
|
+
* statusLine entry is rendered as `{ type: 'command', command: 'peaks
|
|
12
|
+
* statusline' }` because that is the shape Claude Code expects; future
|
|
13
|
+
* adapters may need a different entry shape (e.g. Cursor's `statusBar`
|
|
14
|
+
* field) and would override this in their adapter.
|
|
15
|
+
*
|
|
16
|
+
* Slice #3 refactor (this commit): the service is now per-IDE aware via an
|
|
17
|
+
* optional `options.ide` parameter. The CLI command is responsible for
|
|
18
|
+
* resolving the IDE (env → stdin shape → cwd → fallback to 'claude-code')
|
|
19
|
+
* via `detectIdeFromContext` and passing the result here. When `ide` is
|
|
20
|
+
* omitted, the service defaults to `'claude-code'` so existing tests and
|
|
21
|
+
* downstream callers continue to work without modification.
|
|
6
22
|
*
|
|
7
23
|
* Writes preserve all other settings keys, reject symlinked targets, and use an
|
|
8
24
|
* atomic rename so a partial write can never corrupt an existing settings file.
|
|
9
25
|
*/
|
|
10
26
|
export type StatusLineScope = 'project' | 'global';
|
|
27
|
+
export type StatusLineSettingsOptions = {
|
|
28
|
+
/**
|
|
29
|
+
* Which IDE's adapter to install for. Defaults to `'claude-code'` for
|
|
30
|
+
* backward compatibility. The CLI command should resolve this from
|
|
31
|
+
* `detectIdeFromContext({ env, cwd, parsedStdin })` and pass the result.
|
|
32
|
+
* Throws if the IDE is not registered in the adapter registry.
|
|
33
|
+
*/
|
|
34
|
+
readonly ide?: IdeId;
|
|
35
|
+
};
|
|
11
36
|
export type StatusLineSettingsPlan = {
|
|
12
37
|
scope: StatusLineScope;
|
|
13
38
|
settingsPath: string;
|
|
@@ -21,11 +46,13 @@ export type StatusLineSettingsResult = StatusLineSettingsPlan & {
|
|
|
21
46
|
applied: boolean;
|
|
22
47
|
};
|
|
23
48
|
export declare const STATUSLINE_COMMAND = "peaks statusline";
|
|
24
|
-
export
|
|
49
|
+
export { isInsidePath };
|
|
50
|
+
export declare function planStatusLineInstall(scope: StatusLineScope, projectRoot?: string, options?: StatusLineSettingsOptions): StatusLineSettingsPlan;
|
|
25
51
|
export declare function applyStatusLineInstall(scope: StatusLineScope, projectRoot?: string, options?: {
|
|
26
52
|
force?: boolean;
|
|
53
|
+
ide?: IdeId;
|
|
27
54
|
}): StatusLineSettingsResult;
|
|
28
|
-
export declare function removeStatusLineInstall(scope: StatusLineScope, projectRoot?: string): {
|
|
55
|
+
export declare function removeStatusLineInstall(scope: StatusLineScope, projectRoot?: string, options?: StatusLineSettingsOptions): {
|
|
29
56
|
scope: StatusLineScope;
|
|
30
57
|
settingsPath: string;
|
|
31
58
|
removed: boolean;
|