peaks-cli 1.3.1 → 1.3.3

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.
Files changed (111) hide show
  1. package/README.md +6 -2
  2. package/bin/peaks.js +0 -0
  3. package/dist/src/cli/commands/core-artifact-commands.js +49 -11
  4. package/dist/src/cli/commands/gate-commands.js +28 -19
  5. package/dist/src/cli/commands/hook-handle.d.ts +17 -0
  6. package/dist/src/cli/commands/hook-handle.js +111 -0
  7. package/dist/src/cli/commands/hooks-commands.js +72 -21
  8. package/dist/src/cli/commands/progress-commands.js +9 -2
  9. package/dist/src/cli/commands/progress-start-spawn.js +30 -4
  10. package/dist/src/cli/commands/slice-commands.js +4 -2
  11. package/dist/src/cli/commands/statusline-commands.js +75 -17
  12. package/dist/src/cli/commands/sub-agent-commands.d.ts +5 -0
  13. package/dist/src/cli/commands/sub-agent-commands.js +488 -0
  14. package/dist/src/cli/commands/sub-agent-dispatch-guard.d.ts +55 -0
  15. package/dist/src/cli/commands/sub-agent-dispatch-guard.js +57 -0
  16. package/dist/src/cli/commands/workspace-commands.js +70 -14
  17. package/dist/src/cli/program.js +9 -0
  18. package/dist/src/hooks/pre-tool-use-sub-agent.d.ts +28 -0
  19. package/dist/src/hooks/pre-tool-use-sub-agent.js +105 -0
  20. package/dist/src/services/artifacts/artifact-prerequisites.d.ts +12 -0
  21. package/dist/src/services/artifacts/artifact-prerequisites.js +39 -8
  22. package/dist/src/services/artifacts/request-artifact-service.js +116 -76
  23. package/dist/src/services/config/config-types.d.ts +1 -1
  24. package/dist/src/services/context/artifact-meta.d.ts +72 -0
  25. package/dist/src/services/context/artifact-meta.js +105 -0
  26. package/dist/src/services/context/context-guard.d.ts +49 -0
  27. package/dist/src/services/context/context-guard.js +91 -0
  28. package/dist/src/services/context/dispatch-context-guard.d.ts +27 -0
  29. package/dist/src/services/context/dispatch-context-guard.js +192 -0
  30. package/dist/src/services/context/headroom-client.d.ts +34 -0
  31. package/dist/src/services/context/headroom-client.js +117 -0
  32. package/dist/src/services/context/shared-channel.d.ts +92 -0
  33. package/dist/src/services/context/shared-channel.js +285 -0
  34. package/dist/src/services/context/threshold.d.ts +35 -0
  35. package/dist/src/services/context/threshold.js +76 -0
  36. package/dist/src/services/dispatch/batch-counter.d.ts +27 -0
  37. package/dist/src/services/dispatch/batch-counter.js +85 -0
  38. package/dist/src/services/dispatch/dispatch-record-writer.d.ts +93 -0
  39. package/dist/src/services/dispatch/dispatch-record-writer.js +261 -0
  40. package/dist/src/services/dispatch/heartbeat-truncator.d.ts +26 -0
  41. package/dist/src/services/dispatch/heartbeat-truncator.js +13 -0
  42. package/dist/src/services/dispatch/leak-detector.d.ts +11 -0
  43. package/dist/src/services/dispatch/leak-detector.js +72 -0
  44. package/dist/src/services/dispatch/sub-agent-dispatcher.d.ts +127 -0
  45. package/dist/src/services/dispatch/sub-agent-dispatcher.js +98 -0
  46. package/dist/src/services/doctor/doctor-service.d.ts +62 -0
  47. package/dist/src/services/doctor/doctor-service.js +276 -1
  48. package/dist/src/services/ide/adapters/claude-code-adapter.d.ts +18 -0
  49. package/dist/src/services/ide/adapters/claude-code-adapter.js +53 -0
  50. package/dist/src/services/ide/adapters/trae-adapter.d.ts +34 -0
  51. package/dist/src/services/ide/adapters/trae-adapter.js +70 -0
  52. package/dist/src/services/ide/hook-protocol.d.ts +44 -0
  53. package/dist/src/services/ide/hook-protocol.js +71 -0
  54. package/dist/src/services/ide/hook-translator.d.ts +72 -0
  55. package/dist/src/services/ide/hook-translator.js +128 -0
  56. package/dist/src/services/ide/ide-detector.d.ts +10 -0
  57. package/dist/src/services/ide/ide-detector.js +19 -0
  58. package/dist/src/services/ide/ide-registry.d.ts +14 -0
  59. package/dist/src/services/ide/ide-registry.js +45 -0
  60. package/dist/src/services/ide/ide-types.d.ts +120 -0
  61. package/dist/src/services/ide/ide-types.js +2 -0
  62. package/dist/src/services/ide/shared/atomic-json.d.ts +15 -0
  63. package/dist/src/services/ide/shared/atomic-json.js +58 -0
  64. package/dist/src/services/ide/shared/safe-path.d.ts +11 -0
  65. package/dist/src/services/ide/shared/safe-path.js +29 -0
  66. package/dist/src/services/progress/progress-service.d.ts +1 -1
  67. package/dist/src/services/progress/progress-service.js +18 -14
  68. package/dist/src/services/security/safe-settings-path.d.ts +12 -0
  69. package/dist/src/services/security/safe-settings-path.js +104 -0
  70. package/dist/src/services/session/session-manager.d.ts +22 -1
  71. package/dist/src/services/session/session-manager.js +137 -28
  72. package/dist/src/services/signal/cancel-handler.d.ts +14 -0
  73. package/dist/src/services/signal/cancel-handler.js +76 -0
  74. package/dist/src/services/skill/resume-detector.d.ts +54 -0
  75. package/dist/src/services/skill/resume-detector.js +334 -0
  76. package/dist/src/services/skill/skill-scheduler.d.ts +40 -0
  77. package/dist/src/services/skill/skill-scheduler.js +53 -0
  78. package/dist/src/services/skills/hooks-settings-service.d.ts +47 -29
  79. package/dist/src/services/skills/hooks-settings-service.js +190 -144
  80. package/dist/src/services/skills/statusline-settings-service.d.ts +33 -6
  81. package/dist/src/services/skills/statusline-settings-service.js +31 -34
  82. package/dist/src/services/slice/slice-archive-service.d.ts +20 -0
  83. package/dist/src/services/slice/slice-archive-service.js +111 -0
  84. package/dist/src/services/slice/slice-check-service.js +20 -1
  85. package/dist/src/services/slice/slice-check-types.d.ts +9 -0
  86. package/dist/src/services/solo/batch-heartbeat-poller.d.ts +51 -0
  87. package/dist/src/services/solo/batch-heartbeat-poller.js +88 -0
  88. package/dist/src/services/solo/status-line-renderer.d.ts +34 -0
  89. package/dist/src/services/solo/status-line-renderer.js +55 -0
  90. package/dist/src/services/workspace/migrate-service.js +124 -2
  91. package/dist/src/services/workspace/migrate-types.d.ts +50 -7
  92. package/dist/src/services/workspace/reconcile-service.d.ts +69 -0
  93. package/dist/src/services/workspace/reconcile-service.js +267 -48
  94. package/dist/src/services/workspace/reconcile-types.d.ts +37 -0
  95. package/dist/src/services/workspace/workspace-service.js +29 -62
  96. package/dist/src/shared/version.d.ts +1 -1
  97. package/dist/src/shared/version.js +1 -1
  98. package/package.json +2 -1
  99. package/schemas/doctor-report.schema.json +2 -2
  100. package/skills/peaks-ide/SKILL.md +159 -0
  101. package/skills/peaks-qa/SKILL.md +58 -1
  102. package/skills/peaks-qa/references/qa-fanout-contract.md +150 -0
  103. package/skills/peaks-rd/SKILL.md +52 -9
  104. package/skills/peaks-solo/SKILL.md +83 -20
  105. package/skills/peaks-solo/references/context-governance.md +144 -0
  106. package/skills/peaks-solo/references/headroom-integration.md +107 -0
  107. package/skills/peaks-solo/references/runbook.md +3 -3
  108. package/skills/peaks-solo/references/sub-agent-dispatch.md +218 -0
  109. package/skills/peaks-solo/references/swarm-dispatch-contract.md +3 -37
  110. package/skills/peaks-txt/SKILL.md +19 -0
  111. package/skills/peaks-ui/SKILL.md +28 -1
