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,111 @@
1
+ /**
2
+ * Slice #009 / G5 RL-8 — sub-agent dispatch record archival + 30-day GC.
3
+ *
4
+ * Called by the `peaks session finish` / `peaks session abandon` /
5
+ * new-rid-startup hooks. Walks the per-session `.peaks/_sub_agents/<sid>/`
6
+ * tree and moves completed + disposed records to
7
+ * `.peaks/_runtime/<sid>/_archive/_sub_agents/<sliceId>/`.
8
+ *
9
+ * GC policy:
10
+ * - Records with `disposed: true` AND `outcome ∈ {success, failed,
11
+ * timeout, cancelled}` (not "no-execution") → archive + 30-day GC.
12
+ * - Records with `disposed: false` (any outcome) → archive but NOT
13
+ * GC'd. Next session will see them as still-pending in
14
+ * `_archive/.../in-flight/` and can resume reducer work.
15
+ *
16
+ * Lazy GC: `archiveSubAgentRecords` also scans the archive dir and
17
+ * deletes entries older than 30 days. No cron, no background daemon.
18
+ */
19
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, unlinkSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { isDispatchStatus, isOutcome } from '../dispatch/dispatch-record-writer.js';
22
+ export const ARCHIVE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
23
+ /** Build the canonical archive dir for a given session + slice id. */
24
+ export function archiveDir(projectRoot, sessionId, sliceId) {
25
+ return join(projectRoot, '.peaks', '_runtime', sessionId, '_archive', '_sub_agents', sliceId);
26
+ }
27
+ /** Build the in-flight subdir (records not yet disposed). */
28
+ export function inFlightArchiveDir(projectRoot, sessionId, sliceId) {
29
+ return join(archiveDir(projectRoot, sessionId, sliceId), 'in-flight');
30
+ }
31
+ /**
32
+ * Archive the current `.peaks/_sub_agents/<sid>/` tree under
33
+ * `<archiveDir>` (per the sliceId), separating completed vs in-flight.
34
+ * Then run the 30-day GC over the archive dir.
35
+ */
36
+ export function archiveSubAgentRecords(projectRoot, options) {
37
+ const now = options.now ?? (() => new Date());
38
+ const sourceDir = join(projectRoot, '.peaks', '_sub_agents', options.sessionId);
39
+ const completedDir = archiveDir(projectRoot, options.sessionId, options.sliceId);
40
+ const inFlightDir = inFlightArchiveDir(projectRoot, options.sessionId, options.sliceId);
41
+ mkdirSync(completedDir, { recursive: true });
42
+ mkdirSync(inFlightDir, { recursive: true });
43
+ let archivedCompleted = 0;
44
+ let archivedInFlight = 0;
45
+ if (existsSync(sourceDir)) {
46
+ for (const entry of readdirSync(sourceDir)) {
47
+ if (!entry.startsWith('dispatch-') || !entry.endsWith('.json'))
48
+ continue;
49
+ const src = join(sourceDir, entry);
50
+ const record = readRecordOrNull(src);
51
+ if (record === null)
52
+ continue;
53
+ if (record.disposed === true && isCompletedOutcome(record.outcome)) {
54
+ renameSync(src, join(completedDir, entry));
55
+ archivedCompleted += 1;
56
+ }
57
+ else {
58
+ renameSync(src, join(inFlightDir, entry));
59
+ archivedInFlight += 1;
60
+ }
61
+ }
62
+ }
63
+ // 30-day GC: walk the archive root (any sliceId), delete entries
64
+ // whose file mtime is older than the retention.
65
+ const gcDeleted = runGarbageCollection(completedDir, now().getTime());
66
+ return { archivedCompleted, archivedInFlight, gcDeleted };
67
+ }
68
+ function isCompletedOutcome(outcome) {
69
+ return outcome === 'success' || outcome === 'failed' || outcome === 'timeout' || outcome === 'cancelled';
70
+ }
71
+ function readRecordOrNull(path) {
72
+ try {
73
+ const raw = readFileSync(path, 'utf8');
74
+ const parsed = JSON.parse(raw);
75
+ if (typeof parsed !== 'object' || parsed === null)
76
+ return null;
77
+ const obj = parsed;
78
+ if (!isOutcome(obj.outcome) || !isDispatchStatus(obj.status))
79
+ return null;
80
+ if (typeof obj.disposed !== 'boolean')
81
+ return null;
82
+ return obj;
83
+ }
84
+ catch {
85
+ return null;
86
+ }
87
+ }
88
+ function runGarbageCollection(dir, nowMs) {
89
+ if (!existsSync(dir))
90
+ return 0;
91
+ let deleted = 0;
92
+ for (const entry of readdirSync(dir)) {
93
+ const full = join(dir, entry);
94
+ try {
95
+ const stat = statSync(full);
96
+ if (stat.isDirectory()) {
97
+ deleted += runGarbageCollection(full, nowMs);
98
+ continue;
99
+ }
100
+ const ageMs = nowMs - stat.mtimeMs;
101
+ if (ageMs > ARCHIVE_RETENTION_MS) {
102
+ unlinkSync(full);
103
+ deleted += 1;
104
+ }
105
+ }
106
+ catch {
107
+ /* skip unreadable; do not crash the archive op */
108
+ }
109
+ }
110
+ return deleted;
111
+ }
@@ -0,0 +1,51 @@
1
+ import { summarize, type SubAgentLiveView } from './status-line-renderer.js';
2
+ export declare const DEFAULT_POLL_INTERVAL_MS = 10000;
3
+ export declare const DEFAULT_STALE_THRESHOLD_MS: number;
4
+ export type PollRecord = {
5
+ readonly path: string;
6
+ readonly role: string;
7
+ };
8
+ export type PollerStatusEvent = {
9
+ readonly kind: 'status';
10
+ readonly line: string;
11
+ readonly summary: ReturnType<typeof summarize>;
12
+ readonly views: readonly SubAgentLiveView[];
13
+ };
14
+ export type PollerStaleEvent = {
15
+ readonly kind: 'stale';
16
+ readonly path: string;
17
+ readonly role: string;
18
+ readonly lastBeatAgoSec: number;
19
+ readonly thresholdSec: number;
20
+ };
21
+ export type PollerDoneEvent = {
22
+ readonly kind: 'done';
23
+ readonly summary: ReturnType<typeof summarize>;
24
+ };
25
+ export type PollerEvent = PollerStatusEvent | PollerStaleEvent | PollerDoneEvent;
26
+ export type PollerHandlers = {
27
+ onStatus?: (event: PollerStatusEvent) => void;
28
+ onStale?: (event: PollerStaleEvent) => void;
29
+ onDone?: (event: PollerDoneEvent) => void;
30
+ onError?: (error: unknown) => void;
31
+ };
32
+ export type PollerOptions = {
33
+ prefix: string;
34
+ intervalMs?: number;
35
+ staleThresholdMs?: number;
36
+ now?: () => Date;
37
+ };
38
+ export declare class BatchHeartbeatPoller {
39
+ private readonly records;
40
+ private readonly handlers;
41
+ private readonly options;
42
+ private timer;
43
+ private prevStale;
44
+ private prevSummaryDone;
45
+ private running;
46
+ constructor(records: readonly PollRecord[], handlers: PollerHandlers, options: PollerOptions);
47
+ start(): void;
48
+ stop(): void;
49
+ /** Force a tick (testing seam + low-level access for explicit invocation). */
50
+ tick(): void;
51
+ }
@@ -0,0 +1,88 @@
1
+ import { readRecords } from '../dispatch/dispatch-record-writer.js';
2
+ import { renderStatusLine, summarize, viewSubAgent } from './status-line-renderer.js';
3
+ export const DEFAULT_POLL_INTERVAL_MS = 10_000;
4
+ export const DEFAULT_STALE_THRESHOLD_MS = 5 * 60 * 1000;
5
+ const TERMINAL_STATUSES = ['done', 'failed'];
6
+ const TERMINAL_RECORD_STATUSES = [
7
+ 'done',
8
+ 'failed',
9
+ 'cancelled',
10
+ 'no-execution'
11
+ ];
12
+ export class BatchHeartbeatPoller {
13
+ records;
14
+ handlers;
15
+ options;
16
+ timer = null;
17
+ prevStale = new Set();
18
+ prevSummaryDone = 0;
19
+ running = false;
20
+ constructor(records, handlers, options) {
21
+ this.records = records;
22
+ this.handlers = handlers;
23
+ this.options = options;
24
+ }
25
+ start() {
26
+ if (this.running)
27
+ return;
28
+ this.running = true;
29
+ this.tick();
30
+ const interval = this.options.intervalMs ?? DEFAULT_POLL_INTERVAL_MS;
31
+ this.timer = setInterval(() => this.tick(), interval);
32
+ // Don't keep the process alive just for the poller.
33
+ this.timer.unref?.();
34
+ }
35
+ stop() {
36
+ this.running = false;
37
+ if (this.timer) {
38
+ clearInterval(this.timer);
39
+ this.timer = null;
40
+ }
41
+ }
42
+ /** Force a tick (testing seam + low-level access for explicit invocation). */
43
+ tick() {
44
+ let recs;
45
+ try {
46
+ recs = readRecords(this.records.map((r) => r.path));
47
+ }
48
+ catch (error) {
49
+ this.handlers.onError?.(error);
50
+ return;
51
+ }
52
+ const now = this.options.now ?? (() => new Date());
53
+ const summary = summarize(recs);
54
+ const views = recs.map((r) => viewSubAgent(r, now));
55
+ const line = renderStatusLine(this.options.prefix, recs, now);
56
+ this.handlers.onStatus?.({ kind: 'status', line, summary, views });
57
+ const staleThresholdSec = Math.floor((this.options.staleThresholdMs ?? DEFAULT_STALE_THRESHOLD_MS) / 1000);
58
+ for (const rec of recs) {
59
+ const v = viewSubAgent(rec, now);
60
+ if (v.isStale) {
61
+ const key = `${rec.role}::${rec.requestId}`;
62
+ if (!this.prevStale.has(key)) {
63
+ this.prevStale.add(key);
64
+ this.handlers.onStale?.({
65
+ kind: 'stale',
66
+ path: this.records.find((p) => p.role === rec.role)?.path ?? '',
67
+ role: rec.role,
68
+ lastBeatAgoSec: v.lastBeatAgoSec ?? -1,
69
+ thresholdSec: staleThresholdSec
70
+ });
71
+ }
72
+ }
73
+ }
74
+ if (summary.total > 0 && summary.done === summary.total && this.prevSummaryDone !== summary.done) {
75
+ this.prevSummaryDone = summary.done;
76
+ this.handlers.onDone?.({ kind: 'done', summary });
77
+ this.stop();
78
+ }
79
+ else if (recs.length > 0 &&
80
+ recs.every((r) => TERMINAL_RECORD_STATUSES.includes(r.status) || TERMINAL_STATUSES.includes(r.status))) {
81
+ this.handlers.onDone?.({ kind: 'done', summary });
82
+ this.stop();
83
+ }
84
+ else {
85
+ this.prevSummaryDone = summary.done;
86
+ }
87
+ }
88
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * G6.5 — status line renderer for the peaks-solo batch-sync wait period.
3
+ *
4
+ * Single line, 80-120 chars, status-line-friendly. The shape is
5
+ * documented in PRD §G6.5:
6
+ *
7
+ * [peaks-solo] swarm 3/3 running | rd-planning 45% (12s ago) | qa-test-cases 30% (5s ago) | ui-design 20% (2s ago)
8
+ * [peaks-solo] swarm 3/3 running | rd-planning 70% (8s ago) | qa-test-cases 50% (3s ago) | ui-design 30% (6s ago)
9
+ * ...
10
+ * [peaks-solo] swarm 3/3 done in 47.3s
11
+ *
12
+ * Pure helper; the poller calls it once per tick. No IO.
13
+ */
14
+ import type { DispatchRecord } from '../dispatch/dispatch-record-writer.js';
15
+ export type SubAgentLiveView = {
16
+ readonly role: string;
17
+ readonly status: string;
18
+ readonly progress: number | null;
19
+ readonly lastBeatAgoSec: number | null;
20
+ readonly isStale: boolean;
21
+ };
22
+ export type SwarmSummary = {
23
+ readonly total: number;
24
+ readonly running: number;
25
+ readonly done: number;
26
+ readonly failed: number;
27
+ readonly stale: number;
28
+ };
29
+ /** Build a per-sub-agent view of the current state of one record. */
30
+ export declare function viewSubAgent(record: DispatchRecord, now?: () => Date): SubAgentLiveView;
31
+ /** Aggregate swarm summary. */
32
+ export declare function summarize(records: readonly DispatchRecord[]): SwarmSummary;
33
+ /** Render a single status line. */
34
+ export declare function renderStatusLine(prefix: string, records: readonly DispatchRecord[], now?: () => Date): string;
@@ -0,0 +1,55 @@
1
+ const STALE_THRESHOLD_SEC = 5 * 60;
2
+ /** Build a per-sub-agent view of the current state of one record. */
3
+ export function viewSubAgent(record, now = () => new Date()) {
4
+ const latest = record.heartbeats[record.heartbeats.length - 1];
5
+ const lastBeatAgo = record.lastBeatAt
6
+ ? Math.max(0, Math.floor((now().getTime() - new Date(record.lastBeatAt).getTime()) / 1000))
7
+ : null;
8
+ const isStale = lastBeatAgo !== null && lastBeatAgo > STALE_THRESHOLD_SEC;
9
+ return {
10
+ role: record.role,
11
+ status: record.status,
12
+ progress: latest ? latest.progress : null,
13
+ lastBeatAgoSec: lastBeatAgo,
14
+ isStale
15
+ };
16
+ }
17
+ /** Aggregate swarm summary. */
18
+ export function summarize(records) {
19
+ let running = 0;
20
+ let done = 0;
21
+ let failed = 0;
22
+ let stale = 0;
23
+ for (const r of records) {
24
+ const v = viewSubAgent(r);
25
+ if (v.isStale)
26
+ stale += 1;
27
+ if (r.status === 'done')
28
+ done += 1;
29
+ else if (r.status === 'failed' || r.status === 'cancelled')
30
+ failed += 1;
31
+ else
32
+ running += 1;
33
+ }
34
+ return { total: records.length, running, done, failed, stale };
35
+ }
36
+ /** Render a single status line. */
37
+ export function renderStatusLine(prefix, records, now = () => new Date()) {
38
+ if (records.length === 0) {
39
+ return `${prefix} swarm 0/0 idle`;
40
+ }
41
+ const summary = summarize(records);
42
+ const allDone = summary.done === summary.total;
43
+ if (allDone) {
44
+ return `${prefix} swarm ${summary.done}/${summary.total} done`;
45
+ }
46
+ const parts = records.map((r) => renderOne(r, now));
47
+ return `${prefix} swarm ${summary.running}/${summary.total} running | ${parts.join(' | ')}`;
48
+ }
49
+ function renderOne(record, now) {
50
+ const view = viewSubAgent(record, now);
51
+ const pct = view.progress !== null ? `${view.progress}%` : '?%';
52
+ const ago = view.lastBeatAgoSec !== null ? `${view.lastBeatAgoSec}s ago` : 'no beat';
53
+ const stale = view.isStale ? ' ⚠ stale' : '';
54
+ return `${view.role} ${pct} (${ago})${stale}`;
55
+ }
@@ -144,6 +144,42 @@ export declare function migrateOldRuntimeState(projectRoot: string): {
144
144
  message: string;
145
145
  }>;
146
146
  };
