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
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Peaks-Cli Solo Step 0.7 — Resume-mode detector.
3
+ *
4
+ * Mirrors the classification table in
5
+ * `skills/peaks-solo/SKILL.md` "Peaks-Cli Step 0.7: Detect unfinished work
6
+ * and offer resume". The function is a pure read of session artifacts;
7
+ * it performs no side effects and is safe to call from hooks, scripts,
8
+ * and skills.
9
+ *
10
+ * Two classification sources are merged:
11
+ * 1. State-based (PRD / RD / QA request artifact `state:` field) →
12
+ * determines the *deepest completed gate*.
13
+ * 2. File-presence ("Other resume triggers" table) → if a required
14
+ * artifact is missing for a gate that state says is complete, the
15
+ * classifier emits a `resume:<earlier-point>` verdict AND a
16
+ * `warnings[]` entry flagging the inconsistency.
17
+ *
18
+ * Primary vs. abandoned filter: when multiple RD/QA request artifacts
19
+ * exist for the same session (the 8-slice governance pass leaves
20
+ * `deferred`/`abandoned` artifacts alongside the active one), the
21
+ * classifier filters out files whose `state: blocked` field is paired
22
+ * with a `user-requested-abandon` transition note. The remaining
23
+ * artifacts are sorted by filename (alphabetical) and the first is the
24
+ * primary.
25
+ *
26
+ * Legacy path fallback: prefers the canonical
27
+ * `.peaks/_runtime/<sid>/` layout introduced in slice
28
+ * `2026-06-05-peaks-runtime-layer`; falls back to the pre-migration
29
+ * `.peaks/<sid>/` for one minor release so older trees do not show as
30
+ * false "fresh". The `usedLegacyPath` field reports which path was
31
+ * read.
32
+ */
33
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
34
+ import { join } from 'node:path';
35
+ const MID_IMPL_RD_STATES = new Set([
36
+ 'spec-locked',
37
+ 'implemented',
38
+ 'running',
39
+ 'blocked'
40
+ ]);
41
+ /**
42
+ * Classify a session's resume state. Pure function — does not write
43
+ * files, does not call any peaks CLI.
44
+ *
45
+ * @param sid The session id (e.g. `2026-06-06-session-22f08c`).
46
+ * @param peaksRoot The canonical peaks runtime root, i.e. the
47
+ * directory containing `<sid>/` subdirs. For the
48
+ * v1.3.2 layout this is `<repo>/.peaks/_runtime`.
49
+ * The legacy `<repo>/.peaks` layout is also accepted
50
+ * for one minor release (see `usedLegacyPath`).
51
+ */
52
+ export function classifyResume(sid, peaksRoot) {
53
+ const resolved = resolveSessionDir(sid, peaksRoot);
54
+ if (resolved === null) {
55
+ return {
56
+ kind: 'fresh',
57
+ point: null,
58
+ state: null,
59
+ missingArtifacts: [],
60
+ warnings: [],
61
+ abandonedRequestCount: 0,
62
+ usedLegacyPath: false
63
+ };
64
+ }
65
+ const { sessionDir, usedLegacyPath } = resolved;
66
+ const prdStates = readRequestStates(sessionDir, 'prd');
67
+ const rdStatesRaw = readRequestStates(sessionDir, 'rd');
68
+ const qaStatesRaw = readRequestStates(sessionDir, 'qa');
69
+ // Filter abandoned (state=blocked + user-requested-abandon note).
70
+ const abandonedRd = rdStatesRaw.filter((s) => s.abandoned);
71
+ const abandonedQa = qaStatesRaw.filter((s) => s.abandoned);
72
+ const abandonedCount = abandonedRd.length + abandonedQa.length;
73
+ // Primary selection filter: when there are MULTIPLE RD/QA requests,
74
+ // the abandoned ones are excluded from the candidate set so the
75
+ // classifier surfaces the active slice, not the audit-only trail.
76
+ // When there is only ONE request, the abandoned flag is informational
77
+ // only — a single blocked RD with an abandoned note is still the
78
+ // primary, because the user might want to unblock and continue.
79
+ const rdStates = rdStatesRaw.length > 1 ? rdStatesRaw.filter((s) => !s.abandoned) : rdStatesRaw;
80
+ const qaStates = qaStatesRaw.length > 1 ? qaStatesRaw.filter((s) => !s.abandoned) : qaStatesRaw;
81
+ const primaryPrd = pickPrimary(prdStates);
82
+ const primaryRd = pickPrimary(rdStates);
83
+ const primaryQa = pickPrimary(qaStates);
84
+ // Phase 1: TXT handoff present → workflow complete. Always wins.
85
+ if (existsSync(join(sessionDir, 'txt', 'handoff.md'))) {
86
+ return {
87
+ kind: 'complete',
88
+ point: null,
89
+ state: null,
90
+ missingArtifacts: [],
91
+ warnings: [],
92
+ abandonedRequestCount: abandonedCount,
93
+ usedLegacyPath
94
+ };
95
+ }
96
+ // Phase 2: every RD/QA request is abandoned (filtered out) AND
97
+ // there were multiple to begin with. The slice is effectively
98
+ // dead — return fresh so the user can start over without
99
+ // re-attaching to the abandoned work.
100
+ if (primaryRd === null &&
101
+ primaryQa === null &&
102
+ abandonedCount > 0 &&
103
+ (rdStatesRaw.length > 1 || qaStatesRaw.length > 1)) {
104
+ return {
105
+ kind: 'fresh',
106
+ point: null,
107
+ state: null,
108
+ missingArtifacts: [],
109
+ warnings: [],
110
+ abandonedRequestCount: abandonedCount,
111
+ usedLegacyPath
112
+ };
113
+ }
114
+ // Phase 3: mid-implementation RD states (spec-locked / implemented /
115
+ // running / blocked). Wins over the PRD-handed-off branch because
116
+ // the slice IS in flight — the LLM should not be told to "re-run
117
+ // the swarm" when an RD artifact is already mid-edit.
118
+ if (primaryRd !== null && MID_IMPL_RD_STATES.has(primaryRd.state)) {
119
+ return {
120
+ kind: 'in-flight',
121
+ point: null,
122
+ state: primaryRd.state,
123
+ missingArtifacts: [],
124
+ warnings: [],
125
+ abandonedRequestCount: abandonedCount,
126
+ usedLegacyPath
127
+ };
128
+ }
129
+ // Phase 4: terminal gates (QA verdict-issued / RD qa-handoff / PRD
130
+ // handed-off, with file-presence overrides).
131
+ const terminal = classifyTerminalGates(sessionDir, {
132
+ primaryPrd,
133
+ primaryRd,
134
+ primaryQa,
135
+ usedLegacyPath,
136
+ abandonedCount
137
+ });
138
+ if (terminal !== null)
139
+ return terminal;
140
+ // Phase 5: PRD exists with a non-handed-off state — treat as
141
+ // in-flight:spec-locked placeholder. The user can confirm whether
142
+ // they want to advance the PRD or start fresh.
143
+ if (primaryPrd !== null && primaryPrd.state.length > 0) {
144
+ return {
145
+ kind: 'in-flight',
146
+ point: null,
147
+ state: 'spec-locked',
148
+ missingArtifacts: [],
149
+ warnings: [],
150
+ abandonedRequestCount: abandonedCount,
151
+ usedLegacyPath
152
+ };
153
+ }
154
+ return {
155
+ kind: 'fresh',
156
+ point: null,
157
+ state: null,
158
+ missingArtifacts: [],
159
+ warnings: [],
160
+ abandonedRequestCount: abandonedCount,
161
+ usedLegacyPath
162
+ };
163
+ }
164
+ function classifyTerminalGates(sessionDir, ctx) {
165
+ // QA verdict-issued → deepest gate is D. If the test-report is
166
+ // missing the state is inconsistent; fall back to qa-execution.
167
+ if (ctx.primaryQa !== null && ctx.primaryQa.state === 'verdict-issued') {
168
+ const reportPath = join(sessionDir, 'qa', 'test-reports', ctx.primaryQa.filename);
169
+ if (!existsSync(reportPath)) {
170
+ return {
171
+ kind: 'resume',
172
+ point: 'qa-execution',
173
+ state: null,
174
+ missingArtifacts: [`qa/test-reports/${ctx.primaryQa.filename}`],
175
+ warnings: [
176
+ 'inconsistent: qa verdict-issued but no qa/test-reports/<rid>.md; CLI gate should have blocked the transition'
177
+ ],
178
+ abandonedRequestCount: ctx.abandonedCount,
179
+ usedLegacyPath: ctx.usedLegacyPath
180
+ };
181
+ }
182
+ return {
183
+ kind: 'resume',
184
+ point: 'txt-handoff',
185
+ state: null,
186
+ missingArtifacts: ['txt/handoff.md'],
187
+ warnings: [],
188
+ abandonedRequestCount: ctx.abandonedCount,
189
+ usedLegacyPath: ctx.usedLegacyPath
190
+ };
191
+ }
192
+ // RD qa-handoff → deepest gate is C. If the review artifacts are
193
+ // missing the state is inconsistent; fall back to rd-review-fanout.
194
+ if (ctx.primaryRd !== null && ctx.primaryRd.state === 'qa-handoff') {
195
+ const codeReviewPath = join(sessionDir, 'rd', 'code-review.md');
196
+ const securityReviewPath = join(sessionDir, 'rd', 'security-review.md');
197
+ const missing = [];
198
+ if (!existsSync(codeReviewPath))
199
+ missing.push('rd/code-review.md');
200
+ if (!existsSync(securityReviewPath))
201
+ missing.push('rd/security-review.md');
202
+ if (missing.length > 0) {
203
+ return {
204
+ kind: 'resume',
205
+ point: 'rd-review-fanout',
206
+ state: null,
207
+ missingArtifacts: missing,
208
+ warnings: [
209
+ 'inconsistent: rd qa-handoff but review artifacts missing; CLI gate should have blocked the transition'
210
+ ],
211
+ abandonedRequestCount: ctx.abandonedCount,
212
+ usedLegacyPath: ctx.usedLegacyPath
213
+ };
214
+ }
215
+ return {
216
+ kind: 'resume',
217
+ point: 'qa-validation',
218
+ state: null,
219
+ missingArtifacts: ctx.primaryQa === null
220
+ ? [`qa/test-cases/${ctx.primaryRd.filename}`]
221
+ : [],
222
+ warnings: [],
223
+ abandonedRequestCount: ctx.abandonedCount,
224
+ usedLegacyPath: ctx.usedLegacyPath
225
+ };
226
+ }
227
+ // PRD handed-off → deepest gate is B. Walk "Other resume triggers"
228
+ // in priority order: tech-doc > qa/test-cases > in-flight (swarm
229
+ // converged, RD impl not yet started).
230
+ if (ctx.primaryPrd !== null && ctx.primaryPrd.state === 'handed-off') {
231
+ const missing = [];
232
+ if (!existsSync(join(sessionDir, 'rd', 'tech-doc.md'))) {
233
+ missing.push('rd/tech-doc.md');
234
+ }
235
+ // QA test-cases path: use the RD rid if present, else fall back
236
+ // to the PRD rid. The rid is shared across roles.
237
+ const qaCasesRid = ctx.primaryRd !== null
238
+ ? ctx.primaryRd.filename
239
+ : ctx.primaryPrd !== null
240
+ ? ctx.primaryPrd.filename
241
+ : null;
242
+ if (qaCasesRid !== null) {
243
+ const qaCasesPath = join(sessionDir, 'qa', 'test-cases', qaCasesRid);
244
+ if (!existsSync(qaCasesPath)) {
245
+ missing.push(`qa/test-cases/${qaCasesRid}`);
246
+ }
247
+ }
248
+ if (missing.includes('rd/tech-doc.md')) {
249
+ return {
250
+ kind: 'resume',
251
+ point: 'rd-planning',
252
+ state: null,
253
+ missingArtifacts: missing,
254
+ warnings: [],
255
+ abandonedRequestCount: ctx.abandonedCount,
256
+ usedLegacyPath: ctx.usedLegacyPath
257
+ };
258
+ }
259
+ if (missing.some((m) => m.startsWith('qa/test-cases/'))) {
260
+ return {
261
+ kind: 'resume',
262
+ point: 'qa-test-cases',
263
+ state: null,
264
+ missingArtifacts: missing,
265
+ warnings: [],
266
+ abandonedRequestCount: ctx.abandonedCount,
267
+ usedLegacyPath: ctx.usedLegacyPath
268
+ };
269
+ }
270
+ // All post-PRD artifacts present. Either the RD is mid-impl
271
+ // (handled upstream in Phase 3) or the swarm converged but the
272
+ // implementation has not yet started. Report the latter as
273
+ // in-flight:spec-locked so the user can confirm.
274
+ return {
275
+ kind: 'in-flight',
276
+ point: null,
277
+ state: 'spec-locked',
278
+ missingArtifacts: [],
279
+ warnings: [],
280
+ abandonedRequestCount: ctx.abandonedCount,
281
+ usedLegacyPath: ctx.usedLegacyPath
282
+ };
283
+ }
284
+ return null;
285
+ }
286
+ /**
287
+ * Resolve the session directory. Prefers the canonical
288
+ * `.peaks/_runtime/<sid>/`; falls back to the legacy
289
+ * `.peaks/<sid>/` (one level up from the runtime root) for one
290
+ * minor release. Returns `null` when neither path exists.
291
+ */
292
+ function resolveSessionDir(sid, peaksRoot) {
293
+ const canonical = join(peaksRoot, sid);
294
+ if (existsSync(canonical)) {
295
+ return { sessionDir: canonical, usedLegacyPath: false };
296
+ }
297
+ const legacy = join(peaksRoot, '..', sid);
298
+ if (existsSync(legacy)) {
299
+ return { sessionDir: legacy, usedLegacyPath: true };
300
+ }
301
+ return null;
302
+ }
303
+ function readRequestStates(sessionDir, role) {
304
+ const dir = join(sessionDir, role, 'requests');
305
+ if (!existsSync(dir))
306
+ return [];
307
+ return readdirSync(dir)
308
+ .filter((f) => typeof f === 'string' && f.endsWith('.md'))
309
+ .sort()
310
+ .map((filename) => {
311
+ const full = join(dir, filename);
312
+ const content = readFileSync(full, 'utf8');
313
+ return {
314
+ filename,
315
+ state: extractState(content),
316
+ abandoned: hasAbandonedTransitionNote(content)
317
+ };
318
+ });
319
+ }
320
+ function extractState(content) {
321
+ const match = /^-\s*state:\s*(\S+)|^state:\s*(\S+)/m.exec(content);
322
+ if (match === null)
323
+ return '';
324
+ const captured = match[1] ?? match[2] ?? '';
325
+ return captured.trim();
326
+ }
327
+ function hasAbandonedTransitionNote(content) {
328
+ return /user-requested-abandon/.test(content);
329
+ }
330
+ function pickPrimary(states) {
331
+ if (states.length === 0)
332
+ return null;
333
+ return states[0] ?? null;
334
+ }
@@ -0,0 +1,40 @@
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 declare const DEFAULT_HEARTBEAT_INTERVAL_SEC = 30;
26
+ export declare const MIN_HEARTBEAT_INTERVAL_SEC = 5;
27
+ export declare const MAX_HEARTBEAT_INTERVAL_SEC = 600;
28
+ export type SkillHeartbeatConfig = {
29
+ readonly intervalSec: number;
30
+ /** Source of the chosen value (useful for debugging). */
31
+ readonly source: 'default' | 'skill-frontmatter';
32
+ };
33
+ /** Parse a SKILL.md body for a `heartbeatIntervalSec: <N>` line. */
34
+ export declare function parseHeartbeatConfig(skillBody: string): SkillHeartbeatConfig;
35
+ /**
36
+ * Build the heartbeat-instruction paragraph to inline in a sub-agent
37
+ * prompt. The LLM reads this and adjusts its `peaks sub-agent
38
+ * heartbeat` cadence accordingly.
39
+ */
40
+ export declare function heartbeatInstructionParagraph(config: SkillHeartbeatConfig): string;
@@ -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
- * Installs (and removes) the Peaks gate-enforcement PreToolUse hook in a Claude
3
- * Code settings.json. The hook runs `peaks gate enforce` before every Bash call;
4
- * when a SOP guard's gates fail it returns `permissionDecision: "deny"`, which
5
- * blocks the tool call BEFORE Claude Code's permission checks making the gate
6
- * un-bypassable by the agent (it holds even under --dangerously-skip-permissions).
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 any
10
- * other hooks, reject symlinked targets, and use an atomic rename so a partial
11
- * write can never corrupt the settings file. Our entry is merged into (not
12
- * replacing) the existing `hooks.PreToolUse` array and is identified by a
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 = 'project' | 'global';
17
- /** The hook command written into settings for the gate-enforce PreToolUse hook. `${CLAUDE_PROJECT_DIR}` is injected by Claude Code. */
18
- export declare const HOOK_ENFORCE_COMMAND = "peaks gate enforce --project \"${CLAUDE_PROJECT_DIR}\"";
19
- /**
20
- * Hook command for the sub-agent progress auto-spawn. Fires on every Task
21
- * tool call (the harness-enforced mechanism for "sub-agent dispatch"). The
22
- * command itself is non-blocking: `peaks progress start` is idempotent
23
- * (5-minute TTL on the spawn record) so the LLM does not see a fresh
24
- * terminal per Task. The `--quiet` flag keeps the LLM context clean — the
25
- * hook output otherwise adds ~500 tokens per Task call.
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
- /** Substring that identifies a Peaks-managed PreToolUse sub-agent-progress hook entry. */
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;