peaks-loop 3.0.2 → 3.1.0

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.
Files changed (37) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README-en.md +1 -1
  3. package/README.md +1 -1
  4. package/dist/src/cli/commands/core/session-command.js +6 -0
  5. package/dist/src/cli/commands/job-commands.d.ts +3 -0
  6. package/dist/src/cli/commands/job-commands.js +236 -0
  7. package/dist/src/cli/commands/session-auto-compact-hook-command.d.ts +37 -0
  8. package/dist/src/cli/commands/session-auto-compact-hook-command.js +127 -0
  9. package/dist/src/cli/program.js +6 -0
  10. package/dist/src/services/context/auto-compact-dispatcher.js +60 -10
  11. package/dist/src/services/context/main-session-monitor.d.ts +12 -0
  12. package/dist/src/services/context/main-session-monitor.js +12 -0
  13. package/dist/src/services/hooks/auto-compact-hook-install.d.ts +104 -0
  14. package/dist/src/services/hooks/auto-compact-hook-install.js +157 -0
  15. package/dist/src/services/ide/adapters/claude-code-adapter.js +21 -9
  16. package/dist/src/services/job/job-event-emitter.d.ts +30 -0
  17. package/dist/src/services/job/job-event-emitter.js +5 -0
  18. package/dist/src/services/job/job-orchestrator.d.ts +50 -0
  19. package/dist/src/services/job/job-orchestrator.js +104 -0
  20. package/dist/src/services/job/job-resource-snapshot.d.ts +7 -0
  21. package/dist/src/services/job/job-resource-snapshot.js +47 -0
  22. package/dist/src/services/job/job-rotation.d.ts +17 -0
  23. package/dist/src/services/job/job-rotation.js +30 -0
  24. package/dist/src/services/job/job-state-store.d.ts +24 -0
  25. package/dist/src/services/job/job-state-store.js +60 -0
  26. package/dist/src/services/job/job-types.d.ts +444 -0
  27. package/dist/src/services/job/job-types.js +87 -0
  28. package/dist/src/services/job/subagent-job-wrapper.d.ts +35 -0
  29. package/dist/src/services/job/subagent-job-wrapper.js +30 -0
  30. package/dist/src/shared/version.d.ts +1 -1
  31. package/dist/src/shared/version.js +1 -1
  32. package/package.json +1 -1
  33. package/skills/peaks-solo/SKILL.md +91 -77
  34. package/skills/peaks-solo/references/completion-handoff.md +9 -4
  35. package/skills/peaks-solo/references/job-loop.md +68 -0
  36. package/skills/peaks-solo/references/periodic-checkpoint.md +11 -1
  37. package/skills/peaks-solo/references/runbook.md +18 -2
package/CHANGELOG.md CHANGED
@@ -1,5 +1,71 @@
1
1
  # Changelog
2
2
 
