peaks-cli 1.3.1 → 1.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +6 -2
  2. package/bin/peaks.js +0 -0
  3. package/dist/src/cli/commands/core-artifact-commands.js +49 -11
  4. package/dist/src/cli/commands/gate-commands.js +28 -19
  5. package/dist/src/cli/commands/hook-handle.d.ts +17 -0
  6. package/dist/src/cli/commands/hook-handle.js +111 -0
  7. package/dist/src/cli/commands/hooks-commands.js +72 -21
  8. package/dist/src/cli/commands/progress-commands.js +9 -2
  9. package/dist/src/cli/commands/progress-start-spawn.js +30 -4
  10. package/dist/src/cli/commands/slice-commands.js +4 -2
  11. package/dist/src/cli/commands/statusline-commands.js +75 -17
  12. package/dist/src/cli/commands/sub-agent-commands.d.ts +5 -0
  13. package/dist/src/cli/commands/sub-agent-commands.js +488 -0
  14. package/dist/src/cli/commands/sub-agent-dispatch-guard.d.ts +55 -0
  15. package/dist/src/cli/commands/sub-agent-dispatch-guard.js +57 -0
  16. package/dist/src/cli/commands/workspace-commands.js +70 -14
  17. package/dist/src/cli/program.js +9 -0
  18. package/dist/src/hooks/pre-tool-use-sub-agent.d.ts +28 -0
  19. package/dist/src/hooks/pre-tool-use-sub-agent.js +105 -0
  20. package/dist/src/services/artifacts/artifact-prerequisites.d.ts +12 -0
  21. package/dist/src/services/artifacts/artifact-prerequisites.js +39 -8
  22. package/dist/src/services/artifacts/request-artifact-service.js +116 -76
  23. package/dist/src/services/config/config-types.d.ts +1 -1
  24. package/dist/src/services/context/artifact-meta.d.ts +72 -0
  25. package/dist/src/services/context/artifact-meta.js +105 -0
  26. package/dist/src/services/context/context-guard.d.ts +49 -0
  27. package/dist/src/services/context/context-guard.js +91 -0
  28. package/dist/src/services/context/dispatch-context-guard.d.ts +27 -0
  29. package/dist/src/services/context/dispatch-context-guard.js +192 -0
  30. package/dist/src/services/context/headroom-client.d.ts +34 -0
  31. package/dist/src/services/context/headroom-client.js +117 -0
  32. package/dist/src/services/context/shared-channel.d.ts +92 -0
  33. package/dist/src/services/context/shared-channel.js +285 -0
  34. package/dist/src/services/context/threshold.d.ts +35 -0
  35. package/dist/src/services/context/threshold.js +76 -0
  36. package/dist/src/services/dispatch/batch-counter.d.ts +27 -0
  37. package/dist/src/services/dispatch/batch-counter.js +85 -0
  38. package/dist/src/services/dispatch/dispatch-record-writer.d.ts +93 -0
  39. package/dist/src/services/dispatch/dispatch-record-writer.js +261 -0
  40. package/dist/src/services/dispatch/heartbeat-truncator.d.ts +26 -0
  41. package/dist/src/services/dispatch/heartbeat-truncator.js +13 -0
  42. package/dist/src/services/dispatch/leak-detector.d.ts +11 -0
  43. package/dist/src/services/dispatch/leak-detector.js +72 -0
  44. package/dist/src/services/dispatch/sub-agent-dispatcher.d.ts +127 -0
  45. package/dist/src/services/dispatch/sub-agent-dispatcher.js +98 -0
  46. package/dist/src/services/doctor/doctor-service.d.ts +62 -0
  47. package/dist/src/services/doctor/doctor-service.js +276 -1
  48. package/dist/src/services/ide/adapters/claude-code-adapter.d.ts +18 -0
  49. package/dist/src/services/ide/adapters/claude-code-adapter.js +53 -0
  50. package/dist/src/services/ide/adapters/trae-adapter.d.ts +34 -0
  51. package/dist/src/services/ide/adapters/trae-adapter.js +70 -0
  52. package/dist/src/services/ide/hook-protocol.d.ts +44 -0
  53. package/dist/src/services/ide/hook-protocol.js +71 -0
  54. package/dist/src/services/ide/hook-translator.d.ts +72 -0
  55. package/dist/src/services/ide/hook-translator.js +128 -0
  56. package/dist/src/services/ide/ide-detector.d.ts +10 -0
  57. package/dist/src/services/ide/ide-detector.js +19 -0
  58. package/dist/src/services/ide/ide-registry.d.ts +14 -0
  59. package/dist/src/services/ide/ide-registry.js +45 -0
  60. package/dist/src/services/ide/ide-types.d.ts +120 -0
  61. package/dist/src/services/ide/ide-types.js +2 -0
  62. package/dist/src/services/ide/shared/atomic-json.d.ts +15 -0
  63. package/dist/src/services/ide/shared/atomic-json.js +58 -0
  64. package/dist/src/services/ide/shared/safe-path.d.ts +11 -0
  65. package/dist/src/services/ide/shared/safe-path.js +29 -0
  66. package/dist/src/services/progress/progress-service.d.ts +1 -1
  67. package/dist/src/services/progress/progress-service.js +18 -14
  68. package/dist/src/services/security/safe-settings-path.d.ts +12 -0
  69. package/dist/src/services/security/safe-settings-path.js +104 -0
  70. package/dist/src/services/session/session-manager.d.ts +22 -1
  71. package/dist/src/services/session/session-manager.js +137 -28
  72. package/dist/src/services/signal/cancel-handler.d.ts +14 -0
  73. package/dist/src/services/signal/cancel-handler.js +76 -0
  74. package/dist/src/services/skill/resume-detector.d.ts +54 -0
  75. package/dist/src/services/skill/resume-detector.js +334 -0
  76. package/dist/src/services/skill/skill-scheduler.d.ts +40 -0
  77. package/dist/src/services/skill/skill-scheduler.js +53 -0
  78. package/dist/src/services/skills/hooks-settings-service.d.ts +47 -29
  79. package/dist/src/services/skills/hooks-settings-service.js +190 -144
  80. package/dist/src/services/skills/statusline-settings-service.d.ts +33 -6
  81. package/dist/src/services/skills/statusline-settings-service.js +31 -34
  82. package/dist/src/services/slice/slice-archive-service.d.ts +20 -0
  83. package/dist/src/services/slice/slice-archive-service.js +111 -0
  84. package/dist/src/services/slice/slice-check-service.js +20 -1
  85. package/dist/src/services/slice/slice-check-types.d.ts +9 -0
  86. package/dist/src/services/solo/batch-heartbeat-poller.d.ts +51 -0
  87. package/dist/src/services/solo/batch-heartbeat-poller.js +88 -0
  88. package/dist/src/services/solo/status-line-renderer.d.ts +34 -0
  89. package/dist/src/services/solo/status-line-renderer.js +55 -0
  90. package/dist/src/services/workspace/migrate-service.js +124 -2
  91. package/dist/src/services/workspace/migrate-types.d.ts +50 -7
  92. package/dist/src/services/workspace/reconcile-service.d.ts +69 -0
  93. package/dist/src/services/workspace/reconcile-service.js +267 -48
  94. package/dist/src/services/workspace/reconcile-types.d.ts +37 -0
  95. package/dist/src/services/workspace/workspace-service.js +29 -62
  96. package/dist/src/shared/version.d.ts +1 -1
  97. package/dist/src/shared/version.js +1 -1
  98. package/package.json +2 -1
  99. package/schemas/doctor-report.schema.json +2 -2
  100. package/skills/peaks-ide/SKILL.md +159 -0
  101. package/skills/peaks-qa/SKILL.md +58 -1
  102. package/skills/peaks-qa/references/qa-fanout-contract.md +150 -0
  103. package/skills/peaks-rd/SKILL.md +52 -9
  104. package/skills/peaks-solo/SKILL.md +83 -20
  105. package/skills/peaks-solo/references/context-governance.md +144 -0
  106. package/skills/peaks-solo/references/headroom-integration.md +107 -0
  107. package/skills/peaks-solo/references/runbook.md +3 -3
  108. package/skills/peaks-solo/references/sub-agent-dispatch.md +218 -0
  109. package/skills/peaks-solo/references/swarm-dispatch-contract.md +3 -37
  110. package/skills/peaks-txt/SKILL.md +19 -0
  111. package/skills/peaks-ui/SKILL.md +28 -1
