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
@@ -4,6 +4,8 @@ import { findProjectRoot } from '../../services/config/config-safety.js';
4
4
  import { buildStatusLineModel, parseStatusLineStdin } from '../../services/skills/skill-statusline-service.js';
5
5
  import { renderStatusLine } from '../../services/skills/skill-statusline-renderer.js';
6
6
  import { applyStatusLineInstall, planStatusLineInstall, removeStatusLineInstall } from '../../services/skills/statusline-settings-service.js';
7
+ import { readHookStatus as readSettingsStatus } from '../../services/skills/hooks-settings-service.js';
8
+ import { detectIdeFromContext } from '../../services/ide/hook-translator.js';
7
9
  const STDIN_READ_TIMEOUT_MS = 250;
8
10
  /** Read piped stdin if present; resolve quickly with '' when attached to a TTY. */
9
11
  function readStdin() {
@@ -39,72 +41,128 @@ function readStdin() {
39
41
  function resolveScope(options) {
40
42
  return options.global ? 'global' : 'project';
41
43
  }
44
+ /**
45
+ * Resolve the IDE the install should target. The CLI user can override with
46
+ * `--ide <id>`. Otherwise we delegate to `detectIdeFromContext` which checks
47
+ * `process.env[adapter.envVar]` → stdin shape → cwd `.trae`/`.claude` →
48
+ * fallback `'claude-code'`. Pass `parsedStdin: null` since `peaks statusline
49
+ * install` is not invoked from inside an IDE hook — there's no stdin payload.
50
+ */
51
+ function resolveIdeForCommand(options, projectRoot) {
52
+ if (options.ide !== undefined && options.ide.length > 0) {
53
+ return options.ide;
54
+ }
55
+ return detectIdeFromContext({ env: process.env, cwd: projectRoot ?? process.cwd(), parsedStdin: null });
56
+ }
42
57
  export function registerStatusLineCommands(program, io) {
43
- const statusline = program
58
+ // Top-level `peaks statusline` register as a group with subcommands
59
+ // (install | uninstall | status). When the user runs `peaks statusline` with
60
+ // no subcommand, commander falls back to a hidden render subcommand so the
61
+ // status line still renders. This pattern is required by commander 12.x:
62
+ // when a command has both an action AND subcommands, commander's option
63
+ // parser conflates the parent's options with the subcommand's and drops
64
+ // flags. Routing through a subcommand (even one that's hidden) avoids the
65
+ // option-shadowing bug.
66
+ const statusline = addJsonOption(program
44
67
  .command('statusline')
45
- .description('Render the Peaks skill status line for Claude Code (reads session JSON on stdin)')
46
- .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')
68
+ .description('Render the Peaks skill status line for the current session, or manage the adapter-driven statusLine entry. Run with no subcommand to render; with a subcommand (install | uninstall | status) to manage.'));
69
+ // Hidden render subcommand. This is the default behavior when the user types
70
+ // `peaks statusline` with no subcommand. We mark it hidden so `peaks
71
+ // statusline --help` does not show "render" as a subcommand.
72
+ statusline
73
+ .command('render', { hidden: true })
74
+ .description('Render the Peaks skill status line for the current session (reads session JSON on stdin; honors --project for the project label).')
75
+ .option('--project <path>', 'project root path (used to label the status line when stdin is absent)')
47
76
  .action(async (options) => {
48
77
  const raw = await readStdin();
49
78
  const stdin = parseStatusLineStdin(raw);
50
- // When a project override is passed (or no stdin), seed cwd so detection works.
51
79
  const seeded = options.project
52
80
  ? { ...(stdin ?? {}), workspace: { current_dir: options.project } }
53
81
  : stdin;
54
82
  const model = buildStatusLineModel(seeded, Date.now());
55
- io.stdout(renderStatusLine(model));
83
+ const text = renderStatusLine(model);
84
+ if (options.json === true) {
85
+ io.stdout(JSON.stringify({ ok: true, command: 'statusline.render', data: { text } }, null, 2));
86
+ return;
87
+ }
88
+ io.stdout(text);
56
89
  });
57
90
  addJsonOption(statusline
58
91
  .command('install')
59
- .description('Install the Peaks status line into .claude/settings.json (project scope by default)')
92
+ .description("Install the Peaks status line into the adapter's settings.json (project scope by default).")
60
93
  .option('--global', 'install into the user-level ~/.claude/settings.json instead of the project')
61
94
  .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')
95
+ .option('--ide <id>', 'target adapter id (claude-code | trae); default: auto-detect from env/cwd')
62
96
  .option('--force', 'overwrite an existing non-Peaks statusLine entry')
63
97
  .option('--dry-run', 'show what would change without writing')).action((options) => {
64
98
  const scope = resolveScope(options);
65
99
  const projectRoot = scope === 'project'
66
100
  ? (options.project ?? findProjectRoot(process.cwd()) ?? process.cwd())
67
101
  : undefined;
102
+ const ide = resolveIdeForCommand(options, projectRoot);
68
103
  try {
69
104
  if (options.dryRun) {
70
- const plan = planStatusLineInstall(scope, projectRoot);
105
+ const plan = planStatusLineInstall(scope, projectRoot, { ide });
71
106
  const warnings = plan.conflict
72
107
  ? [`An existing statusLine command is set: ${plan.conflictCommand}. Rerun with --force to overwrite.`]
73
108
  : [];
74
- printResult(io, ok('statusline.install', { ...plan, applied: false, dryRun: true }, warnings), options.json);
109
+ printResult(io, ok('statusline.install', { ...plan, ide, applied: false, dryRun: true }, warnings), options.json);
75
110
  return;
76
111
  }
77
- const result = applyStatusLineInstall(scope, projectRoot, options.force ? { force: true } : {});
112
+ const result = applyStatusLineInstall(scope, projectRoot, { force: options.force === true, ide });
78
113
  const warnings = result.conflict && !result.applied
79
114
  ? [`An existing statusLine command is set: ${result.conflictCommand}. Rerun with --force to overwrite.`]
80
115
  : [];
81
116
  const nextActions = result.applied
82
- ? ['Restart Claude Code (or reload the window) so the status line takes effect']
117
+ ? ['Restart the IDE (or reload the workspace) so the status line takes effect']
83
118
  : [];
84
- printResult(io, ok('statusline.install', { ...result, dryRun: false }, warnings, nextActions), options.json);
119
+ printResult(io, ok('statusline.install', { ...result, ide, dryRun: false }, warnings, nextActions), options.json);
85
120
  }
86
121
  catch (error) {
87
122
  const message = getErrorMessage(error);
88
- printResult(io, fail('statusline.install', 'STATUSLINE_INSTALL_FAILED', message, { scope, applied: false }, [message]), options.json);
123
+ printResult(io, fail('statusline.install', 'STATUSLINE_INSTALL_FAILED', message, { scope, ide, applied: false }, [message]), options.json);
89
124
  process.exitCode = 1;
90
125
  }
91
126
  });
92
127
  addJsonOption(statusline
93
128
  .command('uninstall')
94
- .description('Remove the Peaks status line from .claude/settings.json')
129
+ .description("Remove the Peaks status line from the adapter's settings.json.")
95
130
  .option('--global', 'remove from the user-level ~/.claude/settings.json instead of the project')
96
- .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')).action((options) => {
131
+ .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')
132
+ .option('--ide <id>', 'target adapter id (claude-code | trae); default: auto-detect from env/cwd')).action((options) => {
133
+ const scope = resolveScope(options);
134
+ const projectRoot = scope === 'project'
135
+ ? (options.project ?? findProjectRoot(process.cwd()) ?? process.cwd())
136
+ : undefined;
137
+ const ide = resolveIdeForCommand(options, projectRoot);
138
+ try {
139
+ const result = removeStatusLineInstall(scope, projectRoot, { ide });
140
+ printResult(io, ok('statusline.uninstall', { ...result, ide }), options.json);
141
+ }
142
+ catch (error) {
143
+ const message = getErrorMessage(error);
144
+ printResult(io, fail('statusline.uninstall', 'STATUSLINE_UNINSTALL_FAILED', message, { scope, ide, removed: false }, [message]), options.json);
145
+ process.exitCode = 1;
146
+ }
147
+ });
148
+ addJsonOption(statusline
149
+ .command('status')
150
+ .description('Report whether the Peaks status line is installed in the adapter settings.json.')
151
+ .option('--global', 'inspect the user-level ~/.claude/settings.json instead of the project')
152
+ .option('--project <path>', 'project root path (auto-detected from cwd when omitted)')
153
+ .option('--ide <id>', 'target adapter id (claude-code | trae); default: auto-detect from env/cwd')).action((options) => {
97
154
  const scope = resolveScope(options);
98
155
  const projectRoot = scope === 'project'
99
156
  ? (options.project ?? findProjectRoot(process.cwd()) ?? process.cwd())
100
157
  : undefined;
158
+ const ide = resolveIdeForCommand(options, projectRoot);
101
159
  try {
102
- const result = removeStatusLineInstall(scope, projectRoot);
103
- printResult(io, ok('statusline.uninstall', result), options.json);
160
+ const status = readSettingsStatus(scope, projectRoot, { ide });
161
+ printResult(io, ok('statusline.status', { ...status, ide, command: 'peaks statusline' }), options.json);
104
162
  }
105
163
  catch (error) {
106
164
  const message = getErrorMessage(error);
107
- printResult(io, fail('statusline.uninstall', 'STATUSLINE_UNINSTALL_FAILED', message, { scope, removed: false }, [message]), options.json);
165
+ printResult(io, fail('statusline.status', 'STATUSLINE_STATUS_FAILED', message, { scope, ide }, [message]), options.json);
108
166
  process.exitCode = 1;
109
167
  }
110
168
  });
@@ -0,0 +1,5 @@
1
+ import { Command } from 'commander';
2
+ import { type ProgramIO } from '../cli-helpers.js';
3
+ export declare function registerSubAgentCommands(program: Command, io: ProgramIO): void;
4
+ /** Validate a role string. Returns null if valid, otherwise the rejection reason. */
5
+ export declare function validateRole(role: string): string | null;
@@ -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
+ }