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
package/README.md CHANGED
@@ -8,8 +8,8 @@
8
8
  Peaks 是一个**跨 AI IDE 的工作流门禁 CLI + 技能家族**——把项目治理、工作流规划、受控执行、QA 验证、变更追踪组织成可复用的工程流程。CLI 是跨 IDE 稳定的核心(门禁 + JSON 契约 + 不可逆动作),技能 / 钩子 / 配置按各 IDE 的原生格式承载。
9
9
 
10
10
  > **支持的 IDE**:
11
- > - ✅ **Claude Code**(当前实现):11 个 `peaks-*` 技能 + `.claude/settings.json` PreToolUse hook
12
- > - 🚧 **Trae**(适配中):用 Trae 的原生配置承载相同的角色 / 状态机 / 门禁
11
+ > - ✅ **Claude Code**(shipped, 当前主用):11 个 `peaks-*` 技能 + `.claude/settings.json` PreToolUse hook;agent team 在本 IDE 已 dogfood
12
+ > - ⚠️ **Trae**(adapter shipped, real-Trae unverified):slim `IdeAdapter` 已注册到 slice #1 registry(`hookEvent` / `toolMatcher` / `envVar` 是 1.x 假设,**未在真实 Trae 上验证**);真实 Trae 集成 dogfood 留到后续切片
13
13
  > - 📋 **Codex / Cursor / Qoder / 通义灵码 等**(路线图)
14
14
 
15
15
  > **产品定位**:你**用技能工作**,CLI 是跨 IDE 的质量保障层。
@@ -115,6 +115,10 @@ peaks-solo strict 模式做 X # 显式 strict:最严格门禁
115
115
 
116
116
  **3 个 solo 包装 + 7 个角色技能 + 1 个 solo 编排 = 11 个技能家族。** 日常使用中,1 个(`peaks-solo`)覆盖 ≥90% 的需求。
117
117
 
118
+ ## Agent team
119
+
120
+ `peaks` 帮你调度一个 agent team——`peaks-solo` / `peaks-rd` / `peaks-qa` / `peaks-ui` 把 peer sub-agent 派到隔离沙箱里写 PRD / 做架构分析 / 跑测试 / 设计 UI,主 LLM 只看每个 sub-agent 的元数据(路径 + 大小 + 摘要)。
121
+
118
122
  ## 怎么用:技能优先,CLI 是门禁
119
123
 
120
124
  Peaks 里的 `peaks <cmd>` CLI **不是日常使用的主要入口**。它的存在有三个理由,全都是机器层保障:
package/bin/peaks.js CHANGED
File without changes
@@ -8,7 +8,7 @@ import { runDoctor } from '../../services/doctor/doctor-service.js';
8
8
  import { listSkills } from '../../services/skills/skill-registry.js';
9
9
  import { inspectSkillRunbook } from '../../services/skills/skill-runbook-service.js';
10
10
  import { setSkillPresence, clearSkillPresence, getSkillPresence, isSkillPresenceMode, touchSkillHeartbeat } from '../../services/skills/skill-presence-service.js';
11
- import { ensureSession, getSessionMeta, rotateSessionBinding, setSessionMeta, setSessionTitle, listSessionMetas } from '../../services/session/session-manager.js';
11
+ import { getSessionId, getSessionMeta, rotateSessionBinding, setSessionMeta, setSessionTitle, listSessionMetas } from '../../services/session/session-manager.js';
12
12
  import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
13
13
  import { findProjectRoot } from '../../services/config/config-safety.js';
14
14
  import { generateProjectContext } from '../../services/memory/project-context-service.js';