@@ -0,0 +1,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
+ }
@@ -210,7 +210,26 @@ export async function sliceCheck(options) {
210
210
  stages.push(await runTypecheck(options.projectRoot));
211
211
  // Stage 2: full vitest
212
212
  if (!options.skipTests) {
213
- stages.push(await runUnitTests(options.projectRoot));
213
+ const unitTests = await runUnitTests(options.projectRoot);
214
+ // Opt-in override: if --allow-pre-existing-failures is set AND the
215
+ // unit-test stage failed, downgrade `failed` to `skipped` with a
216
+ // reason that names the failure count and points to the long-term
217
+ // fix. Does NOT affect the other 3 stages.
218
+ if (options.allowPreExistingFailures === true &&
219
+ unitTests.status === 'fail') {
220
+ const failureCount = unitTests.data?.failed ?? 0;
221
+ stages.push({
222
+ name: 'unit-tests',
223
+ description: 'npx vitest run (overridden via --allow-pre-existing-failures)',
224
+ status: 'skipped',
225
+ durationMs: unitTests.durationMs,
226
+ detail: `pre-existing failures: ${failureCount} failing test(s) under coverage.exclude or unrelated to this slice; user opted in via --allow-pre-existing-failures. For the long-term fix, mark these tests .skip or move to coverage.exclude (see dogfood-2-f1-f4.md F17c).`,
227
+ data: { ...(unitTests.data ?? {}), overriddenFrom: 'fail', failureCount }
228
+ });
229
+ }
230
+ else {
231
+ stages.push(unitTests);
232
+ }
214
233
  }
215
234
  else {
216
235
  stages.push({
@@ -58,4 +58,13 @@ export type SliceCheckOptions = {
58
58
  * tests (e.g. a docs-only or config-only slice).
59
59
  */
60
60
  skipTests: boolean;
61
+ /**
62
+ * When true, an `unit-tests` stage that fails is reported as `skipped`
63
+ * (with a `reason` naming the pre-existing failure count) instead of
64
+ * `failed`. Used to opt in to bypassing the 28 pre-existing Windows
65
+ * test failures documented in dogfood-2-f1-f4.md F17. Does NOT affect
66
+ * the other 3 stages (typecheck / review-fanout / gate-verify-pipeline).
67
+ * Default: false. The service treats `undefined` the same as `false`.
68
+ */
69
+ allowPreExistingFailures?: boolean;
61
70
  };
@@ -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
+ }
@@ -1,5 +1,5 @@
1
1
  import { execFileSync } from 'node:child_process';
2
- import { mkdir, readFile, readdir, rm } from 'node:fs/promises';
2
+ import { mkdir, readFile, readdir, rename, rm } from 'node:fs/promises';
3
3
  import { join } from 'node:path';
4
4
  import { isDirectory, pathExists } from '../../shared/fs.js';
5
5
  import { isPathInsideArtifactRoot, validateChangeIdOrThrow } from '../../shared/change-id.js';
@@ -325,6 +325,107 @@ async function planSession(sessionId, sessionPath) {
325
325
  }
326
326
  return { sessionId, path: sessionPath, empty: empty && plans.every((p) => p.skipped), files: plans, fallbackChangeId: fallback };
327
327
  }
328
+ /**
329
+ * Slice 003 (2026-06-06-session-layout-canonicalize): one-shot
330
+ * consolidation of every top-level `.peaks/<sid>/` into
331
+ * `.peaks/_runtime/<sid>/`. Idempotent:
332
+ *
333
+ * - If `.peaks/_runtime/<sid>/` does not exist → `fs.rename` the
334
+ * top-level dir to the runtime location.
335
+ * - If `.peaks/_runtime/<sid>/` already exists with the same
336
+ * content → no-op, reported as `skipped-already-canonical`.
337
+ * - If `.peaks/_runtime/<sid>/` already exists with different
338
+ * content → log a conflict, do NOT overwrite.
339
+ * - **F15 carve-out**: if `<sid>/rd/project-scan.md` differs from
340
+ * the runtime copy already in place, log a
341
+ * `f15-conflict-project-scan` and leave the file in place.
342
+ *
343
+ * Path-traversal is impossible because the target is always
344
+ * `peaks/_runtime/<sid>/` and the directory listing only returns
345
+ * names matching the session-id regex.
346
+ */
347
+ async function migrateToRuntime(projectRoot, peaksRoot, apply) {
348
+ void projectRoot;
349
+ const plans = [];
350
+ const moved = [];
351
+ const skipped = [];
352
+ const conflicts = [];
353
+ const runtimeRoot = join(peaksRoot, '_runtime');
354
+ let topLevelEntries;
355
+ try {
356
+ topLevelEntries = await readdir(peaksRoot, { withFileTypes: true });
357
+ }
358
+ catch {
359
+ return { plans, moved, skipped, conflicts };
360
+ }
361
+ for (const entry of topLevelEntries) {
362
+ if (!entry.isDirectory())
363
+ continue;
364
+ if (PROTECTED_TOP_LEVEL_DIRS.has(entry.name))
365
+ continue;
366
+ if (!/^\d{4}-\d{2}-\d{2}-session-/.test(entry.name))
367
+ continue;
368
+ const sessionId = entry.name;
369
+ const fromPath = join(peaksRoot, sessionId);
370
+ const toPath = join(runtimeRoot, sessionId);
371
+ if (await isDirectory(toPath)) {
372
+ // F15 carve-out check
373
+ const fromScan = join(fromPath, 'rd', 'project-scan.md');
374
+ const toScan = join(toPath, 'rd', 'project-scan.md');
375
+ if (await pathExists(fromScan) && await pathExists(toScan)) {
376
+ const fromContent = await readFile(fromScan, 'utf8').catch(() => null);
377
+ const toContent = await readFile(toScan, 'utf8').catch(() => null);
378
+ if (fromContent !== null && toContent !== null && fromContent !== toContent) {
379
+ plans.push({
380
+ from: fromPath,
381
+ to: toPath,
382
+ sessionId,
383
+ action: 'f15-conflict-project-scan',
384
+ reason: 'F15 carve-out: top-level rd/project-scan.md differs from runtime copy; left in place.'
385
+ });
386
+ conflicts.push({
387
+ from: fromScan,
388
+ to: toScan,
389
+ reason: 'f15-conflict-project-scan'
390
+ });
391
+ continue;
392
+ }
393
+ }
394
+ plans.push({
395
+ from: fromPath,
396
+ to: toPath,
397
+ sessionId,
398
+ action: 'skipped-already-canonical',
399
+ reason: 'target _runtime/<sid>/ already exists'
400
+ });
401
+ skipped.push(sessionId);
402
+ continue;
403
+ }
404
+ plans.push({
405
+ from: fromPath,
406
+ to: toPath,
407
+ sessionId,
408
+ action: 'moved',
409
+ reason: 'top-level session dir will be moved to _runtime/'
410
+ });
411
+ if (apply) {
412
+ try {
413
+ await mkdir(runtimeRoot, { recursive: true });
414
+ await rename(fromPath, toPath);
415
+ moved.push(sessionId);
416
+ }
417
+ catch (error) {
418
+ const msg = error instanceof Error ? error.message : String(error);
419
+ conflicts.push({
420
+ from: fromPath,
421
+ to: toPath,
422
+ reason: `rename failed: ${msg}`
423
+ });
424
+ }
425
+ }
426
+ }
427
+ return { plans, moved, skipped, conflicts };
428
+ }
328
429
  async function gitMv(from, to, projectRoot) {
329
430
  const parentDir = join(to, '..');
330
431
  await mkdir(parentDir, { recursive: true });
@@ -470,6 +571,23 @@ export async function migrateWorkspace(options) {
470
571
  deletedSessions.push(session.sessionId);
471
572
  }
472
573
  }
574
+ // Slice 003: the `--to-runtime` step. When set, move every
575
+ // top-level `.peaks/<sid>/` to `.peaks/_runtime/<sid>/`. Idempotent.
576
+ // The F15 carve-out (rd/project-scan.md) is honored: when the
577
+ // top-level `<sid>/rd/project-scan.md` differs from the runtime
578
+ // `<sid>/rd/project-scan.md` already in place, the file is
579
+ // left at the top-level and a conflict is recorded.
580
+ let toRuntimePlans = [];
581
+ let toRuntimeMoved = [];
582
+ let toRuntimeSkipped = [];
583
+ let toRuntimeConflicts = [];
584
+ if (options.toRuntime === true) {
585
+ const result = await migrateToRuntime(options.projectRoot, peaksRoot, options.apply);
586
+ toRuntimePlans = result.plans;
587
+ toRuntimeMoved = result.moved;
588
+ toRuntimeSkipped = result.skipped;
589
+ toRuntimeConflicts = result.conflicts;
590
+ }
473
591
  return {
474
592
  projectRoot: options.projectRoot,
475
593
  sessions: sessionPlans,
@@ -479,6 +597,10 @@ export async function migrateWorkspace(options) {
479
597
  wouldDeleteSessions: wouldDeleteSessions,
480
598
  conflicts,
481
599
  apply: options.apply,
482
- totalFilesMoved: options.apply ? moved.length : 0
600
+ totalFilesMoved: options.apply ? moved.length : 0,
601
+ toRuntimePlans,
602
+ toRuntimeMoved,
603
+ toRuntimeSkipped,
604
+ toRuntimeConflicts
483
605
  };
484
606
  }
@@ -53,14 +53,48 @@ export type MigrateSessionPlan = {
53
53
  /** The fallback change-id derived from the session's most recent `rd/requests/` entry. Null if no requests exist. */
54
54
  fallbackChangeId: string | null;
55
55
  };
56
+ export type MigrateOptions = {
57
+ projectRoot: string;
58
+ /** When true, actually `git mv` the files + `rm -rf` the emptied session dirs. */
59
+ apply: boolean;
60
+ /**
61
+ * Slice 003 (2026-06-06-session-layout-canonicalize): when true, the
62
+ * command performs the **session-dir consolidation** — moves every
63
+ * top-level `.peaks/<sid>/` to `.peaks/_runtime/<sid>/`. Idempotent;
64
+ * conflicts (target exists with different content) are logged but
65
+ * never overwrite. With `apply: false` (the default), the response
66
+ * lists what WOULD move + the conflicts.
67
+ *
68
+ * Mutually exclusive with the reviewable-content migration: the
69
+ * `--to-runtime` step is the data side of slice 003, while the
70
+ * default `migrate` step is the cross-cutting content side
71
+ * (reviewable files → retrospective). Both run when both flags are
72
+ * set; the order is `--to-runtime` first (so the cross-cutting
73
+ * step sees the canonical tree) and then the reviewable-content
74
+ * step.
75
+ */
76
+ toRuntime?: boolean;
77
+ };
78
+ export type MigrateToRuntimeFilePlan = {
79
+ /** Absolute source path (top-level `.peaks/<sid>/`). */
80
+ from: string;
81
+ /** Absolute target path (`.peaks/_runtime/<sid>/`). */
82
+ to: string;
83
+ /** The session id the dir belongs to. */
84
+ sessionId: string;
85
+ /** 'moved' or 'skipped-already-canonical' or 'conflict'. */
86
+ action: 'moved' | 'skipped-already-canonical' | 'conflict-target-exists-with-different-content' | 'f15-conflict-project-scan';
87
+ /** Human-readable reason for the action (for the conflicts list). */
88
+ reason: string;
89
+ };
56
90
  export type MigrateResult = {
57
91
  /** Absolute project root the command operated on. */
58
92
  projectRoot: string;
59
93
  /** All discovered legacy session dirs, sorted by name. */
60
94
  sessions: MigrateSessionPlan[];
61
- /** All moves the apply step WOULD perform (only populated when `apply === false` as well, for symmetry). */
95
+ /** All moves the apply step WOULD perform (only populated when `apply: false` as well, for symmetry). */
62
96
  wouldMove: MigrateFilePlan[];
63
- /** All moves actually performed (only populated when `apply === true`). */
97
+ /** All moves actually performed (only populated when `apply: true`). */
64
98
  moved: MigrateFilePlan[];
65
99
  /** Sessions that became empty after the move and were/will be removed. */
66
100
  deletedSessions: string[];
@@ -76,9 +110,18 @@ export type MigrateResult = {
76
110
  apply: boolean;
77
111
  /** Total files moved or scheduled to move. */
78
112
  totalFilesMoved: number;
79
- };
80
- export type MigrateOptions = {
81
- projectRoot: string;
82
- /** When true, actually `git mv` the files + `rm -rf` the emptied session dirs. */
83
- apply: boolean;
113
+ /**
114
+ * Slice 003: per-session-dir move plans for the `--to-runtime` step.
115
+ * Empty when `toRuntime` was not set. Conflicts include both the
116
+ * top-level/<sid>/ _runtime/<sid>/ collisions AND the F15 carve-out
117
+ * for `rd/project-scan.md`.
118
+ */
119
+ toRuntimePlans?: MigrateToRuntimeFilePlan[];
120
+ toRuntimeMoved?: string[];
121
+ toRuntimeSkipped?: string[];
122
+ toRuntimeConflicts?: Array<{
123
+ from: string;
124
+ to: string;
125
+ reason: string;
126
+ }>;
84
127
  };