147
+ /**
148
+ * One-time sub-agent state migration (slice 2026-06-06-sub-agent-spawn-bug-and-decouple).
149
+ *
150
+ * Move the legacy per-session sub-agent state files at:
151
+ * - `.peaks/<sid>/system/subagent-progress.json`
152
+ * - `.peaks/<sid>/system/progress-spawn.json`
153
+ * into the new canonical home at:
154
+ * - `.peaks/_sub_agents/<sid>/subagent-progress.json`
155
+ * - `.peaks/_sub_agents/<sid>/progress-spawn.json`
156
+ *
157
+ * Behavior:
158
+ * - Idempotent: re-running on a tree that is already on the new layout
159
+ * produces `migratedFiles: []`.
160
+ * - Best-effort: uses `fs.renameSync` and falls back to `copyFileSync +
161
+ * unlinkSync` if rename throws (e.g. cross-device move on Windows).
162
+ * - Empty `<sid>/system/` dir removal (R-2 guard): the legacy `system/`
163
+ * subdir is only removed when it has zero other files, so a tree where
164
+ * the user had unrelated content in `system/` is left untouched.
165
+ * - New-path-wins: when both old and new files exist, the old file is
166
+ * removed (the new path is authoritative).
167
+ *
168
+ * Walks every discovered session — not just the canonical one — so a user
169
+ * with 6 pre-migration sessions gets all of them migrated in one reconcile
170
+ * pass.
171
+ *
172
+ * @returns `{ migratedFiles, errors }`. `migratedFiles` lists the *old*
173
+ * relative paths (e.g. `.peaks/<sid>/system/subagent-progress.json`) that
174
+ * were successfully moved. `errors` lists per-file failures.
175
+ */
176
+ export declare function migrateSubAgentState(projectRoot: string): {
177
+ migratedFiles: string[];
178
+ errors: Array<{
179
+ path: string;
180
+ message: string;
181
+ }>;
182
+ };
147
183
  /**
148
184
  * Top-level orchestrator. Wires migration (added in slice
149
185
  * 2026-06-05-peaks-runtime-layer), discovery, canonical pick, re-point,
@@ -16,11 +16,20 @@
16
16
  * Pure hand-rolled; uses only node:fs, node:path, and the existing
17
17
  * session-manager helper for writing the binding. No new dependencies.
18
18
  */