@@ -120,7 +120,7 @@ export function registerCoreAndArtifactCommands(program, io) {
120
120
  .description('Set the currently active Peaks skill for session-wide visibility')
121
121
  .option('--mode <mode>', 'execution mode')
122
122
  .option('--gate <gate>', 'current gate')
123
- .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')).action(async (name, options) => {
123
+ .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')).action((name, options) => {
124
124
  const projectRoot = options.project ?? findProjectRoot(process.cwd()) ?? process.cwd();
125
125
  if (options.mode !== undefined && !isSkillPresenceMode(options.mode)) {
126
126
  printResult(io, fail('skill.presence:set', 'INVALID_MODE', `Invalid mode: ${options.mode} (expected one of: full-auto, assisted, swarm, strict)`, { name, mode: options.mode }, ['Use a valid mode: full-auto, assisted, swarm, or strict']), options.json);
@@ -128,13 +128,26 @@ export function registerCoreAndArtifactCommands(program, io) {
128
128
  return;
129
129
  }
130
130
  const presence = setSkillPresence(name, options.mode, options.gate, options.project);
131
- // Also update session metadata so session dirs self-document
132
- const sessionId = await ensureSession(projectRoot);
133
- setSessionMeta(projectRoot, sessionId, {
134
- skill: name,
135
- ...(options.mode ? { mode: options.mode } : {}),
136
- ...(options.gate ? { gate: options.gate } : {})
137
- });
131
+ // As of slice 003-2026-06-06-session-layout-canonicalize we do NOT
132
+ // call `ensureSession` here. The CLI wrapper previously spawned a
133
+ // new session on every presence call, which made the canonical
134
+ // session binding drift (the LLM saw the session id change every
135
+ // turn). The presence now reuses the session bound at
136
+ // `.peaks/_runtime/session.json` (or the legacy `.peaks/.session.json`
137
+ // during the back-compat window). If no session is bound, the
138
+ // presence still writes the active-skill marker — downstream code
139
+ // can `peaks workspace init` separately to create the session.
140
+ //
141
+ // Session metadata is updated when a session is bound (read-only
142
+ // path: `getSessionId`). We do not auto-spawn a session.
143
+ const boundSessionId = getSessionId(projectRoot);
144
+ if (boundSessionId !== null) {
145
+ setSessionMeta(projectRoot, boundSessionId, {
146
+ skill: name,
147
+ ...(options.mode ? { mode: options.mode } : {}),
148
+ ...(options.gate ? { gate: options.gate } : {})
149
+ });
150
+ }
138
151
  printResult(io, ok('skill.presence:set', { active: true, ...presence }), options.json);
139
152
  });
140
153
  addJsonOption(skill
@@ -191,9 +204,34 @@ export function registerCoreAndArtifactCommands(program, io) {
191
204
  printResult(io, ok('session.list', { sessions: metas, total: metas.length }), options.json);
192
205
  });
