peaks-cli 1.3.2 → 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 (95) hide show
  1. package/README.md +6 -2
  2. package/dist/src/cli/commands/gate-commands.js +28 -19
  3. package/dist/src/cli/commands/hook-handle.d.ts +17 -0
  4. package/dist/src/cli/commands/hook-handle.js +111 -0
  5. package/dist/src/cli/commands/hooks-commands.js +72 -21
  6. package/dist/src/cli/commands/progress-commands.js +9 -2
  7. package/dist/src/cli/commands/progress-start-spawn.js +30 -4
  8. package/dist/src/cli/commands/statusline-commands.js +75 -17
  9. package/dist/src/cli/commands/sub-agent-commands.d.ts +5 -0
  10. package/dist/src/cli/commands/sub-agent-commands.js +488 -0
  11. package/dist/src/cli/commands/sub-agent-dispatch-guard.d.ts +55 -0
  12. package/dist/src/cli/commands/sub-agent-dispatch-guard.js +57 -0
  13. package/dist/src/cli/commands/workspace-commands.js +3 -0
  14. package/dist/src/cli/program.js +9 -0
  15. package/dist/src/hooks/pre-tool-use-sub-agent.d.ts +28 -0
  16. package/dist/src/hooks/pre-tool-use-sub-agent.js +105 -0
  17. package/dist/src/services/config/config-types.d.ts +1 -1
  18. package/dist/src/services/context/artifact-meta.d.ts +72 -0
  19. package/dist/src/services/context/artifact-meta.js +105 -0
  20. package/dist/src/services/context/context-guard.d.ts +49 -0
  21. package/dist/src/services/context/context-guard.js +91 -0
  22. package/dist/src/services/context/dispatch-context-guard.d.ts +27 -0
  23. package/dist/src/services/context/dispatch-context-guard.js +192 -0
  24. package/dist/src/services/context/headroom-client.d.ts +34 -0
  25. package/dist/src/services/context/headroom-client.js +117 -0
  26. package/dist/src/services/context/shared-channel.d.ts +92 -0
  27. package/dist/src/services/context/shared-channel.js +285 -0
  28. package/dist/src/services/context/threshold.d.ts +35 -0
  29. package/dist/src/services/context/threshold.js +76 -0
  30. package/dist/src/services/dispatch/batch-counter.d.ts +27 -0
  31. package/dist/src/services/dispatch/batch-counter.js +85 -0
  32. package/dist/src/services/dispatch/dispatch-record-writer.d.ts +93 -0
  33. package/dist/src/services/dispatch/dispatch-record-writer.js +261 -0
  34. package/dist/src/services/dispatch/heartbeat-truncator.d.ts +26 -0
  35. package/dist/src/services/dispatch/heartbeat-truncator.js +13 -0
  36. package/dist/src/services/dispatch/leak-detector.d.ts +11 -0
  37. package/dist/src/services/dispatch/leak-detector.js +72 -0
  38. package/dist/src/services/dispatch/sub-agent-dispatcher.d.ts +127 -0
  39. package/dist/src/services/dispatch/sub-agent-dispatcher.js +98 -0
  40. package/dist/src/services/ide/adapters/claude-code-adapter.d.ts +18 -0
  41. package/dist/src/services/ide/adapters/claude-code-adapter.js +53 -0
  42. package/dist/src/services/ide/adapters/trae-adapter.d.ts +34 -0
  43. package/dist/src/services/ide/adapters/trae-adapter.js +70 -0
  44. package/dist/src/services/ide/hook-protocol.d.ts +44 -0
  45. package/dist/src/services/ide/hook-protocol.js +71 -0
  46. package/dist/src/services/ide/hook-translator.d.ts +72 -0
  47. package/dist/src/services/ide/hook-translator.js +128 -0
  48. package/dist/src/services/ide/ide-detector.d.ts +10 -0
  49. package/dist/src/services/ide/ide-detector.js +19 -0
  50. package/dist/src/services/ide/ide-registry.d.ts +14 -0
  51. package/dist/src/services/ide/ide-registry.js +45 -0
  52. package/dist/src/services/ide/ide-types.d.ts +120 -0
  53. package/dist/src/services/ide/ide-types.js +2 -0
  54. package/dist/src/services/ide/shared/atomic-json.d.ts +15 -0
  55. package/dist/src/services/ide/shared/atomic-json.js +58 -0
  56. package/dist/src/services/ide/shared/safe-path.d.ts +11 -0
  57. package/dist/src/services/ide/shared/safe-path.js +29 -0
  58. package/dist/src/services/progress/progress-service.d.ts +1 -1
  59. package/dist/src/services/progress/progress-service.js +18 -14
  60. package/dist/src/services/security/safe-settings-path.d.ts +12 -0
  61. package/dist/src/services/security/safe-settings-path.js +104 -0
  62. package/dist/src/services/signal/cancel-handler.d.ts +14 -0
  63. package/dist/src/services/signal/cancel-handler.js +76 -0
  64. package/dist/src/services/skill/resume-detector.d.ts +54 -0
  65. package/dist/src/services/skill/resume-detector.js +334 -0
  66. package/dist/src/services/skill/skill-scheduler.d.ts +40 -0
  67. package/dist/src/services/skill/skill-scheduler.js +53 -0
  68. package/dist/src/services/skills/hooks-settings-service.d.ts +47 -29
  69. package/dist/src/services/skills/hooks-settings-service.js +190 -144
  70. package/dist/src/services/skills/statusline-settings-service.d.ts +33 -6
  71. package/dist/src/services/skills/statusline-settings-service.js +31 -34
  72. package/dist/src/services/slice/slice-archive-service.d.ts +20 -0
  73. package/dist/src/services/slice/slice-archive-service.js +111 -0
  74. package/dist/src/services/solo/batch-heartbeat-poller.d.ts +51 -0
  75. package/dist/src/services/solo/batch-heartbeat-poller.js +88 -0
  76. package/dist/src/services/solo/status-line-renderer.d.ts +34 -0
  77. package/dist/src/services/solo/status-line-renderer.js +55 -0
  78. package/dist/src/services/workspace/reconcile-service.d.ts +36 -0
  79. package/dist/src/services/workspace/reconcile-service.js +107 -6
  80. package/dist/src/services/workspace/reconcile-types.d.ts +12 -0
  81. package/dist/src/shared/version.d.ts +1 -1
  82. package/dist/src/shared/version.js +1 -1
  83. package/package.json +2 -1
  84. package/skills/peaks-ide/SKILL.md +159 -0
  85. package/skills/peaks-qa/SKILL.md +57 -1
  86. package/skills/peaks-qa/references/qa-fanout-contract.md +150 -0
  87. package/skills/peaks-rd/SKILL.md +50 -8
  88. package/skills/peaks-solo/SKILL.md +77 -20
  89. package/skills/peaks-solo/references/context-governance.md +144 -0
  90. package/skills/peaks-solo/references/headroom-integration.md +107 -0
  91. package/skills/peaks-solo/references/runbook.md +3 -3
  92. package/skills/peaks-solo/references/sub-agent-dispatch.md +218 -0
  93. package/skills/peaks-solo/references/swarm-dispatch-contract.md +3 -37
  94. package/skills/peaks-txt/SKILL.md +17 -0
  95. package/skills/peaks-ui/SKILL.md +27 -1
