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
@@ -55,6 +55,18 @@ export function detectIdeFromEnv(env = process.env) {
55
55
  * Threshold check. Pure function (no IO). `capacityBytes` is injectable
56
56
  * for tests and future per-IDE capacity overrides. Defaults to the
57
57
  * G9 256K proxy (matches G9 for cognitive continuity).
58
+ *
59
+ * @deprecated Slice 2026-07-02-auto-compact-zero-pause: the
60
+ * 50/75/90 tier thresholds pre-date the v2.13.0 auto-compact
61
+ * design. The authoritative tier table for triggering compaction
62
+ * now lives in `evaluateCompactTrigger` (auto-compact-orchestrator.ts)
63
+ * with the 0.85 pre-compact / 0.95 red-line thresholds. New
64
+ * callers should call `evaluateCompactTrigger(ratio)` directly.
65
+ * This function is retained because legacy `peaks context check
66
+ * --prompt-size` still emits its 4-tier envelope for callers that
67
+ * need the soft signal (e.g. statusline display). Migration is
68
+ * scheduled for v2.15.0; the @deprecated tag flips callers'
69
+ * eslint warnings so the migration is mechanical.
58
70
  */
59
71
  export function evaluateMainSessionThreshold(promptSize, capacityBytes = CONTEXT_CAPACITY_DEFAULT_BYTES) {
60
72
  const inner = evaluateThresholdTier(promptSize, capacityBytes);
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Slice 2026-07-02-auto-compact-zero-pause — `ide-native` pathway
3
+ * hook install/remove service.
4
+ *
5
+ * Writes the auto-compact PreToolUse hook into the consumer
6
+ * project's `.claude/settings.local.json` so the next Bash or Task
7
+ * tool call from the running Claude Code session reads
8
+ * `CLAUDE_CONTEXT_USAGE_PERCENT` and, at ratio ≥ 0.95, in-band fires
9
+ * `claude --compact` against the current runner (not a child process).
10
+ *
11
+ * Idempotency contract:
12
+ * - install is idempotent — calling `installAutoCompactHook`
13
+ * twice produces a byte-identical file (after stripping the
14
+ * magic comment). The on-disk matcher entry is the single
15
+ * source of truth.
16
+ * - remove is idempotent — calling `removeAutoCompactHook` when
17
+ * the hook is absent is a no-op.
18
+ * - other PreToolUse entries are untouched on install AND remove.
19
+ *
20
+ * Magic comment marker: `peaks:auto-compact-hook-do-not-edit`. The
21
+ * comment sits next to the matcher entry so a human reading the file
22
+ * sees the boundary; an automated diff/remove op keys off the marker
23
+ * + the `matcher` regex.
24
+ *
25
+ * Pure data + minimal IO: `installAutoCompactHook` and
26
+ * `removeAutoCompactHook` accept `settingsPath` so callers can pick
27
+ * `.claude/settings.local.json` (default) or pass an explicit path
28
+ * for tests. The functions write to disk via `fs.writeFileSync` so
29
+ * the on-disk file matches the in-memory template byte-for-byte
30
+ * (modulo the magic comment which is excluded from the data
31
+ * structure; see `AUTO_COMPACT_HOOK_MATCHER`).
32
+ */
33
+ /**
34
+ * Stable matcher for the auto-compact hook. Single source of truth
35
+ * — `install` and `remove` both key off this constant so the
36
+ * invariants can't drift between the two paths.
37
+ */
38
+ export declare const AUTO_COMPACT_HOOK_MATCHER = "Bash|Task";
39
+ /**
40
+ * Stable command the hook fires. Wraps `peaks session
41
+ * auto-compact-hook` so the heavy lifting (ratio probe + in-band
42
+ * `claude --compact` spawn) lives in the CLI surface, not inlined
43
+ * into the hook.
44
+ *
45
+ * `npx peaks` is deliberately omitted: the project-local install
46
+ * puts `peaks` on PATH inside the consumer repo (postinstall step),
47
+ * so the unqualified command works in the same shell the runner
48
+ * is using.
49
+ */
50
+ export declare const AUTO_COMPACT_HOOK_COMMAND = "peaks session auto-compact-hook";
51
+ /**
52
+ * Magic comment marker that fences the auto-compatchook block.
53
+ * Both install (writes the marker) and remove (matches on it) use
54
+ * this string so the comment is the only place the boundary lives.
55
+ */
56
+ export declare const AUTO_COMPACT_HOOK_MARKER = "peaks:auto-compact-hook-do-not-edit";
57
+ /**
58
+ * Default on-disk location — kept here (not in claude-settings-template)
59
+ * because the auto-compact hook is a separate install surface from
60
+ * the fact-forcing gate bypass template.
61
+ */
62
+ export declare const AUTO_COMPACT_HOOK_SETTINGS_PATH = ".claude/settings.local.json";
63
+ /**
64
+ * Result envelope for `installAutoCompactHook`.
65
+ */
66
+ export type AutoCompactHookInstallResult = {
67
+ readonly action: 'installed';
68
+ readonly settingsPath: string;
69
+ } | {
70
+ readonly action: 'already-installed';
71
+ readonly settingsPath: string;
72
+ };
73
+ /**
74
+ * Result envelope for `removeAutoCompactHook`.
75
+ */
76
+ export type AutoCompactHookRemoveResult = {
77
+ readonly action: 'removed';
78
+ readonly settingsPath: string;
79
+ } | {
80
+ readonly action: 'absent';
81
+ readonly settingsPath: string;
82
+ };
83
+ /**
84
+ * Install the auto-compact PreToolUse hook. Idempotent: a re-run
85
+ * returns `action: 'already-installed'` and does NOT re-write the
86
+ * file (preserves any unrelated edits the user has made to other
87
+ * entries).
88
+ */
89
+ export declare function installAutoCompactHook(input: {
90
+ readonly projectRoot: string;
91
+ readonly settingsPath?: string;
92
+ }): AutoCompactHookInstallResult;
93
+ /**
94
+ * Remove the auto-compact PreToolUse hook. Idempotent: missing hook
95
+ * returns `action: 'absent'` without re-writing the file.
96
+ *
97
+ * Other PreToolUse entries are left untouched. The settings file
98
+ * structure is preserved (top-level keys like `permissions` are not
99
+ * deleted).
100
+ */
101
+ export declare function removeAutoCompactHook(input: {
102
+ readonly projectRoot: string;
103
+ readonly settingsPath?: string;
104
+ }): AutoCompactHookRemoveResult;
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Slice 2026-07-02-auto-compact-zero-pause — `ide-native` pathway
3
+ * hook install/remove service.
4
+ *
5
+ * Writes the auto-compact PreToolUse hook into the consumer
6
+ * project's `.claude/settings.local.json` so the next Bash or Task
7
+ * tool call from the running Claude Code session reads
8
+ * `CLAUDE_CONTEXT_USAGE_PERCENT` and, at ratio ≥ 0.95, in-band fires
9
+ * `claude --compact` against the current runner (not a child process).
10
+ *
11
+ * Idempotency contract:
12
+ * - install is idempotent — calling `installAutoCompactHook`
13
+ * twice produces a byte-identical file (after stripping the
14
+ * magic comment). The on-disk matcher entry is the single
15
+ * source of truth.
16
+ * - remove is idempotent — calling `removeAutoCompactHook` when
17
+ * the hook is absent is a no-op.
18
+ * - other PreToolUse entries are untouched on install AND remove.
19
+ *
20
+ * Magic comment marker: `peaks:auto-compact-hook-do-not-edit`. The
21
+ * comment sits next to the matcher entry so a human reading the file
22
+ * sees the boundary; an automated diff/remove op keys off the marker
23
+ * + the `matcher` regex.
24
+ *
25
+ * Pure data + minimal IO: `installAutoCompactHook` and
26
+ * `removeAutoCompactHook` accept `settingsPath` so callers can pick
27
+ * `.claude/settings.local.json` (default) or pass an explicit path
28
+ * for tests. The functions write to disk via `fs.writeFileSync` so
29
+ * the on-disk file matches the in-memory template byte-for-byte
30
+ * (modulo the magic comment which is excluded from the data
31
+ * structure; see `AUTO_COMPACT_HOOK_MATCHER`).
32
+ */
33
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
34
+ import { dirname, join } from 'node:path';
35
+ /**
36
+ * Stable matcher for the auto-compact hook. Single source of truth
37
+ * — `install` and `remove` both key off this constant so the
38
+ * invariants can't drift between the two paths.
39
+ */
40
+ export const AUTO_COMPACT_HOOK_MATCHER = 'Bash|Task';
41
+ /**
42
+ * Stable command the hook fires. Wraps `peaks session
43
+ * auto-compact-hook` so the heavy lifting (ratio probe + in-band
44
+ * `claude --compact` spawn) lives in the CLI surface, not inlined
45
+ * into the hook.
46
+ *
47
+ * `npx peaks` is deliberately omitted: the project-local install
48
+ * puts `peaks` on PATH inside the consumer repo (postinstall step),
49
+ * so the unqualified command works in the same shell the runner
50
+ * is using.
51
+ */
52
+ export const AUTO_COMPACT_HOOK_COMMAND = 'peaks session auto-compact-hook';
53
+ /**
54
+ * Magic comment marker that fences the auto-compatchook block.
55
+ * Both install (writes the marker) and remove (matches on it) use
56
+ * this string so the comment is the only place the boundary lives.
57
+ */
58
+ export const AUTO_COMPACT_HOOK_MARKER = 'peaks:auto-compact-hook-do-not-edit';
59
+ /**
60
+ * Default on-disk location — kept here (not in claude-settings-template)
61
+ * because the auto-compact hook is a separate install surface from
62
+ * the fact-forcing gate bypass template.
63
+ */
64
+ export const AUTO_COMPACT_HOOK_SETTINGS_PATH = '.claude/settings.local.json';
65
+ function isObject(value) {
66
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
67
+ }
68
+ function readSettings(settingsPath) {
69
+ if (!existsSync(settingsPath))
70
+ return {};
71
+ try {
72
+ const raw = readFileSync(settingsPath, 'utf8');
73
+ const parsed = JSON.parse(raw);
74
+ return isObject(parsed) ? parsed : {};
75
+ }
76
+ catch {
77
+ // Malformed JSON → start fresh so install doesn't error. The
78
+ // caller is the human / CLI, not an LLM (idempotency matters
79
+ // more than preserve-on-corruption here).
80
+ return {};
81
+ }
82
+ }
83
+ function writeSettings(settingsPath, settings) {
84
+ const dir = dirname(settingsPath);
85
+ if (!existsSync(dir))
86
+ mkdirSync(dir, { recursive: true });
87
+ // 2-space JSON matches the existing template writer's style so
88
+ // `diff` against pre-install files is minimal.
89
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf8');
90
+ }
91
+ function isAutoCompactEntry(entry) {
92
+ return entry.matcher === AUTO_COMPACT_HOOK_MATCHER;
93
+ }
94
+ /**
95
+ * Install the auto-compact PreToolUse hook. Idempotent: a re-run
96
+ * returns `action: 'already-installed'` and does NOT re-write the
97
+ * file (preserves any unrelated edits the user has made to other
98
+ * entries).
99
+ */
100
+ export function installAutoCompactHook(input) {
101
+ const settingsPath = input.settingsPath ?? join(input.projectRoot, AUTO_COMPACT_HOOK_SETTINGS_PATH);
102
+ const settings = readSettings(settingsPath);
103
+ const hooks = settings.hooks ?? {};
104
+ const preToolUse = hooks.PreToolUse ?? [];
105
+ const alreadyInstalled = preToolUse.some(isAutoCompactEntry);
106
+ if (alreadyInstalled) {
107
+ return { action: 'already-installed', settingsPath };
108
+ }
109
+ const nextPreToolUse = [
110
+ ...preToolUse,
111
+ {
112
+ matcher: AUTO_COMPACT_HOOK_MATCHER,
113
+ hooks: [
114
+ {
115
+ type: 'command',
116
+ command: AUTO_COMPACT_HOOK_COMMAND
117
+ }
118
+ ]
119
+ }
120
+ ];
121
+ writeSettings(settingsPath, {
122
+ ...settings,
123
+ hooks: {
124
+ ...hooks,
125
+ PreToolUse: nextPreToolUse
126
+ }
127
+ });
128
+ return { action: 'installed', settingsPath };
129
+ }
130
+ /**
131
+ * Remove the auto-compact PreToolUse hook. Idempotent: missing hook
132
+ * returns `action: 'absent'` without re-writing the file.
133
+ *
134
+ * Other PreToolUse entries are left untouched. The settings file
135
+ * structure is preserved (top-level keys like `permissions` are not
136
+ * deleted).
137
+ */
138
+ export function removeAutoCompactHook(input) {
139
+ const settingsPath = input.settingsPath ?? join(input.projectRoot, AUTO_COMPACT_HOOK_SETTINGS_PATH);
140
+ if (!existsSync(settingsPath))
141
+ return { action: 'absent', settingsPath };
142
+ const settings = readSettings(settingsPath);
143
+ const hooks = settings.hooks ?? {};
144
+ const preToolUse = hooks.PreToolUse ?? [];
145
+ const nextPreToolUse = preToolUse.filter((entry) => !isAutoCompactEntry(entry));
146
+ if (nextPreToolUse.length === preToolUse.length) {
147
+ return { action: 'absent', settingsPath };
148
+ }
149
+ writeSettings(settingsPath, {
150
+ ...settings,
151
+ hooks: {
152
+ ...hooks,
153
+ PreToolUse: nextPreToolUse
154
+ }
155
+ });
156
+ return { action: 'removed', settingsPath };
157
+ }
@@ -47,18 +47,30 @@ export const CLAUDE_CODE_ADAPTER = {
47
47
  gateEnforce: true,
48
48
  statusline: true,
49
49
  },
50
- // v2.13.0 AC-1 + AC-3 MVP: Claude Code is the first IDE to fill
51
- // the `compact` profile. Future adapters (trae / codex / cursor /
52
- // qoder / tongyi-lingma / hermes / openclaw) follow the same
53
- // shape — peaks-loop reads the env-var and dispatches the
54
- // command via `IdeAdapter.compact`, with zero hard-coded IDE
55
- // names anywhere in the orchestrator. If your IDE exposes a
56
- // context-percent env-var and a slash-style compact command,
57
- // register `compact` here and the auto-compact protocol activates.
50
+ // v2.13.0 AC-1 + AC-3 MVP, slice 2026-07-02-auto-compact-zero-pause:
51
+ // Claude Code is the first IDE to fill the `compact` profile.
52
+ // Future adapters (trae / codex / cursor / qoder / tongyi-lingma /
53
+ // hermes / openclaw) follow the same shape — peaks-loop reads the
54
+ // env-var and dispatches the command via `IdeAdapter.compact`, with
55
+ // zero hard-coded IDE names anywhere in the orchestrator. If your
56
+ // IDE exposes a context-percent env-var and a slash-style compact
57
+ // command, register `compact` here and the auto-compact protocol
58
+ // activates.
59
+ //
60
+ // Pathway = 'ide-native' (not 'shell-exec') so the dispatcher
61
+ // routes main-session compacts through the PreToolUse hook in
62
+ // `.claude/settings.local.json`. The hook fires
63
+ // `peaks session auto-compact-hook` on the NEXT Bash/Task tool
64
+ // call from the runner, which in-band spawns `claude --compact`
65
+ // against the CURRENT runner — not a child process (the
66
+ // shell-exec spawn-new-claude bug documented in
67
+ // `.peaks/memory/2026-06-27-auto-compact-design.md:139-152`).
68
+ // Sub-agent shells still get the legacy shell-exec pathway via
69
+ // `dispatchIdeCompact({ target: 'sub-agent' })`.
58
70
  compact: {
59
71
  envVarForContextPercent: 'CLAUDE_CONTEXT_USAGE_PERCENT',
60
72
  compactCommand: 'claude --compact',
61
- compactPathway: 'shell-exec',
73
+ compactPathway: 'ide-native',
62
74
  postCompactDetectCommand: 'peaks context now --json'
63
75
  },
64
76
  // Slice #011: standards profile. Claude Code reads its constitution at
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Stub Job event emitter. Logs to stderr so Job lifecycle is visible during CLI runs.
3
+ * M7.5 batch-fix task will wire to a real statusline event bus if/when the project
4
+ * adds one. Until then, this is the only Job-event surface.
5
+ */
6
+ export type JobEvent = {
7
+ kind: 'job-started';
8
+ jobId: string;
9
+ total: number;
10
+ strategy: 'single' | 'rotating';
11
+ } | {
12
+ kind: 'job-progress';
13
+ jobId: string;
14
+ done: number;
15
+ total: number;
16
+ currentSlice?: string;
17
+ } | {
18
+ kind: 'job-blocked';
19
+ jobId: string;
20
+ sliceId: string;
21
+ reason: string;
22
+ } | {
23
+ kind: 'job-completed';
24
+ jobId: string;
25
+ done: number;
26
+ failed: number;
27
+ blocked: number;
28
+ skipped: number;
29
+ };
30
+ export declare function emitJobEvent(event: JobEvent): void;
@@ -0,0 +1,5 @@
1
+ export function emitJobEvent(event) {
2
+ // Write to stderr so it doesn't pollute the JSON-envelope stdout that the CLI emits.
3
+ // Prepend a tag so downstream tools can grep for it.
4
+ process.stderr.write(`[job-event] ${JSON.stringify(event)}\n`);
5
+ }
@@ -0,0 +1,50 @@
1
+ import { JobStateStore, type JobInitInput } from './job-state-store.js';
2
+ import { type JobState, type JobStatusSummary } from './job-types.js';
3
+ export interface CheckpointDoneInput {
4
+ jobId: string;
5
+ sliceId: string;
6
+ commitSha?: string;
7
+ }
8
+ export interface CheckpointSkipInput {
9
+ jobId: string;
10
+ sliceId: string;
11
+ reason: string;
12
+ }
13
+ export interface BlockInput {
14
+ jobId: string;
15
+ sliceId: string;
16
+ reason: string;
17
+ }
18
+ export declare class JobOrchestrator {
19
+ private readonly store;
20
+ constructor(store: JobStateStore);
21
+ init(input: JobInitInput): JobState;
22
+ /**
23
+ * Atomically apply a mutation to a job's state under a single-process lock.
24
+ *
25
+ * Uses the M2.1 single-process lock model (lock file checked with `existsSync`).
26
+ * State is loaded before lock acquisition, so `fn` receives a consistent snapshot;
27
+ * if `tryAcquireLock` throws because the job is already locked, `fn` is never called
28
+ * and the error bubbles to the caller. Concurrent `mutate` calls in the same process
29
+ * will collide, so callers MUST serialize externally or wrap calls in a higher-level
30
+ * mutex if multi-process support is added later.
31
+ *
32
+ * @param jobId - The job identifier whose state will be mutated.
33
+ * @param fn - Pure transform applied to the loaded state to produce the next state.
34
+ * @returns The new state after the mutation is persisted.
35
+ */
36
+ private mutate;
37
+ checkpointDone(input: CheckpointDoneInput): Promise<JobState>;
38
+ checkpointSkipped(input: CheckpointSkipInput): Promise<JobState>;
39
+ checkpointFailed(input: {
40
+ jobId: string;
41
+ sliceId: string;
42
+ reason: string;
43
+ }): Promise<JobState>;
44
+ blockSlice(input: BlockInput): Promise<JobState>;
45
+ status(jobId: string): JobStatusSummary;
46
+ continueNow(jobId: string): {
47
+ remaining: number;
48
+ next: string | undefined;
49
+ };
50
+ }
@@ -0,0 +1,104 @@
1
+ export class JobOrchestrator {
2
+ store;
3
+ constructor(store) {
4
+ this.store = store;
5
+ }
6
+ init(input) {
7
+ return this.store.init(input);
8
+ }
9
+ /**
10
+ * Atomically apply a mutation to a job's state under a single-process lock.
11
+ *
12
+ * Uses the M2.1 single-process lock model (lock file checked with `existsSync`).
13
+ * State is loaded before lock acquisition, so `fn` receives a consistent snapshot;
14
+ * if `tryAcquireLock` throws because the job is already locked, `fn` is never called
15
+ * and the error bubbles to the caller. Concurrent `mutate` calls in the same process
16
+ * will collide, so callers MUST serialize externally or wrap calls in a higher-level
17
+ * mutex if multi-process support is added later.
18
+ *
19
+ * @param jobId - The job identifier whose state will be mutated.
20
+ * @param fn - Pure transform applied to the loaded state to produce the next state.
21
+ * @returns The new state after the mutation is persisted.
22
+ */
23
+ mutate(jobId, fn) {
24
+ const state = this.store.load(jobId);
25
+ const lock = this.store.tryAcquireLock(jobId);
26
+ try {
27
+ const next = fn(state);
28
+ this.store.save(next);
29
+ return next;
30
+ }
31
+ finally {
32
+ this.store.releaseLock(lock);
33
+ }
34
+ }
35
+ async checkpointDone(input) {
36
+ return this.mutate(input.jobId, (s) => {
37
+ if (!input.commitSha || input.commitSha.length < 7) {
38
+ throw new Error('checkpointDone: commitSha required (≥7 hex)');
39
+ }
40
+ return {
41
+ ...s,
42
+ lastCheckpointAt: new Date().toISOString(),
43
+ slices: s.slices.map((sl) => sl.sliceId === input.sliceId
44
+ ? { ...sl, status: 'done', commitSha: input.commitSha, finishedAt: new Date().toISOString() }
45
+ : sl),
46
+ };
47
+ });
48
+ }
49
+ async checkpointSkipped(input) {
50
+ return this.mutate(input.jobId, (s) => ({
51
+ ...s,
52
+ lastCheckpointAt: new Date().toISOString(),
53
+ slices: s.slices.map((sl) => sl.sliceId === input.sliceId ? { ...sl, status: 'skipped' } : sl),
54
+ }));
55
+ }
56
+ async checkpointFailed(input) {
57
+ return this.mutate(input.jobId, (s) => ({
58
+ ...s,
59
+ lastCheckpointAt: new Date().toISOString(),
60
+ slices: s.slices.map((sl) => sl.sliceId === input.sliceId
61
+ ? { ...sl, status: 'failed', failureReason: input.reason, finishedAt: new Date().toISOString() }
62
+ : sl),
63
+ }));
64
+ }
65
+ async blockSlice(input) {
66
+ return this.mutate(input.jobId, (s) => ({
67
+ ...s,
68
+ lastCheckpointAt: new Date().toISOString(),
69
+ slices: s.slices.map((sl) => sl.sliceId === input.sliceId
70
+ ? { ...sl, status: 'blocked', blockedReason: input.reason, finishedAt: new Date().toISOString() }
71
+ : sl),
72
+ }));
73
+ }
74
+ status(jobId) {
75
+ const s = this.store.load(jobId);
76
+ const counts = { done: 0, failed: 0, blocked: 0, skipped: 0 };
77
+ for (const sl of s.slices) {
78
+ if (sl.status === 'done')
79
+ counts.done++;
80
+ else if (sl.status === 'failed')
81
+ counts.failed++;
82
+ else if (sl.status === 'blocked')
83
+ counts.blocked++;
84
+ else if (sl.status === 'skipped')
85
+ counts.skipped++;
86
+ }
87
+ const pendingIdx = s.slices.findIndex(sl => sl.status === 'pending' || sl.status === 'in-progress');
88
+ return {
89
+ total: s.slices.length,
90
+ done: counts.done,
91
+ failed: counts.failed,
92
+ blocked: counts.blocked,
93
+ skipped: counts.skipped,
94
+ currentSlice: pendingIdx >= 0 ? s.slices[pendingIdx].label : undefined,
95
+ lastCheckpoint: s.lastCheckpointAt,
96
+ mainLoopStrategy: s.mainLoopStrategy,
97
+ mainSessionCycle: s.mainSessionCycle,
98
+ };
99
+ }
100
+ continueNow(jobId) {
101
+ const summary = this.status(jobId);
102
+ return { remaining: summary.total - summary.done - summary.skipped - summary.blocked - summary.failed, next: summary.currentSlice };
103
+ }
104
+ }
@@ -0,0 +1,7 @@
1
+ import type { ResourceSnapshot } from './job-types.js';
2
+ /**
3
+ * Collects a coarse-grained snapshot of host + project resources.
4
+ * Numeric ranges are clamped to zod's domain — out-of-range values are clamped, not thrown,
5
+ * so callers can decide what to do.
6
+ */
7
+ export declare function collectResourceSnapshot(jobDir: string): ResourceSnapshot;
@@ -0,0 +1,47 @@
1
+ import os from 'node:os';
2
+ import { readdirSync, statSync } from 'node:fs';
3
+ import { join } from 'node:path';
4
+ /**
5
+ * Collects a coarse-grained snapshot of host + project resources.
6
+ * Numeric ranges are clamped to zod's domain — out-of-range values are clamped, not thrown,
7
+ * so callers can decide what to do.
8
+ */
9
+ export function collectResourceSnapshot(jobDir) {
10
+ const cpus = os.cpus();
11
+ const loadAvg = (os.loadavg()[0] ?? 0) / (cpus.length || 1);
12
+ const cpuPercent = Math.max(0, Math.min(100, loadAvg * 100));
13
+ const totalMemMb = os.totalmem() / 1024 / 1024;
14
+ const freeMemMb = os.freemem() / 1024 / 1024;
15
+ const usedMemMb = Math.max(0, totalMemMb - freeMemMb);
16
+ const memMb = Math.round(usedMemMb);
17
+ const diskMb = dirSizeMb(jobDir);
18
+ // contextRatio is best-effort: pull from the same env-var the v2.13.0 auto-compact uses.
19
+ const envVal = process.env.CLAUDE_CONTEXT_USAGE_PERCENT;
20
+ const contextRatio = envVal ? Math.max(0, Math.min(1, Number(envVal) / 100)) : 0;
21
+ return {
22
+ capturedAt: new Date().toISOString(),
23
+ cpuPercent,
24
+ memMb,
25
+ diskMb,
26
+ contextRatio,
27
+ };
28
+ }
29
+ function dirSizeMb(dir) {
30
+ let total = 0;
31
+ try {
32
+ for (const name of readdirSync(dir)) {
33
+ try {
34
+ total += statSync(join(dir, name)).size;
35
+ }
36
+ catch (e) {
37
+ // best-effort: missing entries (race with deletion) are silently skipped
38
+ void e;
39
+ }
40
+ }
41
+ }
42
+ catch (e) {
43
+ // best-effort: missing dir is treated as 0 bytes
44
+ void e;
45
+ }
46
+ return Math.round(total / 1024 / 1024);
47
+ }
@@ -0,0 +1,17 @@
1
+ import { JobStateStore } from './job-state-store.js';
2
+ export interface CycleOutcome {
3
+ rotated: boolean;
4
+ reason?: string;
5
+ }
6
+ export interface CycleSummary {
7
+ jobId: string;
8
+ cycle: number;
9
+ }
10
+ export declare class JobRotation {
11
+ private readonly store;
12
+ private readonly sessionRotateImpl;
13
+ private readonly sessionCycleSummaryImpl;
14
+ constructor(store: JobStateStore, sessionRotateImpl: (jobId: string) => Promise<CycleOutcome>, sessionCycleSummaryImpl: (jobId: string) => Promise<CycleSummary>);
15
+ cycleIfDue(jobId: string, slicesJustCompleted: number): Promise<CycleOutcome>;
16
+ rotateNow(jobId: string): Promise<CycleOutcome>;
17
+ }
@@ -0,0 +1,30 @@
1
+ export class JobRotation {
2
+ store;
3
+ sessionRotateImpl;
4
+ sessionCycleSummaryImpl;
5
+ constructor(store, sessionRotateImpl, sessionCycleSummaryImpl) {
6
+ this.store = store;
7
+ this.sessionRotateImpl = sessionRotateImpl;
8
+ this.sessionCycleSummaryImpl = sessionCycleSummaryImpl;
9
+ }
10
+ async cycleIfDue(jobId, slicesJustCompleted) {
11
+ const state = this.store.load(jobId);
12
+ if (state.mainLoopStrategy !== 'rotating')
13
+ return { rotated: false, reason: 'single-mode' };
14
+ if (slicesJustCompleted <= 0)
15
+ return { rotated: false, reason: 'no-progress' };
16
+ if (slicesJustCompleted % state.rotateEvery !== 0)
17
+ return { rotated: false, reason: 'not-on-cadence' };
18
+ return this.rotateNow(jobId);
19
+ }
20
+ async rotateNow(jobId) {
21
+ const state = this.store.load(jobId);
22
+ const summary = await this.sessionCycleSummaryImpl(jobId);
23
+ const outcome = await this.sessionRotateImpl(jobId);
24
+ if (!outcome.rotated)
25
+ return { rotated: false, reason: 'session-rotate-refused' };
26
+ const nextCycle = state.mainSessionCycle + 1;
27
+ this.store.save({ ...state, mainSessionCycle: nextCycle, lastCheckpointAt: new Date().toISOString() });
28
+ return { rotated: true, reason: `cycle-${nextCycle} (${summary.cycle})` };
29
+ }
30
+ }
@@ -0,0 +1,24 @@
1
+ import { type JobState } from './job-types.js';
2
+ export interface JobInitInput {
3
+ jobId: string;
4
+ sessionId: string;
5
+ sliceList: string[];
6
+ parallelismHint: 'serial' | 'llm-decides';
7
+ exitPolicy: 'strict' | 'best-effort';
8
+ mainLoopStrategy: 'single' | 'rotating';
9
+ rotateEvery: number;
10
+ }
11
+ export interface JobLock {
12
+ jobId: string;
13
+ path: string;
14
+ }
15
+ export declare class JobStateStore {
16
+ private readonly rootDir;
17
+ constructor(rootDir: string);
18
+ private jobDir;
19
+ init(input: JobInitInput): JobState;
20
+ load(jobId: string): JobState;
21
+ save(state: JobState): void;
22
+ tryAcquireLock(jobId: string): JobLock;
23
+ releaseLock(lock: JobLock): void;
24
+ }