193
206
  addJsonOption(session
194
- .command('info <sessionId>')
195
- .description('Show full metadata for a session directory')).action((sessionId, options) => {
207
+ .command('info [sessionId]')
208
+ .description('Show full metadata for a session directory. Pass --active to resolve the canonical binding from .peaks/_runtime/session.json (the "one command a sub-agent runs to find the parent\'s sid" primitive).')
209
+ .option('--active', 'resolve the canonical session id from .peaks/_runtime/session.json (ignores [sessionId] when set)')).action(async (sessionId, options) => {
196
210
  const projectRoot = findProjectRoot(process.cwd()) ?? process.cwd();
211
+ // Slice 007 — sub-agent session sharing. A sub-agent that does
212
+ // not know the parent's sid reads it from the binding via
213
+ // `peaks session info --active`. The call uses the
214
+ // canonicalize-on-read path so a stored "projectRoot: '.'" and a
215
+ // caller-passed absolute realpath both resolve to the same
216
+ // binding. Without this primitive the sub-agent has no way to
217
+ // discover the parent sid short of scanning the filesystem.
218
+ if (options.active === true) {
219
+ // Import lazily to avoid a cycle with workspace-commands.
220
+ const { getSessionIdCanonical } = await import('../../services/session/session-manager.js');
221
+ const activeSid = getSessionIdCanonical(projectRoot);
222
+ if (activeSid === null) {
223
+ printResult(io, fail('session.info', 'NO_ACTIVE_BINDING', 'No canonical session binding at .peaks/_runtime/session.json', { projectRoot }, ['Run `peaks workspace init` or `peaks skill presence:set` to anchor a session']), options.json);
224
+ process.exitCode = 1;
225
+ return;
226
+ }
227
+ printResult(io, ok('session.info', { active: true, sessionId: activeSid, source: '.peaks/_runtime/session.json' }), options.json);
228
+ return;
229
+ }
230
+ if (sessionId === undefined) {
231
+ printResult(io, fail('session.info', 'SESSION_ID_REQUIRED', 'session.info requires a <sessionId> or --active', {}, ['Pass a <sessionId> argument, or use --active to resolve the canonical binding']), options.json);
232
+ process.exitCode = 1;
233
+ return;
234
+ }
197
235
  const meta = getSessionMeta(projectRoot, sessionId);
198
236
  if (meta === null) {
199
237
  printResult(io, fail('session.info', 'SESSION_NOT_FOUND', `Session "${sessionId}" not found or has no metadata`, { sessionId }, ['Use `peaks session list` to see available sessions']), options.json);
@@ -1,7 +1,15 @@
1
1
  import { enforceBashCommand, recordGateBypass, GateBypassError } from '../../services/sop/gate-enforce-service.js';
2
2
  import { fail, ok } from '../../shared/result.js';
3
3
  import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
4
- /** Read the PreToolUse hook payload. `PEAKS_HOOK_STDIN` is a test seam; production reads stdin. */
4
+ import { detectIdeFromContext, parseClaudeShapeStdin } from '../../services/ide/hook-translator.js';
5
+ import { formatDecisionResponse } from '../../services/ide/hook-protocol.js';
6
+ import { getAdapter } from '../../services/ide/ide-registry.js';
7
+ /**
8
+ * Read the PreToolUse hook payload. `PEAKS_HOOK_STDIN` is a test seam; production
9
+ * reads stdin. The CLI-side stdin reader is intentionally kept here (not in
10
+ * `hook-translator.ts`) because it owns the `process.stdin` lifecycle and the
11
+ * test-seam env var. The translator operates on already-parsed payloads.
12
+ */
5
13
  async function readHookPayload() {
6
14
  const override = process.env.PEAKS_HOOK_STDIN;
7
15
  if (override !== undefined) {
@@ -20,17 +28,6 @@ async function readHookPayload() {
20
28
  process.stdin.on('error', () => resolveStdin(data));
21
29
  });
22
30
  }
23
- function emitDeny(io, reason) {
24
- // The exact, verified PreToolUse decision shape. permissionDecision:"deny"
25
- // blocks the tool call before Claude Code's permission checks (un-bypassable).
26
- io.stdout(JSON.stringify({
27
- hookSpecificOutput: {
28
- hookEventName: 'PreToolUse',
29
- permissionDecision: 'deny',
30
- permissionDecisionReason: reason
31
- }
32
- }));
33
- }
34
31
  export function registerGateCommands(program, io) {
35
32
  const gate = program.command('gate').description('SOP gate enforcement (PreToolUse hook handler and bypass)');
36
33
  addJsonOption(gate
@@ -41,14 +38,25 @@ export function registerGateCommands(program, io) {
41
38
  // decide must FAIL-OPEN (allow), never block the user's Claude Code.
42
39
  try {
43
40
  const raw = await readHookPayload();
44
- let command;
45
- let toolName;
41
+ let parsedStdin = null;
46
42
  if (raw.trim().length > 0) {
47
- const payload = JSON.parse(raw);
48
- toolName = payload.tool_name;
49
- command = payload.tool_input?.command;
43
+ try {
44
+ parsedStdin = JSON.parse(raw);
45
+ }
46
+ catch {
47
+ // Malformed JSON — fail-open. Detect + parse on null fall back to the
48
+ // default adapter and yield empty tool/command, which short-circuits
49
+ // to the "not a guarded surface" early exit below.
50
+ }
50
51
  }
51
- if (toolName !== 'Bash' || typeof command !== 'string' || command.trim().length === 0) {
52
+ const ide = detectIdeFromContext({ env: process.env, cwd: process.cwd(), parsedStdin });
53
+ const adapter = getAdapter(ide);
54
+ // For slice #1 only the Claude adapter is registered, so the parser is
55
+ // Claude-shaped. Future slices dispatch on `ide` to pick a per-adapter
56
+ // parser; the parser entry-point (`parseXxxShapeStdin`) is the only
57
+ // change required.
58
+ const { toolName, command } = parseClaudeShapeStdin(parsedStdin);
59
+ if (toolName !== adapter.toolMatcher || typeof command !== 'string' || command.trim().length === 0) {
52
60
  // Not a guarded surface — allow (no output = normal permission flow).
53
61
  if (options.json === true) {
54
62
  printResult(io, ok('gate.enforce', { decision: 'allow', skipped: true }), true);
@@ -57,7 +65,8 @@ export function registerGateCommands(program, io) {
57
65
  }
58
66
  const decision = await enforceBashCommand(options.project, command);
59
67
  if (decision.decision === 'deny') {
60
- emitDeny(io, decision.reason);
68
+ const { stdout } = formatDecisionResponse(ide, 'deny', decision.reason);
69
+ io.stdout(stdout);
61
70
  if (options.json === true) {
62
71
  io.stderr(JSON.stringify(ok('gate.enforce', decision)));
63
72
  }
@@ -0,0 +1,17 @@
1
+ import { Command } from 'commander';
2
+ import { type ProgramIO } from '../cli-helpers.js';
3
+ /**
4
+ * `peaks hook handle` —— peaks 自有 hook 协议的单一入口。
5
+ *
6
+ * 该命令是 peaks-cli 拥有的 hook 处理总入口。它:
7
+ * 1. 读 stdin
8
+ * 2. auto-detect 来源 IDE(env / stdin shape / cwd)
9
+ * 3. 归一化到 peaks canonical schema
10
+ * 4. dispatch 到内部 peaks 逻辑(目前:gate enforce 或 progress start)
11
+ * 5. 用 IDE 期望的格式发回决策
12
+ *
13
+ * Slice #1 阶段:peaks hook handle 与 peaks gate enforce / peaks progress start
14
+ * 并存(后者内部走 hook-translator)。Slice #2 把 IDE settings 改成调用
15
+ * peaks hook handle 即可。Slice #3 删除旧命令。
16
+ */
17
+ export declare function registerHookHandleCommand(program: Command, io: ProgramIO): void;
@@ -0,0 +1,111 @@
1
+ import { addJsonOption, printResult } from '../cli-helpers.js';
2
+ import { detectIdeFromContext, parseAdapterStdin, parseClaudeShapeStdin, pluckObject, pluckString } from '../../services/ide/hook-translator.js';
3
+ import { buildCanonicalHook, formatDecisionResponse } from '../../services/ide/hook-protocol.js';
4
+ import { getAdapter } from '../../services/ide/ide-registry.js';
5
+ import { fail, ok } from '../../shared/result.js';
6
+ /**
7
+ * Read the hook payload. `PEAKS_HOOK_STDIN` is a test seam (same convention as
8
+ * `gate-commands.ts`); production reads stdin. The TTY short-circuit means an
9
+ * interactive shell invocation is treated as an empty payload (allow).
10
+ */
11
+ async function readStdin() {
12
+ const override = process.env.PEAKS_HOOK_STDIN;
13
+ if (override !== undefined) {
14
+ return override;
15
+ }
16
+ if (process.stdin.isTTY)
17
+ return '';
18
+ return new Promise((resolveStdin) => {
19
+ let data = '';
20
+ process.stdin.setEncoding('utf8');
21
+ process.stdin.on('data', (chunk) => { data += chunk; });
22
+ process.stdin.on('end', () => resolveStdin(data));
23
+ process.stdin.on('error', () => resolveStdin(data));
24
+ });
25
+ }
26
+ /**
27
+ * `peaks hook handle` —— peaks 自有 hook 协议的单一入口。
28
+ *
29
+ * 该命令是 peaks-cli 拥有的 hook 处理总入口。它:
30
+ * 1. 读 stdin
31
+ * 2. auto-detect 来源 IDE(env / stdin shape / cwd)
32
+ * 3. 归一化到 peaks canonical schema
33
+ * 4. dispatch 到内部 peaks 逻辑(目前:gate enforce 或 progress start)
34
+ * 5. 用 IDE 期望的格式发回决策
35
+ *
36
+ * Slice #1 阶段:peaks hook handle 与 peaks gate enforce / peaks progress start
37
+ * 并存(后者内部走 hook-translator)。Slice #2 把 IDE settings 改成调用
38
+ * peaks hook handle 即可。Slice #3 删除旧命令。
39
+ */
40
+ export function registerHookHandleCommand(program, io) {
41
+ const hook = program.command('hook').description('Peaks 自有 hook 协议单一入口(slice #1 新增;后续 slice 将逐步替代 gate enforce / progress start)');
42
+ addJsonOption(hook
43
+ .command('handle')
44
+ .description('Read stdin hook payload, auto-detect IDE, dispatch to peaks gate/progress logic, output IDE-formatted decision')
45
+ .option('--project <path>', 'project the gates evaluate against (default: current directory)', '.')).action(async (options) => {
46
+ try {
47
+ const raw = await readStdin();
48
+ let parsed = null;
49
+ if (raw.trim().length > 0) {
50
+ try {
51
+ parsed = JSON.parse(raw);
52
+ }
53
+ catch {
54
+ // Malformed JSON — treat as empty payload (fail-open)
55
+ parsed = null;
56
+ }
57
+ }
58
+ const ide = detectIdeFromContext({ env: process.env, cwd: process.cwd(), parsedStdin: parsed });
59
+ const adapter = getAdapter(ide);
60
+ // Slice #3: per-adapter stdin parser dispatch. The detected IDE
61
+ // determines which parser runs; unknown IDEs fall back to the Claude
62
+ // shape (preserves slice #1's "fail-open to Claude" semantics).
63
+ const { toolName, command } = parseAdapterStdin(ide, parsed);
64
+ // Also try the Claude parser as a secondary fallback so a Trae user who
65
+ // accidentally pipes a Claude-shaped payload still gets the right fields.
66
+ const claudeShape = parseClaudeShapeStdin(parsed);
67
+ const fallbackToolName = toolName ?? claudeShape.toolName ?? pluckString(parsed, ['toolName']);
68
+ const fallbackCommand = command ?? claudeShape.command ?? pluckString(parsed, ['toolInput', 'command']);
69
+ const projectRoot = process.env[adapter.envVar] ?? options.project;
70
+ const hook = buildCanonicalHook({
71
+ toolName: fallbackToolName ?? '',
72
+ toolInput: pluckObject(parsed, ['tool_input']) ?? pluckObject(parsed, ['toolInput']) ?? {},
73
+ projectRoot,
74
+ rawIdeFormat: ide,
75
+ rawPayload: parsed
76
+ });
77
+ // Dispatch by toolName. For slice #1, we only handle Bash and Task.
78
+ // Other tools: allow (no-op; future events will be added here).
79
+ if (hook.toolName === 'Bash' && typeof fallbackCommand === 'string' && fallbackCommand.trim().length > 0) {
80
+ // Lazy import to avoid circular: peaks gate enforce logic
81
+ const { enforceBashCommand } = await import('../../services/sop/gate-enforce-service.js');
82
+ const decision = await enforceBashCommand(projectRoot, fallbackCommand);
83
+ if (decision.decision === 'deny') {
84
+ const formatted = formatDecisionResponse(ide, 'deny', decision.reason);
85
+ io.stdout(formatted.stdout);
86
+ if (options.json === true) {
87
+ io.stderr(JSON.stringify(ok('hook.handle', { ide, tool: hook.toolName, decision: 'deny', reason: decision.reason })));
88
+ }
89
+ return;
90
+ }
91
+ }
92
+ else if (hook.toolName === 'Task') {
93
+ // peaks progress start is a fire-and-forget; do not block hook.handle.
94
+ // Slice #1: simply acknowledge (no terminal spawn from hook handle itself;
95
+ // the legacy `peaks progress start` command still does that).
96
+ }
97
+ const allow = formatDecisionResponse(ide, 'allow');
98
+ io.stdout(allow.stdout);
99
+ if (options.json === true) {
100
+ printResult(io, ok('hook.handle', { ide, tool: hook.toolName, decision: 'allow' }), true);
101
+ }
102
+ }
103
+ catch (error) {
104
+ // Fail-open: a bug in hook.handle must not brick Claude Code.
105
+ io.stderr(`hook handle: internal error, allowing command (${error instanceof Error ? error.message : String(error)})`);
106
+ if (options.json === true) {
107
+ printResult(io, fail('hook.handle', 'HOOK_HANDLE_FAILED', error instanceof Error ? error.message : 'unknown', {}), true);
108
+ }
109
+ }
110
+ });
111
+ }
@@ -1,89 +1,140 @@
1
1
  import { fail, ok } from '../../shared/result.js';
2
2
  import { addJsonOption, printResult, getErrorMessage } from '../cli-helpers.js';
3
3
  import { findProjectRoot } from '../../services/config/config-safety.js';
4
- import { applyHookInstall, PEAKS_HOOK_ENTRIES, planHookInstall, readHookStatus, removeHookInstall } from '../../services/skills/hooks-settings-service.js';
4
+ import { applyHookInstall, planHookInstall, readHookStatus, removeHookInstall } from '../../services/skills/hooks-settings-service.js';
5
+ import { detectIdeFromContext } from '../../services/ide/hook-translator.js';
6
+ import { getAdapter } from '../../services/ide/ide-registry.js';
5
7
  function resolveScope(options) {
6
8
  return options.global ? 'global' : 'project';
7
9
  }
8
10
  function resolveProjectRoot(scope, project) {
9
11
  return scope === 'project' ? (project ?? findProjectRoot(process.cwd()) ?? process.cwd()) : undefined;
10
12
  }
13
+ /**
14
+ * Resolve the IDE the install should target. The CLI user can override with
15
+ * `--ide <id>`. Otherwise we delegate to `detectIdeFromContext` which checks
16
+ * `process.env[adapter.envVar]` → stdin shape → cwd `.trae`/`.claude` →
17
+ * fallback `'claude-code'`. Pass `parsedStdin: null` since `peaks hooks
18
+ * install` is not invoked from inside an IDE hook — there's no stdin payload.
19
+ */
20
+ function resolveIdeForCommand(options, projectRoot) {
21
+ if (options.ide !== undefined && options.ide.length > 0) {
22
+ return options.ide;
23
+ }
24
+ return detectIdeFromContext({ env: process.env, cwd: projectRoot ?? process.cwd(), parsedStdin: null });
25
+ }
26
+ // Slice #3: compute the per-IDE peaks hook entries for the CLI response
27
+ // summary. Replaces the slice #1 PEAKS_HOOK_ENTRIES constant which was
28
+ // hardcoded to claude-code values. Slice 2026-06-06-sub-agent-spawn-bug-
29
+ // and-decouple: the sub-agent progress matcher now reads from
30
+ // `adapter.subAgentToolMatcher` instead of being hardcoded to 'Task', so
31
+ // every IDE self-reports its sub-agent tool name (claude-code: 'Task',
32
+ // future adapters: whatever the adapter declares).
33
+ function listInstalledEntriesForIde(ide) {
34
+ const adapter = getAdapter(ide);
35
+ if (ide === 'trae') {
36
+ return [
37
+ { matcher: adapter.toolMatcher, sentinel: 'peaks hook handle' },
38
+ { matcher: adapter.subAgentToolMatcher, sentinel: 'peaks progress start' }
39
+ ];
40
+ }
41
+ // Default (claude-code) and any future registered adapters.
42
+ return [
43
+ { matcher: adapter.toolMatcher, sentinel: 'peaks gate enforce' },
44
+ { matcher: adapter.subAgentToolMatcher, sentinel: 'peaks progress start' }
45
+ ];
46
+ }
11
47
  export function registerHooksCommands(program, io) {
12
48
  const hooks = program
13
49
  .command('hooks')
14
- .description('Manage the Peaks PreToolUse hooks in .claude/settings.json: (1) Bash→peaks gate enforce (SOP gate), (2) Task→peaks progress start (auto-spawn sub-agent progress terminal). Both are installed / removed together.');
50
+ .description("Manage the Peaks-managed hook entries in the adapter's settings.json (default: .claude/settings.json for Claude, .trae/settings.json for Trae): (1) gate-enforce hook (SOP gate), (2) progress-start hook (auto-spawn sub-agent progress terminal). Both are installed / removed together. The IDE is auto-detected from env / cwd; override with --ide <id>.");
15
51
  addJsonOption(hooks
16
52
  .command('install')
17
- .description(`Install all peaks-managed PreToolUse hooks (${PEAKS_HOOK_ENTRIES.map((e) => e.matcher).join(', ')}) into the target .claude/settings.json. Idempotent: re-runs are no-ops. Project scope by default.`)
53
+ .description(`Install all peaks-managed hook entries into the adapter's settings.json. Idempotent: re-runs are no-ops. Project scope by default.`)
18
54
  .option('--global', 'install into the user-level ~/.claude/settings.json instead of the project')
19
55
  .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')
56
+ .option('--ide <id>', "target adapter id (claude-code | trae); default: auto-detect from env/cwd")
20
57
  .option('--dry-run', 'show what would change without writing')).action((options) => {
21
58
  const scope = resolveScope(options);
22
59
  const projectRoot = resolveProjectRoot(scope, options.project);
60
+ const ide = resolveIdeForCommand(options, projectRoot);
23
61
  try {
24
62
  if (options.dryRun === true) {
25
- const plan = planHookInstall(scope, projectRoot);
63
+ const plan = planHookInstall(scope, projectRoot, { ide });
64
+ const dryRunEntries = listInstalledEntriesForIde(ide);
26
65
  printResult(io, ok('hooks.install', {
27
66
  ...plan,
67
+ ide,
28
68
  applied: false,
29
69
  dryRun: true,
30
- entries: PEAKS_HOOK_ENTRIES.map((e) => ({ matcher: e.matcher, sentinel: e.sentinel }))
31
- }, [], [`would install ${PEAKS_HOOK_ENTRIES.length} peaks-managed hook entries`]), options.json);
70
+ entries: dryRunEntries
71
+ }, [], [`would install ${dryRunEntries.length} peaks-managed hook entries`]), options.json);
32
72
  return;
33
73
  }
34
- const result = applyHookInstall(scope, projectRoot);
74
+ const result = applyHookInstall(scope, projectRoot, { ide });
75
+ // Slice #3: build the per-IDE entries summary from the actual installed
76
+ // entries, not the slice #1 PEAKS_HOOK_ENTRIES constant (which is the
77
+ // claude-code default). The user's JSON envelope must reflect the IDE
78
+ // they targeted.
79
+ const installedEntries = listInstalledEntriesForIde(ide);
35
80
  const nextActions = result.applied
36
81
  ? [
37
- 'Restart Claude Code (or reload the window) so the PreToolUse hooks take effect',
38
- `Installed: ${PEAKS_HOOK_ENTRIES.map((e) => `${e.matcher}→${e.sentinel}`).join(', ')}`
82
+ 'Restart the IDE (or reload the workspace) so the hook entries take effect',
83
+ `Installed: ${installedEntries.map((e) => `${e.matcher}→${e.sentinel}`).join(', ')}`
39
84
  ]
40
85
  : [];
41
86
  printResult(io, ok('hooks.install', {
42
87
  ...result,
88
+ ide,
43
89
  dryRun: false,
44
- entries: PEAKS_HOOK_ENTRIES.map((e) => ({ matcher: e.matcher, sentinel: e.sentinel }))
90
+ entries: installedEntries.map((e) => ({ matcher: e.matcher, sentinel: e.sentinel }))
45
91
  }, [], nextActions), options.json);
46
92
  }
47
93
  catch (error) {
48
94
  const message = getErrorMessage(error);
49
- printResult(io, fail('hooks.install', 'HOOKS_INSTALL_FAILED', message, { scope, applied: false }, [message]), options.json);
95
+ printResult(io, fail('hooks.install', 'HOOKS_INSTALL_FAILED', message, { scope, ide, applied: false }, [message]), options.json);
50
96
  process.exitCode = 1;
51
97
  }
52
98
  });
53
99
  addJsonOption(hooks
54
100
  .command('uninstall')
55
- .description('Remove all peaks-managed PreToolUse hooks (gate-enforce + progress-start) from the target .claude/settings.json. Third-party hooks are preserved.')
101
+ .description("Remove all peaks-managed hook entries (gate-enforce + progress-start) from the target settings.json. Third-party hooks are preserved.")
56
102
  .option('--global', 'remove from the user-level ~/.claude/settings.json instead of the project')
57
- .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')).action((options) => {
103
+ .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')
104
+ .option('--ide <id>', 'target adapter id (claude-code | trae); default: auto-detect from env/cwd')).action((options) => {
58
105
  const scope = resolveScope(options);
59
106
  const projectRoot = resolveProjectRoot(scope, options.project);
107
+ const ide = resolveIdeForCommand(options, projectRoot);
60
108
  try {
61
- const result = removeHookInstall(scope, projectRoot);
62
- printResult(io, ok('hooks.uninstall', result), options.json);
109
+ const result = removeHookInstall(scope, projectRoot, { ide });
110
+ printResult(io, ok('hooks.uninstall', { ...result, ide }), options.json);
63
111
  }
64
112
  catch (error) {
65
113
  const message = getErrorMessage(error);
66
- printResult(io, fail('hooks.uninstall', 'HOOKS_UNINSTALL_FAILED', message, { scope, removed: false }, [message]), options.json);
114
+ printResult(io, fail('hooks.uninstall', 'HOOKS_UNINSTALL_FAILED', message, { scope, ide, removed: false }, [message]), options.json);
67
115
  process.exitCode = 1;
68
116
  }
69
117
  });
70
118
  addJsonOption(hooks
71
119
  .command('status')
72
- .description('Report which peaks-managed PreToolUse hooks are installed (gate-enforce + progress-start).')
120
+ .description('Report which peaks-managed hook entries are installed.')
73
121
  .option('--global', 'inspect the user-level ~/.claude/settings.json instead of the project')
74
- .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')).action((options) => {
122
+ .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')
123
+ .option('--ide <id>', 'target adapter id (claude-code | trae); default: auto-detect from env/cwd')).action((options) => {
75
124
  const scope = resolveScope(options);
76
125
  const projectRoot = resolveProjectRoot(scope, options.project);
126
+ const ide = resolveIdeForCommand(options, projectRoot);
77
127
  try {
78
- const status = readHookStatus(scope, projectRoot);
128
+ const status = readHookStatus(scope, projectRoot, { ide });
79
129
  printResult(io, ok('hooks.status', {
80
130
  ...status,
81
- entries: PEAKS_HOOK_ENTRIES.map((e) => ({ matcher: e.matcher, sentinel: e.sentinel }))
131
+ ide,
132
+ entries: listInstalledEntriesForIde(ide)
82
133
  }), options.json);
83
134
  }
84
135
  catch (error) {
85
136
  const message = getErrorMessage(error);
86
- printResult(io, fail('hooks.status', 'HOOKS_STATUS_FAILED', message, { scope }, [message]), options.json);
137
+ printResult(io, fail('hooks.status', 'HOOKS_STATUS_FAILED', message, { scope, ide }, [message]), options.json);
87
138
  process.exitCode = 1;
88
139
  }
89
140
  });
@@ -15,7 +15,7 @@ export function registerProgressCommands(program, io) {
15
15
  // peaks progress step
16
16
  // LLM-side: called by the LLM on phase transitions. Near-zero
17
17
  // token cost — one Bash call per phase change. Writes
18
- // `.peaks/<sid>/system/subagent-progress.json`. No auto-spawn
18
+ // `.peaks/_sub_agents/<sid>/subagent-progress.json`. No auto-spawn
19
19
  // here; the LLM invokes `peaks progress start` separately when
20
20
  // the user-visible window needs to open.
21
21
  // ─────────────────────────────────────────────────────────────────
@@ -193,7 +193,14 @@ export function registerProgressCommands(program, io) {
193
193
  // asked for visible "this is peaks-cli" branding so the
194
194
  // spawned terminal is identifiable at a glance; the title
195
195
  // also makes `peaks progress close` self-documenting.
196
- const windowTitle = `peaks-cli: sub-agent progress${reasonSuffix}`;
196
+ //
197
+ // Em-dash (U+2014) instead of a colon. On Windows, cmd /c's
198
+ // script parser interprets a `:` even inside quotes as a
199
+ // drive-letter prefix, so `peaks-cli: sub-agent progress`
200
+ // triggers the "Windows 找不到文件 'sub-agent'" dialog. The
201
+ // em-dash is a no-op for cmd / c, bash, and AppleScript
202
+ // string parsing — the visible branding is preserved.
203
+ const windowTitle = `peaks-cli — sub-agent progress${reasonSuffix}`;
197
204
  const watchCommand = `${peaksBin} progress watch --project "${canonical}"`;
198
205
  // Build the platform-specific spawn command + args. This
199
206
  // is extracted to ./progress-start-spawn.ts so the three
@@ -45,9 +45,25 @@ function buildPosixTitleCmd(windowTitle) {
45
45
  const escaped = windowTitle.replaceAll("'", "'\\''");
46
46
  return `printf '\\033]0;${escaped}\\007'`;
47
47
  }
48
- /** cmd.exe `title` builtin call. Quoting is intentionally bare. */
48
+ /**
49
+ * cmd.exe `title` builtin call. Quoting is REQUIRED: cmd /k parses the
50
+ * subsequent tokens as a command line, and a `:` in the unquoted title (the
51
+ * canonical peaks windowTitle starts with `peaks-cli:`) gets mis-read as a
52
+ * drive-letter prefix, triggering Windows' "找不到文件 'sub-agent'" dialog.
53
+ *
54
+ * The double-quote wrap makes the whole title one parameter to `title`.
55
+ * Defensive guard: reject embedded `"`, `\n`, or `\r` (the `cmd /k` parser
56
+ * will not survive a literal newline or an un-escaped quote in the title).
57
+ * Callers (progress-commands.ts:267) compose the title from CLI args; if
58
+ * the user passed a `--reason` containing one of these characters, the
59
+ * spawn attempt is abandoned with a tagged result so the CLI can surface
60
+ * a clear error envelope.
61
+ */
49
62
  function buildWinTitleCmd(windowTitle) {
50
- return `title ${windowTitle}`;
63
+ if (windowTitle.includes('"') || windowTitle.includes('\n') || windowTitle.includes('\r')) {
64
+ return { ok: false, unsupported: true };
65
+ }
66
+ return { ok: true, cmd: `title "${windowTitle}"` };
51
67
  }
52
68
  /** Shared helper: the shell command that runs the watch. */
53
69
  function buildWatchCommand(peaksBin, projectRoot) {
@@ -103,11 +119,21 @@ export function buildStartSpawn(options) {
103
119
  return { ok: true, command: terminal, args: ['-e', bannerShell] };
104
120
  }
105
121
  if (currentPlatform === 'win32') {
106
- const bannerCmd = `${winTitleCmd} && echo peaks-cli --- sub-agent progress && ${watchCommand}`;
122
+ if (!winTitleCmd.ok) {
123
+ return { ok: false, unsupported: true };
124
+ }
125
+ const bannerCmd = `${winTitleCmd.cmd} && echo peaks-cli --- sub-agent progress && ${watchCommand}`;
107
126
  return {
108
127
  ok: true,
109
128
  command: 'cmd',
110
- args: ['/c', 'start', `"${windowTitle}"`, 'cmd', '/k', bannerCmd]
129
+ // Wrap bannerCmd in an EXTRA pair of outer quotes so the OUTER
130
+ // `cmd /c`'s script parser sees the banner as a single arg
131
+ // (not a `&&`-chained script). The INNER `cmd /k` in the new
132
+ // window strips the extra outer quotes and parses the banner
133
+ // as a normal script. windowTitle is left unquoted: Node's
134
+ // spawn applies the correct Windows escaping naturally,
135
+ // which is more robust than pre-quoting.
136
+ args: ['/c', 'start', windowTitle, 'cmd', '/k', `"${bannerCmd}"`]
111
137
  };
112
138
  }
113
139
  return { ok: false, unsupported: true };
@@ -16,14 +16,16 @@ export function registerSliceCommands(program, io) {
16
16
  .option('--project <path>', 'target project root', '.')
17
17
  .option('--rid <rid>', 'request id; defaults to the active current-change binding')
18
18
  .option('--refresh-fanout', 're-run the 3-way review fan-out (peaks-rd) even if the review files already exist', false)
19
- .option('--skip-tests', 'skip the unit-test stage (e.g. docs-only slices)', false)).action(async (options) => {
19
+ .option('--skip-tests', 'skip the unit-test stage (e.g. docs-only slices)', false)
20
+ .option('--allow-pre-existing-failures', 'opt-in: if the unit-test stage fails, report it as `skipped` with a reason naming the failure count (useful when the repo has unrelated pre-existing failures; the long-term fix is to .skip or coverage.exclude those tests)', false)).action(async (options) => {
20
21
  try {
21
22
  const projectRoot = resolveCanonicalProjectRoot(options.project);
22
23
  const result = await sliceCheck({
23
24
  projectRoot,
24
25
  ...(options.rid ? { rid: options.rid } : {}),
25
26
  refreshFanout: options.refreshFanout === true,
26
- skipTests: options.skipTests === true
27
+ skipTests: options.skipTests === true,
28
+ allowPreExistingFailures: options.allowPreExistingFailures === true
27
29
  });
28
30
  const warnings = [];
29
31
  if (result.stages.some((s) => s.status === 'fail')) {