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.
- package/README.md +6 -2
- package/bin/peaks.js +0 -0
- package/dist/src/cli/commands/core-artifact-commands.js +49 -11
- package/dist/src/cli/commands/gate-commands.js +28 -19
- package/dist/src/cli/commands/hook-handle.d.ts +17 -0
- package/dist/src/cli/commands/hook-handle.js +111 -0
- package/dist/src/cli/commands/hooks-commands.js +72 -21
- package/dist/src/cli/commands/progress-commands.js +9 -2
- package/dist/src/cli/commands/progress-start-spawn.js +30 -4
- package/dist/src/cli/commands/slice-commands.js +4 -2
- package/dist/src/cli/commands/statusline-commands.js +75 -17
- package/dist/src/cli/commands/sub-agent-commands.d.ts +5 -0
- package/dist/src/cli/commands/sub-agent-commands.js +488 -0
- package/dist/src/cli/commands/sub-agent-dispatch-guard.d.ts +55 -0
- package/dist/src/cli/commands/sub-agent-dispatch-guard.js +57 -0
- package/dist/src/cli/commands/workspace-commands.js +70 -14
- package/dist/src/cli/program.js +9 -0
- package/dist/src/hooks/pre-tool-use-sub-agent.d.ts +28 -0
- package/dist/src/hooks/pre-tool-use-sub-agent.js +105 -0
- package/dist/src/services/artifacts/artifact-prerequisites.d.ts +12 -0
- package/dist/src/services/artifacts/artifact-prerequisites.js +39 -8
- package/dist/src/services/artifacts/request-artifact-service.js +116 -76
- package/dist/src/services/config/config-types.d.ts +1 -1
- package/dist/src/services/context/artifact-meta.d.ts +72 -0
- package/dist/src/services/context/artifact-meta.js +105 -0
- package/dist/src/services/context/context-guard.d.ts +49 -0
- package/dist/src/services/context/context-guard.js +91 -0
- package/dist/src/services/context/dispatch-context-guard.d.ts +27 -0
- package/dist/src/services/context/dispatch-context-guard.js +192 -0
- package/dist/src/services/context/headroom-client.d.ts +34 -0
- package/dist/src/services/context/headroom-client.js +117 -0
- package/dist/src/services/context/shared-channel.d.ts +92 -0
- package/dist/src/services/context/shared-channel.js +285 -0
- package/dist/src/services/context/threshold.d.ts +35 -0
- package/dist/src/services/context/threshold.js +76 -0
- package/dist/src/services/dispatch/batch-counter.d.ts +27 -0
- package/dist/src/services/dispatch/batch-counter.js +85 -0
- package/dist/src/services/dispatch/dispatch-record-writer.d.ts +93 -0
- package/dist/src/services/dispatch/dispatch-record-writer.js +261 -0
- package/dist/src/services/dispatch/heartbeat-truncator.d.ts +26 -0
- package/dist/src/services/dispatch/heartbeat-truncator.js +13 -0
- package/dist/src/services/dispatch/leak-detector.d.ts +11 -0
- package/dist/src/services/dispatch/leak-detector.js +72 -0
- package/dist/src/services/dispatch/sub-agent-dispatcher.d.ts +127 -0
- package/dist/src/services/dispatch/sub-agent-dispatcher.js +98 -0
- package/dist/src/services/doctor/doctor-service.d.ts +62 -0
- package/dist/src/services/doctor/doctor-service.js +276 -1
- package/dist/src/services/ide/adapters/claude-code-adapter.d.ts +18 -0
- package/dist/src/services/ide/adapters/claude-code-adapter.js +53 -0
- package/dist/src/services/ide/adapters/trae-adapter.d.ts +34 -0
- package/dist/src/services/ide/adapters/trae-adapter.js +70 -0
- package/dist/src/services/ide/hook-protocol.d.ts +44 -0
- package/dist/src/services/ide/hook-protocol.js +71 -0
- package/dist/src/services/ide/hook-translator.d.ts +72 -0
- package/dist/src/services/ide/hook-translator.js +128 -0
- package/dist/src/services/ide/ide-detector.d.ts +10 -0
- package/dist/src/services/ide/ide-detector.js +19 -0
- package/dist/src/services/ide/ide-registry.d.ts +14 -0
- package/dist/src/services/ide/ide-registry.js +45 -0
- package/dist/src/services/ide/ide-types.d.ts +120 -0
- package/dist/src/services/ide/ide-types.js +2 -0
- package/dist/src/services/ide/shared/atomic-json.d.ts +15 -0
- package/dist/src/services/ide/shared/atomic-json.js +58 -0
- package/dist/src/services/ide/shared/safe-path.d.ts +11 -0
- package/dist/src/services/ide/shared/safe-path.js +29 -0
- package/dist/src/services/progress/progress-service.d.ts +1 -1
- package/dist/src/services/progress/progress-service.js +18 -14
- package/dist/src/services/security/safe-settings-path.d.ts +12 -0
- package/dist/src/services/security/safe-settings-path.js +104 -0
- package/dist/src/services/session/session-manager.d.ts +22 -1
- package/dist/src/services/session/session-manager.js +137 -28
- package/dist/src/services/signal/cancel-handler.d.ts +14 -0
- package/dist/src/services/signal/cancel-handler.js +76 -0
- package/dist/src/services/skill/resume-detector.d.ts +54 -0
- package/dist/src/services/skill/resume-detector.js +334 -0
- package/dist/src/services/skill/skill-scheduler.d.ts +40 -0
- package/dist/src/services/skill/skill-scheduler.js +53 -0
- package/dist/src/services/skills/hooks-settings-service.d.ts +47 -29
- package/dist/src/services/skills/hooks-settings-service.js +190 -144
- package/dist/src/services/skills/statusline-settings-service.d.ts +33 -6
- package/dist/src/services/skills/statusline-settings-service.js +31 -34
- package/dist/src/services/slice/slice-archive-service.d.ts +20 -0
- package/dist/src/services/slice/slice-archive-service.js +111 -0
- package/dist/src/services/slice/slice-check-service.js +20 -1
- package/dist/src/services/slice/slice-check-types.d.ts +9 -0
- package/dist/src/services/solo/batch-heartbeat-poller.d.ts +51 -0
- package/dist/src/services/solo/batch-heartbeat-poller.js +88 -0
- package/dist/src/services/solo/status-line-renderer.d.ts +34 -0
- package/dist/src/services/solo/status-line-renderer.js +55 -0
- package/dist/src/services/workspace/migrate-service.js +124 -2
- package/dist/src/services/workspace/migrate-types.d.ts +50 -7
- package/dist/src/services/workspace/reconcile-service.d.ts +69 -0
- package/dist/src/services/workspace/reconcile-service.js +267 -48
- package/dist/src/services/workspace/reconcile-types.d.ts +37 -0
- package/dist/src/services/workspace/workspace-service.js +29 -62
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +2 -1
- package/schemas/doctor-report.schema.json +2 -2
- package/skills/peaks-ide/SKILL.md +159 -0
- package/skills/peaks-qa/SKILL.md +58 -1
- package/skills/peaks-qa/references/qa-fanout-contract.md +150 -0
- package/skills/peaks-rd/SKILL.md +52 -9
- package/skills/peaks-solo/SKILL.md +83 -20
- package/skills/peaks-solo/references/context-governance.md +144 -0
- package/skills/peaks-solo/references/headroom-integration.md +107 -0
- package/skills/peaks-solo/references/runbook.md +3 -3
- package/skills/peaks-solo/references/sub-agent-dispatch.md +218 -0
- package/skills/peaks-solo/references/swarm-dispatch-contract.md +3 -37
- package/skills/peaks-txt/SKILL.md +19 -0
- 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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
};
|