19
- import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, statSync, unlinkSync } from 'node:fs';
19
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, readdirSync, renameSync, rmSync, rmdirSync, statSync, unlinkSync } from 'node:fs';
20
20
  import { dirname, join, resolve } from 'node:path';
21
21
  import { getSessionIdCanonical, setCurrentSessionBinding } from '../session/session-manager.js';
22
22
  const SESSION_ID_PATTERN = /^\d{4}-\d{2}-\d{2}-session-[a-f0-9]+$/;
23
23
  const META_FILE = 'session.json';
24
+ // Sub-agent state file basenames (slice 2026-06-06-sub-agent-spawn-bug-and-decouple).
25
+ // The legacy location was `.peaks/<sid>/system/<filename>`; the canonical new
26
+ // location is `.peaks/_sub_agents/<sid>/<filename>`. `migrateSubAgentState`
27
+ // moves the two files between these homes on every `reconcileWorkspace` run.
28
+ const SUB_AGENT_MIGRATION_FILES = [
29
+ 'subagent-progress.json',
30
+ 'progress-spawn.json'
31
+ ];
32
+ const SUB_AGENTS_DIR = '_sub_agents';
24
33
  // As of slice 2026-06-05-peaks-runtime-layer these old paths are the
25
34
  // back-compat read-only fallbacks; the canonical new home is
