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,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* R-2 path-safety guard for sub-agent state files.
|
|
3
|
+
*
|
|
4
|
+
* Slice 2026-06-07-sub-agent-dispatch-decouple (G4) — reuses the same
|
|
5
|
+
* R-2 symlink/junction guard as `assertSafeSettingsFile` for
|
|
6
|
+
* `.peaks/_sub_agents/<sid>/dispatch-<rid>-<ts>.json` paths.
|
|
7
|
+
*
|
|
8
|
+
* Why a separate helper:
|
|
9
|
+
* - The settings-file guard is a per-IDE 8th field (settings.json path);
|
|
10
|
+
* this one is for runtime sub-agent trace records.
|
|
11
|
+
* - Both reject paths that resolve outside the project root after
|
|
12
|
+
* symlink resolution, and both reject `..` segments before
|
|
13
|
+
* resolution (defense in depth).
|
|
14
|
+
*
|
|
15
|
+
* The guard is intentionally small and pure (no IO beyond `realpathSync`)
|
|
16
|
+
* so it can be called from CLI handlers and from service helpers alike.
|
|
17
|
+
*/
|
|
18
|
+
import { realpathSync } from 'node:fs';
|
|
19
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
20
|
+
const SUB_AGENTS_DIR = '_sub_agents';
|
|
21
|
+
/** Build the canonical record path for a given session/rid/timestamp. */
|
|
22
|
+
export function dispatchRecordPath(projectRoot, sid, rid, ts = new Date()) {
|
|
23
|
+
const safeSid = sanitizeSegment(sid, 'sessionId');
|
|
24
|
+
const safeRid = sanitizeSegment(rid, 'requestId');
|
|
25
|
+
const tsCompact = ts.toISOString().replace(/[:.]/g, '-');
|
|
26
|
+
return resolve(projectRoot, '.peaks', SUB_AGENTS_DIR, safeSid, `dispatch-${safeRid}-${tsCompact}.json`);
|
|
27
|
+
}
|
|
28
|
+
/** The directory under which dispatch records live. */
|
|
29
|
+
export function dispatchRecordsDir(projectRoot, sid) {
|
|
30
|
+
const safeSid = sanitizeSegment(sid, 'sessionId');
|
|
31
|
+
return resolve(projectRoot, '.peaks', SUB_AGENTS_DIR, safeSid);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Assert that `recordPath` lives under `projectRoot/.peaks/_sub_agents/<sid>/`.
|
|
35
|
+
* Rejects symlink/junction escapes and `..` segments.
|
|
36
|
+
*
|
|
37
|
+
* Throws an Error with `.code = 'INVALID_RECORD_PATH'` on rejection so
|
|
38
|
+
* the CLI can map to `{ok: false, code: "INVALID_RECORD_PATH"}`.
|
|
39
|
+
*/
|
|
40
|
+
export function assertSafeDispatchRecordPath(recordPath, projectRoot) {
|
|
41
|
+
if (!isAbsolute(recordPath)) {
|
|
42
|
+
throw invalidPathError(recordPath, 'must be absolute');
|
|
43
|
+
}
|
|
44
|
+
// Reject lexical `..` segments in the raw path BEFORE the OS-level resolver
|
|
45
|
+
// collapses them. POSIX `path.normalize` will turn `/a/b/../c` into `/a/c`,
|
|
46
|
+
// silently dropping the `..` — and that is exactly the symlink/junction
|
|
47
|
+
// escape we are guarding against. Test the raw string form.
|
|
48
|
+
const rawSegments = recordPath.split(/[\\/]/);
|
|
49
|
+
if (rawSegments.includes('..')) {
|
|
50
|
+
throw invalidPathError(recordPath, 'must not contain .. segments');
|
|
51
|
+
}
|
|
52
|
+
const expected = resolve(projectRoot, '.peaks', SUB_AGENTS_DIR);
|
|
53
|
+
const rel = relative(expected, recordPath);
|
|
54
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
55
|
+
throw invalidPathError(recordPath, 'must be under .peaks/_sub_agents/');
|
|
56
|
+
}
|
|
57
|
+
let realRecord;
|
|
58
|
+
let realRoot;
|
|
59
|
+
try {
|
|
60
|
+
// For existing files, realpathSync resolves symlinks/junctions.
|
|
61
|
+
// For new files (the common case for `dispatch` CLI writing the
|
|
62
|
+
// record for the first time), we realpath the parent + check the
|
|
63
|
+
// leaf's basename shape.
|
|
64
|
+
const parent = dirname(recordPath);
|
|
65
|
+
const realParent = realpathSync(parent);
|
|
66
|
+
realRecord = resolve(realParent, recordPath.slice(parent.length + 1));
|
|
67
|
+
realRoot = realpathSync(projectRoot);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
// Realpath can fail if the parent does not exist yet (CLI is about
|
|
71
|
+
// to create it). Fall back to lexical comparison against the
|
|
72
|
+
// canonical projectRoot — the write will then create the file,
|
|
73
|
+
// and any symlink in the parent will be caught on the next read.
|
|
74
|
+
const fallback = resolve(projectRoot, '.peaks', SUB_AGENTS_DIR);
|
|
75
|
+
const rel2 = relative(fallback, recordPath);
|
|
76
|
+
if (rel2.startsWith('..') || isAbsolute(rel2)) {
|
|
77
|
+
throw invalidPathError(recordPath, 'must be under .peaks/_sub_agents/');
|
|
78
|
+
}
|
|
79
|
+
return recordPath;
|
|
80
|
+
}
|
|
81
|
+
const realRel = relative(realRoot, realRecord);
|
|
82
|
+
if (realRel.startsWith('..' + sep) || realRel === '..' || isAbsolute(realRel)) {
|
|
83
|
+
throw invalidPathError(recordPath, 'escapes project root via symlink');
|
|
84
|
+
}
|
|
85
|
+
return realRecord;
|
|
86
|
+
}
|
|
87
|
+
function sanitizeSegment(segment, label) {
|
|
88
|
+
if (typeof segment !== 'string' || segment.length === 0) {
|
|
89
|
+
throw new Error(`Invalid ${label}: empty`);
|
|
90
|
+
}
|
|
91
|
+
if (!/^[A-Za-z0-9._-]+$/.test(segment)) {
|
|
92
|
+
throw new Error(`Invalid ${label}: must match [A-Za-z0-9._-]+ (got ${JSON.stringify(segment)})`);
|
|
93
|
+
}
|
|
94
|
+
if (segment.includes('..')) {
|
|
95
|
+
throw new Error(`Invalid ${label}: must not contain ..`);
|
|
96
|
+
}
|
|
97
|
+
return segment;
|
|
98
|
+
}
|
|
99
|
+
function invalidPathError(path, reason) {
|
|
100
|
+
const err = new Error(`Unsafe dispatch record path (${reason}): ${path}`);
|
|
101
|
+
err.code = 'INVALID_RECORD_PATH';
|
|
102
|
+
err.path = path;
|
|
103
|
+
return err;
|
|
104
|
+
}
|
|
@@ -45,6 +45,13 @@ export type SessionMeta = {
|
|
|
45
45
|
* no binding was present. The caller is expected to do something
|
|
46
46
|
* with that — at minimum surface it in the CLI response so the
|
|
47
47
|
* user can find the directory again if they need to.
|
|
48
|
+
*
|
|
49
|
+
* Slice 008 (F22 fix): the read uses the canonicalize-on-read
|
|
50
|
+
* variant so a binding written with `projectRoot: "."` (relative
|
|
51
|
+
* form, anchored from inside the project dir) is still found when
|
|
52
|
+
* the caller passes the absolute realpath. Pre-F22 the
|
|
53
|
+
* strict-equality read returned null in that case, and rotate
|
|
54
|
+
* silently no-op'd (the CLI reported "no prior binding").
|
|
48
55
|
*/
|
|
49
56
|
export declare function rotateSessionBinding(projectRoot: string): string | null;
|
|
50
57
|
/**
|
|
@@ -75,6 +82,11 @@ export declare function setSessionTitle(projectRoot: string, sessionId: string,
|
|
|
75
82
|
/**
|
|
76
83
|
* List all session directories under .peaks with their metadata.
|
|
77
84
|
* Returns sessions sorted by sessionId descending (most recent first).
|
|
85
|
+
*
|
|
86
|
+
* As of slice 2026-06-06-session-layout-canonicalize the session
|
|
87
|
+
* dirs live at the canonical runtime home `.peaks/_runtime/<sid>/`.
|
|
88
|
+
* The legacy top-level layout is read for back-compat (one minor
|
|
89
|
+
* release) but is not authoritative.
|
|
78
90
|
*/
|
|
79
91
|
export declare function listSessionMetas(projectRoot: string): SessionMeta[];
|
|
80
92
|
export declare function ensureSession(projectRoot: string): Promise<string>;
|
|
@@ -118,14 +130,23 @@ export declare function getSessionIdCanonical(projectRoot: string): string | nul
|
|
|
118
130
|
* Get the absolute path to the current session directory.
|
|
119
131
|
* Creates the session if it doesn't exist.
|
|
120
132
|
*
|
|
133
|
+
* As of slice 2026-06-06-session-layout-canonicalize the canonical
|
|
134
|
+
* home is `.peaks/_runtime/<sid>/`. The legacy top-level layout
|
|
135
|
+
* `.peaks/<sid>/` is the back-compat read fallback only.
|
|
136
|
+
*
|
|
121
137
|
* @param projectRoot - Root directory of the project
|
|
122
|
-
* @returns Absolute path to session directory (e.g., "/path/to/project/.peaks/2026-05-26-session-a3f8b1")
|
|
138
|
+
* @returns Absolute path to session directory (e.g., "/path/to/project/.peaks/_runtime/2026-05-26-session-a3f8b1")
|
|
123
139
|
*/
|
|
124
140
|
export declare function getCurrentSessionDir(projectRoot: string): Promise<string>;
|
|
125
141
|
/**
|
|
126
142
|
* List all session directories in the .peaks folder.
|
|
127
143
|
* Returns session IDs (directory names) sorted by date.
|
|
128
144
|
*
|
|
145
|
+
* As of slice 2026-06-06-session-layout-canonicalize the canonical
|
|
146
|
+
* home is `.peaks/_runtime/<sid>/`. The legacy top-level layout
|
|
147
|
+
* `.peaks/<sid>/` is read for back-compat (one minor release) so
|
|
148
|
+
* pre-migration trees keep working.
|
|
149
|
+
*
|
|
129
150
|
* @param projectRoot - Root directory of the project
|
|
130
151
|
* @returns Array of session IDs
|
|
131
152
|
*/
|
|
@@ -180,9 +180,16 @@ function writeSessionFile(projectRoot, info) {
|
|
|
180
180
|
* no binding was present. The caller is expected to do something
|
|
181
181
|
* with that — at minimum surface it in the CLI response so the
|
|
182
182
|
* user can find the directory again if they need to.
|
|
183
|
+
*
|
|
184
|
+
* Slice 008 (F22 fix): the read uses the canonicalize-on-read
|
|
185
|
+
* variant so a binding written with `projectRoot: "."` (relative
|
|
186
|
+
* form, anchored from inside the project dir) is still found when
|
|
187
|
+
* the caller passes the absolute realpath. Pre-F22 the
|
|
188
|
+
* strict-equality read returned null in that case, and rotate
|
|
189
|
+
* silently no-op'd (the CLI reported "no prior binding").
|
|
183
190
|
*/
|
|
184
191
|
export function rotateSessionBinding(projectRoot) {
|
|
185
|
-
const previous =
|
|
192
|
+
const previous = readSessionFileCanonical(projectRoot);
|
|
186
193
|
if (previous === null) {
|
|
187
194
|
return null;
|
|
188
195
|
}
|
|
@@ -221,7 +228,14 @@ export function setCurrentSessionBinding(projectRoot, sessionId) {
|
|
|
221
228
|
return info;
|
|
222
229
|
}
|
|
223
230
|
function getMetaFilePath(projectRoot, sessionId) {
|
|
224
|
-
|
|
231
|
+
// As of slice 2026-06-06-session-layout-canonicalize, the per-session
|
|
232
|
+
// `session.json` (the file written by `setSessionMeta`) lives at the
|
|
233
|
+
// canonical runtime home `.peaks/_runtime/<sid>/session.json`, NOT
|
|
234
|
+
// at the top-level `.peaks/<sid>/session.json` (which would imply
|
|
235
|
+
// the legacy session-scoped layout and conflict with the workspace
|
|
236
|
+
// service's `_runtime/<sid>/` invariant). The migration in slice
|
|
237
|
+
// 003 moved any top-level meta files into the runtime home.
|
|
238
|
+
return join(projectRoot, '.peaks', '_runtime', sessionId, META_FILE);
|
|
225
239
|
}
|
|
226
240
|
function readSessionMeta(projectRoot, sessionId) {
|
|
227
241
|
const metaPath = getMetaFilePath(projectRoot, sessionId);
|
|
@@ -241,7 +255,9 @@ function readSessionMeta(projectRoot, sessionId) {
|
|
|
241
255
|
}
|
|
242
256
|
function writeSessionMeta(projectRoot, sessionId, meta) {
|
|
243
257
|
const metaPath = getMetaFilePath(projectRoot, sessionId);
|
|
244
|
-
|
|
258
|
+
// As of slice 003, the meta file lives at `.peaks/_runtime/<sid>/session.json`.
|
|
259
|
+
// The parent dir of that file is the canonical runtime session dir.
|
|
260
|
+
const metaDir = dirname(metaPath);
|
|
245
261
|
if (!existsSync(metaDir)) {
|
|
246
262
|
mkdirSync(metaDir, { recursive: true });
|
|
247
263
|
}
|
|
@@ -282,23 +298,67 @@ export function setSessionTitle(projectRoot, sessionId, title) {
|
|
|
282
298
|
/**
|
|
283
299
|
* List all session directories under .peaks with their metadata.
|
|
284
300
|
* Returns sessions sorted by sessionId descending (most recent first).
|
|
301
|
+
*
|
|
302
|
+
* As of slice 2026-06-06-session-layout-canonicalize the session
|
|
303
|
+
* dirs live at the canonical runtime home `.peaks/_runtime/<sid>/`.
|
|
304
|
+
* The legacy top-level layout is read for back-compat (one minor
|
|
305
|
+
* release) but is not authoritative.
|
|
285
306
|
*/
|
|
286
307
|
export function listSessionMetas(projectRoot) {
|
|
308
|
+
const runtimeRoot = join(projectRoot, '.peaks', '_runtime');
|
|
287
309
|
const peaksRoot = join(projectRoot, '.peaks');
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
const
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
310
|
+
const seen = new Set();
|
|
311
|
+
const result = [];
|
|
312
|
+
const collect = (root) => {
|
|
313
|
+
if (!existsSync(root))
|
|
314
|
+
return;
|
|
315
|
+
const names = [];
|
|
316
|
+
try {
|
|
317
|
+
const out = readdirSync(root, { withFileTypes: true });
|
|
318
|
+
for (const e of out) {
|
|
319
|
+
if (e.isDirectory())
|
|
320
|
+
names.push(e.name);
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
catch {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
for (const name of names) {
|
|
327
|
+
if (!/^\d{4}-\d{2}-\d{2}-session-[a-f0-9]+$/.test(name))
|
|
328
|
+
continue;
|
|
329
|
+
if (seen.has(name))
|
|
330
|
+
continue;
|
|
331
|
+
seen.add(name);
|
|
332
|
+
const meta = readSessionMeta(projectRoot, name);
|
|
333
|
+
result.push(meta ?? { sessionId: name, projectRoot, createdAt: '' });
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
// Canonical home first, then the legacy top-level (back-compat).
|
|
337
|
+
collect(runtimeRoot);
|
|
338
|
+
collect(peaksRoot);
|
|
339
|
+
result.sort((a, b) => b.sessionId.localeCompare(a.sessionId));
|
|
340
|
+
return result;
|
|
341
|
+
}
|
|
342
|
+
/**
|
|
343
|
+
* Back-compat read for the legacy top-level meta file (the one that
|
|
344
|
+
* pre-slice 003 trees still have at `.peaks/<sid>/session.json`).
|
|
345
|
+
* Kept as a separate helper so the canonical reader is the default.
|
|
346
|
+
*/
|
|
347
|
+
function readSessionMetaCompat(peaksRoot, sessionId) {
|
|
348
|
+
const metaPath = join(peaksRoot, sessionId, META_FILE);
|
|
349
|
+
if (!existsSync(metaPath))
|
|
350
|
+
return null;
|
|
351
|
+
try {
|
|
352
|
+
const raw = readFileSync(metaPath, 'utf8');
|
|
353
|
+
const parsed = JSON.parse(raw);
|
|
354
|
+
if (typeof parsed?.sessionId !== 'string' || parsed.sessionId.length === 0) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
return parsed;
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
return null;
|
|
361
|
+
}
|
|
302
362
|
}
|
|
303
363
|
/**
|
|
304
364
|
* Get or create the current session for a project.
|
|
@@ -322,6 +382,26 @@ export async function ensureSession(projectRoot) {
|
|
|
322
382
|
if (existing) {
|
|
323
383
|
return existing.sessionId;
|
|
324
384
|
}
|
|
385
|
+
// Slice 007 — sub-agent session sharing. When the strict-equality
|
|
386
|
+
// read returns null (e.g. the binding was written with the relative
|
|
387
|
+
// form "." from inside the project dir, but the caller passes the
|
|
388
|
+
// absolute realpath), fall through to the canonical-fallback read.
|
|
389
|
+
// `ensureSession` is a session-creating primitive — its caller
|
|
390
|
+
// wants the existing binding if one exists, even if the projectRoot
|
|
391
|
+
// forms differ. Without this fallback, a sub-agent that anchors via
|
|
392
|
+
// `cd <repo> && peaks skill presence:set` and then runs
|
|
393
|
+
// `peaks request init --project <abs-path>` would auto-generate a
|
|
394
|
+
// new session and create an orphan dir.
|
|
395
|
+
//
|
|
396
|
+
// The strict-equality read is preserved for other modules
|
|
397
|
+
// (notably `shared/change-id.ts` via `buildArtifactRelativePath`)
|
|
398
|
+
// that depend on the "no session bound" code path — switching the
|
|
399
|
+
// default would cascade into ~30 test failures in those modules.
|
|
400
|
+
// The canonical-fallback is opt-in for `ensureSession` only.
|
|
401
|
+
const canonical = getSessionIdCanonical(projectRoot);
|
|
402
|
+
if (canonical !== null) {
|
|
403
|
+
return canonical;
|
|
404
|
+
}
|
|
325
405
|
const sessionId = generateSessionId();
|
|
326
406
|
const now = new Date().toISOString();
|
|
327
407
|
const info = {
|
|
@@ -387,31 +467,60 @@ export function getSessionIdCanonical(projectRoot) {
|
|
|
387
467
|
* Get the absolute path to the current session directory.
|
|
388
468
|
* Creates the session if it doesn't exist.
|
|
389
469
|
*
|
|
470
|
+
* As of slice 2026-06-06-session-layout-canonicalize the canonical
|
|
471
|
+
* home is `.peaks/_runtime/<sid>/`. The legacy top-level layout
|
|
472
|
+
* `.peaks/<sid>/` is the back-compat read fallback only.
|
|
473
|
+
*
|
|
390
474
|
* @param projectRoot - Root directory of the project
|
|
391
|
-
* @returns Absolute path to session directory (e.g., "/path/to/project/.peaks/2026-05-26-session-a3f8b1")
|
|
475
|
+
* @returns Absolute path to session directory (e.g., "/path/to/project/.peaks/_runtime/2026-05-26-session-a3f8b1")
|
|
392
476
|
*/
|
|
393
477
|
export async function getCurrentSessionDir(projectRoot) {
|
|
394
478
|
const sessionId = await ensureSession(projectRoot);
|
|
395
|
-
return join(projectRoot, '.peaks', sessionId);
|
|
479
|
+
return join(projectRoot, '.peaks', '_runtime', sessionId);
|
|
396
480
|
}
|
|
397
481
|
/**
|
|
398
482
|
* List all session directories in the .peaks folder.
|
|
399
483
|
* Returns session IDs (directory names) sorted by date.
|
|
400
484
|
*
|
|
485
|
+
* As of slice 2026-06-06-session-layout-canonicalize the canonical
|
|
486
|
+
* home is `.peaks/_runtime/<sid>/`. The legacy top-level layout
|
|
487
|
+
* `.peaks/<sid>/` is read for back-compat (one minor release) so
|
|
488
|
+
* pre-migration trees keep working.
|
|
489
|
+
*
|
|
401
490
|
* @param projectRoot - Root directory of the project
|
|
402
491
|
* @returns Array of session IDs
|
|
403
492
|
*/
|
|
404
493
|
export function listSessions(projectRoot) {
|
|
494
|
+
const runtimeRoot = join(projectRoot, '.peaks', '_runtime');
|
|
405
495
|
const peaksRoot = join(projectRoot, '.peaks');
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
496
|
+
const seen = new Set();
|
|
497
|
+
const result = [];
|
|
498
|
+
const collect = (root) => {
|
|
499
|
+
if (!existsSync(root))
|
|
500
|
+
return;
|
|
501
|
+
const names = [];
|
|
502
|
+
try {
|
|
503
|
+
const out = readdirSync(root, { withFileTypes: true });
|
|
504
|
+
for (const e of out) {
|
|
505
|
+
if (e.isDirectory())
|
|
506
|
+
names.push(e.name);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
for (const name of names) {
|
|
513
|
+
if (!/^\d{4}-\d{2}-\d{2}-session-[a-f0-9]+$/.test(name))
|
|
514
|
+
continue;
|
|
515
|
+
if (seen.has(name))
|
|
516
|
+
continue;
|
|
517
|
+
seen.add(name);
|
|
518
|
+
result.push(name);
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
collect(runtimeRoot);
|
|
522
|
+
collect(peaksRoot);
|
|
523
|
+
return result.sort().reverse();
|
|
415
524
|
}
|
|
416
525
|
/**
|
|
417
526
|
* Get the path to project-scan.md for the current session.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface CancelResult {
|
|
2
|
+
readonly cancelled: number;
|
|
3
|
+
readonly scanned: number;
|
|
4
|
+
readonly paths: readonly string[];
|
|
5
|
+
}
|
|
6
|
+
/**
|
|
7
|
+
* Walk `.peaks/_sub_agents/<sid>/`, mark every in-flight record as
|
|
8
|
+
* `cancelled + disposed`, write back atomically (tmp + rename), and
|
|
9
|
+
* return the count. Records that already have `completedAt` set are
|
|
10
|
+
* skipped (they were already finished by the time cancel fired).
|
|
11
|
+
*/
|
|
12
|
+
export declare function cancelInFlightDispatches(projectRoot: string, sessionId: string, options?: {
|
|
13
|
+
now?: () => Date;
|
|
14
|
+
}): CancelResult;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice #009 / G5 RL-9 — user cancel must dispose in-flight dispatch records.
|
|
3
|
+
*
|
|
4
|
+
* When Solo's main loop receives SIGINT (Ctrl-C) or the user runs
|
|
5
|
+
* `peaks workflow cancel --rid <rid>`, the loop MUST mark in-flight
|
|
6
|
+
* dispatch records as `outcome: "cancelled"` + `disposed: true` +
|
|
7
|
+
* `disposedAt: now()` BEFORE exiting.
|
|
8
|
+
*
|
|
9
|
+
* "In-flight" = a record with `createdAt` populated AND
|
|
10
|
+
* `completedAt: null`. Those are the ones that the LLM got a toolCall
|
|
11
|
+
* for but did not (yet) confirm completion.
|
|
12
|
+
*
|
|
13
|
+
* This module is the helper Solo calls. The wiring (signal listener,
|
|
14
|
+
* cancel command side-effect) is the Solo main loop's responsibility;
|
|
15
|
+
* this module is the pure data operation.
|
|
16
|
+
*/
|
|
17
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
const CANCELLED_OUTCOME = 'cancelled';
|
|
20
|
+
const CANCELLED_STATUS = 'cancelled';
|
|
21
|
+
/**
|
|
22
|
+
* Walk `.peaks/_sub_agents/<sid>/`, mark every in-flight record as
|
|
23
|
+
* `cancelled + disposed`, write back atomically (tmp + rename), and
|
|
24
|
+
* return the count. Records that already have `completedAt` set are
|
|
25
|
+
* skipped (they were already finished by the time cancel fired).
|
|
26
|
+
*/
|
|
27
|
+
export function cancelInFlightDispatches(projectRoot, sessionId, options = {}) {
|
|
28
|
+
const now = options.now ?? (() => new Date());
|
|
29
|
+
const dir = join(projectRoot, '.peaks', '_sub_agents', sessionId);
|
|
30
|
+
if (!existsSync(dir)) {
|
|
31
|
+
return { cancelled: 0, scanned: 0, paths: [] };
|
|
32
|
+
}
|
|
33
|
+
let scanned = 0;
|
|
34
|
+
const cancelledPaths = [];
|
|
35
|
+
for (const entry of readdirSync(dir)) {
|
|
36
|
+
if (!entry.startsWith('dispatch-') || !entry.endsWith('.json'))
|
|
37
|
+
continue;
|
|
38
|
+
const fullPath = join(dir, entry);
|
|
39
|
+
scanned += 1;
|
|
40
|
+
const record = readRecordOrNull(fullPath);
|
|
41
|
+
if (record === null)
|
|
42
|
+
continue;
|
|
43
|
+
if (record.completedAt !== null)
|
|
44
|
+
continue; // already finished
|
|
45
|
+
const cancelled = {
|
|
46
|
+
...record,
|
|
47
|
+
completedAt: now().toISOString(),
|
|
48
|
+
outcome: CANCELLED_OUTCOME,
|
|
49
|
+
status: CANCELLED_STATUS,
|
|
50
|
+
disposed: true,
|
|
51
|
+
disposedAt: now().toISOString()
|
|
52
|
+
};
|
|
53
|
+
writeFileSync(fullPath, JSON.stringify(cancelled, null, 2) + '\n', 'utf8');
|
|
54
|
+
cancelledPaths.push(fullPath);
|
|
55
|
+
}
|
|
56
|
+
return { cancelled: cancelledPaths.length, scanned, paths: cancelledPaths };
|
|
57
|
+
}
|
|
58
|
+
function readRecordOrNull(path) {
|
|
59
|
+
try {
|
|
60
|
+
const raw = readFileSync(path, 'utf8');
|
|
61
|
+
const parsed = JSON.parse(raw);
|
|
62
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
63
|
+
return null;
|
|
64
|
+
const obj = parsed;
|
|
65
|
+
if (typeof obj.createdAt !== 'string')
|
|
66
|
+
return null;
|
|
67
|
+
if (typeof obj.completedAt !== 'string' && obj.completedAt !== null)
|
|
68
|
+
return null;
|
|
69
|
+
if (typeof obj.role !== 'string')
|
|
70
|
+
return null;
|
|
71
|
+
return obj;
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type ResumeKind = 'fresh' | 'complete' | 'resume' | 'in-flight';
|
|
2
|
+
/**
|
|
3
|
+
* Step at which the slice should resume. The values are stable strings
|
|
4
|
+
* (not raw step numbers) so log output is greppable and a future
|
|
5
|
+
* workflow reshuffle can keep the names but remap the steps.
|
|
6
|
+
*/
|
|
7
|
+
export type ResumePoint = 'rd-planning' | 'rd-review-fanout' | 'rd-perf-baseline' | 'qa-test-cases' | 'qa-execution' | 'qa-validation' | 'txt-handoff';
|
|
8
|
+
/**
|
|
9
|
+
* Mid-implementation RD/QA states. The skill body treats these as
|
|
10
|
+
* "ask the user to confirm the in-flight gate" — there is no safe
|
|
11
|
+
* auto-resume for a slice the LLM is currently driving.
|
|
12
|
+
*/
|
|
13
|
+
export type InFlightState = 'spec-locked' | 'implemented' | 'running' | 'blocked';
|
|
14
|
+
export type ResumeClassification = {
|
|
15
|
+
kind: ResumeKind;
|
|
16
|
+
/** Set when `kind === 'resume'`; the step that should be re-entered. */
|
|
17
|
+
point: ResumePoint | null;
|
|
18
|
+
/** Set when `kind === 'in-flight'`; the current non-terminal state. */
|
|
19
|
+
state: InFlightState | null;
|
|
20
|
+
/**
|
|
21
|
+
* Files that the SKILL.md spec says should already exist for the
|
|
22
|
+
* current gate but were not found. Informational — the LLM uses this
|
|
23
|
+
* to decide what to produce next. Includes legacy-missing files when
|
|
24
|
+
* the legacy path was read.
|
|
25
|
+
*/
|
|
26
|
+
missingArtifacts: string[];
|
|
27
|
+
/**
|
|
28
|
+
* Inconsistencies between state and file presence (e.g. RD is
|
|
29
|
+
* `state: qa-handoff` but `rd/code-review.md` is absent). The
|
|
30
|
+
* classifier still emits a `resume:` verdict, but the warning is the
|
|
31
|
+
* audit trail.
|
|
32
|
+
*/
|
|
33
|
+
warnings: string[];
|
|
34
|
+
/** Number of RD/QA requests filtered as `user-requested-abandon`. */
|
|
35
|
+
abandonedRequestCount: number;
|
|
36
|
+
/**
|
|
37
|
+
* True when the canonical `.peaks/_runtime/<sid>/` path was absent
|
|
38
|
+
* and the classifier fell back to `.peaks/<sid>/`. Lets the SKILL.md
|
|
39
|
+
* surface a one-time migration reminder to the user.
|
|
40
|
+
*/
|
|
41
|
+
usedLegacyPath: boolean;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Classify a session's resume state. Pure function — does not write
|
|
45
|
+
* files, does not call any peaks CLI.
|
|
46
|
+
*
|
|
47
|
+
* @param sid The session id (e.g. `2026-06-06-session-22f08c`).
|
|
48
|
+
* @param peaksRoot The canonical peaks runtime root, i.e. the
|
|
49
|
+
* directory containing `<sid>/` subdirs. For the
|
|
50
|
+
* v1.3.2 layout this is `<repo>/.peaks/_runtime`.
|
|
51
|
+
* The legacy `<repo>/.peaks` layout is also accepted
|
|
52
|
+
* for one minor release (see `usedLegacyPath`).
|
|
53
|
+
*/
|
|
54
|
+
export declare function classifyResume(sid: string, peaksRoot: string): ResumeClassification;
|