@@ -0,0 +1,488 @@
1
+ /**
2
+ * `peaks sub-agent` CLI commands — slice 2026-06-07-sub-agent-context-governance.
3
+ *
4
+ * Five sub-commands live in this file:
5
+ * 1. `dispatch <role>` — G2 + G7 + G7.7 + G9: emit a per-IDE tool-call
6
+ * descriptor. New flags: --write-artifact (G7), --use-headroom
7
+ * (G7.7/G9), --force (G9 CLI 兜底).
8
+ * 2. `heartbeat --record <path> ...` — G6: append a heartbeat.
9
+ * 3. `share --batch ... --key ... --value ...` — G8.4: write a shared
10
+ * channel entry (dispatcher-mediated cross sub-agent signal).
11
+ * 4. `shared-read --batch ...` — G8.4: read sibling shared entries.
12
+ * 5. (reserved) `list / show / gc` — G5.3 RL-10: stub for future
13
+ * slices.
14
+ *
15
+ * Skill-first / CLI-auxiliary red line (PB-4 / AC-19/20):
16
+ * These commands are primitives that the peaks-solo / peaks-rd /
17
+ * peaks-qa SKILL.md compose. Users do NOT invoke them directly. The
18
+ * --help text is explicit about this; the dispatch envelope's
19
+ * `nextActions` reinforces the point.
20
+ */
21
+ import { existsSync } from 'node:fs';
22
+ import { randomUUID } from 'node:crypto';
23
+ import { fail, getErrorMessage, ok } from '../../shared/result.js';
24
+ import { addJsonOption, printResult } from '../cli-helpers.js';
25
+ import { detectInstalledIde } from '../../services/ide/ide-detector.js';
26
+ import { getAdapter } from '../../services/ide/ide-registry.js';
27
+ import { SubAgentNotSupportedError } from '../../services/dispatch/sub-agent-dispatcher.js';
28
+ import { noteDispatched, BATCH_LIMIT } from '../../services/dispatch/batch-counter.js';
29
+ import { appendHeartbeat, writeInitialDispatchRecord } from '../../services/dispatch/dispatch-record-writer.js';
30
+ import { assertSafeDispatchRecordPath } from '../../services/security/safe-settings-path.js';
31
+ import { evaluatePromptSize } from '../../services/context/context-guard.js';
32
+ import { buildArtifactMeta, buildContextImpact } from '../../services/context/artifact-meta.js';
33
+ import { assertSafeArtifactPath } from '../../services/context/dispatch-context-guard.js';
34
+ import { compressPrompt } from '../../services/context/headroom-client.js';
35
+ import { readSharedChannel, writeSharedEntry, SHARED_CHANNEL_SOFT_VALUE_WARN } from '../../services/context/shared-channel.js';
36
+ const RECOMMENDED_ROLES = 'rd | qa | ui | txt | qa-business | qa-perf | qa-security | qa-business-<*> | general-purpose';
37
+ const HEARTBEAT_STATUSES = [
38
+ 'queued', 'running', 'finalizing', 'done', 'failed', 'stale'
39
+ ];
40
+ const PROMPT_LIMIT_BYTES = 256 * 1024;
41
+ const HEADROOM_MODES = ['balanced', 'aggressive', 'conservative'];
42
+ export function registerSubAgentCommands(program, io) {
43
+ const subAgent = program
44
+ .command('sub-agent')
45
+ .description('Sub-agent dispatch primitive (skill-first / CLI-auxiliary). ' +
46
+ 'These commands are the primitives that peaks-solo / peaks-rd / ' +
47
+ 'peaks-qa SKILL.md compose. Users do not invoke this directly.');
48
+ // ─────────────────────────────────────────────────────────────────
49
+ // peaks sub-agent dispatch <role> --prompt ... --json
50
+ // ─────────────────────────────────────────────────────────────────
51
+ addJsonOption(subAgent
52
+ .command('dispatch')
53
+ .description('Build an IDE-specific tool-call descriptor for a sub-agent dispatch. ' +
54
+ 'Dry-run by design; the LLM executes the returned toolCall in its own ' +
55
+ 'environment. Flags: --write-artifact (G7), --use-headroom (G7.7), ' +
56
+ '--force (G9 CLI 兜底). ' +
57
+ 'See skills/peaks-solo/references/sub-agent-dispatch.md for the ' +
58
+ 'orchestrator contract.')
59
+ .argument('<role>', 'sub-agent role (e.g. rd | qa | ui | txt | qa-business | qa-business-api)')
60
+ .requiredOption('--prompt <text>', 'the prompt to send to the sub-agent')
61
+ .option('--prompt-length <bytes>', 'DOGFOOD ONLY: synthesize a prompt of this size (overrides --prompt content for size only; content is "x" repeated)')
62
+ .option('--request-id <rid>', 'the same <rid> used by peaks request init')
63
+ .option('--session-id <sid>', 'override active session id (default: peaks session info --active)')
64
+ .option('--project <path>', 'target project root (defaults to cwd)')
65
+ .option('--batch-id <uuid>', 'batch id for the dispatch (default: auto-generated UUID)')
66
+ .option('--write-artifact <path>', 'G7: register an artifact file at <path>; CLI computes sha256 + size + writes ArtifactMeta to the dispatch record')
67
+ .option('--use-headroom', 'G7.7/G9: compress the prompt via headroom-ai before dispatch (opt-in; falls back to G7 metadata-only if headroom unavailable)')
68
+ .option('--headroom-mode <mode>', `G7.7: headroom mode (${HEADROOM_MODES.join(' | ')}); default balanced`)
69
+ .option('--force', 'G9: override the 80% hard reject threshold at CLI (NOT allowed at hook layer per RL-30 strict)')).action(async (role, options) => {
70
+ const asJson = options.json === true;
71
+ const validation = validateRole(role);
72
+ if (validation !== null) {
73
+ printResult(io, fail('sub-agent.dispatch', 'INVALID_ROLE', validation, { role, toolCall: null, dispatchRecordPath: null }, [
74
+ 'Use a non-empty role string with no control characters.',
75
+ `Recommended: ${RECOMMENDED_ROLES}.`
76
+ ]), asJson);
77
+ process.exitCode = 1;
78
+ return;
79
+ }
80
+ if (!options.prompt || options.prompt.length === 0) {
81
+ printResult(io, fail('sub-agent.dispatch', 'MISSING_PROMPT', '--prompt is required', { role, toolCall: null, dispatchRecordPath: null }, [
82
+ 'Re-run with a non-empty --prompt value.'
83
+ ]), asJson);
84
+ process.exitCode = 1;
85
+ return;
86
+ }
87
+ // DOGFOOD ONLY: --prompt-length overrides the actual prompt content with
88
+ // a synthetic prompt of the given size in bytes. The original --prompt
89
+ // is still required (commander needs it). This avoids ARG_MAX limits
90
+ // on Windows when the dogfood prompt is > 200KB.
91
+ if (typeof options.promptLength === 'string' && options.promptLength.length > 0) {
92
+ const len = Number.parseInt(options.promptLength, 10);
93
+ if (Number.isInteger(len) && len > 0) {
94
+ options.prompt = 'x'.repeat(len);
95
+ }
96
+ }
97
+ if (options.prompt.length > PROMPT_LIMIT_BYTES) {
98
+ printResult(io, fail('sub-agent.dispatch', 'PROMPT_TOO_LARGE', `prompt exceeds ${PROMPT_LIMIT_BYTES} bytes (got ${options.prompt.length})`, { role, toolCall: null, dispatchRecordPath: null }, [
99
+ 'Truncate the prompt or split into multiple dispatches.',
100
+ 'Pass --force to override the 80% threshold at CLI (NOT allowed at hook layer).'
101
+ ]), asJson);
102
+ process.exitCode = 1;
103
+ return;
104
+ }
105
+ // G9 CLI 兜底 — evaluate prompt size against the threshold table.
106
+ const decision = evaluatePromptSize(options.prompt.length, { force: options.force === true });
107
+ if (!decision.allow) {
108
+ printResult(io, fail('sub-agent.dispatch', decision.code, `prompt size ${options.prompt.length} bytes exceeds threshold (tier=${decision.evaluation.tier}, ratio=${decision.evaluation.ratio.toFixed(3)})`, {
109
+ role,
110
+ toolCall: null,
111
+ dispatchRecordPath: null
112
+ }, [
113
+ decision.suggest ?? 'Trim prompt or pass --force to override at CLI.',
114
+ 'PreToolUse hook layer will still reject regardless of --force (RL-30 strict).'
115
+ ]), asJson);
116
+ process.exitCode = 1;
117
+ return;
118
+ }
119
+ try {
120
+ const projectRoot = options.project ?? process.cwd();
121
+ const sid = options.sessionId ?? 'unknown-sid';
122
+ const rid = options.requestId ?? 'unknown-rid';
123
+ const batchId = options.batchId ?? randomUUID();
124
+ const ide = detectInstalledIde(projectRoot) ?? 'claude-code';
125
+ const adapter = getAdapter(ide);
126
+ if (!adapter.subAgentDispatcher.supportsRole(role)) {
127
+ printResult(io, fail('sub-agent.dispatch', 'IDE_NOT_SUPPORTED', `IDE ${ide} does not support role "${role}"`, { role, toolCall: null, dispatchRecordPath: null }, [
128
+ 'Switch to a registered IDE (e.g. claude-code) or pick a role the current IDE supports.'
129
+ ]), asJson);
130
+ process.exitCode = 1;
131
+ return;
132
+ }
133
+ // G7.7 headroom compress (opt-in). If headroom fails or is unavailable,
134
+ // fall back to the original prompt + emit warning.
135
+ let effectivePrompt = options.prompt;
136
+ let headroomCompressed = false;
137
+ let headroomResult = null;
138
+ const warnings = [...decision.warnings];
139
+ if (options.useHeadroom === true) {
140
+ const mode = isHeadroomMode(options.headroomMode) ? options.headroomMode : 'balanced';
141
+ headroomResult = await compressPrompt(effectivePrompt, mode);
142
+ if (headroomResult.warning !== null) {
143
+ warnings.push(headroomResult.warning);
144
+ }
145
+ if (headroomResult.compressed && headroomResult.compressedPrompt !== null) {
146
+ effectivePrompt = headroomResult.compressedPrompt;
147
+ headroomCompressed = true;
148
+ }
149
+ }
150
+ let toolCall;
151
+ try {
152
+ toolCall = adapter.subAgentDispatcher.buildToolCall({ role, prompt: effectivePrompt, requestId: rid, sessionId: sid });
153
+ }
154
+ catch (error) {
155
+ if (error instanceof SubAgentNotSupportedError) {
156
+ printResult(io, fail('sub-agent.dispatch', 'IDE_NOT_SUPPORTED', error.message, { role, toolCall: null, dispatchRecordPath: null }, [
157
+ 'Switch IDE or pick a role the current IDE supports.'
158
+ ]), asJson);
159
+ process.exitCode = 1;
160
+ return;
161
+ }
162
+ throw error;
163
+ }
164
+ // G7 — optional --write-artifact: build ArtifactMeta, attach to record.
165
+ let artifactMeta = null;
166
+ if (typeof options.writeArtifact === 'string' && options.writeArtifact.length > 0) {
167
+ try {
168
+ assertSafeArtifactPath(options.writeArtifact, projectRoot);
169
+ if (!existsSync(options.writeArtifact)) {
170
+ warnings.push('ARTIFACT_NOT_FOUND');
171
+ }
172
+ else {
173
+ artifactMeta = buildArtifactMeta({
174
+ path: options.writeArtifact,
175
+ rid,
176
+ role,
177
+ idx: 1, // single dispatch, idx=1
178
+ summary: null
179
+ });
180
+ }
181
+ }
182
+ catch (err) {
183
+ warnings.push(`ARTIFACT_PATH_INVALID: ${getErrorMessage(err)}`);
184
+ }
185
+ }
186
+ const { path: dispatchRecordPath } = writeInitialDispatchRecord({
187
+ projectRoot,
188
+ sessionId: sid,
189
+ requestId: rid,
190
+ role,
191
+ prompt: effectivePrompt,
192
+ toolCall,
193
+ batchId
194
+ });
195
+ const counter = noteDispatched(projectRoot, sid, batchId);
196
+ if (counter.warning) {
197
+ warnings.push(counter.warning.message);
198
+ }
199
+ const contextImpact = buildContextImpact({
200
+ promptSize: effectivePrompt.length,
201
+ artifactSizes: artifactMeta ? [artifactMeta.size] : []
202
+ });
203
+ const nextActions = [
204
+ 'Tool call is dry-run; LLM must execute the tool to actually dispatch the sub-agent.',
205
+ 'After dispatching, the sub-agent should call `peaks sub-agent heartbeat --record ' + dispatchRecordPath + '` periodically.'
206
+ ];
207
+ if (counter.warning) {
208
+ nextActions.push(`Batch is over the RL-1 limit (${BATCH_LIMIT}); consider splitting into multiple batches.`);
209
+ }
210
+ if (headroomResult && headroomResult.warning === 'HEADROOM_UNAVAILABLE') {
211
+ nextActions.push('Headroom daemon unavailable; dispatched with G7 metadata-only fallback.');
212
+ }
213
+ printResult(io, ok('sub-agent.dispatch', {
214
+ role,
215
+ ide: adapter.subAgentDispatcher.label,
216
+ prompt: effectivePrompt,
217
+ originalPromptSize: options.prompt.length,
218
+ promptSize: effectivePrompt.length,
219
+ toolCall,
220
+ dispatchRecordPath,
221
+ batchId,
222
+ dispatchedInBatch: counter.count,
223
+ headroomCompressed,
224
+ headroomResult: headroomResult
225
+ ? {
226
+ mode: headroomResult.mode,
227
+ compressed: headroomResult.compressed,
228
+ compressionRatio: headroomResult.compressionRatio,
229
+ tokensSaved: headroomResult.tokensSaved,
230
+ warning: headroomResult.warning
231
+ }
232
+ : null,
233
+ forcedAt: decision.forcedAt,
234
+ contextImpact,
235
+ artifactMetas: artifactMeta ? [artifactMeta] : []
236
+ }, warnings, nextActions), asJson);
237
+ }
238
+ catch (error) {
239
+ printResult(io, fail('sub-agent.dispatch', 'DISPATCH_ERROR', getErrorMessage(error), { role, toolCall: null, dispatchRecordPath: null }, [
240
+ 'See error message; if you are dispatching from a SKILL.md, the LLM should retry with a smaller prompt or pick a different role.'
241
+ ]), asJson);
242
+ process.exitCode = 1;
243
+ }
244
+ });
245
+ // ─────────────────────────────────────────────────────────────────
246
+ // peaks sub-agent heartbeat --record <path> --status <state> --progress <pct> --json
247
+ // ─────────────────────────────────────────────────────────────────
248
+ addJsonOption(subAgent
249
+ .command('heartbeat')
250
+ .description('Append a heartbeat entry to a dispatch record. Fire-and-forget: ' +
251
+ 'the parent Dispatcher polls this record during the batch-sync ' +
252
+ 'wait and renders a status line. Sub-agents should call this at ' +
253
+ 'least every 30s (configurable via SKILL.md heartbeatIntervalSec).')
254
+ .requiredOption('--record <path>', 'absolute path to a dispatch record JSON')
255
+ .requiredOption('--status <state>', 'queued | running | finalizing | done | failed | stale')
256
+ .requiredOption('--progress <pct>', 'integer 0-100')
257
+ .option('--note <text>', 'free-form progress note (≤ 200 chars)')).action((options) => {
258
+ const asJson = options.json === true;
259
+ if (!options.record || !existsSync(options.record)) {
260
+ printResult(io, fail('sub-agent.heartbeat', 'INVALID_RECORD_PATH', `record not found: ${options.record ?? '(empty)'}`, { recordPath: options.record ?? null, truncated: false }, [
261
+ 'Pass the absolute path from the `peaks sub-agent dispatch` envelope.'
262
+ ]), asJson);
263
+ process.exitCode = 1;
264
+ return;
265
+ }
266
+ if (!HEARTBEAT_STATUSES.includes(options.status)) {
267
+ printResult(io, fail('sub-agent.heartbeat', 'INVALID_STATUS', `--status must be one of ${HEARTBEAT_STATUSES.join(' | ')} (got ${options.status})`, { recordPath: options.record, truncated: false }, [
268
+ 'Use one of the documented statuses; poller compares lastBeatAt against now() - 5min to set `stale`.'
269
+ ]), asJson);
270
+ process.exitCode = 1;
271
+ return;
272
+ }
273
+ const progress = Number.parseInt(options.progress ?? 'NaN', 10);
274
+ if (!Number.isInteger(progress) || progress < 0 || progress > 100) {
275
+ printResult(io, fail('sub-agent.heartbeat', 'INVALID_PROGRESS', `--progress must be integer 0-100 (got ${options.progress})`, { recordPath: options.record, truncated: false }, [
276
+ 'Use 0..100 inclusive.'
277
+ ]), asJson);
278
+ process.exitCode = 1;
279
+ return;
280
+ }
281
+ if (options.note !== undefined && options.note.length > 200) {
282
+ printResult(io, fail('sub-agent.heartbeat', 'NOTE_TOO_LONG', `--note must be ≤ 200 chars (got ${options.note.length})`, { recordPath: options.record, truncated: false }, [
283
+ 'Shorten the note; the record file is not a log file.'
284
+ ]), asJson);
285
+ process.exitCode = 1;
286
+ return;
287
+ }
288
+ try {
289
+ // R-2 guard: ensure the path lives under `.peaks/_sub_agents/`.
290
+ assertSafeDispatchRecordPath(options.record, deriveProjectRoot(options.record));
291
+ const result = appendHeartbeat({
292
+ recordPath: options.record,
293
+ status: options.status,
294
+ progress,
295
+ ...(options.note !== undefined ? { note: options.note } : {})
296
+ });
297
+ printResult(io, ok('sub-agent.heartbeat', {
298
+ recordPath: options.record,
299
+ heartbeatCount: result.record.heartbeats.length,
300
+ lastBeatAt: result.record.lastBeatAt,
301
+ status: result.record.status,
302
+ truncated: result.truncated
303
+ }, [], ['Continue business logic; heartbeat is fire-and-forget.']), asJson);
304
+ }
305
+ catch (error) {
306
+ const code = error.code ?? 'HEARTBEAT_ERROR';
307
+ printResult(io, fail('sub-agent.heartbeat', code, getErrorMessage(error), { recordPath: options.record ?? null, truncated: false }, [
308
+ 'See error message; if the record file is missing or corrupted, the parent Dispatcher will mark the sub-agent as stale after 5 minutes.'
309
+ ]), asJson);
310
+ process.exitCode = 1;
311
+ }
312
+ });
313
+ // ─────────────────────────────────────────────────────────────────
314
+ // peaks sub-agent share --batch <batchId> --key <k> --value <json> --json
315
+ // G8.4: cross sub-agent shared channel write.
316
+ // ─────────────────────────────────────────────────────────────────
317
+ addJsonOption(subAgent
318
+ .command('share')
319
+ .description('G8.4: write a shared entry to the cross sub-agent shared channel. ' +
320
+ 'Dispatcher-mediated indirect signal: sub-agent A writes, dispatcher ' +
321
+ 'stores, sub-agent B (still in flight) reads via `peaks sub-agent ' +
322
+ 'shared-read`. Not peer-to-peer; pseudo-swarm property 3 preserved.')
323
+ .requiredOption('--batch <batchId>', 'batchId (from `peaks sub-agent dispatch` envelope)')
324
+ .requiredOption('--key <k>', 'entry key (convention: "<role>.<event>")')
325
+ .requiredOption('--value <json>', 'JSON object value (≤ 1KB soft warn, ≥ 64KB rejected)')
326
+ .option('--from <role>', 'sub-agent role string; defaults to dispatch record role if available')
327
+ .option('--request-id <rid>', 'request id (default: "unknown-rid")')
328
+ .option('--session-id <sid>', 'session id (default: "unknown-sid")')
329
+ .option('--project <path>', 'target project root (defaults to cwd)')).action((options) => {
330
+ const asJson = options.json === true;
331
+ if (!options.batch || !options.key || !options.value) {
332
+ printResult(io, fail('sub-agent.share', 'MISSING_ARG', '--batch, --key, and --value are required', { ok: false }, [
333
+ 'Re-run with --batch <batchId> --key <key> --value <jsonObject>.'
334
+ ]), asJson);
335
+ process.exitCode = 1;
336
+ return;
337
+ }
338
+ let parsedValue;
339
+ try {
340
+ const parsed = JSON.parse(options.value);
341
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
342
+ throw new Error('value must be a JSON object');
343
+ }
344
+ parsedValue = parsed;
345
+ }
346
+ catch (err) {
347
+ printResult(io, fail('sub-agent.share', 'INVALID_VALUE', `value must be a JSON object: ${getErrorMessage(err)}`, { ok: false }, [
348
+ 'Pass --value as a JSON object literal, e.g. --value \'{"reason":"x"}\'.'
349
+ ]), asJson);
350
+ process.exitCode = 1;
351
+ return;
352
+ }
353
+ try {
354
+ const projectRoot = options.project ?? process.cwd();
355
+ const sid = options.sessionId ?? 'unknown-sid';
356
+ const rid = options.requestId ?? 'unknown-rid';
357
+ const from = options.from ?? 'unknown-role';
358
+ const result = writeSharedEntry({
359
+ projectRoot,
360
+ sid,
361
+ rid,
362
+ batchId: options.batch,
363
+ key: options.key,
364
+ from,
365
+ value: parsedValue
366
+ });
367
+ if (!result.ok) {
368
+ const code = result.code;
369
+ printResult(io, fail('sub-agent.share', code, result.message, { ok: false, batchId: options.batch }, [
370
+ code === 'VALUE_TOO_LARGE'
371
+ ? 'Reduce value size; 1KB is a soft warning, 64KB is a hard reject.'
372
+ : 'See error message; check --batch, --key, --value arguments.'
373
+ ]), asJson);
374
+ process.exitCode = 1;
375
+ return;
376
+ }
377
+ const warnings = [];
378
+ if (result.lastWriteWins) {
379
+ warnings.push('LAST_WRITE_WINS');
380
+ }
381
+ if (result.softWarning) {
382
+ warnings.push(`VALUE_SIZE_SOFT_WARN: ${result.entry.valueSize} > ${SHARED_CHANNEL_SOFT_VALUE_WARN} bytes`);
383
+ }
384
+ printResult(io, ok('sub-agent.share', {
385
+ ok: true,
386
+ batchId: options.batch,
387
+ entryKey: options.key,
388
+ writtenAt: result.entry.at,
389
+ channelSize: result.channelSize,
390
+ lastWriteWins: result.lastWriteWins,
391
+ valueSize: result.entry.valueSize
392
+ }, warnings, [
393
+ 'Sub-agents in the same batch can read this entry via `peaks sub-agent shared-read --batch ' + options.batch + '`.'
394
+ ]), asJson);
395
+ }
396
+ catch (error) {
397
+ const code = error.code ?? 'SHARE_ERROR';
398
+ printResult(io, fail('sub-agent.share', code, getErrorMessage(error), { ok: false, batchId: options.batch }, [
399
+ 'See error message; check that the path lives under .peaks/_sub_agents/<sid>/shared/.'
400
+ ]), asJson);
401
+ process.exitCode = 1;
402
+ }
403
+ });
404
+ // ─────────────────────────────────────────────────────────────────
405
+ // peaks sub-agent shared-read --batch <batchId> --json
406
+ // G8.4: cross sub-agent shared channel read.
407
+ // ─────────────────────────────────────────────────────────────────
408
+ addJsonOption(subAgent
409
+ .command('shared-read')
410
+ .description('G8.4: read entries from the cross sub-agent shared channel. ' +
411
+ 'Returns sibling sub-agent status. Supports --since (ISO8601) ' +
412
+ 'and --key (glob pattern with * wildcard).')
413
+ .requiredOption('--batch <batchId>', 'batchId (from `peaks sub-agent dispatch` envelope)')
414
+ .option('--since <iso>', 'only return entries written after this ISO8601 timestamp')
415
+ .option('--key <pattern>', 'glob pattern, e.g. "rd.*" or "*.completed"')
416
+ .option('--request-id <rid>', 'request id (default: "unknown-rid")')
417
+ .option('--session-id <sid>', 'session id (default: "unknown-sid")')
418
+ .option('--project <path>', 'target project root (defaults to cwd)')).action((options) => {
419
+ const asJson = options.json === true;
420
+ if (!options.batch) {
421
+ printResult(io, fail('sub-agent.shared-read', 'MISSING_BATCH', '--batch is required', { ok: false }, [
422
+ 'Re-run with --batch <batchId>.'
423
+ ]), asJson);
424
+ process.exitCode = 1;
425
+ return;
426
+ }
427
+ try {
428
+ const projectRoot = options.project ?? process.cwd();
429
+ const sid = options.sessionId ?? 'unknown-sid';
430
+ const rid = options.requestId ?? 'unknown-rid';
431
+ const channel = readSharedChannel({
432
+ projectRoot,
433
+ sid,
434
+ rid,
435
+ batchId: options.batch,
436
+ ...(options.since !== undefined ? { since: options.since } : {}),
437
+ ...(options.key !== undefined ? { keyPattern: options.key } : {})
438
+ });
439
+ printResult(io, ok('sub-agent.shared-read', {
440
+ ok: true,
441
+ batchId: options.batch,
442
+ entries: channel.entries,
443
+ totalEntries: Object.keys(channel.entries).length,
444
+ channelSize: JSON.stringify(channel).length,
445
+ updatedAt: channel.updatedAt
446
+ }, [], [
447
+ 'Shared channel is dispatcher-mediated; do not attempt to read sibling dispatch records directly.'
448
+ ]), asJson);
449
+ }
450
+ catch (error) {
451
+ const code = error.code ?? 'SHARED_READ_ERROR';
452
+ printResult(io, fail('sub-agent.shared-read', code, getErrorMessage(error), { ok: false, batchId: options.batch }, [
453
+ 'See error message; check that the batchId matches the dispatch envelope.'
454
+ ]), asJson);
455
+ process.exitCode = 1;
456
+ }
457
+ });
458
+ }
459
+ /** Validate a role string. Returns null if valid, otherwise the rejection reason. */
460
+ export function validateRole(role) {
461
+ if (typeof role !== 'string' || role.length === 0) {
462
+ return 'role must be a non-empty string';
463
+ }
464
+ if (role.length > 256) {
465
+ return 'role must be ≤ 256 chars';
466
+ }
467
+ for (let i = 0; i < role.length; i += 1) {
468
+ const code = role.charCodeAt(i);
469
+ if (code <= 0x20 || code === 0x7F) {
470
+ return 'role must not contain whitespace or control characters';
471
+ }
472
+ }
473
+ return null;
474
+ }
475
+ function isHeadroomMode(value) {
476
+ if (typeof value !== 'string')
477
+ return false;
478
+ return HEADROOM_MODES.includes(value);
479
+ }
480
+ /** Best-effort project root derivation for the R-2 path guard. */
481
+ function deriveProjectRoot(recordPath) {
482
+ const parts = recordPath.split(/[\\/]/);
483
+ const idx = parts.lastIndexOf('.peaks');
484
+ if (idx <= 0) {
485
+ return process.cwd();
486
+ }
487
+ return parts.slice(0, idx).join('/') || '/';
488
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * `peaks sub-agent-dispatch-guard` — G9.5 / RL-30 strict hook-only atom.
3
+ *
4
+ * This is the **second-layer** gate (PreToolUse hook) for the G9 forced
5
+ * compression threshold. It re-validates the prompt size against the
6
+ * threshold table in `src/services/context/threshold.ts` and returns
7
+ * `{allow: true/false, reason, suggest}` JSON to the LLM platform.
8
+ *
9
+ * **NO `--force` flag is exposed at this layer** (RL-30 strict). The
10
+ * hook is the strictest layer in the G9 chain. If the CLI is bypassed
11
+ * (e.g. a user manually invokes the dispatch CLI with `--force` to
12
+ * override the 80% threshold), the hook catches it and returns
13
+ * `{allow: false}` regardless.
14
+ *
15
+ * This atom is **hidden from `peaks --help`** per dev-preference
16
+ * "skill-first / CLI-auxiliary" + PB-2 byte-stable. It is registered
17
+ * via the LLM platform's PreToolUse hook chain (e.g. Claude Code's
18
+ * `settings.json` `PreToolUse` array) and is not a user-facing command.
19
+ *
20
+ * The `peaks hooks install` command reads `IdeAdapter.promptSizeAware`
21
+ * to decide whether to register this hook for a given IDE.
22
+ */
23
+ import { Command } from 'commander';
24
+ import { type ContextGuardDecision } from '../../services/context/context-guard.js';
25
+ export declare const HOOK_GUARD_RESULT_TYPE: "peaks-hook-guard/v1";
26
+ export interface HookGuardResult {
27
+ readonly schema: typeof HOOK_GUARD_RESULT_TYPE;
28
+ readonly allow: boolean;
29
+ readonly code: ContextGuardDecision['code'];
30
+ readonly reason: string;
31
+ readonly suggest: string | null;
32
+ readonly tier: ContextGuardDecision['evaluation']['tier'];
33
+ readonly ratio: number;
34
+ readonly bytesUsed: number;
35
+ readonly capacityBytes: number;
36
+ readonly warnings: readonly string[];
37
+ }
38
+ /**
39
+ * Build the hook-guard result for a given prompt size. Pure function;
40
+ * no IO. The CLI atom (registered below) calls this and prints JSON.
41
+ *
42
+ * Even if the caller passes `force = true` in the input (it shouldn't —
43
+ * the hook CLI doesn't expose that flag), this function ignores it
44
+ * and treats the prompt as if no override were available. This is the
45
+ * RL-30 strict semantics.
46
+ */
47
+ export declare function evaluateHookGuard(promptSize: number): HookGuardResult;
48
+ /**
49
+ * Register the `peaks sub-agent-dispatch-guard` command. Intentionally
50
+ * NOT registered in the main `peaks --help` quickstart (dev-preference
51
+ * PB-2 byte-stable). The caller (the `peaks hooks install` flow) calls
52
+ * this directly via the imported function; the CLI registration in
53
+ * `src/cli/index.ts` uses a hidden command (no `description`, no help).
54
+ */
55
+ export declare function registerSubAgentDispatchGuard(program: Command): void;
@@ -0,0 +1,57 @@
1
+ import { evaluatePromptSize } from '../../services/context/context-guard.js';
2
+ export const HOOK_GUARD_RESULT_TYPE = 'peaks-hook-guard/v1';
3
+ /**
4
+ * Build the hook-guard result for a given prompt size. Pure function;
5
+ * no IO. The CLI atom (registered below) calls this and prints JSON.
6
+ *
7
+ * Even if the caller passes `force = true` in the input (it shouldn't —
8
+ * the hook CLI doesn't expose that flag), this function ignores it
9
+ * and treats the prompt as if no override were available. This is the
10
+ * RL-30 strict semantics.
11
+ */
12
+ export function evaluateHookGuard(promptSize) {
13
+ // Intentionally pass `force: false` always. The hook layer is strict.
14
+ const decision = evaluatePromptSize(promptSize, { force: false });
15
+ return {
16
+ schema: HOOK_GUARD_RESULT_TYPE,
17
+ allow: decision.allow,
18
+ code: decision.code,
19
+ reason: decision.allow
20
+ ? `prompt size ${promptSize} bytes within threshold (tier=${decision.evaluation.tier})`
21
+ : `prompt size ${promptSize} bytes exceeds threshold (tier=${decision.evaluation.tier}, ratio=${decision.evaluation.ratio.toFixed(3)})`,
22
+ suggest: decision.suggest,
23
+ tier: decision.evaluation.tier,
24
+ ratio: decision.evaluation.ratio,
25
+ bytesUsed: decision.evaluation.bytesUsed,
26
+ capacityBytes: decision.evaluation.capacityBytes,
27
+ warnings: decision.warnings
28
+ };
29
+ }
30
+ /**
31
+ * Register the `peaks sub-agent-dispatch-guard` command. Intentionally
32
+ * NOT registered in the main `peaks --help` quickstart (dev-preference
33
+ * PB-2 byte-stable). The caller (the `peaks hooks install` flow) calls
34
+ * this directly via the imported function; the CLI registration in
35
+ * `src/cli/index.ts` uses a hidden command (no `description`, no help).
36
+ */
37
+ export function registerSubAgentDispatchGuard(program) {
38
+ program
39
+ .command('sub-agent-dispatch-guard')
40
+ .description('INTERNAL: PreToolUse hook guard (G9.5 / RL-30 strict)')
41
+ .requiredOption('--prompt <text>', 'the prompt to validate (size in bytes is what gets checked)')
42
+ .option('--prompt-length <bytes>', 'DOGFOOD ONLY: synthesize a prompt of this size (overrides --prompt content for size only)')
43
+ .action((options) => {
44
+ let prompt = options.prompt;
45
+ if (typeof options.promptLength === 'string' && options.promptLength.length > 0) {
46
+ const len = Number.parseInt(options.promptLength, 10);
47
+ if (Number.isInteger(len) && len > 0) {
48
+ prompt = 'x'.repeat(len);
49
+ }
50
+ }
51
+ const promptSize = Buffer.byteLength(prompt, 'utf8');
52
+ const result = evaluateHookGuard(promptSize);
53
+ // Always exit 0 — the LLM platform reads `allow` from JSON.
54
+ // The decision is encoded in `allow` / `code`, not the exit code.
55
+ process.stdout.write(JSON.stringify(result, null, 2) + '\n');
56
+ });
57
+ }
@@ -264,6 +264,9 @@ export function registerWorkspaceCommands(program, io) {
264
264
  if (result.systemCleaned.length > 0) {
265
265
  nextActions.push(`Removed ${result.systemCleaned.length} F3 system/ subdir(s).`);
266
266
  }
267
+ if (result.subAgentStateMigrated > 0) {
268
+ nextActions.push(`Migrated ${result.subAgentStateMigrated} legacy sub-agent state file(s) into .peaks/_sub_agents/.`);
269
+ }
267
270
  printResult(io, ok('workspace.reconcile', result, warnings, nextActions), options.json);
268
271
  if (result.errors.length > 0) {
269
272
  process.exitCode = 1;
@@ -17,7 +17,10 @@ import { registerScanCommands } from './commands/scan-commands.js';
17
17
  import { registerShadcnCommands } from './commands/shadcn-commands.js';
18
18
  import { registerSliceCommands } from './commands/slice-commands.js';
19
19
  import { registerSopCommands } from './commands/sop-commands.js';
20
+ import { registerSubAgentCommands } from './commands/sub-agent-commands.js';
21
+ import { registerSubAgentDispatchGuard } from './commands/sub-agent-dispatch-guard.js';
20
22
  import { registerGateCommands } from './commands/gate-commands.js';
23
+ import { registerHookHandleCommand } from './commands/hook-handle.js';
21
24
  import { registerHooksCommands } from './commands/hooks-commands.js';
22
25
  import { registerStatusLineCommands } from './commands/statusline-commands.js';
23
26
  import { registerUnderstandCommands } from './commands/understand-commands.js';
@@ -92,7 +95,13 @@ Run peaks (no arguments) for a quickstart. You likely want one of:
92
95
  registerShadcnCommands(program, io);
93
96
  registerSliceCommands(program, io);
94
97
  registerSopCommands(program, io);
98
+ registerSubAgentCommands(program, io);
99
+ // Slice #010 G9.5: register the hook-only internal atom. Hidden from
100
+ // `peaks --help` (no description text); used by `peaks hooks install`
101
+ // to wire the PreToolUse hook chain.
102
+ registerSubAgentDispatchGuard(program);
95
103
  registerGateCommands(program, io);
104
+ registerHookHandleCommand(program, io);
96
105
  registerHooksCommands(program, io);
97
106
  registerStatusLineCommands(program, io);
98
107
  registerUnderstandCommands(program, io);