3
+ ## 3.1.0 — 2026-07-03
4
+
5
+ ### Fixed — peaks-solo external-references authority declaration
6
+
7
+ - `skills/peaks-solo/SKILL.md` External-references paragraph now ends with `Peaks-Loop Solo gates and artifacts remain authoritative.` to satisfy `tests/unit/skill-external-invocation.test.ts:63` (`PEAKS_AUTHORITATIVE_PATTERN`). One-line surgical patch; no behaviour change.
8
+
9
+ ### Added — Peaks-Loop Job
10
+
11
+ - New `peaks job *` subcommand family: init / status / checkpoint / continue / resume / block / handoff / rotate-now / subagent-cleanup (9 commands).
12
+ - New CLI flag `--main-loop-strategy single|rotating` on `job init`, with rotating-mode hard default for ≥3 slices and `rotateEvery=3` cadence.
13
+ - New `--watch` poll mode + statusline event stub (`emitJobEvent`) for ambient progress visibility. Real statusline wire-up deferred to a follow-up slice.
14
+ - Sub-agent wrapper `SubAgentJobWrapper` enforces `--budget-mb 512` default + cleanup gate before slice checkpoint.
15
+ - New peaks-solo SKILL.md Steps 0.8 / 0.81 / 0.85 / 0.86 / 0.87 wrapping the existing single-rid runbook for multi-slice jobs.
16
+ - 9 hard-red-line rules embedded in the skill prose (cost re-ask ban, slice-coalesce ban, fake-completion ban, detached-mode ban, cleanup-skip ban, rotate-skip ban, etc).
17
+
18
+ ### New services
19
+
20
+ - `src/services/job/job-types.ts` — Zod schemas + types for SliceState, JobState, ResourceSnapshot, JobStatusSummary, plus CLI input schemas (JobInitInput, JobCheckpointInput, JobBlockInput).
21
+ - `src/services/job/job-state-store.ts` — on-disk state store with per-job lockfile.
22
+ - `src/services/job/job-orchestrator.ts` — state machine with strict / best-effort exit policies.
23
+ - `src/services/job/job-rotation.ts` — main-session rotation cadence (single / rotating mode).
24
+ - `src/services/job/subagent-job-wrapper.ts` — budget + cleanup gate.
25
+ - `src/services/job/job-resource-snapshot.ts` — cpu / mem / disk / context collector.
26
+ - `src/services/job/job-event-emitter.ts` — stderr-stub event emit (real statusline wire-up pending).
27
+
28
+ ### Migration
29
+
30
+ - Existing single-rid flows are unchanged. The Job is opt-in via peaks-solo Step 0.8 detection; users who do not invoke multi-slice requests see no behavior difference.
31
+ - The `peaks session rotate` and `peaks session cycle-summary` subcommands referenced in the spec are NOT shipped in v1; the rotation path uses constructor-injected stub callbacks. M6.5 follow-up will add the real subcommands.
32
+
33
+ ### Spec / plan
34
+
35
+ - Design: `docs/superpowers/specs/2026-07-03-peaks-loop-job-design.md` (v3).
36
+ - Plans: `docs/superpowers/plans/2026-07-03-peaks-job/` (M1-M7 + README).
37
+
38
+ ## [3.0.3] — 2026-07-02 — auto-compact zero-pause (ide-native pathway)
39
+
40
+ **PATCH bump from 3.0.2**. Closes the user-pause gap on long Claude Code sessions: at context ratio ≥ 0.95 the runner now compacts itself via a PreToolUse hook, no human intervention required.
41
+
42
+ ### Added — `ide-native` pathway + `peaks session auto-compact-hook`
43
+
44
+ The v2.13.0 auto-compact design shipped `shell-exec` as the only pathway, which spawns a NEW claude child process and cannot compact the current runner. Long-task users hit a UX gap: AI CLI still stopped and asked the human to run `/compact` manually.
45
+
46
+ - **New `ide-native` pathway** — `src/services/context/auto-compact-dispatcher.ts:138-155` routes main-session compacts through `installAutoCompactHook`, which writes a PreToolUse hook into `.claude/settings.local.json` (matcher `Bash|Task`, command `peaks session auto-compact-hook`).
47
+ - **New `peaks session auto-compact-hook` CLI** — `src/cli/commands/session-auto-compact-hook-command.ts`. Reads `CLAUDE_CONTEXT_USAGE_PERCENT`; below 0.95 exits 0 silent; at ratio ≥ 0.95 spawns `claude --compact` in-band against the **current** runner (detached + unref, never blocks the runner's tool call). ENOENT-safe: if `claude` is not on PATH (e.g. user runs Claude Code as an MCP), the hook logs a stderr hint and exits 0 silent rather than crashing the runner.
48
+ - **New `installAutoCompactHook` / `removeAutoCompactHook` service** — `src/services/hooks/auto-compact-hook-install.ts`. Idempotent: re-install returns `already-installed` and does NOT re-write the file; remove preserves other PreToolUse entries (the existing fact-forcing bypass hook on every peaks-loop consumer project survives).
49
+ - **Lazy install** — hook is installed on first `peaks solo auto-compact` invocation, NOT on `peaks workspace init`. User has opted in by running the command; no zero-touch surprise.
50
+ - **Claude Code adapter** — `compactPathway` changed from `shell-exec` to `ide-native` for `target='main'`. Sub-agent shells still get the legacy `shell-exec` pathway.
51
+
52
+ ### Fixed — Solo Step N+2 prose (was 75% / "do NOT auto-execute")
53
+
54
+ - `skills/peaks-solo/SKILL.md` Step N+2 paragraph rewritten: thresholds now 0.85 pre-compact / 0.95 red-line (matches v2.13.0 auto-compact), explicit Karpathy §4 compact-red-line exception cited, `peaks solo context-now --json` is the canonical probe primitive (replaces `peaks context check --prompt-size <bytes>` hand-pass).
55
+ - `src/services/context/main-session-monitor.ts` — `@deprecated` JSDoc on `evaluateMainSessionThreshold` cross-links to `evaluateCompactTrigger` (0.85/0.95); legacy 4-tier envelope retained for statusline callers (migration to v2.15.0).
56
+
57
+ ### Tests — 4 new suites + 3 modified files
58
+
59
+ - `tests/unit/services/hooks/auto-compact-hook-install.test.ts` (7 cases) — install / idempotent / remove / preservation of unrelated entries.
60
+ - `tests/unit/services/context/auto-compact-dispatcher-ide-native.test.ts` (9 cases) — `dispatchIdeCompact({ target: 'main' })` routes to `ide-native`; round-trip install / remove.
61
+ - `tests/unit/skills/solo-step-n-plus-2-prose.test.ts` (6 cases) — pins the 0.85 / 0.95 / Karpathy §4 contract; asserts `75%` is NOT in the paragraph.
62
+ - `tests/unit/cli/session-auto-compact-hook-command.test.ts` (3 cases) — dogfood surfacing: below-threshold silent, missing env-var silent, red-line + ENOENT exits 0 with stderr hint.
63
+ - `tests/unit/services/context/main-session-monitor.test.ts` (+2 AC-4 cases), `tests/unit/context/auto-compact-main-target.test.ts` (pathway assertion updated) — coverage.
64
+
65
+ ### Dogfood verified
66
+
67
+ Validated on `C:/Users/smallMark/Desktop/peaksclaw/ice-cola` (consumer project). End-to-end: install hook alongside existing fact-forcing bypass → idempotent re-run → below-threshold silent → red-line + no-claude-on-PATH exits 0 with hint → remove strips only the auto-compact matcher. No regressions in 5,139 / 5,158 vitest pass.
68
+
3
69
  ## [3.0.2] — 2026-07-02 — change-id shim retirement (v2.19.0) + Understand Anything hybrid context (3.0.2)
4
70
 
5
71
  **PATCH bump from 3.0.1**. Two independent slices bundled into one release:
package/README-en.md CHANGED
@@ -137,7 +137,7 @@ peaks project dashboard --project . --json # one-shot project view
137
137
 
138
138
  **3 solo wrappers + 7 role skills + 2 standalone audits + 1 orchestrator = 13 skills.** In daily use, 1 skill (`peaks-solo`) covers ≥ 90% of needs.
139
139
 
140
- > **v2.13.0**: zero-human-intervention **auto-compact** (`peaks solo context-now` + `peaks solo auto-compact`) — peaks-loop probes context-fill % autonomously. ≥85% writes a pre-compact checkpoint + convergence plan + dispatches IDE compact. ≥95% forces a synchronous RED-LINE compact. The LLM-runner stays alive with context < 95% without human intervention. **v2.13.4 fix**: auto-compact now targets the **main-session** context (not a sub-agent shell) by writing a `.peaks/_runtime/<sid>/txt/auto-compact-pending.json` intent file that the main-session LLM picks up on its next turn and fires `/compact` in-band.
140
+ > **v2.13.0**: zero-human-intervention **auto-compact** (`peaks solo context-now` + `peaks solo auto-compact`) — peaks-loop probes context-fill % autonomously. ≥85% writes a pre-compact checkpoint + convergence plan + dispatches IDE compact. ≥95% forces a synchronous RED-LINE compact. The LLM-runner stays alive with context < 95% without human intervention. **v2.13.4 fix**: auto-compact now targets the **main-session** context (not a sub-agent shell). **3.0.3 new `ide-native` pathway**: installs a PreToolUse hook into `.claude/settings.local.json` (`Bash|Task` matcher → `peaks session auto-compact-hook`); the runner's next Bash/Task tool call in-band spawns `claude --compact`, no longer relying on the LLM to remember to fire `/compact`. Hook is ENOENT-safe (exits 0 + stderr hint if `claude` is not on PATH; runner is never interrupted).
141
141
 
142
142
  ## 🚧 Killer feature: un-bypassable gates
143
143
 
package/README.md CHANGED
@@ -175,7 +175,7 @@ peaks project dashboard --project . --json # 当前项目一眼看完
175
175
 
176
176
  **3 solo 包装 + 7 角色技能 + 2 独立审计 + 1 编排器 = 13 个。日常 1 个 `peaks-solo` 覆盖 ≥ 90%。**
177
177
 
178
- > **v2.13.0 新增**:zero-human-intervention **auto-compact**(`peaks solo context-now` + `peaks solo auto-compact`)——peaks-loop 自己探测 context %,≥85% 主动收敛(写 checkpoint + 收敛计划 + 触发 IDE compact),≥95% 强制同步压缩红线。LLM-runner 上下文始终 < 95% 自主运行,零介入。**v2.13.4 修复**:`auto-compact` 现在默认压缩**主会话**上下文(不是 sub-agent shell 自己的),通过写 `.peaks/_runtime/<sid>/txt/auto-compact-pending.json` 意图文件让主 LLM 下一轮 in-band 触发 `/compact`。
178
+ > **v2.13.0 新增**:zero-human-intervention **auto-compact**(`peaks solo context-now` + `peaks solo auto-compact`)——peaks-loop 自己探测 context %,≥85% 主动收敛(写 checkpoint + 收敛计划 + 触发 IDE compact),≥95% 强制同步压缩红线。LLM-runner 上下文始终 < 95% 自主运行,零介入。**v2.13.4 修复**:`auto-compact` 现在默认压缩**主会话**上下文(不是 sub-agent shell 自己的)。**3.0.3 新增 `ide-native` 通路**:在 `.claude/settings.local.json` 装 PreToolUse hook(`Bash|Task` matcher → `peaks session auto-compact-hook`),runner 下一次 Bash/Task tool call **in-band** 触发 `claude --compact`,不再依赖 LLM 自己 fire `/compact`。Hook ENOENT-safe(claude CLI 不在 PATH 时退出 0 + stderr 提示,runner 不会被中断)。
179
179
 
180
180
  ## 🚧 杀手锏:不可绕过的门禁
181
181
 
@@ -13,6 +13,7 @@ import { addJsonOption, getErrorMessage, printResult } from '../../cli-helpers.j
13
13
  // saw them — they were effectively hidden.
14
14
  import { registerSessionCheckpointCommand } from '../session-checkpoint-command.js';
15
15
  import { registerSessionResumeCommand } from '../session-resume-command.js';
16
+ import { registerSessionAutoCompactHookCommand } from '../session-auto-compact-hook-command.js';
16
17
  export function registerSessionCommand(program, io) {
17
18
  const session = program.command('session').description('Manage Peaks session directories');
18
19
  addJsonOption(session
@@ -186,6 +187,11 @@ export function registerSessionCommand(program, io) {
186
187
  // `<TAB>`-discovery and the LLM never saw them.
187
188
  registerSessionCheckpointCommand(session, io);
188
189
  registerSessionResumeCommand(session, io);
190
+ // Slice 2026-07-02-auto-compact-zero-pause: the Bash command the
191
+ // PreToolUse hook in `.claude/settings.local.json` fires. Keeping
192
+ // it eager (same fix as checkpoint + resume) so `peaks session --help`
193
+ // lists it for LLM `<TAB>`-discovery.
194
+ registerSessionAutoCompactHookCommand(session);
189
195
  addJsonOption(session
190
196
  .command('rotate')
191
197
  .description('Drop the project-level session binding so the next peaks call auto-generates a fresh session id. The on-disk session directory is left intact — only .peaks/.session.json is removed.')
@@ -0,0 +1,3 @@
1
+ import { Command } from 'commander';
2
+ import { type ProgramIO } from '../cli-helpers.js';
3
+ export declare function registerJobCommands(program: Command, io?: ProgramIO): void;
@@ -0,0 +1,236 @@
1
+ // src/cli/commands/job-commands.ts
2
+ import { join } from 'node:path';
3
+ import { Command } from 'commander';
4
+ import { fail, ok } from '../../shared/result.js';
5
+ import { addJsonOption, printResult } from '../cli-helpers.js';
6
+ import { JobStateStore } from '../../services/job/job-state-store.js';
7
+ import { JobOrchestrator } from '../../services/job/job-orchestrator.js';
8
+ import { JobRotation } from '../../services/job/job-rotation.js';
9
+ import { SubAgentJobWrapper } from '../../services/job/subagent-job-wrapper.js';
10
+ import { emitJobEvent } from '../../services/job/job-event-emitter.js';
11
+ import { JobInitInputSchema, JobCheckpointInputSchema, JobBlockInputSchema, } from '../../services/job/job-types.js';
12
+ import { getCurrentSessionId } from '../../services/skills/skill-presence-service.js';
13
+ function projectRoot(opts) {
14
+ // Reuse the workspace root resolver from peaks CLI; for now, CWD as a safe placeholder.
15
+ return opts.project ?? process.cwd();
16
+ }
17
+ /**
18
+ * Resolves the on-disk root for Job state files.
19
+ *
20
+ * Per spec §3.3 + §4.5 (2.7.1 single-scope-axis layout), Job state lives at:
21
+ * `<projectRoot>/.peaks/_runtime/<sessionId>/job/<jobId>/state.json`
22
+ *
23
+ * The `JobStateStore` itself only knows its `rootDir` + `jobId` and joins them. We
24
+ * compute the canonical root here (per-call) so the store can stay layout-agnostic.
25
+ *
26
+ * Resolution order:
27
+ * 1. `--session-id` flag (explicit override)
28
+ * 2. `getCurrentSessionId(project)` — reads `.peaks/_runtime/session.json` per peaks-solo
29
+ * 3. Error (NO_ACTIVE_SESSION) — must never silently fall back to a random uuid
30
+ */
31
+ function resolveJobStateRoot(opts) {
32
+ const project = projectRoot(opts);
33
+ const sessionId = opts.sessionId ?? getCurrentSessionId(project);
34
+ if (!sessionId) {
35
+ throw new Error('NO_ACTIVE_SESSION: peaks job requires --session-id or an active peaks-solo session via peaks workspace init');
36
+ }
37
+ const rootDir = join(project, '.peaks', '_runtime', sessionId, 'job');
38
+ return { rootDir, sessionId, projectRoot: project };
39
+ }
40
+ export function registerJobCommands(program, io = { stdout: (t) => process.stdout.write(t), stderr: (t) => process.stderr.write(t) }) {
41
+ const job = new Command('job').description('Drive long multi-slice work as one Job (peaks-solo Step 0.8+)');
42
+ job
43
+ .command('init')
44
+ .requiredOption('--job-id <jid>')
45
+ .requiredOption('--slice-list <list>')
46
+ .option('--parallelism-hint <serial|llm-decides>', 'llm-decides')
47
+ .option('--exit-policy <strict|best-effort>', 'strict')
48
+ .option('--main-loop-strategy <single|rotating>', 'rotating')
49
+ .option('--rotate-every <n>', 'rotate every N slices (rotating mode)', '3')
50
+ .option('--session-id <sid>', 'session id (default: read from .peaks/_runtime/session.json; required to land in the 2.7.1 single-scope-axis layout)')
51
+ .option('--project <repo>')
52
+ .action(async (opts) => {
53
+ const project = projectRoot(opts);
54
+ // Resolve sessionId: explicit flag > canonical session binding > FAIL.
55
+ // Per spec §3.3, Job state lives at .peaks/_runtime/<sessionId>/job/<jobId>/state.json —
56
+ // a random UUID would scatter state across dirs and break resume/auto-compact.
57
+ let sessionId = opts.sessionId ?? getCurrentSessionId(project);
58
+ if (!sessionId) {
59
+ return printResult(io, fail('init', 'NO_ACTIVE_SESSION', 'peaks job init requires --session-id (or an active peaks-solo session via peaks workspace init)', { project }, [
60
+ 'Re-run with --session-id <sid>',
61
+ 'Or run `peaks workspace init` to create a session first'
62
+ ]), opts);
63
+ }
64
+ const parsed = JobInitInputSchema.safeParse({
65
+ jobId: opts.jobId,
66
+ sessionId,
67
+ sliceList: opts.sliceList.split(',').map((s) => s.trim()).filter(Boolean),
68
+ parallelismHint: opts.parallelismHint,
69
+ exitPolicy: opts.exitPolicy,
70
+ mainLoopStrategy: opts.mainLoopStrategy,
71
+ rotateEvery: Number(opts.rotateEvery),
72
+ project,
73
+ json: opts.json,
74
+ });
75
+ if (!parsed.success)
76
+ return printResult(io, fail('init', 'INVALID_INIT', parsed.error.message, {}), opts);
77
+ const jobRoot = resolveJobStateRoot(opts);
78
+ const store = new JobStateStore(jobRoot.rootDir);
79
+ const orch = new JobOrchestrator(store);
80
+ const state = orch.init({
81
+ jobId: parsed.data.jobId,
82
+ sessionId: parsed.data.sessionId,
83
+ sliceList: parsed.data.sliceList,
84
+ parallelismHint: parsed.data.parallelismHint,
85
+ exitPolicy: parsed.data.exitPolicy,
86
+ mainLoopStrategy: parsed.data.mainLoopStrategy,
87
+ rotateEvery: parsed.data.rotateEvery,
88
+ });
89
+ try {
90
+ emitJobEvent({ kind: 'job-started', jobId: state.jobId, total: state.slices.length, strategy: state.mainLoopStrategy });
91
+ }
92
+ catch (e) {
93
+ // best-effort: event emission failures must not abort job init
94
+ void e;
95
+ }
96
+ printResult(io, ok('init', { jobId: state.jobId, sliceCount: state.slices.length, statePath: `${jobRoot.rootDir}/${state.jobId}/state.json` }), opts);
97
+ });
98
+ addJsonOption(job.commands.find(c => c.name() === 'init'));
99
+ job
100
+ .command('status')
101
+ .requiredOption('--job-id <jid>')
102
+ .option('--watch', 'poll every 3s')
103
+ .option('--show-cost', 'overlay cost from peaks budget')
104
+ .option('--project <repo>')
105
+ .action(async (opts) => {
106
+ const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
107
+ const orch = new JobOrchestrator(store);
108
+ const s = orch.status(opts.jobId);
109
+ if (opts.watch) {
110
+ const draw = () => {
111
+ const bar = `[${'='.repeat(s.done)}${' '.repeat(s.total - s.done)}]`;
112
+ process.stdout.write(`\rjob ${opts.jobId}: ${bar} ${s.done}/${s.total}${s.currentSlice ? ` next=${s.currentSlice}` : ''} `);
113
+ };
114
+ draw();
115
+ const iv = setInterval(() => { const u = orch.status(opts.jobId); Object.assign(s, u); draw(); if (u.done + u.failed + u.skipped + u.blocked >= u.total)
116
+ clearInterval(iv); }, 3000);
117
+ process.on('SIGINT', () => { clearInterval(iv); process.stdout.write('\n'); process.exit(0); });
118
+ return;
119
+ }
120
+ try {
121
+ emitJobEvent({ kind: 'job-progress', jobId: opts.jobId, done: s.done, total: s.total, ...(s.currentSlice ? { currentSlice: s.currentSlice } : {}) });
122
+ }
123
+ catch (e) {
124
+ // best-effort: event emission failures must not abort job status
125
+ void e;
126
+ }
127
+ printResult(io, ok('status', s), opts);
128
+ });
129
+ addJsonOption(job.commands.find(c => c.name() === 'status'));
130
+ // M4.2: wire rotate-now to JobRotation (session-rotate callbacks are stubs pending M6.5 batch-fix).
131
+ job.command('rotate-now')
132
+ .requiredOption('--job-id <jid>')
133
+ .option('--project <repo>')
134
+ .action(async (opts) => {
135
+ const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
136
+ const rotation = new JobRotation(store, async (_jid) => { /* delegate to peaks session rotate — implementation wired in M6.5 batch-fix */ return { rotated: true }; }, async (jid) => ({ jobId: jid, cycle: 0 }));
137
+ const r = await rotation.rotateNow(opts.jobId);
138
+ printResult(io, ok('rotate-now', r), opts);
139
+ });
140
+ addJsonOption(job.commands.find(c => c.name() === 'rotate-now'));
141
+ job.command('subagent-cleanup')
142
+ .requiredOption('--job-id <jid>')
143
+ .requiredOption('--batch-id <bid>')
144
+ .option('--force')
145
+ .option('--project <repo>')
146
+ .action(async (opts) => {
147
+ const wrapper = new SubAgentJobWrapper(new JobStateStore(resolveJobStateRoot(opts).rootDir), async () => ({ batchId: opts.batchId }));
148
+ const r = await wrapper.cleanup({ jobId: opts.jobId, batchId: opts.batchId, force: !!opts.force });
149
+ printResult(io, ok('subagent-cleanup', r), opts);
150
+ });
151
+ addJsonOption(job.commands.find(c => c.name() === 'subagent-cleanup'));
152
+ // M3.2: wire the remaining 5 subcommand slots — block, checkpoint, continue, handoff, resume.
153
+ job
154
+ .command('checkpoint')
155
+ .requiredOption('--job-id <jid>')
156
+ .requiredOption('--slice-id <rid>')
157
+ .requiredOption('--state <done|failed|skipped>')
158
+ .option('--commit-sha <sha>')
159
+ .option('--reason <text>')
160
+ .option('--project <repo>')
161
+ .action(async (opts) => {
162
+ const parsed = JobCheckpointInputSchema.safeParse({
163
+ jobId: opts.jobId, sliceId: opts.sliceId, state: opts.state,
164
+ commitSha: opts.commitSha, reason: opts.reason,
165
+ project: projectRoot(opts), json: opts.json,
166
+ });
167
+ if (!parsed.success)
168
+ return printResult(io, fail('checkpoint', 'INVALID_CHECKPOINT', parsed.error.message, {}), opts);
169
+ const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
170
+ const orch = new JobOrchestrator(store);
171
+ if (parsed.data.state === 'done') {
172
+ await orch.checkpointDone({ jobId: parsed.data.jobId, sliceId: parsed.data.sliceId, ...(parsed.data.commitSha ? { commitSha: parsed.data.commitSha } : {}) });
173
+ }
174
+ else if (parsed.data.state === 'skipped') {
175
+ await orch.checkpointSkipped({ jobId: parsed.data.jobId, sliceId: parsed.data.sliceId, reason: parsed.data.reason });
176
+ }
177
+ else {
178
+ await orch.checkpointFailed({ jobId: parsed.data.jobId, sliceId: parsed.data.sliceId, reason: parsed.data.reason });
179
+ }
180
+ printResult(io, ok('checkpoint', { sliceId: parsed.data.sliceId, status: parsed.data.state }), opts);
181
+ });
182
+ addJsonOption(job.commands.find(c => c.name() === 'checkpoint'));
183
+ job
184
+ .command('block')
185
+ .requiredOption('--job-id <jid>')
186
+ .requiredOption('--slice-id <rid>')
187
+ .requiredOption('--reason <text>')
188
+ .option('--project <repo>')
189
+ .action(async (opts) => {
190
+ const parsed = JobBlockInputSchema.safeParse({
191
+ jobId: opts.jobId, sliceId: opts.sliceId, reason: opts.reason,
192
+ project: projectRoot(opts), json: opts.json,
193
+ });
194
+ if (!parsed.success)
195
+ return printResult(io, fail('block', 'INVALID_BLOCK', parsed.error.message, {}), opts);
196
+ const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
197
+ const orch = new JobOrchestrator(store);
198
+ await orch.blockSlice(parsed.data);
199
+ printResult(io, ok('block', { blocked: parsed.data.sliceId, reason: parsed.data.reason }), opts);
200
+ });
201
+ addJsonOption(job.commands.find(c => c.name() === 'block'));
202
+ job
203
+ .command('continue')
204
+ .requiredOption('--job-id <jid>')
205
+ .option('--project <repo>')
206
+ .action(async (opts) => {
207
+ const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
208
+ const orch = new JobOrchestrator(store);
209
+ const r = orch.continueNow(opts.jobId);
210
+ printResult(io, ok('continue', r), opts);
211
+ });
212
+ addJsonOption(job.commands.find(c => c.name() === 'continue'));
213
+ job
214
+ .command('resume')
215
+ .requiredOption('--job-id <jid>')
216
+ .option('--project <repo>')
217
+ .action(async (opts) => {
218
+ const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
219
+ const orch = new JobOrchestrator(store);
220
+ const s = orch.status(opts.jobId);
221
+ printResult(io, ok('resume', { resumed: opts.jobId, ...s }), opts);
222
+ });
223
+ addJsonOption(job.commands.find(c => c.name() === 'resume'));
224
+ job
225
+ .command('handoff')
226
+ .requiredOption('--job-id <jid>')
227
+ .option('--project <repo>')
228
+ .action(async (opts) => {
229
+ const store = new JobStateStore(resolveJobStateRoot(opts).rootDir);
230
+ const orch = new JobOrchestrator(store);
231
+ const s = orch.status(opts.jobId);
232
+ printResult(io, ok('handoff', { handoffFor: opts.jobId, ...s }), opts);
233
+ });
234
+ addJsonOption(job.commands.find(c => c.name() === 'handoff'));
235
+ program.addCommand(job);
236
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `peaks session auto-compact-hook` — slice 2026-07-02-auto-compact-zero-pause.
3
+ *
4
+ * The Bash entrypoint the PreToolUse hook fires on every Bash/Task
5
+ * tool call from the Claude Code runner. Behaviour:
6
+ *
7
+ * 1. Read `CLAUDE_CONTEXT_USAGE_PERCENT` (or any adapter-declared
8
+ * env-var via the registry — claude-code MVP). If ratio is below
9
+ * the red-line threshold (0.95), exit 0 silent — the runner's
10
+ * normal flow continues.
11
+ * 2. If ratio is ≥ 0.95 (red line), write a one-line stderr hint
12
+ * so the human sees what happened, then in-band spawn
13
+ * `claude --compact` so the **current** runner's context
14
+ * window collapses (not a new claude process).
15
+ * 3. Always exit 0. The hook is a probe, not a gate — the gate is
16
+ * `claude --compact` itself returning 0.
17
+ *
18
+ * Idempotency: re-running the hook is safe; if `claude --compact`
19
+ * is already in flight, the new process just exits 0 immediately
20
+ * (Claude Code's compact is a one-shot operation against the
21
+ * current runner).
22
+ *
23
+ * Why a CLI subcommand instead of an inline JS hook:
24
+ * - Keeps the hook payload in `.claude/settings.local.json` small
25
+ * (one line: `peaks session auto-compact-hook`).
26
+ * - Lets the heavy lifting (ratio parsing, IDE-aware env-var
27
+ * resolution, spawning `claude --compact`) live in TypeScript
28
+ * where it's unit-testable.
29
+ * - Discoverable via `peaks session --help` — future LLM sessions
30
+ * see the primitive exists.
31
+ *
32
+ * Karpathy §2: 50 lines, no abstractions, no speculative error
33
+ * handling. The hook command is a single-purpose muscle; if it
34
+ * grows beyond ~80 lines, that's a signal to split.
35
+ */
36
+ import type { Command } from 'commander';
37
+ export declare function registerSessionAutoCompactHookCommand(session: Command): void;
@@ -0,0 +1,127 @@
1
+ /**
2
+ * `peaks session auto-compact-hook` — slice 2026-07-02-auto-compact-zero-pause.
3
+ *
4
+ * The Bash entrypoint the PreToolUse hook fires on every Bash/Task
5
+ * tool call from the Claude Code runner. Behaviour:
6
+ *
7
+ * 1. Read `CLAUDE_CONTEXT_USAGE_PERCENT` (or any adapter-declared
8
+ * env-var via the registry — claude-code MVP). If ratio is below
9
+ * the red-line threshold (0.95), exit 0 silent — the runner's
10
+ * normal flow continues.
11
+ * 2. If ratio is ≥ 0.95 (red line), write a one-line stderr hint
12
+ * so the human sees what happened, then in-band spawn
13
+ * `claude --compact` so the **current** runner's context
14
+ * window collapses (not a new claude process).
15
+ * 3. Always exit 0. The hook is a probe, not a gate — the gate is
16
+ * `claude --compact` itself returning 0.
17
+ *
18
+ * Idempotency: re-running the hook is safe; if `claude --compact`
19
+ * is already in flight, the new process just exits 0 immediately
20
+ * (Claude Code's compact is a one-shot operation against the
21
+ * current runner).
22
+ *
23
+ * Why a CLI subcommand instead of an inline JS hook:
24
+ * - Keeps the hook payload in `.claude/settings.local.json` small
25
+ * (one line: `peaks session auto-compact-hook`).
26
+ * - Lets the heavy lifting (ratio parsing, IDE-aware env-var
27
+ * resolution, spawning `claude --compact`) live in TypeScript
28
+ * where it's unit-testable.
29
+ * - Discoverable via `peaks session --help` — future LLM sessions
30
+ * see the primitive exists.
31
+ *
32
+ * Karpathy §2: 50 lines, no abstractions, no speculative error
33
+ * handling. The hook command is a single-purpose muscle; if it
34
+ * grows beyond ~80 lines, that's a signal to split.
35
+ */
36
+ import { spawn } from 'node:child_process';
37
+ import { detectIdeFromEnv } from '../../services/context/main-session-monitor.js';
38
+ import { getAdapter } from '../../services/ide/ide-registry.js';
39
+ /**
40
+ * Red-line ratio threshold. Must match the orchestrator's
41
+ * AUTO_COMPACT_RED_LINE_RATIO constant (0.95). Kept here as a
42
+ * literal so the hook command stays self-contained — a future
43
+ * slice may move this to a shared constant module if the value
44
+ * starts drifting.
45
+ */
46
+ const RED_LINE_RATIO = 0.95;
47
+ /**
48
+ * Parse `CLAUDE_CONTEXT_USAGE_PERCENT` (or the adapter-declared
49
+ * env-var) into a ratio in [0, 1]. Returns null on missing /
50
+ * malformed input. Mirrors `readEnvPercent` in
51
+ * `auto-compact-reader.ts` so the hook command and the
52
+ * orchestrator agree on parsing semantics.
53
+ */
54
+ function readRatioFromEnv(env) {
55
+ const detected = detectIdeFromEnv(env);
56
+ const ideId = (detected === 'unknown' ? 'claude-code' : detected);
57
+ const adapter = getAdapter(ideId);
58
+ if (!adapter.compact)
59
+ return null;
60
+ const varName = adapter.compact.envVarForContextPercent;
61
+ const raw = env[varName];
62
+ if (typeof raw !== 'string' || raw.length === 0)
63
+ return null;
64
+ const parsed = Number.parseFloat(raw);
65
+ if (!Number.isFinite(parsed))
66
+ return null;
67
+ // Allow either "0..1" or "0..100" encoding. Claude Code writes
68
+ // "0..1" today; statusline tools sometimes write "0..100". Normalize.
69
+ const ratio = parsed > 1.5 ? parsed / 100 : Math.max(0, Math.min(1, parsed));
70
+ return { ratio, varName };
71
+ }
72
+ export function registerSessionAutoCompactHookCommand(session) {
73
+ session
74
+ .command('auto-compact-hook')
75
+ .description('PreToolUse hook entrypoint — read CLAUDE_CONTEXT_USAGE_PERCENT and, ' +
76
+ 'at ratio ≥ 0.95, in-band spawn `claude --compact` against the current ' +
77
+ 'runner. Designed for invocation from `.claude/settings.local.json` ' +
78
+ 'PreToolUse hooks; safe to re-run.')
79
+ .action(() => {
80
+ const probe = readRatioFromEnv(process.env);
81
+ if (probe === null) {
82
+ // No signal → exit 0 silent. The hook must never block the
83
+ // runner's normal flow on a missing env-var.
84
+ return;
85
+ }
86
+ if (probe.ratio < RED_LINE_RATIO) {
87
+ // Below red line → exit 0 silent. The orchestrator's
88
+ // pre-compact zone (0.85–0.95) is handled out-of-band by
89
+ // the LLM firing `peaks solo auto-compact --execute`; the
90
+ // hook only fires on the synchronous red-line path.
91
+ return;
92
+ }
93
+ // Red line reached — compact the current runner in-band.
94
+ // The `detached: true` + `unref()` pair lets the hook return
95
+ // immediately while Claude Code's own slash-command processor
96
+ // picks up `claude --compact` against the runner's session.
97
+ // This is the fix for the shell-exec-spawns-a-child-process
98
+ // bug documented in `.peaks/memory/2026-06-27-auto-compact-design.md:139-152`.
99
+ //
100
+ // Dogfood 2026-07-02 fix: when `claude` is not on PATH (the
101
+ // user runs Claude Code as an MCP rather than via the CLI
102
+ // binary), `spawn` raises ENOENT. Catching this MUST NOT crash
103
+ // the runner's tool call — the hook must exit 0 silently with
104
+ // a one-line stderr hint so the runner keeps working. The user
105
+ // can then run `/compact` manually (or `peaks solo auto-compact`
106
+ // from another terminal).
107
+ try {
108
+ const child = spawn('claude', ['--compact'], {
109
+ stdio: 'ignore',
110
+ detached: true,
111
+ env: process.env
112
+ });
113
+ child.on('error', () => {
114
+ // Spawn failed (ENOENT or EPERM). Exit 0 silently with a
115
+ // stderr hint — the hook contract is "never crash the
116
+ // runner's tool call".
117
+ process.stderr.write('[peaks:auto-compact-hook] claude CLI not on PATH; cannot fire in-band. Run `/compact` manually or set up the Claude Code CLI binary.\n');
118
+ });
119
+ child.unref();
120
+ process.stderr.write(`[peaks:auto-compact-hook] ratio=${(probe.ratio * 100).toFixed(1)}% ≥ ${(RED_LINE_RATIO * 100).toFixed(0)}%; firing claude --compact in-band\n`);
121
+ }
122
+ catch {
123
+ // Defensive: `spawn` can throw synchronously on some platforms.
124
+ process.stderr.write('[peaks:auto-compact-hook] failed to spawn claude --compact; run `/compact` manually.\n');
125
+ }
126
+ });
127
+ }
@@ -74,6 +74,8 @@ import { registerObservabilityCommands } from './commands/observability-commands
74
74
  // CLI primitives. See src/services/compact/ and
75
75
  // src/cli/commands/compact-command.ts.
76
76
  import { registerCompactCommands } from './commands/compact-command.js';
77
+ // Slice 2026-07-01-job-m3.1: `peaks job init|status|...` — Job orchestration CLI surface.
78
+ import { registerJobCommands } from './commands/job-commands.js';
77
79
  import { applyRetention } from '../services/log/retention.js';
78
80
  import { writeLogEntry, maybeWriteStderr } from '../services/log/logger.js';
79
81
  export { printResult } from './cli-helpers.js';
@@ -341,5 +343,9 @@ Run peaks (no arguments) for a quickstart. You likely want one of:
341
343
  // suggest|recommend|survival|dry-run|force` — strategic-compact CLI
342
344
  // primitives. See src/cli/commands/compact-command.ts.
343
345
  registerCompactCommands(program, io);
346
+ // Slice 2026-07-01-job-m3.1: `peaks job init|status|...` — Job
347
+ // orchestration CLI surface. M3.1 wires init + status + 2 stubs;
348
+ // the remaining 5 subcommands land in M3.2.
349
+ registerJobCommands(program, io);
344
350
  return program;
345
351
  }
@@ -100,16 +100,36 @@ export async function dispatchIdeCompact(input) {
100
100
  timeoutMs
101
101
  });
102
102
  case 'ide-native':
103
- // Future slice: write the compact intent to the IDE's hook
104
- // file (per `IdeSettingsLocation`). For v2.13.0 MVP the
105
- // shell-exec pathway covers Claude Code; this branch stays
106
- // reserved.
107
- return {
108
- ok: false,
109
- ide: ideId,
110
- pathway: 'ide-native',
111
- message: `ide-native compact pathway is reserved for a future slice; adapter '${ideId}' should declare shell-exec or llm-self-compress for now.`
112
- };
103
+ // Slice 2026-07-02-auto-compact-zero-pause: write the auto-compact
104
+ // PreToolUse hook into `.claude/settings.local.json`. The hook
105
+ // command (`peaks session auto-compact-hook`) reads
106
+ // `CLAUDE_CONTEXT_USAGE_PERCENT` on every subsequent Bash/Task
107
+ // tool call from the runner and, at ratio ≥ 0.95, in-band spawns
108
+ // `claude --compact` against the CURRENT runner (not a child
109
+ // process — the bug documented in
110
+ // `.peaks/memory/2026-06-27-auto-compact-design.md:139-152`).
111
+ //
112
+ // `ide-native` is ONLY for the main-session runner. When the
113
+ // caller is a sub-agent shell (which has its own ephemeral
114
+ // context window — not the runner's), fall through to
115
+ // shell-exec so the sub-agent's `compactCommand` spawns a
116
+ // child claude process and the sub-agent's own runner doesn't
117
+ // get a PreToolUse hook installed in the wrong place.
118
+ if (target === 'sub-agent') {
119
+ return await dispatchShellExec({
120
+ ideId,
121
+ command: profile.compactCommand,
122
+ timeoutMs
123
+ });
124
+ }
125
+ // Lazy install: we only get here when the caller explicitly
126
+ // invokes `peaks solo auto-compact --execute`, so the user has
127
+ // already opted in. No zero-touch surprise on workspace init.
128
+ return await dispatchIdeNativeHook({
129
+ projectRoot: input.projectRoot,
130
+ sessionId: input.sessionId,
131
+ target
132
+ });
113
133
  case 'llm-self-compress':
114
134
  return {
115
135
  ok: true,
@@ -188,3 +208,33 @@ function dispatchShellExec(input) {
188
208
  });
189
209
  });
190
210
  }
211
+ /**
212
+ * Slice 2026-07-02-auto-compact-zero-pause: implement the
213
+ * `ide-native` pathway. Writes the auto-compact PreToolUse hook
214
+ * into `.claude/settings.local.json` (idempotent; the install
215
+ * service is a no-op if the hook is already present). On the next
216
+ * Bash/Task tool call from the runner, the hook fires
217
+ * `peaks session auto-compact-hook` which in-band spawns
218
+ * `claude --compact` against the CURRENT runner session.
219
+ *
220
+ * Returns `ok: true, pathway: 'ide-native'` regardless of install
221
+ * action (`installed` vs `already-installed`) — both states
222
+ * achieve the operational goal: the hook is wired and the next
223
+ * Bash call will trigger it.
224
+ */
225
+ async function dispatchIdeNativeHook(input) {
226
+ // Lazy dynamic import — matches the existing pattern in
227
+ // `runAutoCompact` for `auto-compact-reader.ts` (line 311) and
228
+ // avoids a static cycle if future slices add cross-imports
229
+ // between dispatcher and hook-install.
230
+ const { installAutoCompactHook } = await import('../hooks/auto-compact-hook-install.js');
231
+ const result = installAutoCompactHook({ projectRoot: input.projectRoot });
232
+ return {
233
+ ok: true,
234
+ ide: 'claude-code',
235
+ pathway: 'ide-native',
236
+ message: result.action === 'installed'
237
+ ? `Auto-compact PreToolUse hook installed at ${result.settingsPath}. Next Bash/Task tool call will read CLAUDE_CONTEXT_USAGE_PERCENT and compact in-band at ratio ≥ 95%.`
238
+ : `Auto-compact PreToolUse hook already installed at ${result.settingsPath}; next Bash/Task tool call will trigger compact in-band at ratio ≥ 95%.`
239
+ };
240
+ }
@@ -56,6 +56,18 @@ export declare function detectIdeFromEnv(env?: NodeJS.ProcessEnv): IdeKind;
56
56
  * Threshold check. Pure function (no IO). `capacityBytes` is injectable
57
57
  * for tests and future per-IDE capacity overrides. Defaults to the
58
58
  * G9 256K proxy (matches G9 for cognitive continuity).
59
+ *
60
+ * @deprecated Slice 2026-07-02-auto-compact-zero-pause: the
61
+ * 50/75/90 tier thresholds pre-date the v2.13.0 auto-compact
62
+ * design. The authoritative tier table for triggering compaction
63
+ * now lives in `evaluateCompactTrigger` (auto-compact-orchestrator.ts)
64
+ * with the 0.85 pre-compact / 0.95 red-line thresholds. New
65
+ * callers should call `evaluateCompactTrigger(ratio)` directly.
66
+ * This function is retained because legacy `peaks context check
67
+ * --prompt-size` still emits its 4-tier envelope for callers that
68
+ * need the soft signal (e.g. statusline display). Migration is
69
+ * scheduled for v2.15.0; the @deprecated tag flips callers'
70
+ * eslint warnings so the migration is mechanical.
59
71
  */
60
72
  export declare function evaluateMainSessionThreshold(promptSize: number, capacityBytes?: number): MainSessionEvaluation;
61
73
  export interface InFlightBatchProbe {