26
35
  // `.peaks/_runtime/`. `migrateOldRuntimeState` moves them to the new
@@ -488,6 +497,89 @@ function copyDirRecursiveSync(src, dest) {
488
497
  }
489
498
  }
490
499
  }
500
+ /**
501
+ * One-time sub-agent state migration (slice 2026-06-06-sub-agent-spawn-bug-and-decouple).
502
+ *
503
+ * Move the legacy per-session sub-agent state files at:
504
+ * - `.peaks/<sid>/system/subagent-progress.json`
505
+ * - `.peaks/<sid>/system/progress-spawn.json`
506
+ * into the new canonical home at:
507
+ * - `.peaks/_sub_agents/<sid>/subagent-progress.json`
508
+ * - `.peaks/_sub_agents/<sid>/progress-spawn.json`
509
+ *
510
+ * Behavior:
511
+ * - Idempotent: re-running on a tree that is already on the new layout
512
+ * produces `migratedFiles: []`.
513
+ * - Best-effort: uses `fs.renameSync` and falls back to `copyFileSync +
514
+ * unlinkSync` if rename throws (e.g. cross-device move on Windows).
515
+ * - Empty `<sid>/system/` dir removal (R-2 guard): the legacy `system/`
516
+ * subdir is only removed when it has zero other files, so a tree where
517
+ * the user had unrelated content in `system/` is left untouched.
518
+ * - New-path-wins: when both old and new files exist, the old file is
519
+ * removed (the new path is authoritative).
520
+ *
521
+ * Walks every discovered session — not just the canonical one — so a user
522
+ * with 6 pre-migration sessions gets all of them migrated in one reconcile
523
+ * pass.
524
+ *
525
+ * @returns `{ migratedFiles, errors }`. `migratedFiles` lists the *old*
526
+ * relative paths (e.g. `.peaks/<sid>/system/subagent-progress.json`) that
527
+ * were successfully moved. `errors` lists per-file failures.
528
+ */
529
+ export function migrateSubAgentState(projectRoot) {
530
+ const root = resolve(projectRoot);
531
+ const newDir = join(root, '.peaks', SUB_AGENTS_DIR);
532
+ const migratedFiles = [];
533
+ const errors = [];
534
+ for (const session of discoverSessions(projectRoot)) {
535
+ const oldSystemDir = join(session.path, 'system');
536
+ if (!existsSync(oldSystemDir))
537
+ continue;
538
+ const newSessionDir = join(newDir, session.sessionId);
539
+ mkdirSync(newSessionDir, { recursive: true });
540
+ for (const fname of SUB_AGENT_MIGRATION_FILES) {
541
+ const oldPath = join(oldSystemDir, fname);
542
+ const newPath = join(newSessionDir, fname);
543
+ if (!existsSync(oldPath))
544
+ continue;
545
+ if (existsSync(newPath)) {
546
+ // New path is authoritative; remove stale old file.
547
+ try {
548
+ rmSync(oldPath, { force: true });
549
+ }
550
+ catch { /* best effort */ }
551
+ continue;
552
+ }
553
+ try {
554
+ try {
555
+ renameSync(oldPath, newPath);
556
+ }
557
+ catch (renameError) {
558
+ // Cross-device or locked-file fallback: copy + unlink.
559
+ copyFileSync(oldPath, newPath);
560
+ unlinkSync(oldPath);
561
+ }
562
+ migratedFiles.push(join('.peaks', session.sessionId, 'system', fname));
563
+ }
564
+ catch (error) {
565
+ errors.push({
566
+ path: oldPath,
567
+ message: error instanceof Error ? error.message : String(error)
568
+ });
569
+ }
570
+ }
571
+ // R-2 guard: only remove the legacy system/ dir when it has zero
572
+ // remaining files (the user might have unrelated content there).
573
+ try {
574
+ const remaining = readdirSync(oldSystemDir);
575
+ if (remaining.length === 0) {
576
+ rmdirSync(oldSystemDir);
577
+ }
578
+ }
579
+ catch { /* best effort */ }
580
+ }
581
+ return { migratedFiles, errors };
582
+ }
491
583
  /**
492
584
  * Top-level orchestrator. Wires migration (added in slice
493
585
  * 2026-06-05-peaks-runtime-layer), discovery, canonical pick, re-point,
@@ -503,11 +595,19 @@ export function reconcileWorkspace(options) {
503
595
  // before that read means the new path is the only path observed by
504
596
  // `getSessionIdCanonical` after this call returns.
505
597
  const migration = migrateOldRuntimeState(projectRoot);
506
- const migrateErrors = migration.errors.map((e) => ({
507
- kind: 'migrate',
508
- path: e.path,
509
- message: e.message
510
- }));
598
+ const subAgentMigration = migrateSubAgentState(projectRoot);
599
+ const migrateErrors = [
600
+ ...migration.errors.map((e) => ({
601
+ kind: 'migrate',
602
+ path: e.path,
603
+ message: e.message
604
+ })),
605
+ ...subAgentMigration.errors.map((e) => ({
606
+ kind: 'migrate',
607
+ path: e.path,
608
+ message: e.message
609
+ }))
610
+ ];
511
611
  const sessions = discoverSessions(projectRoot);
512
612
  const activeSkillSessionId = readActiveSkillSessionId(projectRoot);
513
613
  const canonical = pickCanonicalSession(sessions, activeSkillSessionId);
@@ -575,6 +675,7 @@ export function reconcileWorkspace(options) {
575
675
  apply,
576
676
  repointed,
577
677
  migratedFiles: migration.migratedFiles,
678
+ subAgentStateMigrated: subAgentMigration.migratedFiles.length,
578
679
  errors: [...migrateErrors, ...deletionResult.errors],
579
680
  changeMarker,
580
681
  systemCleaned
@@ -65,6 +65,18 @@ export type ReconcileResult = {
65
65
  * consumers can ignore this field.
66
66
  */
