peaks-cli 1.3.2 → 1.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -2
- 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/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 +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/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 +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/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/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/skills/peaks-ide/SKILL.md +159 -0
- package/skills/peaks-qa/SKILL.md +57 -1
- package/skills/peaks-qa/references/qa-fanout-contract.md +150 -0
- package/skills/peaks-rd/SKILL.md +50 -8
- package/skills/peaks-solo/SKILL.md +77 -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 +17 -0
- package/skills/peaks-ui/SKILL.md +27 -1
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { closeSync, constants, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
export const ATOMIC_JSON_FILE_MODE = 0o600;
|
|
5
|
+
/**
|
|
6
|
+
* Read a JSON object file using a no-follow open. Returns an empty object when
|
|
7
|
+
* the file does not exist or is empty. Throws when the file exists but does not
|
|
8
|
+
* contain a JSON object (so callers can distinguish "no settings" from
|
|
9
|
+
* "malformed settings").
|
|
10
|
+
*/
|
|
11
|
+
export function readJsonObjectFile(filePath) {
|
|
12
|
+
if (!existsSync(filePath))
|
|
13
|
+
return {};
|
|
14
|
+
const fd = openSync(filePath, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
15
|
+
try {
|
|
16
|
+
const raw = readFileSync(fd, 'utf8').trim();
|
|
17
|
+
if (raw.length === 0)
|
|
18
|
+
return {};
|
|
19
|
+
const parsed = JSON.parse(raw);
|
|
20
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
21
|
+
throw new Error('settings file must contain a JSON object');
|
|
22
|
+
}
|
|
23
|
+
return parsed;
|
|
24
|
+
}
|
|
25
|
+
finally {
|
|
26
|
+
closeSync(fd);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Atomically write a JSON file: create a unique temp file in the same
|
|
31
|
+
* directory, fsync its contents via close, then `rename` over the target. A
|
|
32
|
+
* failure during rename removes the temp file (best effort). The target is
|
|
33
|
+
* created with 0o600 permissions.
|
|
34
|
+
*/
|
|
35
|
+
export function atomicWriteJson(filePath, value) {
|
|
36
|
+
const dir = dirname(filePath);
|
|
37
|
+
mkdirSync(dir, { recursive: true });
|
|
38
|
+
const tempPath = join(dir, `.settings.${randomUUID()}.tmp`);
|
|
39
|
+
const fd = openSync(tempPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, ATOMIC_JSON_FILE_MODE);
|
|
40
|
+
try {
|
|
41
|
+
writeFileSync(fd, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
closeSync(fd);
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
renameSync(tempPath, filePath);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
try {
|
|
51
|
+
unlinkSync(tempPath);
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// best effort cleanup
|
|
55
|
+
}
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type HookScope = 'project' | 'global';
|
|
2
|
+
/** True iff `childPath` resolves to `parentPath` or any path nested inside it. */
|
|
3
|
+
export declare function isInsidePath(childPath: string, parentPath: string): boolean;
|
|
4
|
+
/**
|
|
5
|
+
* Reject settings targets that are symlinked or escape the configured root.
|
|
6
|
+
* Used by the hook / statusline / MCP install paths to keep the project root the
|
|
7
|
+
* sole owner of writable settings files.
|
|
8
|
+
*/
|
|
9
|
+
export declare function assertSafeSettingsFile(scope: HookScope, root: string, dirName: string, settingsFileName: string): {
|
|
10
|
+
settingsPath: string;
|
|
11
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { existsSync, lstatSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, relative } from 'node:path';
|
|
3
|
+
/** True iff `childPath` resolves to `parentPath` or any path nested inside it. */
|
|
4
|
+
export function isInsidePath(childPath, parentPath) {
|
|
5
|
+
const rel = relative(parentPath, childPath);
|
|
6
|
+
return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Reject settings targets that are symlinked or escape the configured root.
|
|
10
|
+
* Used by the hook / statusline / MCP install paths to keep the project root the
|
|
11
|
+
* sole owner of writable settings files.
|
|
12
|
+
*/
|
|
13
|
+
export function assertSafeSettingsFile(scope, root, dirName, settingsFileName) {
|
|
14
|
+
const settingsPath = join(root, dirName, settingsFileName);
|
|
15
|
+
const dirPath = join(root, dirName);
|
|
16
|
+
if (existsSync(dirPath) && lstatSync(dirPath).isSymbolicLink()) {
|
|
17
|
+
throw new Error(`${dirName} directory must not be a symlink`);
|
|
18
|
+
}
|
|
19
|
+
if (existsSync(settingsPath)) {
|
|
20
|
+
if (lstatSync(settingsPath).isSymbolicLink()) {
|
|
21
|
+
throw new Error(`${settingsFileName} must not be a symlink`);
|
|
22
|
+
}
|
|
23
|
+
const realRoot = realpathSync(root);
|
|
24
|
+
if (!isInsidePath(realpathSync(settingsPath), realRoot)) {
|
|
25
|
+
throw new Error(`${settingsFileName} must stay inside the ${scope} root`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return { settingsPath };
|
|
29
|
+
}
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Sub-agent progress surfacing for the RD/QA sub-agents in
|
|
3
3
|
* `peaks-solo`'s Swarm phase. A sub-agent (or the LLM via the
|
|
4
4
|
* `peaks progress step` CLI) writes a stable JSON file at
|
|
5
|
-
* `.peaks/<sid>/
|
|
5
|
+
* `.peaks/_sub_agents/<sid>/subagent-progress.json`. The user-side
|
|
6
6
|
* `peaks progress watch` CLI polls this file in a separate
|
|
7
7
|
* terminal tab and renders elapsed / spinner / sub-step. The
|
|
8
8
|
* `peaks progress start` CLI auto-spawns the watch in a new
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Sub-agent progress surfacing for the RD/QA sub-agents in
|
|
3
3
|
* `peaks-solo`'s Swarm phase. A sub-agent (or the LLM via the
|
|
4
4
|
* `peaks progress step` CLI) writes a stable JSON file at
|
|
5
|
-
* `.peaks/<sid>/
|
|
5
|
+
* `.peaks/_sub_agents/<sid>/subagent-progress.json`. The user-side
|
|
6
6
|
* `peaks progress watch` CLI polls this file in a separate
|
|
7
7
|
* terminal tab and renders elapsed / spinner / sub-step. The
|
|
8
8
|
* `peaks progress start` CLI auto-spawns the watch in a new
|
|
@@ -34,20 +34,24 @@ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from '
|
|
|
34
34
|
import { dirname, join, resolve } from 'node:path';
|
|
35
35
|
import { getSessionIdCanonical } from '../session/session-manager.js';
|
|
36
36
|
import { findProjectRoot } from '../config/config-safety.js';
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
// As of slice 2026-06-06-sub-agent-spawn-bug-and-decouple, the per-session
|
|
38
|
+
// sub-agent state files live under `.peaks/_sub_agents/<sid>/`, NOT under
|
|
39
|
+
// `.peaks/<sid>/system/`. The new path mirrors the existing `_runtime/`
|
|
40
|
+
// and `_dogfood/` convention (leading underscore = meta-classification, not
|
|
41
|
+
// a per-session artifact). The previous `<sid>/system/...` locations are
|
|
42
|
+
// migrated to the new path on first run of `peaks workspace reconcile
|
|
43
|
+
// --apply` (see `migrateSubAgentState` in reconcile-service.ts).
|
|
44
|
+
const SUB_AGENTS_DIR = '_sub_agents';
|
|
45
|
+
const PROGRESS_FILE_NAME = 'subagent-progress.json';
|
|
46
|
+
const SPAWN_FILE_NAME = 'progress-spawn.json';
|
|
39
47
|
function progressPath(projectRoot) {
|
|
40
|
-
// The progress file lives
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
// should too. Without the session prefix, a session rotation
|
|
45
|
-
// would orphan the file in the project root, and switching
|
|
46
|
-
// sessions would have the watch reading the wrong slice's
|
|
47
|
-
// progress.
|
|
48
|
+
// The progress file lives at `.peaks/_sub_agents/<sid>/subagent-progress.json`.
|
|
49
|
+
// The leading `_sub_agents/` is a meta-classification (mirrors `_runtime/`,
|
|
50
|
+
// `_dogfood/`) — the SID is the per-session discriminator inside that meta
|
|
51
|
+
// dir. Without the SID, sessions would collide on the same file.
|
|
48
52
|
const sessionId = getSessionIdCanonical(projectRoot);
|
|
49
53
|
const subDir = sessionId ?? 'unbound';
|
|
50
|
-
return join(projectRoot, '.peaks', subDir,
|
|
54
|
+
return join(projectRoot, '.peaks', SUB_AGENTS_DIR, subDir, PROGRESS_FILE_NAME);
|
|
51
55
|
}
|
|
52
56
|
function ensureParentDir(path) {
|
|
53
57
|
const dir = dirname(path);
|
|
@@ -199,12 +203,12 @@ export function subAgentProgressPath(projectRoot) {
|
|
|
199
203
|
export function subAgentSpawnPath(projectRoot) {
|
|
200
204
|
const sessionId = getSessionIdCanonical(projectRoot);
|
|
201
205
|
const subDir = sessionId ?? 'unbound';
|
|
202
|
-
return join(projectRoot, '.peaks', subDir,
|
|
206
|
+
return join(projectRoot, '.peaks', SUB_AGENTS_DIR, subDir, SPAWN_FILE_NAME);
|
|
203
207
|
}
|
|
204
208
|
function spawnRecordPath(projectRoot) {
|
|
205
209
|
const sessionId = getSessionIdCanonical(projectRoot);
|
|
206
210
|
const subDir = sessionId ?? 'unbound';
|
|
207
|
-
return join(projectRoot, '.peaks', subDir,
|
|
211
|
+
return join(projectRoot, '.peaks', SUB_AGENTS_DIR, subDir, SPAWN_FILE_NAME);
|
|
208
212
|
}
|
|
209
213
|
export function writeSpawnRecord(options) {
|
|
210
214
|
const sessionId = getSessionIdCanonical(options.projectRoot);
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Build the canonical record path for a given session/rid/timestamp. */
|
|
2
|
+
export declare function dispatchRecordPath(projectRoot: string, sid: string, rid: string, ts?: Date): string;
|
|
3
|
+
/** The directory under which dispatch records live. */
|
|
4
|
+
export declare function dispatchRecordsDir(projectRoot: string, sid: string): string;
|
|
5
|
+
/**
|
|
6
|
+
* Assert that `recordPath` lives under `projectRoot/.peaks/_sub_agents/<sid>/`.
|
|
7
|
+
* Rejects symlink/junction escapes and `..` segments.
|
|
8
|
+
*
|
|
9
|
+
* Throws an Error with `.code = 'INVALID_RECORD_PATH'` on rejection so
|
|
10
|
+
* the CLI can map to `{ok: false, code: "INVALID_RECORD_PATH"}`.
|
|
11
|
+
*/
|
|
12
|
+
export declare function assertSafeDispatchRecordPath(recordPath: string, projectRoot: string): string;
|
|
@@ -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
|
+
}
|
|
@@ -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;
|