peaks-loop 4.0.17 → 4.0.19
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/CHANGELOG.md +59 -0
- package/dist/cli/commands/doctor/invoke-from-code.d.ts +10 -0
- package/dist/cli/commands/doctor/invoke-from-code.js +30 -0
- package/dist/cli/commands/primer-command.d.ts +59 -0
- package/dist/cli/commands/primer-command.js +160 -0
- package/dist/cli/commands/sub-agent/detached.d.ts +29 -0
- package/dist/cli/commands/sub-agent/detached.js +60 -0
- package/dist/cli/commands/vendor-detect.d.ts +10 -0
- package/dist/cli/commands/vendor-detect.js +16 -0
- package/dist/cli/program.js +8 -0
- package/dist/services/config/config-safety.d.ts +23 -0
- package/dist/services/config/config-safety.js +62 -0
- package/dist/services/dispatch/dispatch-record-writer.d.ts +86 -9
- package/dist/services/dispatch/dispatch-record-writer.js +58 -10
- package/dist/services/skills/hooks-settings-service.js +21 -2
- package/dist/services/skills/{outer-cache-hook-constants.d.ts → session-start-hook-constants.d.ts} +20 -0
- package/dist/services/skills/{outer-cache-hook-constants.js → session-start-hook-constants.js} +20 -0
- package/dist/services/skills/skill-statusline-renderer.d.ts +50 -1
- package/dist/services/skills/skill-statusline-renderer.js +96 -8
- package/dist/services/skills/skill-statusline-service.d.ts +41 -0
- package/dist/services/skills/skill-statusline-service.js +56 -6
- package/package.json +5 -4
- package/skills/bee/peaks-qa/SKILL.md +4 -0
- package/skills/bee/peaks-rd/SKILL.md +4 -0
- package/skills/peaks-code/SKILL.md +3 -0
- package/skills/peaks-code/references/sub-agent-dispatch.md +16 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,64 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 4.0.19 — 2026-08-11 (detached sub-agent + G8 infinite-context — single-ship)
|
|
4
|
+
|
|
5
|
+
**New feature — detached sub-agent mode (Phase A-E single-ship)**:
|
|
6
|
+
- New monorepo package `peaks-loop-internal-runtime` (npm name `peaks-loop-internal-runtime`; sibling of `peaks-loop-shared`; private; consumed via `workspace:*`).
|
|
7
|
+
- 11 new modules: `ProcessSupervisor` (Windows `DETACHED_PROCESS` + POSIX `setsid`), `LifecycleOwner` (closure invariant — 100% cleanup of pid / log.txt / status.json / owner-session on every exit path), `VendorAdapter` interface + `ClaudeAdapter` / `CodexAdapter` / `CopilotAdapter` + `VendorAdapterRegistry`, `PromptBuilder` (5-8KB minimum-context slice + forbidden-marker guard `@@@ORCHESTRATOR_SESSION_HISTORY_BOUNDARY@@@`), `StatusProtocol` (heartbeat merge + stale detection + autoCompact events), `AutoCompactAdapter` (G8 marker `<peaks-auto-compact threshold="0.85|0.95">` + scratch file parser), `ResourceBudgetGuard` (RSS ≤ 200MB / CPU ≤ 5% / fan-out ≤ 8 / `--no-throttle` user override), `dispatchDetached` orchestrator (wires all collaborators), `peaks-loop-internal-runtime/src/index.ts` (public re-exports).
|
|
8
|
+
- 2 new CLI handlers: `src/cli/commands/sub-agent/detached.ts` (`peaks sub-agent dispatch <role> --mode detached --vendor claude|codex|copilot --no-throttle --max-concurrent <N>`), `src/cli/commands/vendor-detect.ts` (`peaks vendor-detect` — reports installed vendor CLIs + recommends default), `src/cli/commands/doctor/invoke-from-code.ts` (`peaks doctor invoke --from-code` — writes proposal stub for peaks-doctor bridge).
|
|
9
|
+
- New bench: `benchmarks/runtime-detached/{baseline.ts, resource-budget-bench.ts}`. Idle sample: rssMb=79, cpuPct=0.34 (passes §5.3 ceiling rails).
|
|
10
|
+
|
|
11
|
+
**G8 infinite-context auto-compact protocol (Phase A critical path)**:
|
|
12
|
+
- Child vendor LLM receives `<peaks-auto-compact>` marker in minimum-context prompt.
|
|
13
|
+
- At ≥ 0.85 of vendor window: child writes scratch file `.peaks/_runtime/<sid>/detached/<rid>/compact/<n>.json` (summary + decisionsKept + discardedOptions) + calls `peaks runtime write-compact-event` to append `autoCompactEvents[]` to dispatch record.
|
|
14
|
+
- At ≥ 0.95: synchronous compact + status.json `note: 'compact-emergency'` + peaks main process notification.
|
|
15
|
+
- **Unlimited spend authorized**: G8 red line; `tokenUsage` recorded on dispatch record for audit visibility only (no alert, no kill, no user ask).
|
|
16
|
+
|
|
17
|
+
**LifecycleOwner closure invariant — core red line**:
|
|
18
|
+
- Every exit path (success / crash / OOM-killed / SIGTERM / orchestrator session exit) MUST remove `pid` + `log.txt` + `status.json` + `owner-session` (archived to `log-archive.txt` + `status-final.json` for forensics).
|
|
19
|
+
- Orphan reaper (RL-15: user-only decision; peaks does NOT auto-kill).
|
|
20
|
+
- Unit + integration test coverage (4 lifecycle + 3 integration closure audit).
|
|
21
|
+
|
|
22
|
+
**Performance ceiling rails (spec §5.3)**:
|
|
23
|
+
- peaks runtime RSS ≤ 200 MB idle / CPU ≤ 5% idle / fan-out ≤ 8 (default).
|
|
24
|
+
- `--no-throttle --max-concurrent <N>` bypasses (user accepts risk; adds warning to envelope).
|
|
25
|
+
- `peaks sub-agent cleanup --orphan` is the only orphan-killing path.
|
|
26
|
+
|
|
27
|
+
**Publish lockstep 3 packages (gate-cli-version extension)**:
|
|
28
|
+
- `gate-cli-version` now verifies 3 lockstep dimensions: root `package.json#version` ↔ peaks-loop-shared `dist/version.js#CLI_VERSION` ↔ peaks-loop-internal-runtime `src/index.ts#RUNTIME_VERSION` (private package — NOT in publish list; runtime is consumed via workspace:* only).
|
|
29
|
+
- 2/2 lockstep test passes (`tests/unit/publish/lockstep-three-packages.test.ts`).
|
|
30
|
+
|
|
31
|
+
**Doc updates**:
|
|
32
|
+
- `skills/peaks-code/SKILL.md` + `skills/peaks-code/references/sub-agent-dispatch.md` — Detached mode section + G11.5 orchestrator prose obligation.
|
|
33
|
+
- `skills/bee/peaks-rd/SKILL.md` + `skills/bee/peaks-qa/SKILL.md` — reviewer / sub-role `--mode detached` paragraphs.
|
|
34
|
+
- `skills/peaks-code/references/lease-dashboard.html` — `detachedGraphView` empty container hook (Phase E render deferred).
|
|
35
|
+
- Spec: `docs/superpowers/specs/2026-08-10-peaks-detached-sub-agent-design.md`.
|
|
36
|
+
- Plan: `docs/superpowers/plans/2026-08-10-peaks-detached-sub-agent-plan.md`.
|
|
37
|
+
|
|
38
|
+
**Tests added**: 11 vitest files (15 unit + 3 integration suites): process-supervisor, lifecycle, vendor/{claude,codex,copilot}-adapter, prompt-builder, status-protocol, auto-compact-adapter, resource-budget, dispatch, sub-agent-detached, vendor-detect, doctor-invoke-from-code, lockstep-three-packages. 0 regressions in existing 106+ dispatch tests.
|
|
39
|
+
|
|
40
|
+
**CI fix (publish #142)**:
|
|
41
|
+
- Explicit `.js` extensions in all relative imports under `packages/peaks-loop-internal-runtime/src` (peaks-loop uses `module: NodeNext`; vitest hides this but `tsc -p tsconfig.build.json` requires `.js`).
|
|
42
|
+
- Added `"peaks-loop-internal-runtime": "workspace:*"` to root `package.json` (CLI handlers consume runtime via npm name import, not relative path into `packages/`).
|
|
43
|
+
|
|
44
|
+
**Backwards compat**: 100%. Default dispatch mode remains `in-process`; existing 106+ dispatch tests untouched; peak-loop-shared retains its 0.0.x SemVer.
|
|
45
|
+
|
|
46
|
+
**For full design context**: `docs/superpowers/specs/2026-08-10-peaks-detached-sub-agent-design.md` §0 + §3.2 + §3.5 + §5.3 + §6.3. Closure sediment: `.peaks/memory/2026-08-11-runtime-detached-4-0-19-ship-pending.md`.
|
|
47
|
+
|
|
48
|
+
## 4.0.18 — 2026-08-10 (statusline 24h overlay)
|
|
49
|
+
|
|
50
|
+
**Bug fix — statusline doesn't reflect 24h mode substate after transition**:
|
|
51
|
+
- `src/services/skills/skill-statusline-service.ts`: adds `read24hOverlay` helper (name-distinct from canonical `src/services/24h-mode/store.ts:108 read24hState` so the two readers with divergent null vs empty-snapshot semantics are not confused); adds `TwentyFourHourOverlay` type (minimal `{ state: string }`); adds `twentyFourHourState: TwentyFourHourOverlay | null` field to `StatusLineModel`; populates the field in all 5 return paths of `buildStatusLineModel` (lines for projectRoot-null, invalid-presence, idle, outer-mismatch idle, and final active/stale — only the active branch calls `read24hOverlay`).
|
|
52
|
+
- `src/services/skills/skill-statusline-renderer.ts`: adds `format24hSuffix(overlay, palette, capability, noColor)` exported helper; extends `renderActive` signature to a 7th arg `twentyFourHourState`; appends `[24h-<state.toLowerCase()>]` suffix in 3 active-return branches (attention-gate / activeLeaf / normal); updates the call site at line 822.
|
|
53
|
+
- 12 new vitest cases in `tests/unit/skills/skill-statusline-sid-only-marker.test.ts` (3 service-layer `read24hOverlay` + 1 `buildStatusLineModel` integration + 3 `format24hSuffix` helper-level + 4 renderer-integration AC-1..AC-4 + 1 malformed-shape hardening).
|
|
54
|
+
- 5 typed-fixture sites updated to include `twentyFourHourState: null` (`tests/unit/services/skills/skill-statusline-renderer.test.ts` 5 factories + `tests/unit/skills/skill-statusline-sid-only-marker.test.ts` 3 directly-constructed literals).
|
|
55
|
+
|
|
56
|
+
**Safety semantics preserved**:
|
|
57
|
+
- v2.15.0 `presence:check-stale --project . --json` still returns `stale: true` on outer-mismatch. `skill-presence-service.ts` / `presence-lease-service.ts` / `workspace-service.ts` / `session/**` / `audit/**` / `src/services/24h-mode/**` / `src/cli/commands/session-24h-mode.ts` are FORBIDDEN files in this slice — zero edits.
|
|
58
|
+
- No new dependencies. No enum/API/command-surface changes.
|
|
59
|
+
|
|
60
|
+
**Migration**: zero changes required for existing users. Active 24h mode sessions automatically pick up the new `[24h-<state>]` suffix on next statusline render after the user runs `peaks session 24h-mode transition`. Sessions without a 24h-state.json file render unchanged (back-compat).
|
|
61
|
+
|
|
3
62
|
## 4.0.17 — 2026-08-07 (Vitest worker cap + statusline SIGTERM + 9-slice shipped)
|
|
4
63
|
|
|
5
64
|
**Core fix — vitest worker concurrency cap (root-cause)**:
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase D Task 24: peaks doctor invoke --from-code CLI.
|
|
3
|
+
* Contract surface for peaks-code Step 11 → peaks-doctor bridge.
|
|
4
|
+
* Writes proposal stub to .peaks/_runtime/<sid>/doctor/proposal.md.
|
|
5
|
+
* Real LLM call is delegated to peaks-doctor (Phase D Task 24 detail).
|
|
6
|
+
* Spec: docs/superpowers/specs/2026-08-10-peaks-detached-sub-agent-design.md §3.6
|
|
7
|
+
*/
|
|
8
|
+
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
export async function doctorInvokeFromCode(opts) {
|
|
11
|
+
const dir = join('.peaks', '_runtime', opts.sid, 'doctor');
|
|
12
|
+
mkdirSync(dir, { recursive: true });
|
|
13
|
+
const proposalPath = join(dir, 'proposal.md');
|
|
14
|
+
// Stub: real implementation invokes peaks-doctor sub-skill
|
|
15
|
+
// (LLM-driven analysis of .peaks/_runtime/<sid>/txt/handoff.md
|
|
16
|
+
// + dispatch records + autoCompactEvents; emits OpenSpec proposals).
|
|
17
|
+
writeFileSync(proposalPath, [
|
|
18
|
+
'# doctor proposal (stub)',
|
|
19
|
+
'',
|
|
20
|
+
'## capability: <TBD>',
|
|
21
|
+
'## kind: <TBD>',
|
|
22
|
+
'',
|
|
23
|
+
'Real implementation: peaks-doctor LLM-driven analysis of',
|
|
24
|
+
'.peaks/_runtime/<sid>/txt/handoff.md + dispatch records.',
|
|
25
|
+
].join('\n'));
|
|
26
|
+
// Normalize to POSIX-style separators so callers (and tests) can rely
|
|
27
|
+
// on a forward-slash contract regardless of host OS.
|
|
28
|
+
const normalizedPath = proposalPath.replaceAll('\\', '/');
|
|
29
|
+
return { ok: true, command: 'doctor.invoke.from-code', data: { proposalPath: normalizedPath } };
|
|
30
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice rid-statusline-stale-ux AC-2: `peaks session primer --project <path>`.
|
|
3
|
+
*
|
|
4
|
+
* Lightweight SessionStart primer that fires rotation + presence
|
|
5
|
+
* cleanup BEFORE the first statusline render of a fresh session.
|
|
6
|
+
* Independent subcommand (NOT a flag on `peaks workspace init` per
|
|
7
|
+
* task regulation: "don't add a flag to workspace init"). Cleaner
|
|
8
|
+
* permission boundary — primer cannot be mistaken for init by users.
|
|
9
|
+
*
|
|
10
|
+
* Performs only 3 things:
|
|
11
|
+
* 1. ensureSessionWithRotation(projectRoot) — the real rotation
|
|
12
|
+
* entry point (verified at
|
|
13
|
+
* src/services/session/session-binding-bridge.ts:461-527).
|
|
14
|
+
* 2. If outer-mismatch rotation occurred: call
|
|
15
|
+
* clearStalePresenceOnRotation with the verified single-options-
|
|
16
|
+
* object signature (mirrors init-command.ts:324-329 pattern with
|
|
17
|
+
* all 3 fields populated). Note: on 4.0.11-A sid-scoped leases
|
|
18
|
+
* this is a legacy-compat no-op; the real cleanup mechanism is
|
|
19
|
+
* rotation itself (rebinding changes the bound sid so the
|
|
20
|
+
* reader looks in the new sid's empty lease dir).
|
|
21
|
+
* 3. gcStalePresenceLeases({ projectRoot, trigger: 'manual' }) —
|
|
22
|
+
* sync; documented as legacy-compat no-op on 4.0.11-A sid-scoped
|
|
23
|
+
* data.
|
|
24
|
+
*
|
|
25
|
+
* SKIPS (per RD §4.2.1):
|
|
26
|
+
* - bootstrapProjectScan
|
|
27
|
+
* - materializeClaudeSettingsLocal
|
|
28
|
+
* - resolveFirstTimeHooksInstall
|
|
29
|
+
* - applyHookInstall (prevents accidental first-launch hook install)
|
|
30
|
+
*
|
|
31
|
+
* Mounted as a CHILD of the existing `session` commander group at
|
|
32
|
+
* `src/cli/commands/core/session-command.ts:32` (verified pattern:
|
|
33
|
+
* `session.command('list')...`). NOT `program.command('session primer')`.
|
|
34
|
+
*
|
|
35
|
+
* The action handler is exported as `runPrimerAction` so the
|
|
36
|
+
* integration test suite can drive it directly without going through
|
|
37
|
+
* Commander's argv-parsing wrapper (which would mangle empty /
|
|
38
|
+
* whitespace / NUL-byte inputs before reaching the action body).
|
|
39
|
+
*/
|
|
40
|
+
import type { Command } from 'commander';
|
|
41
|
+
import type { ProgramIO } from '../cli-helpers.js';
|
|
42
|
+
export type PrimerOptions = {
|
|
43
|
+
project: string;
|
|
44
|
+
json?: boolean;
|
|
45
|
+
};
|
|
46
|
+
/**
|
|
47
|
+
* Pure action body for `peaks session primer`. Exported so
|
|
48
|
+
* integration tests can drive it directly with crafted inputs (empty
|
|
49
|
+
* string, whitespace, NUL byte) that would otherwise be rejected by
|
|
50
|
+
* Commander's argv parser.
|
|
51
|
+
*
|
|
52
|
+
* Returns `{ exitCode: 0 | 1 }` so callers (CLI path or test path)
|
|
53
|
+
* can apply the exit code uniformly. NEVER calls `process.exit` —
|
|
54
|
+
* exits are the responsibility of the CLI wrapper.
|
|
55
|
+
*/
|
|
56
|
+
export declare function runPrimerAction(opts: PrimerOptions, io: ProgramIO): Promise<{
|
|
57
|
+
exitCode: 0 | 1;
|
|
58
|
+
}>;
|
|
59
|
+
export declare function registerPrimerCommand(program: Command, io: ProgramIO): void;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slice rid-statusline-stale-ux AC-2: `peaks session primer --project <path>`.
|
|
3
|
+
*
|
|
4
|
+
* Lightweight SessionStart primer that fires rotation + presence
|
|
5
|
+
* cleanup BEFORE the first statusline render of a fresh session.
|
|
6
|
+
* Independent subcommand (NOT a flag on `peaks workspace init` per
|
|
7
|
+
* task regulation: "don't add a flag to workspace init"). Cleaner
|
|
8
|
+
* permission boundary — primer cannot be mistaken for init by users.
|
|
9
|
+
*
|
|
10
|
+
* Performs only 3 things:
|
|
11
|
+
* 1. ensureSessionWithRotation(projectRoot) — the real rotation
|
|
12
|
+
* entry point (verified at
|
|
13
|
+
* src/services/session/session-binding-bridge.ts:461-527).
|
|
14
|
+
* 2. If outer-mismatch rotation occurred: call
|
|
15
|
+
* clearStalePresenceOnRotation with the verified single-options-
|
|
16
|
+
* object signature (mirrors init-command.ts:324-329 pattern with
|
|
17
|
+
* all 3 fields populated). Note: on 4.0.11-A sid-scoped leases
|
|
18
|
+
* this is a legacy-compat no-op; the real cleanup mechanism is
|
|
19
|
+
* rotation itself (rebinding changes the bound sid so the
|
|
20
|
+
* reader looks in the new sid's empty lease dir).
|
|
21
|
+
* 3. gcStalePresenceLeases({ projectRoot, trigger: 'manual' }) —
|
|
22
|
+
* sync; documented as legacy-compat no-op on 4.0.11-A sid-scoped
|
|
23
|
+
* data.
|
|
24
|
+
*
|
|
25
|
+
* SKIPS (per RD §4.2.1):
|
|
26
|
+
* - bootstrapProjectScan
|
|
27
|
+
* - materializeClaudeSettingsLocal
|
|
28
|
+
* - resolveFirstTimeHooksInstall
|
|
29
|
+
* - applyHookInstall (prevents accidental first-launch hook install)
|
|
30
|
+
*
|
|
31
|
+
* Mounted as a CHILD of the existing `session` commander group at
|
|
32
|
+
* `src/cli/commands/core/session-command.ts:32` (verified pattern:
|
|
33
|
+
* `session.command('list')...`). NOT `program.command('session primer')`.
|
|
34
|
+
*
|
|
35
|
+
* The action handler is exported as `runPrimerAction` so the
|
|
36
|
+
* integration test suite can drive it directly without going through
|
|
37
|
+
* Commander's argv-parsing wrapper (which would mangle empty /
|
|
38
|
+
* whitespace / NUL-byte inputs before reaching the action body).
|
|
39
|
+
*/
|
|
40
|
+
import { printResult } from '../cli-helpers.js';
|
|
41
|
+
import { fail, ok } from 'peaks-loop-shared/result';
|
|
42
|
+
import { resolveCanonicalProjectRootStrict, InvalidProjectRootError } from '../../services/config/config-safety.js';
|
|
43
|
+
import { ensureSessionWithRotation } from '../../services/session/session-manager.js';
|
|
44
|
+
import { clearStalePresenceOnRotation } from '../../services/skills/skill-presence-service.js';
|
|
45
|
+
import { gcStalePresenceLeases } from '../../services/skills/presence-lease-service.js';
|
|
46
|
+
/**
|
|
47
|
+
* Pure action body for `peaks session primer`. Exported so
|
|
48
|
+
* integration tests can drive it directly with crafted inputs (empty
|
|
49
|
+
* string, whitespace, NUL byte) that would otherwise be rejected by
|
|
50
|
+
* Commander's argv parser.
|
|
51
|
+
*
|
|
52
|
+
* Returns `{ exitCode: 0 | 1 }` so callers (CLI path or test path)
|
|
53
|
+
* can apply the exit code uniformly. NEVER calls `process.exit` —
|
|
54
|
+
* exits are the responsibility of the CLI wrapper.
|
|
55
|
+
*/
|
|
56
|
+
export async function runPrimerAction(opts, io) {
|
|
57
|
+
// Explicit empty / whitespace-only guard BEFORE the strict
|
|
58
|
+
// resolver. Commander's requiredOption checks presence, not
|
|
59
|
+
// emptiness; node's `resolve('')` returns cwd (silent
|
|
60
|
+
// fall-through), so we must short-circuit explicitly.
|
|
61
|
+
if (!opts.project || opts.project.trim() === '') {
|
|
62
|
+
printResult(io, fail('session.primer', 'PRIMER_EMPTY_PROJECT', '--project <path> required and must be non-empty', { project: opts.project }, ['Pass a non-empty absolute path to --project']), opts.json);
|
|
63
|
+
return { exitCode: 1 };
|
|
64
|
+
}
|
|
65
|
+
// P1 H1 option A: use NEW strict helper. Throws on NUL /
|
|
66
|
+
// non-canonical / non-existent path. The fail-open
|
|
67
|
+
// `resolveCanonicalProjectRoot` is not safe here because primer
|
|
68
|
+
// is invoked from a SessionStart hook on a path pulled from
|
|
69
|
+
// `${CLAUDE_PROJECT_DIR}` — the env var is user-controlled and
|
|
70
|
+
// may contain path traversal payloads.
|
|
71
|
+
let projectRoot;
|
|
72
|
+
try {
|
|
73
|
+
projectRoot = resolveCanonicalProjectRootStrict(opts.project);
|
|
74
|
+
}
|
|
75
|
+
catch (error) {
|
|
76
|
+
if (error instanceof InvalidProjectRootError) {
|
|
77
|
+
printResult(io, fail('session.primer', `PRIMER_INVALID_PROJECT_ROOT_${error.reason.replace(/-/g, '_').toUpperCase()}`, error.message, { project: opts.project, reason: error.reason }, ['Pass a non-empty absolute canonical path to --project']), opts.json);
|
|
78
|
+
return { exitCode: 1 };
|
|
79
|
+
}
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
// Real rotation entry point (verified
|
|
83
|
+
// src/services/session/session-binding-bridge.ts:461-527).
|
|
84
|
+
// NOT the invented `runRotationCheck` from cycle-2 RD.
|
|
85
|
+
const rotation = await ensureSessionWithRotation(projectRoot);
|
|
86
|
+
const rotationOccurred = rotation.previousSessionId !== null
|
|
87
|
+
&& rotation.rotationReason === 'outer-session-mismatch';
|
|
88
|
+
let clearOutcome = null;
|
|
89
|
+
if (rotationOccurred) {
|
|
90
|
+
// VERIFIED single-options-object signature (see
|
|
91
|
+
// skill-presence-service.ts:584-588). Mirror the real caller
|
|
92
|
+
// pattern at init-command.ts:324-329 with all 3 fields
|
|
93
|
+
// populated. NOTE: on 4.0.11-A sid-scoped leases this is a
|
|
94
|
+
// legacy-compat no-op (clearSkillPresence only unlinks legacy
|
|
95
|
+
// single-slot files; the real cleanup mechanism is rotation
|
|
96
|
+
// itself).
|
|
97
|
+
clearOutcome = clearStalePresenceOnRotation({
|
|
98
|
+
projectRootOverride: projectRoot,
|
|
99
|
+
currentOuterSessionId: process.env.PEAKS_OUTER_SESSION_ID
|
|
100
|
+
?? process.env.CLAUDE_CODE_SESSION_ID,
|
|
101
|
+
rotatedOutSessionId: rotation.previousSessionId
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
// Sync call (verified presence-lease-service.ts:383-414) —
|
|
105
|
+
// documented as legacy-compat no-op on 4.0.11-A sid-scoped
|
|
106
|
+
// data; iterates `input.leases ?? []` and gets `[]` on the
|
|
107
|
+
// real init-command.ts:463-466 caller.
|
|
108
|
+
const gcOutcome = gcStalePresenceLeases({
|
|
109
|
+
projectRoot,
|
|
110
|
+
trigger: 'manual'
|
|
111
|
+
});
|
|
112
|
+
printResult(io, ok('session.primer', {
|
|
113
|
+
projectRoot,
|
|
114
|
+
sessionId: rotation.sessionId,
|
|
115
|
+
rotationOccurred,
|
|
116
|
+
...(rotation.previousSessionId !== null
|
|
117
|
+
? { previousSessionId: rotation.previousSessionId }
|
|
118
|
+
: {}),
|
|
119
|
+
clearOutcome,
|
|
120
|
+
gcOutcome: {
|
|
121
|
+
removed: gcOutcome.removed,
|
|
122
|
+
retained: gcOutcome.retained
|
|
123
|
+
}
|
|
124
|
+
}), opts.json);
|
|
125
|
+
return { exitCode: 0 };
|
|
126
|
+
}
|
|
127
|
+
export function registerPrimerCommand(program, io) {
|
|
128
|
+
// Reuse the existing `session` commander group registered earlier
|
|
129
|
+
// in `createProgram` (via autoRegisterAllCommands → session-command.ts:32).
|
|
130
|
+
// Creating a new `program.command('session')` here throws
|
|
131
|
+
// `cannot add command 'session' as already have command 'session'`
|
|
132
|
+
// because commander disallows duplicate top-level command names.
|
|
133
|
+
// This regression was caught in publish.yml gate-changeset step
|
|
134
|
+
// (run #134, exit 1) on the first 4.0.18 release attempt.
|
|
135
|
+
const session = program.commands.find((c) => c.name() === 'session');
|
|
136
|
+
if (!session) {
|
|
137
|
+
throw new Error('registerPrimerCommand: session group not registered; ' +
|
|
138
|
+
'autoRegisterAllCommands must run before registerPrimerCommand');
|
|
139
|
+
}
|
|
140
|
+
session
|
|
141
|
+
.command('primer')
|
|
142
|
+
.description('Lightweight SessionStart primer: rotation + presence cleanup. ' +
|
|
143
|
+
'Does NOT bootstrap project scan / materialize settings / install hooks. ' +
|
|
144
|
+
'Idempotent; safe to run on every SessionStart.')
|
|
145
|
+
.requiredOption('--project <path>', 'Project root (must be non-empty canonical path)')
|
|
146
|
+
.option('--json', 'emit a JSON envelope { ok, data } to stdout')
|
|
147
|
+
.action(async (opts) => {
|
|
148
|
+
try {
|
|
149
|
+
const { exitCode } = await runPrimerAction(opts, io);
|
|
150
|
+
if (exitCode !== 0) {
|
|
151
|
+
process.exitCode = exitCode;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
156
|
+
printResult(io, fail('session.primer', 'PRIMER_FAILED', message, { project: opts.project }, ['Verify the project path exists, is writable, and is canonical']), opts.json);
|
|
157
|
+
process.exitCode = 1;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
export interface DispatchFlags {
|
|
2
|
+
role: string;
|
|
3
|
+
prompt: string;
|
|
4
|
+
requestId: string;
|
|
5
|
+
mode?: 'in-process' | 'detached';
|
|
6
|
+
vendor?: 'claude' | 'codex' | 'copilot';
|
|
7
|
+
project: string;
|
|
8
|
+
json: boolean;
|
|
9
|
+
/** Task 11.5: bypass ResourceBudgetGuard (user accepts risk) */
|
|
10
|
+
noThrottle?: boolean;
|
|
11
|
+
/** Task 11.5: override max concurrent (default 8) */
|
|
12
|
+
maxConcurrent?: number;
|
|
13
|
+
}
|
|
14
|
+
export declare function dispatch(f: DispatchFlags): Promise<{
|
|
15
|
+
ok: boolean;
|
|
16
|
+
command: string;
|
|
17
|
+
data: {
|
|
18
|
+
mode: string;
|
|
19
|
+
vendor: "claude" | "codex" | "copilot" | undefined;
|
|
20
|
+
pid: number;
|
|
21
|
+
dispatchRecordPath: string;
|
|
22
|
+
maxConcurrent: number;
|
|
23
|
+
noThrottle: boolean;
|
|
24
|
+
orchestratorVisibleHint: string;
|
|
25
|
+
expectedCompletionSeconds: number;
|
|
26
|
+
};
|
|
27
|
+
warnings: string[];
|
|
28
|
+
nextActions: string[];
|
|
29
|
+
}>;
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase A Task 11 + 11.5: peaks sub-agent dispatch --mode detached CLI handler.
|
|
3
|
+
* Vendor-neutral detached sub-agent dispatch. Spawns real OS process via
|
|
4
|
+
* peaks-loop-internal-runtime/dispatch.dispatchDetached. --no-throttle and
|
|
5
|
+
* --max-concurrent flags bypass / scope ResourceBudgetGuard (Task 11.5).
|
|
6
|
+
*
|
|
7
|
+
* Default mode is in-process (backward compat — existing 106+ tests untouched).
|
|
8
|
+
* This handler only fires when the user explicitly passes --mode detached.
|
|
9
|
+
* Spec: docs/superpowers/specs/2026-08-10-peaks-detached-sub-agent-design.md §3.1 §5.3
|
|
10
|
+
*/
|
|
11
|
+
import { dispatchDetached, ResourceBudgetGuard } from 'peaks-loop-internal-runtime';
|
|
12
|
+
export async function dispatch(f) {
|
|
13
|
+
if (f.mode !== 'detached') {
|
|
14
|
+
throw new Error('src/cli/commands/sub-agent/detached.ts only handles --mode detached; ' +
|
|
15
|
+
'peaks sub-agent dispatch default mode remains in-process (backward compat)');
|
|
16
|
+
}
|
|
17
|
+
// Task 11.5: ResourceBudgetGuard gate
|
|
18
|
+
const maxConcurrent = f.maxConcurrent ?? 8;
|
|
19
|
+
const guard = new ResourceBudgetGuard({ maxRssMb: 200, maxCpuPct: 5 });
|
|
20
|
+
const enforce = guard.enforce({ active: 1 }, { maxConcurrent });
|
|
21
|
+
const warnings = [];
|
|
22
|
+
if (enforce.throttle && !f.noThrottle) {
|
|
23
|
+
throw new Error('RESOURCE_BUDGET_THROTTLED: concurrent fan-out > max-concurrent; pass --no-throttle to bypass');
|
|
24
|
+
}
|
|
25
|
+
if (f.noThrottle) {
|
|
26
|
+
warnings.push('user-overrode: --no-throttle (peak runtime may exceed performance ceiling)');
|
|
27
|
+
}
|
|
28
|
+
const sid = process.env.PEAKS_SESSION_ID ?? 'local';
|
|
29
|
+
const r = await dispatchDetached({
|
|
30
|
+
sid,
|
|
31
|
+
rid: f.requestId,
|
|
32
|
+
role: f.role,
|
|
33
|
+
vendor: (f.vendor ?? 'claude'),
|
|
34
|
+
userTask: f.prompt,
|
|
35
|
+
files: [],
|
|
36
|
+
refs: [],
|
|
37
|
+
runtimeDir: `.peaks/_runtime/${sid}/detached`,
|
|
38
|
+
subAgentsDir: `.peaks/_sub_agents/${sid}`,
|
|
39
|
+
});
|
|
40
|
+
return {
|
|
41
|
+
ok: true,
|
|
42
|
+
command: 'sub-agent.dispatch.detached',
|
|
43
|
+
data: {
|
|
44
|
+
mode: 'detached',
|
|
45
|
+
vendor: f.vendor,
|
|
46
|
+
pid: r.pid,
|
|
47
|
+
dispatchRecordPath: r.dispatchRecordPath,
|
|
48
|
+
maxConcurrent,
|
|
49
|
+
noThrottle: f.noThrottle ?? false,
|
|
50
|
+
orchestratorVisibleHint: `⏳ Spawning detached sub-agent via ${f.vendor ?? 'claude'}: rid=${f.requestId} (ETA ~60s)`,
|
|
51
|
+
expectedCompletionSeconds: 60,
|
|
52
|
+
},
|
|
53
|
+
warnings,
|
|
54
|
+
nextActions: [
|
|
55
|
+
'Sub-agent runs as detached OS process. Status at .peaks/_runtime/<sid>/detached/<rid>/status.json',
|
|
56
|
+
'Use `peaks sub-agent list --mode detached` to monitor.',
|
|
57
|
+
'Run `peaks sub-agent cleanup --orphan` to reap orphan processes (RL-15: user-only decision).',
|
|
58
|
+
],
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase B Task 20: peaks vendor-detect CLI.
|
|
3
|
+
* Reports which vendor CLIs are installed on PATH + recommends default.
|
|
4
|
+
* Spec: docs/superpowers/specs/2026-08-10-peaks-detached-sub-agent-design.md §3.3
|
|
5
|
+
*/
|
|
6
|
+
import { defaultRegistry } from 'peaks-loop-internal-runtime';
|
|
7
|
+
export async function vendorDetect(opts) {
|
|
8
|
+
const reg = defaultRegistry();
|
|
9
|
+
const list = reg.list();
|
|
10
|
+
const installed = [];
|
|
11
|
+
for (const a of list)
|
|
12
|
+
if (await a.detectInstalled())
|
|
13
|
+
installed.push(a.id);
|
|
14
|
+
const recommended = installed[0] ?? null;
|
|
15
|
+
return { ok: true, command: 'vendor-detect', data: { installed, recommended } };
|
|
16
|
+
}
|
package/dist/cli/program.js
CHANGED
|
@@ -16,6 +16,7 @@ import { registerCronSchedulerCommand } from './commands/cron-scheduler-commands
|
|
|
16
16
|
import { registerWorkspaceCommands } from './commands/workspace-commands.js';
|
|
17
17
|
import { registerSopCommands } from './commands/sop-commands.js';
|
|
18
18
|
import { registerSkillVisibilityCommand } from './commands/skill-visibility.js';
|
|
19
|
+
import { registerPrimerCommand } from './commands/primer-command.js';
|
|
19
20
|
import { applyRetention, cleanupEccCache } from '../services/log/retention.js';
|
|
20
21
|
import { writeLogEntry, maybeWriteStderr } from '../services/log/logger.js';
|
|
21
22
|
import { printSuperCommandCatalog } from './cli-helpers.js';
|
|
@@ -209,5 +210,12 @@ Run peaks (no arguments) for a quickstart. You likely want one of:
|
|
|
209
210
|
// registerCoreAndArtifactCommands instead of being created twice.
|
|
210
211
|
autoRegisterAllCommands(program, io);
|
|
211
212
|
registerSkillVisibilityCommand(program, repoRoot);
|
|
213
|
+
// Slice rid-statusline-stale-ux AC-2: register `peaks session primer`
|
|
214
|
+
// so it appears in `peaks session --help` for LLM `<TAB>`-discovery.
|
|
215
|
+
// Mounted as a CHILD of the existing `session` commander group
|
|
216
|
+
// (verified at src/cli/commands/core/session-command.ts:32). NOT
|
|
217
|
+
// `program.command('session primer')` (that registers a single
|
|
218
|
+
// literal command name, not a child of the session group).
|
|
219
|
+
registerPrimerCommand(program, io);
|
|
212
220
|
return program;
|
|
213
221
|
}
|
|
@@ -28,6 +28,29 @@ export declare function resolveProjectRootForConfig(startPath: string): string;
|
|
|
28
28
|
* `git rev-parse` exit; both fall through to the heuristic.
|
|
29
29
|
*/
|
|
30
30
|
export declare function resolveCanonicalProjectRoot(startPath: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Slice rid-statusline-stale-ux AC-2 + P1 H1 option A: strict
|
|
33
|
+
* canonicalization for use in trust-boundary paths (e.g.
|
|
34
|
+
* `peaks session primer`).
|
|
35
|
+
*
|
|
36
|
+
* Unlike `resolveCanonicalProjectRoot` (fail-open, returns `start`
|
|
37
|
+
* on any resolution failure), this helper THROWS on:
|
|
38
|
+
* - empty / whitespace-only input
|
|
39
|
+
* - NUL byte in input
|
|
40
|
+
* - non-existent path (realpathSync ENOENT)
|
|
41
|
+
* - non-canonical (symlink) input (when the input path and its
|
|
42
|
+
* realpath differ in a way that suggests the user passed a
|
|
43
|
+
* symlinked path through a security-sensitive surface)
|
|
44
|
+
*
|
|
45
|
+
* Existing fail-open `resolveCanonicalProjectRoot` is retained for
|
|
46
|
+
* back-compat — many callers depend on the fail-open behavior
|
|
47
|
+
* (e.g. `init-command.ts:32`). The strict variant is opt-in.
|
|
48
|
+
*/
|
|
49
|
+
export declare class InvalidProjectRootError extends Error {
|
|
50
|
+
readonly reason: 'empty' | 'nul-byte' | 'non-existent' | 'non-canonical';
|
|
51
|
+
constructor(reason: 'empty' | 'nul-byte' | 'non-existent' | 'non-canonical', input: string);
|
|
52
|
+
}
|
|
53
|
+
export declare function resolveCanonicalProjectRootStrict(startPath: string): string;
|
|
31
54
|
export declare function getProjectConfigPath(projectRoot: string | null): string | null;
|
|
32
55
|
export declare function getProjectBootstrapConfigPath(projectRoot: string): string;
|
|
33
56
|
export declare function validateProjectBootstrapConfigPathForWrite(projectRoot: string, configPath: string): void;
|
|
@@ -131,6 +131,68 @@ export function resolveCanonicalProjectRoot(startPath) {
|
|
|
131
131
|
}
|
|
132
132
|
return start;
|
|
133
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Slice rid-statusline-stale-ux AC-2 + P1 H1 option A: strict
|
|
136
|
+
* canonicalization for use in trust-boundary paths (e.g.
|
|
137
|
+
* `peaks session primer`).
|
|
138
|
+
*
|
|
139
|
+
* Unlike `resolveCanonicalProjectRoot` (fail-open, returns `start`
|
|
140
|
+
* on any resolution failure), this helper THROWS on:
|
|
141
|
+
* - empty / whitespace-only input
|
|
142
|
+
* - NUL byte in input
|
|
143
|
+
* - non-existent path (realpathSync ENOENT)
|
|
144
|
+
* - non-canonical (symlink) input (when the input path and its
|
|
145
|
+
* realpath differ in a way that suggests the user passed a
|
|
146
|
+
* symlinked path through a security-sensitive surface)
|
|
147
|
+
*
|
|
148
|
+
* Existing fail-open `resolveCanonicalProjectRoot` is retained for
|
|
149
|
+
* back-compat — many callers depend on the fail-open behavior
|
|
150
|
+
* (e.g. `init-command.ts:32`). The strict variant is opt-in.
|
|
151
|
+
*/
|
|
152
|
+
export class InvalidProjectRootError extends Error {
|
|
153
|
+
reason;
|
|
154
|
+
constructor(reason, input) {
|
|
155
|
+
super(`Invalid project root (${reason}): "${input}"`);
|
|
156
|
+
this.reason = reason;
|
|
157
|
+
this.name = 'InvalidProjectRootError';
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
export function resolveCanonicalProjectRootStrict(startPath) {
|
|
161
|
+
if (!startPath || startPath.trim() === '') {
|
|
162
|
+
throw new InvalidProjectRootError('empty', startPath);
|
|
163
|
+
}
|
|
164
|
+
if (startPath.indexOf('\0') !== -1) {
|
|
165
|
+
throw new InvalidProjectRootError('nul-byte', startPath);
|
|
166
|
+
}
|
|
167
|
+
const start = resolve(startPath);
|
|
168
|
+
let realStart;
|
|
169
|
+
try {
|
|
170
|
+
realStart = realpathSync(start);
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
throw new InvalidProjectRootError('non-existent', startPath);
|
|
174
|
+
}
|
|
175
|
+
if (realStart !== start) {
|
|
176
|
+
// User passed a path through a symlink; reject as non-canonical
|
|
177
|
+
// for trust-boundary entry points.
|
|
178
|
+
throw new InvalidProjectRootError('non-canonical', startPath);
|
|
179
|
+
}
|
|
180
|
+
// Delegate to the existing canonicalization (git root → heuristic)
|
|
181
|
+
// AFTER the strict pre-checks pass.
|
|
182
|
+
const canonical = resolveCanonicalProjectRoot(startPath);
|
|
183
|
+
if (canonical === startPath || canonical === realStart) {
|
|
184
|
+
return canonical;
|
|
185
|
+
}
|
|
186
|
+
// Canonicalization moved the path (e.g. to a git root) — accept it
|
|
187
|
+
// but ensure it also exists.
|
|
188
|
+
try {
|
|
189
|
+
realpathSync(canonical);
|
|
190
|
+
return canonical;
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
throw new InvalidProjectRootError('non-existent', startPath);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
134
196
|
function resolveProjectRootFromGit(startPath) {
|
|
135
197
|
// execFileSync (not execSync) so a malicious `startPath` cannot
|
|
136
198
|
// inject argv into the spawned git invocation. The child only
|