@@ -1,38 +1,57 @@
1
- import { closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
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
- /** The hook command written into settings for the gate-enforce PreToolUse hook. `${CLAUDE_PROJECT_DIR}` is injected by Claude Code. */
6
- export const HOOK_ENFORCE_COMMAND = 'peaks gate enforce --project "${CLAUDE_PROJECT_DIR}"';
7
- /**
8
- * Hook command for the sub-agent progress auto-spawn. Fires on every Task
9
- * tool call (the harness-enforced mechanism for "sub-agent dispatch"). The
10
- * command itself is non-blocking: `peaks progress start` is idempotent
11
- * (5-minute TTL on the spawn record) so the LLM does not see a fresh
12
- * terminal per Task. The `--quiet` flag keeps the LLM context clean — the
13
- * hook output otherwise adds ~500 tokens per Task call.
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
- /** Substring that identifies a Peaks-managed PreToolUse sub-agent-progress hook entry. */
13
+ /** Sentinel substring identifying a peaks-managed sub-agent-progress hook entry. */
19
14
  export const HOOK_PROGRESS_SENTINEL = 'peaks progress start';
20
- const HOOK_GATE_MATCHER = 'Bash';
21
- const HOOK_PROGRESS_MATCHER = 'Task';
22
- /**
23
- * Substring sentinels that identify a Peaks-managed PreToolUse hook entry.
24
- * Used to keep `uninstall` and `isInstalled` checks tight: we only touch
25
- * entries we wrote, never third-party hooks.
26
- */
27
- const PEAKS_HOOK_SENTINELS = [HOOK_ENFORCE_SENTINEL, HOOK_PROGRESS_SENTINEL];
28
- export const PEAKS_HOOK_ENTRIES = [
29
- { sentinel: HOOK_ENFORCE_SENTINEL, matcher: HOOK_GATE_MATCHER, command: HOOK_ENFORCE_COMMAND },
30
- { sentinel: HOOK_PROGRESS_SENTINEL, matcher: HOOK_PROGRESS_MATCHER, command: HOOK_PROGRESS_COMMAND }
31
- ];
32
- function isInsidePath(childPath, parentPath) {
33
- const rel = relative(parentPath, childPath);
34
- return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
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
- return join(resolveSettingsRoot(scope, projectRoot), '.claude', 'settings.json');
46
- }
47
- function assertSafeSettingsPath(scope, root, settingsPath) {
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 atomicWriteJson(settingsPath, settings) {
81
- const dir = dirname(settingsPath);
82
- mkdirSync(dir, { recursive: true });
83
- const tempPath = join(dir, `.settings.${randomUUID()}.tmp`);
84
- const fd = openSync(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
85
- try {
86
- writeFileSync(fd, `${JSON.stringify(settings, null, 2)}\n`, 'utf8');
87
- }
88
- finally {
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 PreToolUse matcher entries (tolerant of any prior shape). */
105
- function readPreToolUse(settings) {
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 pre = hooks.PreToolUse;
110
- return Array.isArray(pre) ? pre : [];
84
+ const arr = hooks[eventKey];
85
+ return Array.isArray(arr) ? arr : [];
111
86
  }
112
- /** True when every command handler in the entry matches a known peaks sentinel. */
113
- function entryIsPeaksManaged(entry) {
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 PEAKS_HOOK_SENTINELS.some((sentinel) => cmd.includes(sentinel));
101
+ return sentinels.some((sentinel) => cmd.includes(sentinel));
122
102
  });
123
103
  }
124
- function isInstalled(settings) {
125
- return readPreToolUse(settings).some(entryIsPeaksManaged);
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
- assertSafeSettingsPath(scope, root, settingsPath);
139
+ const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
140
+ assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
131
141
  const exists = existsSync(settingsPath);
132
- const settings = readSettings(settingsPath);
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: isInstalled(settings),
138
- desiredCommand: HOOK_ENFORCE_COMMAND,
139
- sentinel: HOOK_ENFORCE_SENTINEL,
140
- matcher: HOOK_GATE_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 PreToolUse entries into settings, preserving all other keys and hooks. */
144
- function withHooksInstalled(settings) {
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
- const preToolUse = readPreToolUse(settings);
149
- // Drop any existing peaks-managed entries first so re-runs are idempotent
150
- // even if the command string changed (e.g. a bug fix in the command).
151
- const nonPeaks = preToolUse.filter((entry) => !entryIsPeaksManaged(entry));
152
- const ourEntries = PEAKS_HOOK_ENTRIES.map((spec) => ({
153
- matcher: spec.matcher,
154
- hooks: [{ type: 'command', command: spec.command }]
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: { ...existingHooks, PreToolUse: [...nonPeaks, ...ourEntries] }
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
- assertSafeSettingsPath(scope, root, settingsPath);
193
+ const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
194
+ assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
165
195
  const exists = existsSync(settingsPath);
166
- const settings = readSettings(settingsPath);
167
- if (isInstalled(settings)) {
168
- return { scope, settingsPath, exists, alreadyInstalled: true, desiredCommand: HOOK_ENFORCE_COMMAND, applied: false, sentinel: HOOK_ENFORCE_SENTINEL, matcher: HOOK_GATE_MATCHER };
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, withHooksInstalled(settings));
171
- return { scope, settingsPath, exists, alreadyInstalled: false, desiredCommand: HOOK_ENFORCE_COMMAND, applied: true, sentinel: HOOK_ENFORCE_SENTINEL, matcher: HOOK_GATE_MATCHER };
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
- assertSafeSettingsPath(scope, root, settingsPath);
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 = readSettings(settingsPath);
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
- if (kept.length > 0) {
189
- nextHooks.PreToolUse = kept;
190
- }
191
- else {
192
- delete nextHooks.PreToolUse;
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: true };
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
- assertSafeSettingsPath(scope, root, settingsPath);
252
+ const settingsPath = resolveSettingsPath(scope, ide, projectRoot);
253
+ assertSafeSettingsPathCompat(scope, ide, root, settingsPath);
208
254
  const exists = existsSync(settingsPath);
209
- const settings = exists ? readSettings(settingsPath) : {};
210
- return { scope, settingsPath, exists, installed: isInstalled(settings) };
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 a Claude Code
3
- * settings.json. The statusLine renders `peaks statusline` on every turn, giving
4
- * users an out-of-band, harness-painted signal of which Peaks skill is active —
5
- * independent of LLM tokens and immune to context compaction.
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 declare function planStatusLineInstall(scope: StatusLineScope, projectRoot?: string): StatusLineSettingsPlan;
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;
@@ -1,11 +1,15 @@
1
- import { closeSync, constants, existsSync, lstatSync, mkdirSync, openSync, readFileSync, realpathSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
1
+ import { closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
- import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
3
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
4
4
  import { homedir } from 'node:os';
5
+ import { assertSafeSettingsFile, isInsidePath } from '../ide/shared/safe-path.js';
6
+ import { getAdapter } from '../ide/ide-registry.js';
5
7
  export const STATUSLINE_COMMAND = 'peaks statusline';
6
- function isInsidePath(childPath, parentPath) {
7
- const rel = relative(parentPath, childPath);
8
- return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
8
+ // Re-export the shared helper so existing consumers that imported
9
+ // `isInsidePath` from this module keep compiling.
10
+ export { isInsidePath };
11
+ function resolveIde(options) {
12
+ return options?.ide ?? 'claude-code';
9
13
  }
10
14
  function resolveSettingsRoot(scope, projectRoot) {
11
15
  if (scope === 'global')
@@ -15,25 +19,17 @@ function resolveSettingsRoot(scope, projectRoot) {
15
19
  }
16
20
  return resolve(projectRoot);
17
21
  }
18
- function resolveSettingsPath(scope, projectRoot) {
22
+ /**
23
+ * Resolve + safety-check the settings path for the given IDE and scope. The
24
+ * `dirName` and `settingsFileName` come from the registered adapter
25
+ * (`getAdapter(ide)`) so the hardcoded `.claude/settings.json` is gone —
26
+ * future adapters swap by changing the registry, not this file.
27
+ */
28
+ function resolveAndAssertSettingsPath(scope, ide, projectRoot) {
19
29
  const root = resolveSettingsRoot(scope, projectRoot);
20
- return join(root, '.claude', 'settings.json');
21
- }
22
- /** Reject symlinked .claude dir or settings file to prevent escape. */
23
- function assertSafeSettingsPath(scope, root, settingsPath) {
24
- const claudeDir = join(root, '.claude');
25
- if (existsSync(claudeDir) && lstatSync(claudeDir).isSymbolicLink()) {
26
- throw new Error('.claude directory must not be a symlink');
27
- }
28
- if (existsSync(settingsPath)) {
29
- if (lstatSync(settingsPath).isSymbolicLink()) {
30
- throw new Error('settings.json must not be a symlink');
31
- }
32
- const realRoot = realpathSync(root);
33
- if (!isInsidePath(realpathSync(settingsPath), realRoot)) {
34
- throw new Error(`settings.json must stay inside the ${scope} root`);
35
- }
36
- }
30
+ const adapter = getAdapter(ide);
31
+ const { settingsPath } = assertSafeSettingsFile(scope, root, adapter.settings.dirName, adapter.settings.settingsFileName);
32
+ return { root, settingsPath };
37
33
  }
38
34
  function readSettings(settingsPath) {
39
35
  if (!existsSync(settingsPath))
@@ -76,10 +72,9 @@ function buildPlan(scope, settingsPath, settings, exists) {
76
72
  desiredCommand: STATUSLINE_COMMAND
77
73
  };
78
74
  }
79
- export function planStatusLineInstall(scope, projectRoot) {
80
- const root = resolveSettingsRoot(scope, projectRoot);
81
- const settingsPath = resolveSettingsPath(scope, projectRoot);
82
- assertSafeSettingsPath(scope, root, settingsPath);
75
+ export function planStatusLineInstall(scope, projectRoot, options) {
76
+ const ide = resolveIde(options);
77
+ const { settingsPath } = resolveAndAssertSettingsPath(scope, ide, projectRoot);
83
78
  const exists = existsSync(settingsPath);
84
79
  const settings = readSettings(settingsPath);
85
80
  return buildPlan(scope, settingsPath, settings, exists);
@@ -109,9 +104,8 @@ function atomicWriteJson(settingsPath, settings) {
109
104
  }
110
105
  }
111
106
  export function applyStatusLineInstall(scope, projectRoot, options = {}) {
112
- const root = resolveSettingsRoot(scope, projectRoot);
113
- const settingsPath = resolveSettingsPath(scope, projectRoot);
114
- assertSafeSettingsPath(scope, root, settingsPath);
107
+ const ide = resolveIde(options);
108
+ const { settingsPath } = resolveAndAssertSettingsPath(scope, ide, projectRoot);
115
109
  const exists = existsSync(settingsPath);
116
110
  const settings = readSettings(settingsPath);
117
111
  const plan = buildPlan(scope, settingsPath, settings, exists);
@@ -126,10 +120,9 @@ export function applyStatusLineInstall(scope, projectRoot, options = {}) {
126
120
  atomicWriteJson(settingsPath, nextSettings);
127
121
  return { ...plan, applied: true };
128
122
  }
129
- export function removeStatusLineInstall(scope, projectRoot) {
130
- const root = resolveSettingsRoot(scope, projectRoot);
131
- const settingsPath = resolveSettingsPath(scope, projectRoot);
132
- assertSafeSettingsPath(scope, root, settingsPath);
123
+ export function removeStatusLineInstall(scope, projectRoot, options) {
124
+ const ide = resolveIde(options);
125
+ const { settingsPath } = resolveAndAssertSettingsPath(scope, ide, projectRoot);
133
126
  if (!existsSync(settingsPath)) {
134
127
  return { scope, settingsPath, removed: false };
135
128
  }
@@ -142,3 +135,7 @@ export function removeStatusLineInstall(scope, projectRoot) {
142
135
  atomicWriteJson(settingsPath, rest);
143
136
  return { scope, settingsPath, removed: true };
144
137
  }
138
+ // Suppress unused-import warning for `isAbsolute` if it becomes unused in
139
+ // future refactors. The pre-refactor file used it in the local isInsidePath;
140
+ // the shared helper owns that logic now.
141
+ void isAbsolute;
@@ -0,0 +1,20 @@
1
+ export declare const ARCHIVE_RETENTION_MS: number;
2
+ export interface ArchiveResult {
3
+ readonly archivedCompleted: number;
4
+ readonly archivedInFlight: number;
5
+ readonly gcDeleted: number;
6
+ }
7
+ /** Build the canonical archive dir for a given session + slice id. */
8
+ export declare function archiveDir(projectRoot: string, sessionId: string, sliceId: string): string;
9
+ /** Build the in-flight subdir (records not yet disposed). */
10
+ export declare function inFlightArchiveDir(projectRoot: string, sessionId: string, sliceId: string): string;
11
+ /**
12
+ * Archive the current `.peaks/_sub_agents/<sid>/` tree under
13
+ * `<archiveDir>` (per the sliceId), separating completed vs in-flight.
14
+ * Then run the 30-day GC over the archive dir.
15
+ */
16
+ export declare function archiveSubAgentRecords(projectRoot: string, options: {
17
+ sessionId: string;
18
+ sliceId: string;
19
+ now?: () => Date;
20
+ }): ArchiveResult;