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.
- package/CHANGELOG.md +66 -0
- package/README-en.md +1 -1
- package/README.md +1 -1
- package/dist/src/cli/commands/core/session-command.js +6 -0
- package/dist/src/cli/commands/job-commands.d.ts +3 -0
- package/dist/src/cli/commands/job-commands.js +236 -0
- package/dist/src/cli/commands/session-auto-compact-hook-command.d.ts +37 -0
- package/dist/src/cli/commands/session-auto-compact-hook-command.js +127 -0
- package/dist/src/cli/program.js +6 -0
- package/dist/src/services/context/auto-compact-dispatcher.js +60 -10
- package/dist/src/services/context/main-session-monitor.d.ts +12 -0
- package/dist/src/services/context/main-session-monitor.js +12 -0
- package/dist/src/services/hooks/auto-compact-hook-install.d.ts +104 -0
- package/dist/src/services/hooks/auto-compact-hook-install.js +157 -0
- package/dist/src/services/ide/adapters/claude-code-adapter.js +21 -9
- package/dist/src/services/job/job-event-emitter.d.ts +30 -0
- package/dist/src/services/job/job-event-emitter.js +5 -0
- package/dist/src/services/job/job-orchestrator.d.ts +50 -0
- package/dist/src/services/job/job-orchestrator.js +104 -0
- package/dist/src/services/job/job-resource-snapshot.d.ts +7 -0
- package/dist/src/services/job/job-resource-snapshot.js +47 -0
- package/dist/src/services/job/job-rotation.d.ts +17 -0
- package/dist/src/services/job/job-rotation.js +30 -0
- package/dist/src/services/job/job-state-store.d.ts +24 -0
- package/dist/src/services/job/job-state-store.js +60 -0
- package/dist/src/services/job/job-types.d.ts +444 -0
- package/dist/src/services/job/job-types.js +87 -0
- package/dist/src/services/job/subagent-job-wrapper.d.ts +35 -0
- package/dist/src/services/job/subagent-job-wrapper.js +30 -0
- package/dist/src/shared/version.d.ts +1 -1
- package/dist/src/shared/version.js +1 -1
- package/package.json +1 -1
- package/skills/peaks-solo/SKILL.md +91 -77
- package/skills/peaks-solo/references/completion-handoff.md +9 -4
- package/skills/peaks-solo/references/job-loop.md +68 -0
- package/skills/peaks-solo/references/periodic-checkpoint.md +11 -1
- 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
|
|
51
|
-
//
|
|
52
|
-
//
|
|
53
|
-
// shape — peaks-loop reads the
|
|
54
|
-
// command via `IdeAdapter.compact`, with
|
|
55
|
-
// names anywhere in the orchestrator. If your
|
|
56
|
-
// context-percent env-var and a slash-style compact
|
|
57
|
-
// register `compact` here and the auto-compact protocol
|
|
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: '
|
|
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,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
|
+
}
|