peaks-cli 1.2.7 → 1.2.9

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 (54) hide show
  1. package/README.md +12 -0
  2. package/dist/src/cli/commands/core-artifact-commands.js +36 -1
  3. package/dist/src/cli/commands/perf-commands.d.ts +3 -0
  4. package/dist/src/cli/commands/perf-commands.js +41 -0
  5. package/dist/src/cli/commands/progress-close-kill.d.ts +51 -0
  6. package/dist/src/cli/commands/progress-close-kill.js +152 -0
  7. package/dist/src/cli/commands/progress-commands.d.ts +3 -0
  8. package/dist/src/cli/commands/progress-commands.js +348 -0
  9. package/dist/src/cli/commands/progress-start-spawn.d.ts +59 -0
  10. package/dist/src/cli/commands/progress-start-spawn.js +114 -0
  11. package/dist/src/cli/commands/progress-watch-render.d.ts +80 -0
  12. package/dist/src/cli/commands/progress-watch-render.js +308 -0
  13. package/dist/src/cli/commands/project-commands.js +1 -1
  14. package/dist/src/cli/commands/scan-commands.js +22 -0
  15. package/dist/src/cli/program.js +4 -0
  16. package/dist/src/services/config/config-types.d.ts +20 -0
  17. package/dist/src/services/config/config-types.js +5 -1
  18. package/dist/src/services/memory/project-memory-service.d.ts +1 -1
  19. package/dist/src/services/memory/project-memory-service.js +52 -23
  20. package/dist/src/services/perf/perf-baseline-service.d.ts +70 -0
  21. package/dist/src/services/perf/perf-baseline-service.js +213 -0
  22. package/dist/src/services/progress/progress-service.d.ts +179 -0
  23. package/dist/src/services/progress/progress-service.js +276 -0
  24. package/dist/src/services/scan/libraries-service.d.ts +24 -0
  25. package/dist/src/services/scan/libraries-service.js +419 -0
  26. package/dist/src/services/scan/libraries-types.d.ts +59 -0
  27. package/dist/src/services/scan/libraries-types.js +9 -0
  28. package/dist/src/services/session/index.d.ts +1 -1
  29. package/dist/src/services/session/index.js +1 -1
  30. package/dist/src/services/session/session-manager.d.ts +53 -8
  31. package/dist/src/services/session/session-manager.js +150 -3
  32. package/dist/src/services/skills/skill-presence-service.d.ts +27 -1
  33. package/dist/src/services/skills/skill-presence-service.js +112 -9
  34. package/dist/src/services/skills/skill-runbook-service.js +34 -1
  35. package/dist/src/services/workflow/autonomous-resume-writer.js +7 -7
  36. package/dist/src/shared/change-id.d.ts +30 -0
  37. package/dist/src/shared/change-id.js +40 -6
  38. package/dist/src/shared/paths.d.ts +1 -1
  39. package/dist/src/shared/paths.js +2 -1
  40. package/dist/src/shared/version.d.ts +1 -1
  41. package/dist/src/shared/version.js +1 -1
  42. package/package.json +6 -2
  43. package/schemas/library-breaking-changes.data.json +141 -0
  44. package/schemas/library-breaking-changes.meta.json +6 -0
  45. package/schemas/library-breaking-changes.schema.json +50 -0
  46. package/skills/peaks-qa/SKILL.md +25 -0
  47. package/skills/peaks-rd/SKILL.md +221 -2
  48. package/skills/peaks-solo/SKILL.md +76 -316
  49. package/skills/peaks-solo/references/runbook.md +166 -0
  50. package/skills/peaks-solo/references/workflow-gates-and-types.md +177 -0
  51. package/skills/peaks-solo-resume/SKILL.md +81 -0
  52. package/skills/peaks-solo-status/SKILL.md +120 -0
  53. package/skills/peaks-solo-test/SKILL.md +84 -0
  54. package/skills/peaks-txt/SKILL.md +8 -5
