peaks-cli 1.3.2 → 1.3.4
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/dist/src/cli/commands/core-artifact-commands.js +6 -3
- 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/project-commands.js +8 -4
- 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/workflow-commands.js +2 -1
- package/dist/src/cli/commands/workspace-commands.js +3 -0
- 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/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/dashboard/project-dashboard-service.d.ts +23 -0
- package/dist/src/services/dashboard/project-dashboard-service.js +21 -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/ide/adapters/claude-code-adapter.d.ts +18 -0
- package/dist/src/services/ide/adapters/claude-code-adapter.js +80 -0
- package/dist/src/services/ide/adapters/trae-adapter.d.ts +42 -0
- package/dist/src/services/ide/adapters/trae-adapter.js +98 -0
- package/dist/src/services/ide/hook-protocol.d.ts +47 -0
- package/dist/src/services/ide/hook-protocol.js +74 -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 +180 -0
- package/dist/src/services/ide/ide-types.js +2 -0
- package/dist/src/services/ide/resource-profile.d.ts +52 -0
- package/dist/src/services/ide/resource-profile.js +33 -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/memory/project-context-service.js +2 -1
- package/dist/src/services/memory/project-memory-service.js +4 -3
- package/dist/src/services/perf/perf-baseline-service.js +2 -1
- 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/getSessionDir.d.ts +1 -0
- package/dist/src/services/session/getSessionDir.js +27 -0
- package/dist/src/services/session/index.d.ts +1 -0
- package/dist/src/services/session/index.js +1 -0
- 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/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/standards/ide-aware-standards-service.d.ts +94 -0
- package/dist/src/services/standards/ide-aware-standards-service.js +89 -0
- package/dist/src/services/standards/project-standards-service.d.ts +1 -2
- package/dist/src/services/workspace/reconcile-service.d.ts +36 -0
- package/dist/src/services/workspace/reconcile-service.js +107 -6
- package/dist/src/services/workspace/reconcile-types.d.ts +12 -0
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +2 -1
- package/scripts/install-skills.mjs +112 -2
- package/skills/peaks-ide/SKILL.md +159 -0
- package/skills/peaks-ide/references/audit-log-helper.md +52 -0
- package/skills/peaks-qa/SKILL.md +153 -55
- package/skills/peaks-qa/references/qa-fanout-contract.md +150 -0
- package/skills/peaks-rd/SKILL.md +134 -62
- package/skills/peaks-solo/SKILL.md +124 -37
- package/skills/peaks-solo/references/browser-workflow.md +22 -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 +261 -0
- package/skills/peaks-solo/references/swarm-dispatch-contract.md +3 -37
- package/skills/peaks-txt/SKILL.md +17 -0
- package/skills/peaks-ui/SKILL.md +45 -10
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { SubAgentToolCall } from './sub-agent-dispatcher.js';
|
|
2
|
+
/** G6.3 Heartbeat entry — single update written by a running sub-agent. */
|
|
3
|
+
export interface Heartbeat {
|
|
4
|
+
readonly at: string;
|
|
5
|
+
readonly status: HeartbeatStatus;
|
|
6
|
+
readonly progress: number;
|
|
7
|
+
readonly note: string | null;
|
|
8
|
+
}
|
|
9
|
+
export type HeartbeatStatus = 'queued' | 'running' | 'finalizing' | 'done' | 'failed' | 'stale';
|
|
10
|
+
export type DispatchRecordStatus = 'queued' | 'running' | 'finalizing' | 'done' | 'failed' | 'cancelled' | 'no-execution' | 'stale';
|
|
11
|
+
export type DispatchOutcome = 'success' | 'failed' | 'timeout' | 'cancelled' | 'no-execution';
|
|
12
|
+
/** G2+G5+G6 dispatch record schema (AC-26 + AC-34). */
|
|
13
|
+
export interface DispatchRecord {
|
|
14
|
+
readonly version: 2;
|
|
15
|
+
readonly createdAt: string;
|
|
16
|
+
readonly completedAt: string | null;
|
|
17
|
+
readonly outcome: DispatchOutcome;
|
|
18
|
+
readonly artifactPaths: readonly string[];
|
|
19
|
+
readonly disposed: boolean;
|
|
20
|
+
readonly disposedAt: string | null;
|
|
21
|
+
readonly role: string;
|
|
22
|
+
readonly requestId: string;
|
|
23
|
+
readonly sessionId: string;
|
|
24
|
+
readonly prompt: string;
|
|
25
|
+
readonly toolCall: SubAgentToolCall;
|
|
26
|
+
/** G5 batch id (AC-27) — uuid-like opaque token grouping one batch. */
|
|
27
|
+
readonly batchId: string;
|
|
28
|
+
/** G6 fields (AC-34) — backward compat: defaults on read. */
|
|
29
|
+
readonly heartbeats: readonly Heartbeat[];
|
|
30
|
+
readonly lastBeatAt: string | null;
|
|
31
|
+
readonly status: DispatchRecordStatus;
|
|
32
|
+
}
|
|
33
|
+
/** Input for the initial write. */
|
|
34
|
+
export type WriteInitialDispatchInput = {
|
|
35
|
+
projectRoot: string;
|
|
36
|
+
sessionId: string;
|
|
37
|
+
requestId: string;
|
|
38
|
+
role: string;
|
|
39
|
+
prompt: string;
|
|
40
|
+
toolCall: SubAgentToolCall;
|
|
41
|
+
batchId: string;
|
|
42
|
+
/** Override the timestamp (testing). */
|
|
43
|
+
now?: () => Date;
|
|
44
|
+
};
|
|
45
|
+
/** Heartbeat write input. */
|
|
46
|
+
export type AppendHeartbeatInput = {
|
|
47
|
+
recordPath: string;
|
|
48
|
+
status: HeartbeatStatus;
|
|
49
|
+
progress: number;
|
|
50
|
+
note?: string;
|
|
51
|
+
now?: () => Date;
|
|
52
|
+
};
|
|
53
|
+
/** Lifecycle transition input. */
|
|
54
|
+
export type LifecycleInput = {
|
|
55
|
+
recordPath: string;
|
|
56
|
+
outcome: DispatchOutcome;
|
|
57
|
+
status: DispatchRecordStatus;
|
|
58
|
+
artifactPaths?: readonly string[];
|
|
59
|
+
now?: () => Date;
|
|
60
|
+
};
|
|
61
|
+
/** Write a new dispatch record (G2 + G5 + G6). Returns the absolute path. */
|
|
62
|
+
export declare function writeInitialDispatchRecord(input: WriteInitialDispatchInput): {
|
|
63
|
+
path: string;
|
|
64
|
+
record: DispatchRecord;
|
|
65
|
+
};
|
|
66
|
+
/** Append a heartbeat (G6). Idempotent on (at, status) — append-only. */
|
|
67
|
+
export declare function appendHeartbeat(input: AppendHeartbeatInput): {
|
|
68
|
+
record: DispatchRecord;
|
|
69
|
+
truncated: boolean;
|
|
70
|
+
};
|
|
71
|
+
/** Apply truncation: keep most recent 100, mark truncated flag. */
|
|
72
|
+
export declare function applyTruncation(entries: readonly Heartbeat[]): {
|
|
73
|
+
heartbeats: Heartbeat[];
|
|
74
|
+
truncated: boolean;
|
|
75
|
+
};
|
|
76
|
+
/** Mark a record as completed (success / failed / cancelled / no-execution). */
|
|
77
|
+
export declare function markCompleted(input: LifecycleInput): {
|
|
78
|
+
record: DispatchRecord;
|
|
79
|
+
};
|
|
80
|
+
/** Mark a record as disposed (reducer ran). */
|
|
81
|
+
export declare function markDisposed(recordPath: string, now?: () => Date): {
|
|
82
|
+
record: DispatchRecord;
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Read a dispatch record with backward-compat defaults. Old records
|
|
86
|
+
* missing G5 / G6 fields are upgraded on read (no error, no overwrite).
|
|
87
|
+
*/
|
|
88
|
+
export declare function readRecord(recordPath: string): DispatchRecord;
|
|
89
|
+
/** Read multiple records from a list of paths. Tolerates missing files. */
|
|
90
|
+
export declare function readRecords(paths: readonly string[]): DispatchRecord[];
|
|
91
|
+
declare function isDispatchStatus(v: unknown): v is DispatchRecordStatus;
|
|
92
|
+
declare function isOutcome(v: unknown): v is DispatchOutcome;
|
|
93
|
+
export { isDispatchStatus, isOutcome };
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatch record writer — slice 2026-06-07-sub-agent-dispatch-decouple (G2 + G5 + G6).
|
|
3
|
+
*
|
|
4
|
+
* Owns the on-disk format of `.peaks/_sub_agents/<sid>/dispatch-<rid>-<ts>.json`:
|
|
5
|
+
* - G2: atomic write helper (mkdirSync recursive + tmp + rename) and
|
|
6
|
+
* R-2 guard (path must live under `.peaks/_sub_agents/<sid>/`).
|
|
7
|
+
* - G5: lifecycle schema (`createdAt` / `completedAt` / `outcome` /
|
|
8
|
+
* `artifactPaths` / `disposed` / `disposedAt`) per AC-26 + RL-6..RL-9.
|
|
9
|
+
* - G6: heartbeat schema upgrade per AC-33/AC-34 — `heartbeats[]` +
|
|
10
|
+
* `lastBeatAt` + `status` aggregate. Read-side backward compat
|
|
11
|
+
* supplies defaults for old records missing the G6 fields.
|
|
12
|
+
*
|
|
13
|
+
* The write helpers are intentionally small and pure:
|
|
14
|
+
* - `writeInitialDispatchRecord`: append a new dispatch record at the
|
|
15
|
+
* start of a sub-agent dispatch (called by `peaks sub-agent dispatch`).
|
|
16
|
+
* - `appendHeartbeat`: append one heartbeat to an existing record
|
|
17
|
+
* (called by `peaks sub-agent heartbeat`).
|
|
18
|
+
* - `markCompleted` / `markFailed` / `markCancelled` / `markNoExecution`:
|
|
19
|
+
* lifecycle transitions called by the reducer.
|
|
20
|
+
*
|
|
21
|
+
* All writes are atomic (tmp + rename) so a process crash mid-write
|
|
22
|
+
* cannot leave a half-truncated JSON file. All reads tolerate missing
|
|
23
|
+
* G6 fields (backward compat) and the G5 schema fields default to
|
|
24
|
+
* `null` / `false` / `'no-execution'` if the file was written by an
|
|
25
|
+
* older peaks build.
|
|
26
|
+
*/
|
|
27
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
|
|
28
|
+
import { dirname, resolve } from 'node:path';
|
|
29
|
+
import { assertSafeDispatchRecordPath, dispatchRecordPath } from '../security/safe-settings-path.js';
|
|
30
|
+
const MAX_PROMPT_BYTES = 256 * 1024;
|
|
31
|
+
/** Write a new dispatch record (G2 + G5 + G6). Returns the absolute path. */
|
|
32
|
+
export function writeInitialDispatchRecord(input) {
|
|
33
|
+
const { projectRoot, sessionId, requestId, role, prompt, toolCall, batchId } = input;
|
|
34
|
+
const now = input.now ?? (() => new Date());
|
|
35
|
+
if (prompt.length > MAX_PROMPT_BYTES) {
|
|
36
|
+
const err = new Error(`prompt exceeds ${MAX_PROMPT_BYTES} bytes (got ${prompt.length}); ` +
|
|
37
|
+
`truncate or split into multiple dispatches`);
|
|
38
|
+
err.code = 'PROMPT_TOO_LARGE';
|
|
39
|
+
throw err;
|
|
40
|
+
}
|
|
41
|
+
const path = dispatchRecordPath(projectRoot, sessionId, requestId, now());
|
|
42
|
+
const safePath = assertSafeDispatchRecordPath(path, projectRoot);
|
|
43
|
+
const record = {
|
|
44
|
+
version: 2,
|
|
45
|
+
createdAt: now().toISOString(),
|
|
46
|
+
completedAt: null,
|
|
47
|
+
outcome: 'no-execution',
|
|
48
|
+
artifactPaths: [],
|
|
49
|
+
disposed: false,
|
|
50
|
+
disposedAt: null,
|
|
51
|
+
role,
|
|
52
|
+
requestId,
|
|
53
|
+
sessionId,
|
|
54
|
+
prompt,
|
|
55
|
+
toolCall,
|
|
56
|
+
batchId,
|
|
57
|
+
heartbeats: [],
|
|
58
|
+
lastBeatAt: null,
|
|
59
|
+
status: 'queued'
|
|
60
|
+
};
|
|
61
|
+
writeAtomic(safePath, record);
|
|
62
|
+
return { path: safePath, record };
|
|
63
|
+
}
|
|
64
|
+
/** Append a heartbeat (G6). Idempotent on (at, status) — append-only. */
|
|
65
|
+
export function appendHeartbeat(input) {
|
|
66
|
+
const { recordPath, status, progress, note } = input;
|
|
67
|
+
const now = input.now ?? (() => new Date());
|
|
68
|
+
if (!Number.isInteger(progress) || progress < 0 || progress > 100) {
|
|
69
|
+
const err = new Error(`progress must be integer 0..100 (got ${progress})`);
|
|
70
|
+
err.code = 'INVALID_PROGRESS';
|
|
71
|
+
throw err;
|
|
72
|
+
}
|
|
73
|
+
if (note !== undefined && note.length > 200) {
|
|
74
|
+
const err = new Error(`note must be ≤ 200 chars (got ${note.length})`);
|
|
75
|
+
err.code = 'NOTE_TOO_LONG';
|
|
76
|
+
throw err;
|
|
77
|
+
}
|
|
78
|
+
const existing = readRecord(recordPath);
|
|
79
|
+
const entry = {
|
|
80
|
+
at: now().toISOString(),
|
|
81
|
+
status,
|
|
82
|
+
progress,
|
|
83
|
+
note: note ?? null
|
|
84
|
+
};
|
|
85
|
+
const { heartbeats, truncated } = applyTruncation([...existing.heartbeats, entry]);
|
|
86
|
+
const next = {
|
|
87
|
+
...existing,
|
|
88
|
+
heartbeats,
|
|
89
|
+
lastBeatAt: entry.at,
|
|
90
|
+
status: mapStatusToAggregate(status, existing.status)
|
|
91
|
+
};
|
|
92
|
+
writeAtomic(recordPath, next);
|
|
93
|
+
return { record: next, truncated };
|
|
94
|
+
}
|
|
95
|
+
/** Apply truncation: keep most recent 100, mark truncated flag. */
|
|
96
|
+
export function applyTruncation(entries) {
|
|
97
|
+
if (entries.length <= 100) {
|
|
98
|
+
return { heartbeats: [...entries], truncated: false };
|
|
99
|
+
}
|
|
100
|
+
return { heartbeats: entries.slice(-100), truncated: true };
|
|
101
|
+
}
|
|
102
|
+
function mapStatusToAggregate(latest, current) {
|
|
103
|
+
// 'stale' is a poller-driven warning and must not be overwritten by
|
|
104
|
+
// a normal heartbeat that arrives after the stale flag was set.
|
|
105
|
+
if (current === 'stale') {
|
|
106
|
+
return 'stale';
|
|
107
|
+
}
|
|
108
|
+
return latest;
|
|
109
|
+
}
|
|
110
|
+
/** Mark a record as completed (success / failed / cancelled / no-execution). */
|
|
111
|
+
export function markCompleted(input) {
|
|
112
|
+
const existing = readRecord(input.recordPath);
|
|
113
|
+
const next = {
|
|
114
|
+
...existing,
|
|
115
|
+
completedAt: (input.now ?? (() => new Date()))().toISOString(),
|
|
116
|
+
outcome: input.outcome,
|
|
117
|
+
status: input.status,
|
|
118
|
+
artifactPaths: input.artifactPaths ?? existing.artifactPaths
|
|
119
|
+
};
|
|
120
|
+
writeAtomic(input.recordPath, next);
|
|
121
|
+
return { record: next };
|
|
122
|
+
}
|
|
123
|
+
/** Mark a record as disposed (reducer ran). */
|
|
124
|
+
export function markDisposed(recordPath, now = () => new Date()) {
|
|
125
|
+
const existing = readRecord(recordPath);
|
|
126
|
+
const next = {
|
|
127
|
+
...existing,
|
|
128
|
+
disposed: true,
|
|
129
|
+
disposedAt: now().toISOString()
|
|
130
|
+
};
|
|
131
|
+
writeAtomic(recordPath, next);
|
|
132
|
+
return { record: next };
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Read a dispatch record with backward-compat defaults. Old records
|
|
136
|
+
* missing G5 / G6 fields are upgraded on read (no error, no overwrite).
|
|
137
|
+
*/
|
|
138
|
+
export function readRecord(recordPath) {
|
|
139
|
+
if (!existsSync(recordPath)) {
|
|
140
|
+
const err = new Error(`Dispatch record not found: ${recordPath}`);
|
|
141
|
+
err.code = 'RECORD_NOT_FOUND';
|
|
142
|
+
err.path = recordPath;
|
|
143
|
+
throw err;
|
|
144
|
+
}
|
|
145
|
+
const raw = readFileSync(recordPath, 'utf8');
|
|
146
|
+
let parsed;
|
|
147
|
+
try {
|
|
148
|
+
parsed = JSON.parse(raw);
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
const err = new Error(`Invalid dispatch record JSON: ${error.message}`);
|
|
152
|
+
err.code = 'INVALID_RECORD_JSON';
|
|
153
|
+
throw err;
|
|
154
|
+
}
|
|
155
|
+
return upgradeRecord(parsed);
|
|
156
|
+
}
|
|
157
|
+
/** Read multiple records from a list of paths. Tolerates missing files. */
|
|
158
|
+
export function readRecords(paths) {
|
|
159
|
+
const out = [];
|
|
160
|
+
for (const p of paths) {
|
|
161
|
+
try {
|
|
162
|
+
out.push(readRecord(p));
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
const code = error.code;
|
|
166
|
+
if (code === 'RECORD_NOT_FOUND') {
|
|
167
|
+
continue;
|
|
168
|
+
}
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
function upgradeRecord(parsed) {
|
|
175
|
+
if (!isObject(parsed)) {
|
|
176
|
+
throw new Error('Dispatch record root must be an object');
|
|
177
|
+
}
|
|
178
|
+
const obj = parsed;
|
|
179
|
+
const role = stringField(obj, 'role');
|
|
180
|
+
const requestId = stringField(obj, 'requestId');
|
|
181
|
+
const sessionId = stringField(obj, 'sessionId');
|
|
182
|
+
const prompt = stringField(obj, 'prompt');
|
|
183
|
+
const toolCall = obj.toolCall;
|
|
184
|
+
if (!isObject(toolCall) || typeof toolCall.name !== 'string') {
|
|
185
|
+
throw new Error('Dispatch record toolCall must be { name, args }');
|
|
186
|
+
}
|
|
187
|
+
const createdAt = stringField(obj, 'createdAt');
|
|
188
|
+
const heartbeats = Array.isArray(obj.heartbeats)
|
|
189
|
+
? obj.heartbeats.filter(isValidHeartbeat)
|
|
190
|
+
: [];
|
|
191
|
+
const lastBeatAt = typeof obj.lastBeatAt === 'string' ? obj.lastBeatAt : null;
|
|
192
|
+
const status = isDispatchStatus(obj.status) ? obj.status : 'no-execution';
|
|
193
|
+
const completedAt = typeof obj.completedAt === 'string' ? obj.completedAt : null;
|
|
194
|
+
const outcome = isOutcome(obj.outcome) ? obj.outcome : 'no-execution';
|
|
195
|
+
const artifactPaths = Array.isArray(obj.artifactPaths)
|
|
196
|
+
? obj.artifactPaths.filter((p) => typeof p === 'string')
|
|
197
|
+
: [];
|
|
198
|
+
const disposed = obj.disposed === true;
|
|
199
|
+
const disposedAt = typeof obj.disposedAt === 'string' ? obj.disposedAt : null;
|
|
200
|
+
const batchId = typeof obj.batchId === 'string' && obj.batchId.length > 0
|
|
201
|
+
? obj.batchId
|
|
202
|
+
: 'legacy-batch';
|
|
203
|
+
return {
|
|
204
|
+
version: 2,
|
|
205
|
+
createdAt,
|
|
206
|
+
completedAt,
|
|
207
|
+
outcome,
|
|
208
|
+
artifactPaths,
|
|
209
|
+
disposed,
|
|
210
|
+
disposedAt,
|
|
211
|
+
role,
|
|
212
|
+
requestId,
|
|
213
|
+
sessionId,
|
|
214
|
+
prompt,
|
|
215
|
+
toolCall,
|
|
216
|
+
batchId,
|
|
217
|
+
heartbeats,
|
|
218
|
+
lastBeatAt,
|
|
219
|
+
status
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function isObject(v) {
|
|
223
|
+
return typeof v === 'object' && v !== null && !Array.isArray(v);
|
|
224
|
+
}
|
|
225
|
+
function stringField(obj, key) {
|
|
226
|
+
const v = obj[key];
|
|
227
|
+
if (typeof v !== 'string') {
|
|
228
|
+
throw new Error(`Dispatch record field '${key}' must be a string (got ${typeof v})`);
|
|
229
|
+
}
|
|
230
|
+
return v;
|
|
231
|
+
}
|
|
232
|
+
function isValidHeartbeat(v) {
|
|
233
|
+
if (!isObject(v))
|
|
234
|
+
return false;
|
|
235
|
+
return (typeof v.at === 'string' &&
|
|
236
|
+
isHeartbeatStatus(v.status) &&
|
|
237
|
+
typeof v.progress === 'number' &&
|
|
238
|
+
(v.note === null || typeof v.note === 'string'));
|
|
239
|
+
}
|
|
240
|
+
function isHeartbeatStatus(v) {
|
|
241
|
+
return (v === 'queued' || v === 'running' || v === 'finalizing' ||
|
|
242
|
+
v === 'done' || v === 'failed' || v === 'stale');
|
|
243
|
+
}
|
|
244
|
+
function isDispatchStatus(v) {
|
|
245
|
+
return (v === 'queued' || v === 'running' || v === 'finalizing' ||
|
|
246
|
+
v === 'done' || v === 'failed' || v === 'cancelled' ||
|
|
247
|
+
v === 'no-execution' || v === 'stale');
|
|
248
|
+
}
|
|
249
|
+
function isOutcome(v) {
|
|
250
|
+
return (v === 'success' || v === 'failed' || v === 'timeout' ||
|
|
251
|
+
v === 'cancelled' || v === 'no-execution');
|
|
252
|
+
}
|
|
253
|
+
export { isDispatchStatus, isOutcome };
|
|
254
|
+
function writeAtomic(path, record) {
|
|
255
|
+
const dir = dirname(path);
|
|
256
|
+
mkdirSync(dir, { recursive: true });
|
|
257
|
+
const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
|
|
258
|
+
const safeTmp = resolve(dir, tmp.split(/[\\/]/).pop());
|
|
259
|
+
writeFileSync(safeTmp, JSON.stringify(record, null, 2) + '\n', 'utf8');
|
|
260
|
+
renameSync(safeTmp, path);
|
|
261
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* G6.4 / RL-16 — heartbeat truncation helper.
|
|
3
|
+
*
|
|
4
|
+
* Each dispatch record keeps an append-only `heartbeats[]` array so
|
|
5
|
+
* reducers / auditors can reconstruct what the sub-agent did. Without
|
|
6
|
+
* a cap, a long-running sub-agent can blow the record JSON past 1 MB
|
|
7
|
+
* (a 30s cadence over a 1-hour run = 120 entries * ~120 bytes = ~14 KB;
|
|
8
|
+
* a 5s cadence over 24h = 17 000 entries * ~120 bytes = ~2 MB). The
|
|
9
|
+
* 100-entry cap is LLM-friendly: stale heartbeats are not informative
|
|
10
|
+
* once the poller has read them, so dropping the oldest ones is a
|
|
11
|
+
* non-event.
|
|
12
|
+
*
|
|
13
|
+
* Pure helper; no IO. The writer in `dispatch-record-writer.ts` calls
|
|
14
|
+
* this on every `appendHeartbeat`. Exposed as a separate module so
|
|
15
|
+
* tests can pin the contract.
|
|
16
|
+
*/
|
|
17
|
+
import type { Heartbeat } from './dispatch-record-writer.js';
|
|
18
|
+
export declare const HEARTBEAT_TRUNCATE_LIMIT = 100;
|
|
19
|
+
export interface TruncationResult {
|
|
20
|
+
readonly heartbeats: readonly Heartbeat[];
|
|
21
|
+
readonly truncated: boolean;
|
|
22
|
+
/** The number of entries dropped by this truncation. */
|
|
23
|
+
readonly dropped: number;
|
|
24
|
+
}
|
|
25
|
+
/** Apply the 100-entry cap. Returns the most recent N entries + a flag. */
|
|
26
|
+
export declare function truncateHeartbeats(entries: readonly Heartbeat[]): TruncationResult;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export const HEARTBEAT_TRUNCATE_LIMIT = 100;
|
|
2
|
+
/** Apply the 100-entry cap. Returns the most recent N entries + a flag. */
|
|
3
|
+
export function truncateHeartbeats(entries) {
|
|
4
|
+
if (entries.length <= HEARTBEAT_TRUNCATE_LIMIT) {
|
|
5
|
+
return { heartbeats: [...entries], truncated: false, dropped: 0 };
|
|
6
|
+
}
|
|
7
|
+
const start = entries.length - HEARTBEAT_TRUNCATE_LIMIT;
|
|
8
|
+
return {
|
|
9
|
+
heartbeats: entries.slice(start),
|
|
10
|
+
truncated: true,
|
|
11
|
+
dropped: start
|
|
12
|
+
};
|
|
13
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type DispatchRecord } from './dispatch-record-writer.js';
|
|
2
|
+
export declare const DEFAULT_LEAK_THRESHOLD_MS: number;
|
|
3
|
+
export interface LeakedRecord {
|
|
4
|
+
readonly path: string;
|
|
5
|
+
readonly record: DispatchRecord;
|
|
6
|
+
readonly ageMs: number;
|
|
7
|
+
}
|
|
8
|
+
export declare function findLeakedDispatchRecords(projectRoot: string, sessionId: string, options?: {
|
|
9
|
+
now?: () => Date;
|
|
10
|
+
thresholdMs?: number;
|
|
11
|
+
}): readonly LeakedRecord[];
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice #009 / G5 RL-7 — dispatch record leak detector.
|
|
3
|
+
*
|
|
4
|
+
* After peaks-solo's reducer consumes a batch, any dispatch record that
|
|
5
|
+
* is still `disposed === false` AND was created more than `thresholdMs`
|
|
6
|
+
* ago is a **leak**. This helper scans the on-disk records under
|
|
7
|
+
* `.peaks/_sub_agents/<sid>/` and returns the leaked ones. The CLI or
|
|
8
|
+
* the reducer's next-batch step emits a user-visible warning when the
|
|
9
|
+
* list is non-empty.
|
|
10
|
+
*
|
|
11
|
+
* Threshold: 1h (configurable). Rationale: the longest empirical
|
|
12
|
+
* peaks-rd / peaks-qa fan-out + reducer cycle is < 60s; the threshold
|
|
13
|
+
* gives slow slices headroom without hiding leaks for the next session.
|
|
14
|
+
*/
|
|
15
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
16
|
+
import { join } from 'node:path';
|
|
17
|
+
import { isDispatchStatus, isOutcome } from './dispatch-record-writer.js';
|
|
18
|
+
export const DEFAULT_LEAK_THRESHOLD_MS = 60 * 60 * 1000; // 1 hour
|
|
19
|
+
export function findLeakedDispatchRecords(projectRoot, sessionId, options = {}) {
|
|
20
|
+
const now = options.now ?? (() => new Date());
|
|
21
|
+
const thresholdMs = options.thresholdMs ?? DEFAULT_LEAK_THRESHOLD_MS;
|
|
22
|
+
const nowMs = now().getTime();
|
|
23
|
+
const dir = join(projectRoot, '.peaks', '_sub_agents', sessionId);
|
|
24
|
+
if (!existsSync(dir))
|
|
25
|
+
return [];
|
|
26
|
+
const out = [];
|
|
27
|
+
for (const entry of readdirSync(dir)) {
|
|
28
|
+
if (!entry.startsWith('dispatch-') || !entry.endsWith('.json'))
|
|
29
|
+
continue;
|
|
30
|
+
const fullPath = join(dir, entry);
|
|
31
|
+
let raw;
|
|
32
|
+
try {
|
|
33
|
+
raw = readFileSync(fullPath, 'utf8');
|
|
34
|
+
}
|
|
35
|
+
catch {
|
|
36
|
+
continue; // skip unreadable files
|
|
37
|
+
}
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = JSON.parse(raw);
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
continue; // skip malformed files
|
|
44
|
+
}
|
|
45
|
+
if (!isRecordShape(parsed))
|
|
46
|
+
continue;
|
|
47
|
+
if (parsed.disposed === true)
|
|
48
|
+
continue;
|
|
49
|
+
const createdMs = Date.parse(parsed.createdAt);
|
|
50
|
+
if (Number.isNaN(createdMs))
|
|
51
|
+
continue;
|
|
52
|
+
const ageMs = nowMs - createdMs;
|
|
53
|
+
if (ageMs < thresholdMs)
|
|
54
|
+
continue;
|
|
55
|
+
out.push({ path: fullPath, record: parsed, ageMs });
|
|
56
|
+
}
|
|
57
|
+
return out;
|
|
58
|
+
}
|
|
59
|
+
function isRecordShape(v) {
|
|
60
|
+
if (typeof v !== 'object' || v === null)
|
|
61
|
+
return false;
|
|
62
|
+
const obj = v;
|
|
63
|
+
return (typeof obj.createdAt === 'string' &&
|
|
64
|
+
typeof obj.role === 'string' &&
|
|
65
|
+
typeof obj.requestId === 'string' &&
|
|
66
|
+
typeof obj.sessionId === 'string' &&
|
|
67
|
+
typeof obj.prompt === 'string' &&
|
|
68
|
+
isOutcome(obj.outcome) &&
|
|
69
|
+
isDispatchStatus(obj.status) &&
|
|
70
|
+
typeof obj.disposed === 'boolean' &&
|
|
71
|
+
Array.isArray(obj.artifactPaths));
|
|
72
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice #009 — SubAgentDispatcher abstraction.
|
|
3
|
+
*
|
|
4
|
+
* Per-IDE contract: given a sub-agent role + prompt + request/session ids,
|
|
5
|
+
* return a tool-call descriptor that the calling LLM should execute in
|
|
6
|
+
* its native environment. The CLI is IDE-agnostic; per-IDE tool names
|
|
7
|
+
* (Claude Code's `Task`, Trae's UNVERIFIED placeholder) are encapsulated
|
|
8
|
+
* here, never leaked to SKILL.md.
|
|
9
|
+
*
|
|
10
|
+
* Why this exists:
|
|
11
|
+
* - Prior SKILL.md hardcoded `Task(subagent_type="general-purpose", ...)`
|
|
12
|
+
* which made peaks-cli depend on Claude Code's specific sub-agent
|
|
13
|
+
* tool name. Adding a new IDE (Trae, future Cursor, etc.) required
|
|
14
|
+
* editing every SKILL.md that mentioned sub-agent dispatch.
|
|
15
|
+
* - This file (plus the per-IDE adapter wiring) collapses all
|
|
16
|
+
* per-IDE sub-agent specifics to a single `SubAgentDispatcher`
|
|
17
|
+
* instance per adapter. SKILL.md now only references
|
|
18
|
+
* `peaks sub-agent dispatch <role>`, and the IDE-private tool
|
|
19
|
+
* name flows through the returned `data.toolCall` at runtime.
|
|
20
|
+
*
|
|
21
|
+
* Cross-reference: PRD #002 G1 (AC-1..AC-5); RD tech-doc-002 §2.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* Role string namespace. Soft whitelist — the CLI does NOT hard-validate
|
|
25
|
+
* specific role names. Empirically observed (peaks-qa SKILL.md): 3 top
|
|
26
|
+
* roles + 3 sub-roles + arbitrary business subdivisions:
|
|
27
|
+
*
|
|
28
|
+
* - top: rd | qa | ui | txt | general-purpose
|
|
29
|
+
* - qa sub-roles: qa-business | qa-perf | qa-security
|
|
30
|
+
* - business细分: qa-business-regression | qa-business-api
|
|
31
|
+
* | qa-business-frontend | ...
|
|
32
|
+
* - promotable: prd-business | prd-technical | prd-ux |
|
|
33
|
+
* ui-visual | ui-flow | ui-component | ...
|
|
34
|
+
*
|
|
35
|
+
* Any non-empty string is a valid role. CLI emits a "soft whitelist"
|
|
36
|
+
* hint in --help but does not reject unknown values.
|
|
37
|
+
*/
|
|
38
|
+
export type SubAgentRole = string;
|
|
39
|
+
/**
|
|
40
|
+
* IDE-private tool-call descriptor. The LLM, upon receiving this in
|
|
41
|
+
* the CLI's JSON envelope, must invoke the tool named `name` in its
|
|
42
|
+
* own environment with the provided `args`.
|
|
43
|
+
*/
|
|
44
|
+
export interface SubAgentToolCall {
|
|
45
|
+
readonly name: string;
|
|
46
|
+
readonly args: Readonly<Record<string, unknown>>;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Input to `buildToolCall`. The CLI assembles this from the user's
|
|
50
|
+
* command-line args (role, prompt) + state-machine lookups
|
|
51
|
+
* (requestId, sessionId).
|
|
52
|
+
*/
|
|
53
|
+
export interface SubAgentDispatchInput {
|
|
54
|
+
readonly role: SubAgentRole;
|
|
55
|
+
readonly prompt: string;
|
|
56
|
+
readonly requestId: string;
|
|
57
|
+
readonly sessionId: string;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Per-IDE sub-agent dispatcher contract. Each IdeAdapter exposes
|
|
61
|
+
* one of these; the CLI calls `buildToolCall` after validating
|
|
62
|
+
* `supportsRole` (and `null-dispatcher` is the fallback when an
|
|
63
|
+
* IDE cannot dispatch sub-agents at all).
|
|
64
|
+
*/
|
|
65
|
+
export interface SubAgentDispatcher {
|
|
66
|
+
/**
|
|
67
|
+
* Short label used in envelope `ide` field and CLI help text.
|
|
68
|
+
* e.g. "claude-code" / "trae" / "null".
|
|
69
|
+
*/
|
|
70
|
+
readonly label: string;
|
|
71
|
+
/**
|
|
72
|
+
* Whether this dispatcher supports dispatching a given role.
|
|
73
|
+
* claude-code returns true for all non-empty strings; trae is
|
|
74
|
+
* byte-identical (UNVERIFIED pending real Trae dogfood);
|
|
75
|
+
* null-dispatcher always returns false.
|
|
76
|
+
*/
|
|
77
|
+
supportsRole(role: SubAgentRole): boolean;
|
|
78
|
+
/**
|
|
79
|
+
* Build the IDE-specific tool call descriptor for a dispatch.
|
|
80
|
+
* Must be pure: no I/O, no side effects. The CLI wraps the
|
|
81
|
+
* returned descriptor in its JSON envelope.
|
|
82
|
+
*/
|
|
83
|
+
buildToolCall(input: SubAgentDispatchInput): SubAgentToolCall;
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* Claude Code dispatcher. Real, byte-level implementation.
|
|
87
|
+
*
|
|
88
|
+
* - `supportsRole`: any non-empty string (Claude Code's
|
|
89
|
+
* `general-purpose` sub-agent accepts any prompt).
|
|
90
|
+
* - `buildToolCall`: returns `{name: 'Task', args: {subagent_type,
|
|
91
|
+
* description, prompt}}` — the exact shape the `Task` tool
|
|
92
|
+
* in Claude Code expects.
|
|
93
|
+
*/
|
|
94
|
+
export declare const claudeCodeSubAgentDispatcher: SubAgentDispatcher;
|
|
95
|
+
/**
|
|
96
|
+
* Trae dispatcher. UNVERIFIED — Trae sub-agent tool name TBD on real
|
|
97
|
+
* dogfood. Byte-level identical to claude-code by design so:
|
|
98
|
+
* 1. The slice #008 `subAgentToolMatcher: 'Task'` install entry
|
|
99
|
+
* stays byte-stable (the generated `.trae/settings.json` does
|
|
100
|
+
* not change).
|
|
101
|
+
* 2. The dispatcher's return shape is uniform across both
|
|
102
|
+
* adapters — a single byte-equality test can verify the
|
|
103
|
+
* placeholder contract.
|
|
104
|
+
*
|
|
105
|
+
* When real Trae dogfood lands, replace the body of `buildToolCall`
|
|
106
|
+
* with Trae's actual sub-agent tool name + args shape. The interface
|
|
107
|
+
* stays the same; only the per-IDE wiring breaks (intentionally).
|
|
108
|
+
*/
|
|
109
|
+
export declare const traeSubAgentDispatcher: SubAgentDispatcher;
|
|
110
|
+
/**
|
|
111
|
+
* Null dispatcher for IDEs that cannot dispatch sub-agents at all
|
|
112
|
+
* (e.g. a CLI-only IDE that has no LLM tool surface). Used as the
|
|
113
|
+
* fallback by future unsupported-IDE adapters. The CLI returns
|
|
114
|
+
* `{ok: false, code: "IDE_NOT_SUPPORTED"}` when the dispatcher's
|
|
115
|
+
* `supportsRole` returns false.
|
|
116
|
+
*/
|
|
117
|
+
export declare const nullSubAgentDispatcher: SubAgentDispatcher;
|
|
118
|
+
/**
|
|
119
|
+
* Thrown by `nullSubAgentDispatcher.buildToolCall` and any future
|
|
120
|
+
* dispatcher that does not support a given role. The CLI catches
|
|
121
|
+
* this and returns the IDE_NOT_SUPPORTED error envelope.
|
|
122
|
+
*/
|
|
123
|
+
export declare class SubAgentNotSupportedError extends Error {
|
|
124
|
+
readonly role: SubAgentRole;
|
|
125
|
+
readonly code: "IDE_NOT_SUPPORTED";
|
|
126
|
+
constructor(role: SubAgentRole);
|
|
127
|
+
}
|