67
67
  migratedFiles: string[];
68
+ /**
69
+ * Count of legacy per-session sub-agent state files moved from
70
+ * `.peaks/<sid>/system/{subagent-progress,progress-spawn}.json` into
71
+ * `.peaks/_sub_agents/<sid>/` during this reconcile run.
72
+ *
73
+ * Added in slice 2026-06-06-sub-agent-spawn-bug-and-decouple. The
74
+ * detailed list of moved files is not surfaced here (the count is
75
+ * what the CLI summary and QA test assert on); the underlying
76
+ * `migrateSubAgentState` helper returns the full path list for
77
+ * forensics. Additive — older consumers can ignore this field.
78
+ */
79
+ subAgentStateMigrated: number;
68
80
  /**
69
81
  * Errors encountered during the migration step. Each entry has a
70
82
  * `kind: 'migrate'` discriminator so consumers can tell migration
@@ -1 +1 @@
1
- export declare const CLI_VERSION = "1.3.2";
1
+ export declare const CLI_VERSION = "1.3.3";
@@ -1 +1 @@
1
- export const CLI_VERSION = "1.3.2";
1
+ export const CLI_VERSION = "1.3.3";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "peaks-cli",
3
- "version": "1.3.2",
3
+ "version": "1.3.3",
4
4
  "description": "Cross-AI-IDE workflow-gating CLI + skill family (Claude Code shipped, Trae in progress; Codex / Cursor / Qoder / Tongyi Lingma on the roadmap).",
5
5
  "author": "SquabbyZ",
6
6
  "license": "MIT",
@@ -56,6 +56,7 @@
56
56
  "@colbymchenry/codegraph": "0.7.10",
57
57
  "chalk": "^5.6.2",
58
58
  "commander": "^12.1.0",
59
+ "headroom-ai": "0.22.4",
59
60
  "ora": "^8.2.0",
60
61
  "shadcn": "4.7.0",
61
62
  "terminal-kit": "^3.1.2"