@@ -0,0 +1,348 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { platform } from 'node:os';
3
+ import chalk from 'chalk';
4
+ import ora from 'ora';
5
+ import { clearSpawnRecord, phaseAutoClosesSpawn, readSpawnRecord, readSubAgentProgress, resolveProgressProjectRoot, subAgentProgressPath, subAgentSpawnPath, writeSpawnRecord, writeSubAgentProgress } from '../../services/progress/progress-service.js';
6
+ import { resolveCanonicalProjectRoot } from '../../services/config/config-service.js';
7
+ import { fail, ok } from '../../shared/result.js';
8
+ import { addJsonOption, getErrorMessage, printResult } from '../cli-helpers.js';
9
+ import { killSpawnedTerminal } from './progress-close-kill.js';
10
+ import { buildStartSpawn } from './progress-start-spawn.js';
11
+ import { WatchRenderer } from './progress-watch-render.js';
12
+ export function registerProgressCommands(program, io) {
13
+ const progress = program.command('progress').description('Sub-agent progress surfacing (LLM-side step writes, user-side watch, auto-spawn new terminal)');
14
+ // ─────────────────────────────────────────────────────────────────
15
+ // peaks progress step
16
+ // LLM-side: called by the LLM on phase transitions. Near-zero
17
+ // token cost — one Bash call per phase change. Writes
18
+ // `.peaks/<sid>/system/subagent-progress.json`. No auto-spawn
19
+ // here; the LLM invokes `peaks progress start` separately when
20
+ // the user-visible window needs to open.
21
+ // ─────────────────────────────────────────────────────────────────
22
+ addJsonOption(progress
23
+ .command('step')
24
+ .description('Record a sub-agent step / phase transition. Idempotent on (step, phase); transitions append to history.')
25
+ .requiredOption('--request-id <rid>', 'the same <rid> used by peaks request init')
26
+ .requiredOption('--role <role>', 'rd | qa | ui | sc | prd (the role of the sub-agent calling this)')
27
+ .requiredOption('--step <text>', 'free-form human-readable step label, e.g. "running test/ut"')
28
+ .requiredOption('--phase <phase>', 'starting | running | verifying | completing | finished | failed | idle')
29
+ .option('--verdict <verdict>', 'pass | return-to-rd | blocked (only when phase is finished or failed)')
30
+ .option('--project <path>', 'target project root (defaults to git root or cwd)')
31
+ .option('--reason <text>', 'human-readable reason for the step write, recorded in the response data')).action((options) => {
32
+ try {
33
+ const projectRoot = options.project !== undefined
34
+ ? options.project
35
+ : resolveProgressProjectRoot(undefined, process.cwd());
36
+ const canonical = resolveCanonicalProjectRoot(projectRoot);
37
+ const data = writeSubAgentProgress({
38
+ projectRoot: canonical,
39
+ requestId: options.requestId,
40
+ role: options.role,
41
+ step: options.step,
42
+ phase: options.phase,
43
+ ...(options.verdict !== undefined ? { verdict: options.verdict } : {}),
44
+ ...(options.reason !== undefined ? { outerSessionId: options.reason } : {})
45
+ });
46
+ printResult(io, ok('progress.step', {
47
+ projectRoot: canonical,
48
+ path: subAgentProgressPath(canonical),
49
+ sessionId: data.sessionId,
50
+ requestId: data.requestId,
51
+ role: data.role,
52
+ phase: data.current.phase,
53
+ step: data.current.step,
54
+ startedAt: data.current.startedAt,
55
+ updatedAt: data.current.updatedAt
56
+ }), options.json);
57
+ }
58
+ catch (error) {
59
+ printResult(io, fail('progress.step', 'PROGRESS_STEP_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Verify the project path exists and that peaks workspace init has been run for it']), options.json);
60
+ process.exitCode = 1;
61
+ }
62
+ });
63
+ // ─────────────────────────────────────────────────────────────────
64
+ // peaks progress watch
65
+ // User-side: run in a separate terminal. 1s poll + ASCII
66
+ // spinner + elapsed. --once for a single snapshot.
67
+ // ─────────────────────────────────────────────────────────────────
68
+ addJsonOption(progress
69
+ .command('watch')
70
+ .description('Watch the sub-agent progress file in a loop (1s poll + ASCII spinner). --once for a single snapshot.')
71
+ .option('--project <path>', 'target project root (defaults to git root or cwd)')
72
+ .option('--once', 'print a single snapshot and exit (for use in scripts or statusline hooks)', false)
73
+ .option('--interval-ms <ms>', 'poll interval in milliseconds (default 1000)', (value) => Number.parseInt(value, 10))).action(async (options) => {
74
+ try {
75
+ const projectRoot = options.project !== undefined
76
+ ? options.project
77
+ : resolveProgressProjectRoot(undefined, process.cwd());
78
+ const canonical = resolveCanonicalProjectRoot(projectRoot);
79
+ const intervalMs = options.intervalMs !== undefined && Number.isFinite(options.intervalMs) && options.intervalMs > 0
80
+ ? options.intervalMs
81
+ : 1000;
82
+ if (options.once === true) {
83
+ const result = readSubAgentProgress({ projectRoot: canonical });
84
+ if (!result.ok) {
85
+ printResult(io, fail('progress.watch', 'NO_PROGRESS_DATA', `No progress file present yet (${result.reason})`, { projectRoot: canonical, path: subAgentProgressPath(canonical) }, ['Run peaks progress step once on the LLM side to bootstrap the file']), options.json);
86
+ process.exitCode = 1;
87
+ return;
88
+ }
89
+ printResult(io, ok('progress.watch.snapshot', {
90
+ projectRoot: canonical,
91
+ path: result.path,
92
+ data: result.data
93
+ }), options.json);
94
+ return;
95
+ }
96
+ // Long-running watch loop. The render layer lives in
97
+ // ./progress-watch-render.ts; the watch loop just polls
98
+ // the file and calls renderer.tick(data, n). The renderer
99
+ // owns cursor positioning and in-place overwrite so the
100
+ // output does not grow line by line as it did in the
101
+ // previous console.log-based implementation.
102
+ const renderer = new WatchRenderer({
103
+ projectRoot: canonical,
104
+ progressFilePath: subAgentProgressPath(canonical)
105
+ });
106
+ renderer.start();
107
+ // Hint line is painted once BELOW the dynamic block, so
108
+ // it is not erased on each tick. The user sees the
109
+ // spinner + bar above, the static hint at the bottom.
110
+ io.stdout(chalk.gray(' press Ctrl-C to stop watching\n'));
111
+ let tick = 0;
112
+ // eslint-disable-next-line no-constant-condition
113
+ while (true) {
114
+ const result = readSubAgentProgress({ projectRoot: canonical });
115
+ const data = result.ok ? result.data : null;
116
+ renderer.tick(data, tick);
117
+ tick += 1;
118
+ // Auto-close: when the sub-agent reaches a terminal
119
+ // phase (finished or failed per phaseAutoClosesSpawn),
120
+ // paint the final frame so the user can read the
121
+ // verdict, then exit. Exiting the watch process makes
122
+ // most terminal emulators close the window
123
+ // (Terminal.app / gnome-terminal / konsole all do;
124
+ // alacritty / kitty keep it). We also clear the
125
+ // spawn record so a subsequent `peaks progress close`
126
+ // reports "nothing to close" instead of "closed a
127
+ // ghost record".
128
+ //
129
+ // We deliberately do NOT auto-close on `blocked` —
130
+ // `blocked` means the user needs to read the watch
131
+ // output and decide what to do.
132
+ if (data !== null && phaseAutoClosesSpawn(data.current.phase)) {
133
+ renderer.finalize(data);
134
+ clearSpawnRecord(canonical);
135
+ return;
136
+ }
137
+ await new Promise((resolveWait) => setTimeout(resolveWait, intervalMs));
138
+ }
139
+ }
140
+ catch (error) {
141
+ printResult(io, fail('progress.watch', 'PROGRESS_WATCH_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Verify the project path exists and that the progress file is readable']), options.json);
142
+ process.exitCode = 1;
143
+ }
144
+ });
145
+ // ─────────────────────────────────────────────────────────────────
146
+ // peaks progress start
147
+ // The "auto-spawn a new terminal running watch" entry point.
148
+ // Called by the LLM at the first phase transition of a slice,
149
+ // once per session. Cross-platform: macOS uses osascript with
150
+ // Terminal.app; Linux tries gnome-terminal / konsole /
151
+ // xterm in order; Windows uses `start cmd`. The user can
152
+ // close the new terminal at any time; re-invoking is a no-op
153
+ // if a watch is already running in another terminal.
154
+ // ─────────────────────────────────────────────────────────────────
155
+ addJsonOption(progress
156
+ .command('start')
157
+ .description('Auto-spawn a new terminal running `peaks progress watch` for this project. Called by the LLM at the first phase transition; the user can close the new terminal at any time.')
158
+ .option('--project <path>', 'target project root (defaults to git root or cwd)')
159
+ .option('--reason <text>', 'human-readable reason for the auto-spawn, recorded in the response data')).action(async (options) => {
160
+ try {
161
+ const projectRoot = options.project !== undefined
162
+ ? options.project
163
+ : resolveProgressProjectRoot(undefined, process.cwd());
164
+ const canonical = resolveCanonicalProjectRoot(projectRoot);
165
+ const currentPlatform = platform();
166
+ const peaksBin = process.argv[1] ?? 'peaks';
167
+ const reasonSuffix = options.reason !== undefined ? ` — ${options.reason}` : '';
168
+ // Window / tab title shared across platforms. The user
169
+ // asked for visible "this is peaks-cli" branding so the
170
+ // spawned terminal is identifiable at a glance; the title
171
+ // also makes `peaks progress close` self-documenting.
172
+ const windowTitle = `peaks-cli: sub-agent progress${reasonSuffix}`;
173
+ const watchCommand = `${peaksBin} progress watch --project "${canonical}"`;
174
+ // Build the platform-specific spawn command + args. This
175
+ // is extracted to ./progress-start-spawn.ts so the three
176
+ // platform branches can be unit-tested without spawning
177
+ // a real terminal.
178
+ const spawnSpec = buildStartSpawn({
179
+ peaksBin,
180
+ projectRoot: canonical,
181
+ windowTitle,
182
+ platform: currentPlatform
183
+ });
184
+ if (!spawnSpec.ok) {
185
+ printResult(io, fail('progress.start', 'UNSUPPORTED_PLATFORM', `Cannot auto-spawn a terminal on platform "${currentPlatform}". Run \`peaks progress watch --project "${canonical}"\` in a new terminal yourself.`, { projectRoot: canonical }, ['macOS / Linux / Windows are supported; other platforms need a manual terminal']), options.json);
186
+ process.exitCode = 1;
187
+ return;
188
+ }
189
+ const spawnCommand = spawnSpec.command;
190
+ const spawnArgs = spawnSpec.args;
191
+ // Brief ora feedback while the terminal is launching.
192
+ // Skipped entirely in non-TTY mode (the LLM calls this
193
+ // from a Bash tool, where ora would just hang on
194
+ // animation) and in --json mode (where the structured
195
+ // response is the only signal the caller consumes).
196
+ const showSpinner = process.stdout.isTTY === true && options.json !== true;
197
+ const spinner = showSpinner
198
+ ? ora(`auto-spawning ${spawnCommand}…`).start()
199
+ : null;
200
+ try {
201
+ // spawn() with detached:true + unref() is the documented
202
+ // Node.js way to start a long-lived child from a CLI
203
+ // without blocking. We ignore stdio because the spawned
204
+ // terminal owns the child process group from now on; the
205
+ // peaks CLI exits and the terminal keeps running.
206
+ const child = spawn(spawnCommand, spawnArgs, { detached: true, stdio: 'ignore' });
207
+ child.unref();
208
+ // Give the spawn a beat to surface EACCES/ENOENT. We do
209
+ // not await the child (it is intentionally long-lived).
210
+ await new Promise((resolveSpawn, rejectSpawn) => {
211
+ const timer = setTimeout(() => resolveSpawn(), 200);
212
+ child.once('error', (spawnError) => {
213
+ clearTimeout(timer);
214
+ rejectSpawn(spawnError);
215
+ });
216
+ child.once('spawn', () => {
217
+ clearTimeout(timer);
218
+ resolveSpawn();
219
+ });
220
+ });
221
+ if (spinner !== null) {
222
+ spinner.succeed(`spawned ${spawnCommand} (new window is opening)`);
223
+ }
224
+ // Persist the spawn record so `peaks progress close` (and
225
+ // the watch-side auto-exit) can find and kill the window
226
+ // later. We write the record AFTER the spawn fires so a
227
+ // failed spawn never leaves a stale record behind. The
228
+ // record is per-session: a session rotation invalidates it
229
+ // because the new session gets a fresh record path.
230
+ const spawnRecord = writeSpawnRecord({
231
+ projectRoot: canonical,
232
+ pid: child.pid ?? 0,
233
+ platform: currentPlatform,
234
+ command: spawnCommand,
235
+ args: spawnArgs,
236
+ ...(options.reason !== undefined ? { reason: options.reason } : {}),
237
+ windowTitle
238
+ });
239
+ printResult(io, ok('progress.start', {
240
+ projectRoot: canonical,
241
+ platform: currentPlatform,
242
+ spawned: `${spawnCommand} ${spawnArgs.join(' ')}`,
243
+ watchCommand,
244
+ ...(options.reason !== undefined ? { reason: options.reason } : {}),
245
+ ...(spawnRecord === null
246
+ ? {
247
+ spawnRecord: null,
248
+ warning: 'no peaks session binding — `peaks progress close` will not be able to find this window. Close it manually.'
249
+ }
250
+ : {
251
+ spawnRecord: {
252
+ path: subAgentSpawnPath(canonical),
253
+ windowTitle: spawnRecord.windowTitle,
254
+ spawnedAt: spawnRecord.spawnedAt
255
+ }
256
+ }),
257
+ autoClose: 'the watch window will close itself when the sub-agent hits `finished` or `failed`',
258
+ note: 'A new terminal window is opening in the background. It will run `peaks progress watch` and refresh every second. Close the new terminal at any time, or run `peaks progress close` to programmatically close it.'
259
+ }), options.json);
260
+ return;
261
+ }
262
+ catch (spawnError) {
263
+ if (spinner !== null) {
264
+ spinner.fail(`auto-spawn failed: ${getErrorMessage(spawnError)}`);
265
+ }
266
+ printResult(io, fail('progress.start', 'TERMINAL_SPAWN_FAILED', `Auto-spawn failed: ${getErrorMessage(spawnError)}. Run \`peaks progress watch --project "${canonical}"\` in a new terminal yourself.`, { projectRoot: canonical, platform: currentPlatform, attempted: `${spawnCommand} ${spawnArgs.join(' ')}` }, ['Verify a terminal emulator is installed (e.g. gnome-terminal / Terminal.app)']), options.json);
267
+ process.exitCode = 1;
268
+ return;
269
+ }
270
+ }
271
+ catch (error) {
272
+ printResult(io, fail('progress.start', 'PROGRESS_START_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Verify the project path exists and a terminal emulator is available']), options.json);
273
+ process.exitCode = 1;
274
+ }
275
+ });
276
+ // ─────────────────────────────────────────────────────────────────
277
+ // peaks progress close
278
+ // Manual escape hatch: kill the spawned watch window and
279
+ // clear the spawn record. Idempotent — re-running is a no-op
280
+ // once the record is gone, and the response distinguishes
281
+ // "nothing to close" from "closed it" so callers / hooks can
282
+ // tell the difference. The close is best-effort: if the
283
+ // watch process has already exited but the record is stale,
284
+ // we still clear the record.
285
+ // ─────────────────────────────────────────────────────────────────
286
+ addJsonOption(progress
287
+ .command('close')
288
+ .description('Close the spawned `peaks progress watch` window for this session. Idempotent: re-running when no window is open is a no-op.')
289
+ .option('--project <path>', 'target project root (defaults to git root or cwd)')).action(async (options) => {
290
+ try {
291
+ const projectRoot = options.project !== undefined
292
+ ? options.project
293
+ : resolveProgressProjectRoot(undefined, process.cwd());
294
+ const canonical = resolveCanonicalProjectRoot(projectRoot);
295
+ const result = readSpawnRecord(canonical);
296
+ if (!result.ok) {
297
+ // Differentiate the failure modes so callers can decide
298
+ // whether to surface a warning. no-binding means peaks
299
+ // workspace init has not been run; no-spawn-record /
300
+ // invalid-json means there is nothing to close (start
301
+ // has not been called this session, or the window
302
+ // already auto-closed and the record was cleared).
303
+ if (result.reason === 'no-binding') {
304
+ printResult(io, fail('progress.close', 'NO_BINDING', 'no peaks session binding — nothing to close', { projectRoot: canonical, path: subAgentSpawnPath(canonical) }, ['Run peaks workspace init for this project first']), options.json);
305
+ process.exitCode = 1;
306
+ return;
307
+ }
308
+ printResult(io, ok('progress.close', {
309
+ projectRoot: canonical,
310
+ closed: false,
311
+ reason: result.reason,
312
+ note: 'no spawn record found — nothing to close (start has not been called this session, or the window has already auto-closed)'
313
+ }), options.json);
314
+ return;
315
+ }
316
+ const record = result.data;
317
+ // Best-effort close. We try three signals in order:
318
+ // (1) `pkill -f <watch command>` — the long-lived watch
319
+ // process. Killing it makes the terminal emulator
320
+ // close on most platforms (Terminal.app, gnome-
321
+ // terminal, konsole) but not all (alacritty, kitty
322
+ // keep the window).
323
+ // (2) macOS: AppleScript to close the Terminal.app
324
+ // window with the matching custom title.
325
+ // (3) Linux: wmctrl/xdotool by WM class as a fallback.
326
+ // Windows: taskkill /F /FI on the window title.
327
+ // We never throw from the close path — a failed close is
328
+ // a UX paper cut, not a correctness bug. The record is
329
+ // still cleared so the next `progress start` does not
330
+ // see a stale record.
331
+ const closeResult = await killSpawnedTerminal(record, canonical, platform());
332
+ clearSpawnRecord(canonical);
333
+ printResult(io, ok('progress.close', {
334
+ projectRoot: canonical,
335
+ closed: closeResult.signals.length > 0,
336
+ signals: closeResult.signals,
337
+ warnings: closeResult.warnings,
338
+ windowTitle: record.windowTitle,
339
+ spawnedAt: record.spawnedAt,
340
+ note: 'spawn record cleared. The next `peaks progress start` will spawn a fresh window.'
341
+ }), options.json);
342
+ }
343
+ catch (error) {
344
+ printResult(io, fail('progress.close', 'PROGRESS_CLOSE_FAILED', getErrorMessage(error), { projectRoot: options.project }, ['Verify the project path exists and that peaks workspace init has been run for it']), options.json);
345
+ process.exitCode = 1;
346
+ }
347
+ });
348
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Pure platform-specific spawn-args construction for
3
+ * `peaks progress start`. Extracted out of the commander
4
+ * action handler so the three platform branches (darwin /
5
+ * linux / win32) can be unit-tested without spawning real
6
+ * terminals.
7
+ *
8
+ * The CLI action calls `buildStartSpawn` once per start
9
+ * attempt and feeds the returned `{ command, args }` pair to
10
+ * `child_process.spawn(..., { detached: true, stdio: 'ignore' })`.
11
+ *
12
+ * Cross-platform strategy (see progress-commands.ts for the
13
+ * full design notes):
14
+ *
15
+ * - macOS: `osascript -e 'tell application "Terminal" to
16
+ * do script "<shell>"'`. The shell command sets
17
+ * the title via the OSC 0 escape (printf), runs
18
+ * the brand banner, then exec's the watch.
19
+ * - Linux: First existing terminal emulator in
20
+ * { gnome-terminal, konsole, xfce4-terminal,
21
+ * tilix, alacritty, kitty }, with per-emulator
22
+ * flag translation. The shell inside also sets
23
+ * the title via OSC 0 (emulator --title is
24
+ * best-effort only — the shell overrides it).
25
+ * - Win32: `cmd /c start "<title>" cmd /k <shell>`. The
26
+ * shell uses the `title` builtin (cmd.exe
27
+ * builtin) to re-anchor the title before the
28
+ * watch runs, because cmd.exe overrides the
29
+ * start-title with the running command name.
30
+ *
31
+ * The function never throws on unsupported platforms; it
32
+ * returns a discriminated `unsupported` result so the caller
33
+ * can surface a clean error envelope.
34
+ */
35
+ export type StartSpawnSpec = {
36
+ ok: true;
37
+ command: string;
38
+ args: string[];
39
+ } | {
40
+ ok: false;
41
+ unsupported: true;
42
+ };
43
+ export type BuildStartSpawnOptions = {
44
+ /** The peaks binary path the spawned shell will invoke. */
45
+ peaksBin: string;
46
+ /** Canonical project root (used to build the watch command). */
47
+ projectRoot: string;
48
+ /** Window/tab title shared across platforms. */
49
+ windowTitle: string;
50
+ /** Current platform (from `os.platform()`). */
51
+ platform: NodeJS.Platform;
52
+ /**
53
+ * Override for terminal detection on Linux. Defaults to
54
+ * `existsSync('/usr/bin/<name>')` for each candidate. Tests
55
+ * pass a stub to make the linux branch deterministic.
56
+ */
57
+ linuxTerminalExists?: (name: string) => boolean;
58
+ };
59
+ export declare function buildStartSpawn(options: BuildStartSpawnOptions): StartSpawnSpec;
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Pure platform-specific spawn-args construction for
3
+ * `peaks progress start`. Extracted out of the commander
4
+ * action handler so the three platform branches (darwin /
5
+ * linux / win32) can be unit-tested without spawning real
6
+ * terminals.
7
+ *
8
+ * The CLI action calls `buildStartSpawn` once per start
9
+ * attempt and feeds the returned `{ command, args }` pair to
10
+ * `child_process.spawn(..., { detached: true, stdio: 'ignore' })`.
11
+ *
12
+ * Cross-platform strategy (see progress-commands.ts for the
13
+ * full design notes):
14
+ *
15
+ * - macOS: `osascript -e 'tell application "Terminal" to
16
+ * do script "<shell>"'`. The shell command sets
17
+ * the title via the OSC 0 escape (printf), runs
18
+ * the brand banner, then exec's the watch.
19
+ * - Linux: First existing terminal emulator in
20
+ * { gnome-terminal, konsole, xfce4-terminal,
21
+ * tilix, alacritty, kitty }, with per-emulator
22
+ * flag translation. The shell inside also sets
23
+ * the title via OSC 0 (emulator --title is
24
+ * best-effort only — the shell overrides it).
25
+ * - Win32: `cmd /c start "<title>" cmd /k <shell>`. The
26
+ * shell uses the `title` builtin (cmd.exe
27
+ * builtin) to re-anchor the title before the
28
+ * watch runs, because cmd.exe overrides the
29
+ * start-title with the running command name.
30
+ *
31
+ * The function never throws on unsupported platforms; it
32
+ * returns a discriminated `unsupported` result so the caller
33
+ * can surface a clean error envelope.
34
+ */
35
+ import { existsSync } from 'node:fs';
36
+ /** Brand banner the user sees in the spawned shell. */
37
+ const BANNER = 'echo "peaks-cli — sub-agent progress"';
38
+ /**
39
+ * Build the POSIX OSC 0 title escape. The single-quote
40
+ * escape is bash's `'\''` for embedding a single quote in a
41
+ * single-quoted string; we need it because windowTitle may
42
+ * contain user-provided text (the `--reason` argument).
43
+ */
44
+ function buildPosixTitleCmd(windowTitle) {
45
+ const escaped = windowTitle.replaceAll("'", "'\\''");
46
+ return `printf '\\033]0;${escaped}\\007'`;
47
+ }
48
+ /** cmd.exe `title` builtin call. Quoting is intentionally bare. */
49
+ function buildWinTitleCmd(windowTitle) {
50
+ return `title ${windowTitle}`;
51
+ }
52
+ /** Shared helper: the shell command that runs the watch. */
53
+ function buildWatchCommand(peaksBin, projectRoot) {
54
+ return `${peaksBin} progress watch --project "${projectRoot}"`;
55
+ }
56
+ export function buildStartSpawn(options) {
57
+ const { peaksBin, projectRoot, windowTitle, platform: currentPlatform } = options;
58
+ const watchCommand = buildWatchCommand(peaksBin, projectRoot);
59
+ const posixTitleCmd = buildPosixTitleCmd(windowTitle);
60
+ const winTitleCmd = buildWinTitleCmd(windowTitle);
61
+ if (currentPlatform === 'darwin') {
62
+ const innerShell = `${posixTitleCmd}; ${BANNER}; ${watchCommand}`;
63
+ const escapedInner = innerShell.replaceAll('\\', '\\\\').replaceAll('"', '\\"');
64
+ return {
65
+ ok: true,
66
+ command: 'osascript',
67
+ args: [
68
+ '-e',
69
+ `tell application "Terminal" to do script "${escapedInner}"`,
70
+ '-e',
71
+ 'tell application "Terminal" to activate'
72
+ ]
73
+ };
74
+ }
75
+ if (currentPlatform === 'linux') {
76
+ const exists = options.linuxTerminalExists ?? ((name) => existsSync(`/usr/bin/${name}`));
77
+ const candidates = ['gnome-terminal', 'konsole', 'xfce4-terminal', 'tilix', 'alacritty', 'kitty'];
78
+ const terminal = candidates.find((c) => exists(c)) ?? candidates[0];
79
+ const titleArg = ['--title', windowTitle];
80
+ const bannerShell = `bash -c '${posixTitleCmd}; ${BANNER}; exec ${watchCommand}'`;
81
+ if (terminal === 'alacritty' || terminal === 'kitty') {
82
+ return {
83
+ ok: true,
84
+ command: terminal,
85
+ args: ['--class', 'peaks-cli-progress', ...titleArg, '-e', bannerShell]
86
+ };
87
+ }
88
+ if (terminal === 'gnome-terminal' || terminal === 'tilix' || terminal === 'xfce4-terminal') {
89
+ return {
90
+ ok: true,
91
+ command: terminal,
92
+ args: [...titleArg, '--', '/bin/bash', '-lc', bannerShell]
93
+ };
94
+ }
95
+ if (terminal === 'konsole') {
96
+ return {
97
+ ok: true,
98
+ command: terminal,
99
+ args: ['--title', windowTitle, '--p', 'tabtitle', windowTitle, '-e', bannerShell]
100
+ };
101
+ }
102
+ // xterm / fallback: no --title support; bannerShell only.
103
+ return { ok: true, command: terminal, args: ['-e', bannerShell] };
104
+ }
105
+ if (currentPlatform === 'win32') {
106
+ const bannerCmd = `${winTitleCmd} && echo peaks-cli --- sub-agent progress && ${watchCommand}`;
107
+ return {
108
+ ok: true,
109
+ command: 'cmd',
110
+ args: ['/c', 'start', `"${windowTitle}"`, 'cmd', '/k', bannerCmd]
111
+ };
112
+ }
113
+ return { ok: false, unsupported: true };
114
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * In-place progress renderer for `peaks progress watch`.
3
+ *
4
+ * Goals (in order of importance):
5
+ * 1. **In-place overwrite.** Every tick the dynamic rows
6
+ * (status line + progress bar) get rewritten, NOT
7
+ * appended. We bypass `io.stdout` (which is
8
+ * `console.log` and adds a trailing `\n` per call) and
9
+ * write directly to `process.stdout`. The cursor-up
10
+ * and erase-line escapes are the same ones terminal-kit
11
+ * emits; we do not need a terminal-kit dependency for
12
+ * them.
13
+ * 2. **Clear PEAKS-CLI branding.** A static 3-line header
14
+ * with the brand bar, the project root, and the
15
+ * progress-file path is painted once. The user always
16
+ * sees what they are looking at, even after the
17
+ * watch loop has overwritten the dynamic rows a
18
+ * thousand times.
19
+ * 3. **Graceful degrade.** When stdout is not a TTY
20
+ * (CI / pipe / `--json`), we fall back to a single
21
+ * static snapshot per tick (no cursor moves, no
22
+ * SGR colour) and a single newline. This keeps the
23
+ * tool scriptable without dropping into a wall of
24
+ * escape codes.
25
+ *
26
+ * Token-cost note: this module is rendered to the user's
27
+ * terminal, never into LLM context. The watch side has
28
+ * zero token cost.
29
+ */
30
+ import type { SubAgentProgress } from '../../services/progress/progress-service.js';
31
+ export type WatchRendererOptions = {
32
+ projectRoot: string;
33
+ progressFilePath: string;
34
+ };
35
+ export declare class WatchRenderer {
36
+ private readonly projectRoot;
37
+ private readonly progressFilePath;
38
+ private readonly width;
39
+ private readonly isTty;
40
+ private hasRenderedDynamic;
41
+ constructor(options: WatchRendererOptions);
42
+ /**
43
+ * Paint the static header once at the top of the watch
44
+ * (the PEAKS-CLI wordmark, separator, project, path). Then
45
+ * paint the dynamic rows for the first tick. From here on
46
+ * the dynamic rows are the only thing we touch.
47
+ */
48
+ start(): void;
49
+ /**
50
+ * Repaint the 2 dynamic rows in place. First call moves
51
+ * the cursor up N rows from the bottom of the previously
52
+ * painted block; subsequent calls do the same.
53
+ */
54
+ tick(data: SubAgentProgress | null, tickCount: number): void;
55
+ private paintDynamicOnce;
56
+ /**
57
+ * Paint a final 2-line verdict + a farewell line below the
58
+ * dashboard, then return. The cursor stays at the bottom
59
+ * of the farewell so the user's shell prompt lands on the
60
+ * next row.
61
+ */
62
+ finalize(data: SubAgentProgress): void;
63
+ /**
64
+ * Force a non-ANSI snapshot of the current state, used by
65
+ * the `--once` mode and for fallback when stdout is not a
66
+ * TTY. Does NOT touch the cursor state — safe to call from
67
+ * any context.
68
+ */
69
+ static snapshot(data: SubAgentProgress | null): {
70
+ status: string;
71
+ bar: string;
72
+ };
73
+ }
74
+ /**
75
+ * Strip ANSI escapes from a string. Used for visible-length
76
+ * accounting; not for re-painting.
77
+ */
78
+ export declare function stripAnsi(input: string): string;
79
+ /** Reset terminal SGR — used on early-return error paths. */
80
+ export declare function resetTerminal(): void;