@crouton-kit/crouter 0.2.5 → 0.3.1
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/dist/builtin-skills/skills/crouter-development/marketplaces/SKILL.md +9 -9
- package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +19 -19
- package/dist/cli.js +42 -37
- package/dist/commands/__tests__/human.test.d.ts +1 -0
- package/dist/commands/__tests__/human.test.js +214 -0
- package/dist/commands/__tests__/skill.test.d.ts +1 -0
- package/dist/commands/__tests__/skill.test.js +287 -0
- package/dist/commands/debug.d.ts +3 -0
- package/dist/commands/debug.js +179 -0
- package/dist/commands/flow.d.ts +2 -0
- package/dist/commands/flow.js +24 -0
- package/dist/commands/human.d.ts +2 -0
- package/dist/commands/human.js +480 -0
- package/dist/commands/job.d.ts +2 -0
- package/dist/commands/job.js +669 -0
- package/dist/commands/pkg.d.ts +2 -0
- package/dist/commands/pkg.js +1021 -0
- package/dist/commands/plan.d.ts +4 -2
- package/dist/commands/plan.js +306 -22
- package/dist/commands/skill.d.ts +2 -2
- package/dist/commands/skill.js +615 -459
- package/dist/commands/spec.d.ts +3 -2
- package/dist/commands/spec.js +283 -10
- package/dist/commands/sys.d.ts +2 -0
- package/dist/commands/sys.js +712 -0
- package/dist/core/__tests__/argv-parser.test.d.ts +1 -0
- package/dist/core/__tests__/argv-parser.test.js +199 -0
- package/dist/core/__tests__/flow-leaves.test.d.ts +1 -0
- package/dist/core/__tests__/flow-leaves.test.js +248 -0
- package/dist/core/__tests__/job.test.d.ts +1 -0
- package/dist/core/__tests__/job.test.js +346 -0
- package/dist/core/__tests__/pkg.test.d.ts +1 -0
- package/dist/core/__tests__/pkg.test.js +218 -0
- package/dist/core/__tests__/sys.test.d.ts +1 -0
- package/dist/core/__tests__/sys.test.js +208 -0
- package/dist/core/artifact.d.ts +29 -18
- package/dist/core/artifact.js +78 -221
- package/dist/core/auto-update.js +11 -3
- package/dist/core/command.d.ts +36 -0
- package/dist/core/command.js +287 -0
- package/dist/core/errors.d.ts +3 -0
- package/dist/core/errors.js +5 -0
- package/dist/core/fs-utils.d.ts +1 -0
- package/dist/core/fs-utils.js +4 -0
- package/dist/core/help.d.ts +98 -0
- package/dist/core/help.js +163 -0
- package/dist/core/io.d.ts +29 -0
- package/dist/core/io.js +83 -0
- package/dist/core/jobs.d.ts +87 -0
- package/dist/core/jobs.js +353 -0
- package/dist/core/pagination.d.ts +33 -0
- package/dist/core/pagination.js +89 -0
- package/dist/core/self-update.d.ts +21 -0
- package/dist/{commands/update.js → core/self-update.js} +28 -63
- package/dist/core/spawn.d.ts +47 -65
- package/dist/core/spawn.js +78 -228
- package/dist/prompts/agent.d.ts +10 -5
- package/dist/prompts/agent.js +51 -74
- package/dist/prompts/debug.d.ts +8 -0
- package/dist/prompts/debug.js +37 -0
- package/dist/prompts/review.js +4 -11
- package/dist/prompts/skill.d.ts +0 -1
- package/dist/prompts/skill.js +95 -149
- package/package.json +4 -2
- package/dist/commands/agent.d.ts +0 -2
- package/dist/commands/agent.js +0 -265
- package/dist/commands/config.d.ts +0 -2
- package/dist/commands/config.js +0 -146
- package/dist/commands/doctor.d.ts +0 -2
- package/dist/commands/doctor.js +0 -268
- package/dist/commands/marketplace.d.ts +0 -2
- package/dist/commands/marketplace.js +0 -365
- package/dist/commands/plugin.d.ts +0 -2
- package/dist/commands/plugin.js +0 -367
- package/dist/commands/update.d.ts +0 -4
- package/dist/prompts/plan.d.ts +0 -1
- package/dist/prompts/plan.js +0 -175
- package/dist/prompts/spec.d.ts +0 -1
- package/dist/prompts/spec.js +0 -153
|
@@ -0,0 +1,669 @@
|
|
|
1
|
+
// `crtr job` subtree — spawn/worker model backed by jobs.ts persistence.
|
|
2
|
+
//
|
|
3
|
+
// Sub-branches: start {prompt,fork,planner,implementer,reviewer},
|
|
4
|
+
// read {list,status,logs,result}, submit, _fail, cancel.
|
|
5
|
+
//
|
|
6
|
+
// Terminal-write contract:
|
|
7
|
+
// Worker calls `crtr job submit` → jobs.writeResult(jobId, result, 'done').
|
|
8
|
+
// If claude exits without submitting, the wrapper shell calls `crtr job _fail`
|
|
9
|
+
// → jobs.writeResult(jobId, {}, 'failed') IF result.json does not yet exist.
|
|
10
|
+
// `job read result` watches result.json appearance as the sole completion signal.
|
|
11
|
+
//
|
|
12
|
+
// `job read logs` is the only JSONL leaf.
|
|
13
|
+
import { defineBranch, defineLeaf } from '../core/command.js';
|
|
14
|
+
import { emitLine } from '../core/io.js';
|
|
15
|
+
import { InputError } from '../core/io.js';
|
|
16
|
+
import { createJob, writeResult, readResult as jobsReadResult, jobStatus, listJobs, readLog, cancelJob, appendEvent, } from '../core/jobs.js';
|
|
17
|
+
import { spawnAgent, spawnAndDetach, scheduleKillCurrentPane, isInTmux } from '../core/spawn.js';
|
|
18
|
+
import { readConfig } from '../core/config.js';
|
|
19
|
+
import { planHandoffPrompt, implementHandoffPrompt, reviewerHandoffPrompt } from '../prompts/agent.js';
|
|
20
|
+
import { paginate } from '../core/pagination.js';
|
|
21
|
+
import { existsSync } from 'node:fs';
|
|
22
|
+
const WAIT_BUDGET_MS = 10 * 60 * 1000;
|
|
23
|
+
const FOLLOW_POLL_MS = 1000;
|
|
24
|
+
const DEFAULT_KILL_SECS = 2;
|
|
25
|
+
function followUpResult(jobId) {
|
|
26
|
+
return `crtr job read result ${jobId} --wait`;
|
|
27
|
+
}
|
|
28
|
+
function resolveMaxPanes() {
|
|
29
|
+
const cfg = readConfig('user');
|
|
30
|
+
return cfg.max_panes_per_window;
|
|
31
|
+
}
|
|
32
|
+
function assertTmux() {
|
|
33
|
+
if (!isInTmux()) {
|
|
34
|
+
throw new InputError({
|
|
35
|
+
error: 'not_in_tmux',
|
|
36
|
+
message: 'crtr job start requires tmux (TMUX env var not set).',
|
|
37
|
+
next: 'Run inside a tmux session.',
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
// start sub-branch
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
const startPrompt = defineLeaf({
|
|
45
|
+
name: 'prompt',
|
|
46
|
+
help: {
|
|
47
|
+
name: 'job start prompt',
|
|
48
|
+
summary: 'spawn a fresh Claude agent with a prompt; returns a job handle immediately',
|
|
49
|
+
params: [
|
|
50
|
+
{ kind: 'stdin', name: 'prompt', required: true, constraint: 'Prompt text sent to the spawned agent.' },
|
|
51
|
+
{ kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Working directory for the spawned agent. Defaults to process.cwd().' },
|
|
52
|
+
],
|
|
53
|
+
output: [
|
|
54
|
+
{ name: 'job_id', type: 'string', required: true, constraint: 'Use with `job read status`, `job read logs`, `job read result`, `job cancel`.' },
|
|
55
|
+
{ name: 'follow_up', type: 'string', required: true, constraint: 'Recommended next call.' },
|
|
56
|
+
],
|
|
57
|
+
outputKind: 'object',
|
|
58
|
+
effects: [
|
|
59
|
+
'Spawns a Claude agent in a sibling tmux pane.',
|
|
60
|
+
'Creates a job entry at $XDG_STATE_HOME/crtr/jobs/<job_id>/.',
|
|
61
|
+
'On completion, result writes atomically to result.json.',
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
run: async (input) => {
|
|
65
|
+
assertTmux();
|
|
66
|
+
const prompt = input['prompt'];
|
|
67
|
+
const cwd = typeof input['cwd'] === 'string' ? input['cwd'] : process.cwd();
|
|
68
|
+
const { jobId } = createJob('prompt', { cwd });
|
|
69
|
+
const promptWithSubmit = `${prompt}
|
|
70
|
+
|
|
71
|
+
---
|
|
72
|
+
When your task is complete, submit your result:
|
|
73
|
+
\`\`\`bash
|
|
74
|
+
crtr job submit ${jobId} --context-file /tmp/result.json
|
|
75
|
+
\`\`\`
|
|
76
|
+
If you cannot complete the task, still submit with status "failed" and a reason.`;
|
|
77
|
+
const result = spawnAgent({
|
|
78
|
+
prompt: promptWithSubmit,
|
|
79
|
+
cwd,
|
|
80
|
+
jobId,
|
|
81
|
+
maxPanesPerWindow: resolveMaxPanes(),
|
|
82
|
+
});
|
|
83
|
+
if (result.status === 'not-in-tmux') {
|
|
84
|
+
throw new InputError({
|
|
85
|
+
error: 'not_in_tmux',
|
|
86
|
+
message: result.message,
|
|
87
|
+
next: 'Run inside a tmux session.',
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
if (result.status === 'spawn-failed') {
|
|
91
|
+
throw new InputError({
|
|
92
|
+
error: 'spawn_failed',
|
|
93
|
+
message: result.message,
|
|
94
|
+
next: 'Check tmux is running and try again.',
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const paneLabel = result.paneId !== undefined ? result.paneId : 'unknown';
|
|
98
|
+
appendEvent(jobId, { level: 'info', event: 'worker_started', message: `pane ${paneLabel} spawned` });
|
|
99
|
+
return { job_id: jobId, follow_up: followUpResult(jobId) };
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
const startFork = defineLeaf({
|
|
103
|
+
name: 'fork',
|
|
104
|
+
help: {
|
|
105
|
+
name: 'job start fork',
|
|
106
|
+
summary: 'fork the current Claude session into a sibling pane; returns a job handle immediately',
|
|
107
|
+
params: [
|
|
108
|
+
{ kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Working directory. Defaults to process.cwd().' },
|
|
109
|
+
],
|
|
110
|
+
output: [
|
|
111
|
+
{ name: 'job_id', type: 'string', required: true, constraint: 'Use with `job read *` and `job cancel`.' },
|
|
112
|
+
{ name: 'follow_up', type: 'string', required: true, constraint: 'Recommended next call.' },
|
|
113
|
+
],
|
|
114
|
+
outputKind: 'object',
|
|
115
|
+
effects: [
|
|
116
|
+
'Requires $CLAUDE_CODE_SESSION_ID — must run inside Claude Code.',
|
|
117
|
+
'Spawns a forked Claude session in a sibling tmux pane.',
|
|
118
|
+
'Creates a job entry and result sidecar as with `job start prompt`.',
|
|
119
|
+
],
|
|
120
|
+
},
|
|
121
|
+
run: async (input) => {
|
|
122
|
+
assertTmux();
|
|
123
|
+
const parentSessionId = process.env['CLAUDE_CODE_SESSION_ID'];
|
|
124
|
+
if (parentSessionId === undefined || parentSessionId === '') {
|
|
125
|
+
throw new InputError({
|
|
126
|
+
error: 'missing_session_id',
|
|
127
|
+
message: 'crtr job start fork requires $CLAUDE_CODE_SESSION_ID — must run inside Claude Code.',
|
|
128
|
+
next: 'Run this command from within a Claude Code session.',
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
const cwd = typeof input['cwd'] === 'string' ? input['cwd'] : process.cwd();
|
|
132
|
+
const { jobId } = createJob('fork', { cwd });
|
|
133
|
+
const promptWithSubmit = `Fork of session ${parentSessionId}
|
|
134
|
+
|
|
135
|
+
---
|
|
136
|
+
When your task is complete, submit your result:
|
|
137
|
+
\`\`\`bash
|
|
138
|
+
crtr job submit ${jobId} --context-file /tmp/result.json
|
|
139
|
+
\`\`\`
|
|
140
|
+
If you cannot complete the task, still submit with status "failed" and a reason.`;
|
|
141
|
+
const result = spawnAgent({
|
|
142
|
+
prompt: promptWithSubmit,
|
|
143
|
+
cwd,
|
|
144
|
+
jobId,
|
|
145
|
+
fork: { sessionId: parentSessionId },
|
|
146
|
+
maxPanesPerWindow: resolveMaxPanes(),
|
|
147
|
+
});
|
|
148
|
+
if (result.status === 'not-in-tmux') {
|
|
149
|
+
throw new InputError({
|
|
150
|
+
error: 'not_in_tmux',
|
|
151
|
+
message: result.message,
|
|
152
|
+
next: 'Run inside a tmux session.',
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
if (result.status === 'spawn-failed') {
|
|
156
|
+
throw new InputError({
|
|
157
|
+
error: 'spawn_failed',
|
|
158
|
+
message: result.message,
|
|
159
|
+
next: 'Check tmux is running and try again.',
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
const forkPaneLabel = result.paneId !== undefined ? result.paneId : 'unknown';
|
|
163
|
+
appendEvent(jobId, { level: 'info', event: 'worker_started', message: `forked pane ${forkPaneLabel} spawned` });
|
|
164
|
+
return { job_id: jobId, follow_up: followUpResult(jobId) };
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
const startPlanner = defineLeaf({
|
|
168
|
+
name: 'planner',
|
|
169
|
+
help: {
|
|
170
|
+
name: 'job start planner',
|
|
171
|
+
summary: 'launch a planning agent for an approved spec; closes the originating pane after handoff',
|
|
172
|
+
params: [
|
|
173
|
+
{ kind: 'positional', name: 'spec_path', type: 'path', required: true, constraint: 'Absolute path to the spec file.' },
|
|
174
|
+
{ kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Working directory. Defaults to process.cwd().' },
|
|
175
|
+
],
|
|
176
|
+
output: [
|
|
177
|
+
{ name: 'job_id', type: 'string', required: true, constraint: 'Use with `job read *` and `job cancel`.' },
|
|
178
|
+
{ name: 'follow_up', type: 'string', required: true, constraint: 'Recommended next call.' },
|
|
179
|
+
],
|
|
180
|
+
outputKind: 'object',
|
|
181
|
+
effects: [
|
|
182
|
+
'Spawns a planner agent in a sibling tmux pane.',
|
|
183
|
+
'Closes the originating pane after a short delay.',
|
|
184
|
+
'Creates a job entry and result sidecar.',
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
run: async (input) => {
|
|
188
|
+
assertTmux();
|
|
189
|
+
const specPath = input['spec_path'];
|
|
190
|
+
const cwd = typeof input['cwd'] === 'string' ? input['cwd'] : process.cwd();
|
|
191
|
+
if (!existsSync(specPath)) {
|
|
192
|
+
throw new InputError({
|
|
193
|
+
error: 'not_found',
|
|
194
|
+
message: `spec not found: ${specPath}`,
|
|
195
|
+
field: 'spec_path',
|
|
196
|
+
next: 'Provide an absolute path to an existing spec file.',
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
const { jobId } = createJob('planner', { cwd });
|
|
200
|
+
const result = spawnAndDetach({
|
|
201
|
+
prompt: planHandoffPrompt(specPath, jobId),
|
|
202
|
+
cwd,
|
|
203
|
+
jobId,
|
|
204
|
+
placement: 'split-h',
|
|
205
|
+
killAfterSeconds: DEFAULT_KILL_SECS,
|
|
206
|
+
});
|
|
207
|
+
if (result.status === 'not-in-tmux') {
|
|
208
|
+
throw new InputError({
|
|
209
|
+
error: 'not_in_tmux',
|
|
210
|
+
message: result.message,
|
|
211
|
+
next: 'Run inside a tmux session.',
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
if (result.status === 'spawn-failed') {
|
|
215
|
+
throw new InputError({
|
|
216
|
+
error: 'spawn_failed',
|
|
217
|
+
message: result.message,
|
|
218
|
+
next: 'Check tmux is running and try again.',
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
const plannerPaneLabel = result.paneId !== undefined ? result.paneId : 'unknown';
|
|
222
|
+
appendEvent(jobId, { level: 'info', event: 'worker_started', message: `planner pane ${plannerPaneLabel} spawned` });
|
|
223
|
+
return { job_id: jobId, follow_up: followUpResult(jobId) };
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
const startImplementer = defineLeaf({
|
|
227
|
+
name: 'implementer',
|
|
228
|
+
help: {
|
|
229
|
+
name: 'job start implementer',
|
|
230
|
+
summary: 'launch an implementation agent for an approved plan; closes the originating pane after handoff',
|
|
231
|
+
params: [
|
|
232
|
+
{ kind: 'positional', name: 'plan_path', type: 'path', required: true, constraint: 'Absolute path to the plan file.' },
|
|
233
|
+
{ kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Working directory. Defaults to process.cwd().' },
|
|
234
|
+
],
|
|
235
|
+
output: [
|
|
236
|
+
{ name: 'job_id', type: 'string', required: true, constraint: 'Use with `job read *` and `job cancel`.' },
|
|
237
|
+
{ name: 'follow_up', type: 'string', required: true, constraint: 'Recommended next call.' },
|
|
238
|
+
],
|
|
239
|
+
outputKind: 'object',
|
|
240
|
+
effects: [
|
|
241
|
+
'Spawns an implementer agent in a sibling tmux pane.',
|
|
242
|
+
'Closes the originating pane after a short delay.',
|
|
243
|
+
'Creates a job entry and result sidecar.',
|
|
244
|
+
],
|
|
245
|
+
},
|
|
246
|
+
run: async (input) => {
|
|
247
|
+
assertTmux();
|
|
248
|
+
const planPath = input['plan_path'];
|
|
249
|
+
const cwd = typeof input['cwd'] === 'string' ? input['cwd'] : process.cwd();
|
|
250
|
+
if (!existsSync(planPath)) {
|
|
251
|
+
throw new InputError({
|
|
252
|
+
error: 'not_found',
|
|
253
|
+
message: `plan not found: ${planPath}`,
|
|
254
|
+
field: 'plan_path',
|
|
255
|
+
next: 'Provide an absolute path to an existing plan file.',
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
const { jobId } = createJob('implementer', { cwd });
|
|
259
|
+
const result = spawnAndDetach({
|
|
260
|
+
prompt: implementHandoffPrompt(planPath, jobId),
|
|
261
|
+
cwd,
|
|
262
|
+
jobId,
|
|
263
|
+
placement: 'split-h',
|
|
264
|
+
killAfterSeconds: DEFAULT_KILL_SECS,
|
|
265
|
+
});
|
|
266
|
+
if (result.status === 'not-in-tmux') {
|
|
267
|
+
throw new InputError({
|
|
268
|
+
error: 'not_in_tmux',
|
|
269
|
+
message: result.message,
|
|
270
|
+
next: 'Check tmux is running and try again.',
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
if (result.status === 'spawn-failed') {
|
|
274
|
+
throw new InputError({
|
|
275
|
+
error: 'spawn_failed',
|
|
276
|
+
message: result.message,
|
|
277
|
+
next: 'Check tmux is running and try again.',
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
const implPaneLabel = result.paneId !== undefined ? result.paneId : 'unknown';
|
|
281
|
+
appendEvent(jobId, { level: 'info', event: 'worker_started', message: `implementer pane ${implPaneLabel} spawned` });
|
|
282
|
+
return { job_id: jobId, follow_up: followUpResult(jobId) };
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
const startReviewer = defineLeaf({
|
|
286
|
+
name: 'reviewer',
|
|
287
|
+
help: {
|
|
288
|
+
name: 'job start reviewer',
|
|
289
|
+
summary: 'launch a reviewer agent for a plan or spec artifact; the originating pane stays alive to collect the verdict',
|
|
290
|
+
params: [
|
|
291
|
+
{ kind: 'positional', name: 'artifact_path', type: 'path', required: true, constraint: 'Absolute path to the artifact to review.' },
|
|
292
|
+
{ kind: 'flag', name: 'kind', type: 'enum', choices: ['plan', 'spec'], required: true, constraint: 'Artifact kind to review.' },
|
|
293
|
+
{ kind: 'flag', name: 'spec-path', type: 'path', required: false, constraint: 'Absolute path to the spec, for plan reviews. Omit for spec reviews.' },
|
|
294
|
+
{ kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Working directory. Defaults to process.cwd().' },
|
|
295
|
+
],
|
|
296
|
+
output: [
|
|
297
|
+
{ name: 'job_id', type: 'string', required: true, constraint: 'Use with `job read *` and `job cancel`.' },
|
|
298
|
+
{ name: 'follow_up', type: 'string', required: true, constraint: 'Recommended next call.' },
|
|
299
|
+
],
|
|
300
|
+
outputKind: 'object',
|
|
301
|
+
effects: [
|
|
302
|
+
'Spawns a reviewer agent in a sibling tmux pane.',
|
|
303
|
+
'The originating pane stays alive — wait on the result and act on the verdict.',
|
|
304
|
+
'Creates a job entry and result sidecar.',
|
|
305
|
+
],
|
|
306
|
+
},
|
|
307
|
+
run: async (input) => {
|
|
308
|
+
assertTmux();
|
|
309
|
+
const artifactPath = input['artifact_path'];
|
|
310
|
+
const artifactKind = input['kind'];
|
|
311
|
+
const specPath = typeof input['specPath'] === 'string' ? input['specPath'] : undefined;
|
|
312
|
+
const cwd = typeof input['cwd'] === 'string' ? input['cwd'] : process.cwd();
|
|
313
|
+
if (!existsSync(artifactPath)) {
|
|
314
|
+
throw new InputError({
|
|
315
|
+
error: 'not_found',
|
|
316
|
+
message: `artifact not found: ${artifactPath}`,
|
|
317
|
+
field: 'artifact_path',
|
|
318
|
+
next: 'Provide an absolute path to an existing artifact file.',
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
const { jobId } = createJob('reviewer', { cwd });
|
|
322
|
+
// The reviewer is a subordinate the caller waits on (verdict → revise or
|
|
323
|
+
// hand off), NOT a handoff successor. Use spawnAgent so the originating
|
|
324
|
+
// pane (planner/orchestrator) stays alive to collect the result; do not
|
|
325
|
+
// self-kill the caller the way planner/implementer handoffs do.
|
|
326
|
+
const result = spawnAgent({
|
|
327
|
+
prompt: reviewerHandoffPrompt(artifactPath, artifactKind, specPath !== undefined ? specPath : null, jobId),
|
|
328
|
+
cwd,
|
|
329
|
+
jobId,
|
|
330
|
+
maxPanesPerWindow: resolveMaxPanes(),
|
|
331
|
+
});
|
|
332
|
+
if (result.status === 'not-in-tmux') {
|
|
333
|
+
throw new InputError({
|
|
334
|
+
error: 'not_in_tmux',
|
|
335
|
+
message: result.message,
|
|
336
|
+
next: 'Run inside a tmux session.',
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
if (result.status === 'spawn-failed') {
|
|
340
|
+
throw new InputError({
|
|
341
|
+
error: 'spawn_failed',
|
|
342
|
+
message: result.message,
|
|
343
|
+
next: 'Check tmux is running and try again.',
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
const reviewerPaneLabel = result.paneId !== undefined ? result.paneId : 'unknown';
|
|
347
|
+
appendEvent(jobId, { level: 'info', event: 'worker_started', message: `reviewer pane ${reviewerPaneLabel} spawned` });
|
|
348
|
+
return { job_id: jobId, follow_up: followUpResult(jobId) };
|
|
349
|
+
},
|
|
350
|
+
});
|
|
351
|
+
const startBranch = defineBranch({
|
|
352
|
+
name: 'start',
|
|
353
|
+
help: {
|
|
354
|
+
name: 'job start',
|
|
355
|
+
summary: 'spawn agent workers; all return a job handle immediately',
|
|
356
|
+
children: [
|
|
357
|
+
{ name: 'prompt', desc: 'fresh agent with a prompt', useWhen: 'spawning a general-purpose agent' },
|
|
358
|
+
{ name: 'fork', desc: 'fork current session into a sibling pane', useWhen: 'branching the current session\'s context into a new agent' },
|
|
359
|
+
{ name: 'planner', desc: 'planning agent for a spec', useWhen: 'handing off spec → plan decomposition' },
|
|
360
|
+
{ name: 'implementer', desc: 'implementation agent for a plan', useWhen: 'handing off plan → code implementation' },
|
|
361
|
+
{ name: 'reviewer', desc: 'review agent for a plan or spec', useWhen: 'launching a review of a plan or spec artifact' },
|
|
362
|
+
],
|
|
363
|
+
},
|
|
364
|
+
children: [startPrompt, startFork, startPlanner, startImplementer, startReviewer],
|
|
365
|
+
});
|
|
366
|
+
// ---------------------------------------------------------------------------
|
|
367
|
+
// read sub-branch
|
|
368
|
+
// ---------------------------------------------------------------------------
|
|
369
|
+
const readList = defineLeaf({
|
|
370
|
+
name: 'list',
|
|
371
|
+
help: {
|
|
372
|
+
name: 'job read list',
|
|
373
|
+
summary: 'paginated list of jobs, sorted by created_at ascending',
|
|
374
|
+
params: [
|
|
375
|
+
{ kind: 'flag', name: 'limit', type: 'int', required: false, default: 20, constraint: 'Default 20, max 100.' },
|
|
376
|
+
{ kind: 'flag', name: 'cursor', type: 'string', required: false, constraint: 'Opaque token from next_cursor. Omit on first call.' },
|
|
377
|
+
],
|
|
378
|
+
output: [
|
|
379
|
+
{ name: 'items', type: 'object[]', required: true, constraint: 'Each: {job_id, kind, state, created_at}. Sorted by created_at ascending.' },
|
|
380
|
+
{ name: 'next_cursor', type: 'string | null', required: true, constraint: 'null means no more items.' },
|
|
381
|
+
{ name: 'total', type: 'integer | null', required: true, constraint: 'Total count of all jobs.' },
|
|
382
|
+
],
|
|
383
|
+
outputKind: 'object',
|
|
384
|
+
effects: ['None. Read-only.'],
|
|
385
|
+
},
|
|
386
|
+
run: async (input) => {
|
|
387
|
+
const limit = typeof input['limit'] === 'number' ? input['limit'] : 20;
|
|
388
|
+
const cursor = typeof input['cursor'] === 'string' ? input['cursor'] : undefined;
|
|
389
|
+
const all = listJobs();
|
|
390
|
+
const page = paginate(all, { limit, cursor }, {
|
|
391
|
+
defaultLimit: 20,
|
|
392
|
+
maxLimit: 100,
|
|
393
|
+
keyOf: (item) => item.created_at,
|
|
394
|
+
total: 'count',
|
|
395
|
+
});
|
|
396
|
+
return {
|
|
397
|
+
items: page.items,
|
|
398
|
+
next_cursor: page.next_cursor,
|
|
399
|
+
total: page.total,
|
|
400
|
+
};
|
|
401
|
+
},
|
|
402
|
+
});
|
|
403
|
+
const readStatus = defineLeaf({
|
|
404
|
+
name: 'status',
|
|
405
|
+
help: {
|
|
406
|
+
name: 'job read status',
|
|
407
|
+
summary: 'read the current status of a job',
|
|
408
|
+
params: [
|
|
409
|
+
{ kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id from a `job start *` call.' },
|
|
410
|
+
],
|
|
411
|
+
output: [
|
|
412
|
+
{ name: 'job_id', type: 'string', required: true, constraint: 'Echo of input.' },
|
|
413
|
+
{ name: 'state', type: 'string', required: true, constraint: 'One of: live, done, failed, canceled.' },
|
|
414
|
+
{ name: 'age_s', type: 'number', required: true, constraint: 'Seconds since job creation.' },
|
|
415
|
+
{ name: 'last_event', type: 'object | null', required: true, constraint: 'Most recent log event {event, ts}, or null if no events yet.' },
|
|
416
|
+
],
|
|
417
|
+
outputKind: 'object',
|
|
418
|
+
effects: ['None. Read-only.'],
|
|
419
|
+
},
|
|
420
|
+
run: async (input) => {
|
|
421
|
+
const jobId = input['job_id'];
|
|
422
|
+
const status = jobStatus(jobId);
|
|
423
|
+
return {
|
|
424
|
+
job_id: jobId,
|
|
425
|
+
state: status.state,
|
|
426
|
+
age_s: status.age_s,
|
|
427
|
+
last_event: status.last_event,
|
|
428
|
+
};
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
const readLogs = defineLeaf({
|
|
432
|
+
name: 'logs',
|
|
433
|
+
help: {
|
|
434
|
+
name: 'job read logs',
|
|
435
|
+
summary: 'read log events from a job; emits JSONL — one event object per line',
|
|
436
|
+
params: [
|
|
437
|
+
{ kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id from a `job start *` call.' },
|
|
438
|
+
{ kind: 'flag', name: 'since', type: 'string', required: false, constraint: 'ISO 8601 timestamp. Only emit events at or after this time.' },
|
|
439
|
+
{ kind: 'flag', name: 'until', type: 'string', required: false, constraint: 'ISO 8601 timestamp. Only emit events before this time.' },
|
|
440
|
+
{ kind: 'flag', name: 'level', type: 'enum', choices: ['debug', 'info', 'warn', 'error'], required: false, default: 'info', constraint: 'Minimum severity. Default: info.' },
|
|
441
|
+
{ kind: 'flag', name: 'follow', type: 'bool', required: false, constraint: 'When present, stream new events until the job reaches a terminal state, then stop.' },
|
|
442
|
+
],
|
|
443
|
+
output: [
|
|
444
|
+
{
|
|
445
|
+
name: '<event line>',
|
|
446
|
+
type: 'object',
|
|
447
|
+
required: true,
|
|
448
|
+
constraint: 'Each JSONL line is: {ts:string, level:"debug"|"info"|"warn"|"error", event:string, message:string, data?:object}. Emitted one per line.',
|
|
449
|
+
},
|
|
450
|
+
],
|
|
451
|
+
outputKind: 'jsonl',
|
|
452
|
+
effects: ['None. Read-only.'],
|
|
453
|
+
},
|
|
454
|
+
run: async (input) => {
|
|
455
|
+
const jobId = input['job_id'];
|
|
456
|
+
const since = typeof input['since'] === 'string' ? input['since'] : undefined;
|
|
457
|
+
const until = typeof input['until'] === 'string' ? input['until'] : undefined;
|
|
458
|
+
const level = (typeof input['level'] === 'string' ? input['level'] : 'info');
|
|
459
|
+
const follow = input['follow'] === true;
|
|
460
|
+
const minLevel = level;
|
|
461
|
+
// Emit all existing events.
|
|
462
|
+
const events = readLog(jobId, { sinceTs: since, untilTs: until, minLevel });
|
|
463
|
+
for (const ev of events) {
|
|
464
|
+
emitLine(ev);
|
|
465
|
+
}
|
|
466
|
+
if (!follow)
|
|
467
|
+
return;
|
|
468
|
+
// Follow: poll for new events until the job reaches a terminal state.
|
|
469
|
+
// Track the latest emitted timestamp to avoid re-emitting.
|
|
470
|
+
let lastTs = until !== undefined ? until : new Date(0).toISOString();
|
|
471
|
+
// Update lastTs from emitted events.
|
|
472
|
+
for (const ev of events) {
|
|
473
|
+
const e = ev;
|
|
474
|
+
if (typeof e['ts'] === 'string' && e['ts'] > lastTs) {
|
|
475
|
+
lastTs = e['ts'];
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const terminalStates = new Set(['done', 'failed', 'canceled']);
|
|
479
|
+
await new Promise((resolve) => {
|
|
480
|
+
const poll = () => {
|
|
481
|
+
// Check terminal state first.
|
|
482
|
+
const status = jobStatus(jobId);
|
|
483
|
+
// Emit any new events since lastTs.
|
|
484
|
+
const newEvents = readLog(jobId, { sinceTs: lastTs !== new Date(0).toISOString() ? lastTs : undefined, minLevel });
|
|
485
|
+
for (const ev of newEvents) {
|
|
486
|
+
const e = ev;
|
|
487
|
+
if (typeof e['ts'] === 'string' && e['ts'] > lastTs) {
|
|
488
|
+
emitLine(e);
|
|
489
|
+
lastTs = e['ts'];
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
if (terminalStates.has(status.state)) {
|
|
493
|
+
resolve();
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
setTimeout(poll, FOLLOW_POLL_MS);
|
|
497
|
+
};
|
|
498
|
+
setTimeout(poll, FOLLOW_POLL_MS);
|
|
499
|
+
});
|
|
500
|
+
},
|
|
501
|
+
});
|
|
502
|
+
const readResult = defineLeaf({
|
|
503
|
+
name: 'result',
|
|
504
|
+
help: {
|
|
505
|
+
name: 'job read result',
|
|
506
|
+
summary: 'read the final result of a completed job',
|
|
507
|
+
params: [
|
|
508
|
+
{ kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id from a `job start *` call.' },
|
|
509
|
+
{ kind: 'flag', name: 'wait', type: 'bool', required: false, constraint: 'When present, blocks until result.json appears (up to 10 min).' },
|
|
510
|
+
],
|
|
511
|
+
output: [
|
|
512
|
+
{ name: 'job_id', type: 'string', required: true, constraint: 'Echo of input.' },
|
|
513
|
+
{ name: 'status', type: 'string', required: true, constraint: 'One of: done, failed, canceled, timeout.' },
|
|
514
|
+
{ name: 'result', type: 'object', required: false, constraint: 'The result object submitted by the worker. Present when status is done or failed.' },
|
|
515
|
+
],
|
|
516
|
+
outputKind: 'object',
|
|
517
|
+
effects: ['None. Read-only.'],
|
|
518
|
+
},
|
|
519
|
+
run: async (input) => {
|
|
520
|
+
const jobId = input['job_id'];
|
|
521
|
+
const wait = input['wait'] === true;
|
|
522
|
+
const r = await jobsReadResult(jobId, { waitMs: wait ? WAIT_BUDGET_MS : 0 });
|
|
523
|
+
const out = { job_id: jobId, status: r.status };
|
|
524
|
+
if (r.result !== undefined) {
|
|
525
|
+
out['result'] = r.result;
|
|
526
|
+
}
|
|
527
|
+
return out;
|
|
528
|
+
},
|
|
529
|
+
});
|
|
530
|
+
const readBranch = defineBranch({
|
|
531
|
+
name: 'read',
|
|
532
|
+
help: {
|
|
533
|
+
name: 'job read',
|
|
534
|
+
summary: 'read job status, logs, or results',
|
|
535
|
+
children: [
|
|
536
|
+
{ name: 'list', desc: 'paginated job list', useWhen: 'enumerating jobs' },
|
|
537
|
+
{ name: 'status', desc: 'current state and age', useWhen: 'checking if a job is still live' },
|
|
538
|
+
{ name: 'logs', desc: 'stream JSONL log events', useWhen: 'monitoring progress or debugging a job' },
|
|
539
|
+
{ name: 'result', desc: 'read final result', useWhen: 'collecting the output of a completed job' },
|
|
540
|
+
],
|
|
541
|
+
},
|
|
542
|
+
children: [readList, readStatus, readLogs, readResult],
|
|
543
|
+
});
|
|
544
|
+
// ---------------------------------------------------------------------------
|
|
545
|
+
// submit — called by the worker inside its pane
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
const jobSubmit = defineLeaf({
|
|
548
|
+
name: 'submit',
|
|
549
|
+
help: {
|
|
550
|
+
name: 'job submit',
|
|
551
|
+
summary: 'inside a crtr-spawned pane, deliver the result back to the job record',
|
|
552
|
+
params: [
|
|
553
|
+
{ kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id injected as $CRTR_JOB_ID in the spawned pane.' },
|
|
554
|
+
{ kind: 'context-file', name: 'result', required: true, constraint: `Result payload JSON file. Must be a JSON object. Becomes the result.json content.` },
|
|
555
|
+
{ kind: 'flag', name: 'kill-pane', type: 'bool', required: false, constraint: `When present, schedule the current tmux pane to close ${DEFAULT_KILL_SECS}s after submission so the spawned worker does not linger. Reviewer agents should pass this; planner/implementer handoffs already self-kill on spawn.` },
|
|
556
|
+
],
|
|
557
|
+
output: [
|
|
558
|
+
{ name: 'submitted', type: 'boolean', required: true, constraint: 'Always true on success.' },
|
|
559
|
+
{ name: 'pane_kill_scheduled', type: 'boolean', required: true, constraint: 'True when --kill-pane is set and a tmux pane kill was scheduled. False otherwise (--kill-pane not set, not in tmux, or TMUX_PANE unset).' },
|
|
560
|
+
],
|
|
561
|
+
outputKind: 'object',
|
|
562
|
+
effects: [
|
|
563
|
+
'Writes result.json atomically for the job, marking it done.',
|
|
564
|
+
'Updates meta.json status to done.',
|
|
565
|
+
`When --kill-pane is set, schedules \`tmux kill-pane\` on $TMUX_PANE after ${DEFAULT_KILL_SECS}s (detached; submit still returns cleanly).`,
|
|
566
|
+
],
|
|
567
|
+
},
|
|
568
|
+
run: async (input) => {
|
|
569
|
+
const jobId = input['job_id'];
|
|
570
|
+
const result = input['result'];
|
|
571
|
+
if (result === undefined || result === null || typeof result !== 'object' || Array.isArray(result)) {
|
|
572
|
+
throw new InputError({
|
|
573
|
+
error: 'invalid_field',
|
|
574
|
+
message: 'result file must contain a JSON object.',
|
|
575
|
+
field: 'result',
|
|
576
|
+
next: 'Pass a path to a file containing a JSON object via --context-file.',
|
|
577
|
+
});
|
|
578
|
+
}
|
|
579
|
+
const killPane = input['killPane'] === true;
|
|
580
|
+
writeResult(jobId, result, 'done');
|
|
581
|
+
const paneKillScheduled = killPane ? scheduleKillCurrentPane(DEFAULT_KILL_SECS) : false;
|
|
582
|
+
return { submitted: true, pane_kill_scheduled: paneKillScheduled };
|
|
583
|
+
},
|
|
584
|
+
});
|
|
585
|
+
// ---------------------------------------------------------------------------
|
|
586
|
+
// _fail — called by the wrapper shell if claude exits without submitting
|
|
587
|
+
// ---------------------------------------------------------------------------
|
|
588
|
+
const jobFail = defineLeaf({
|
|
589
|
+
name: '_fail',
|
|
590
|
+
help: {
|
|
591
|
+
name: 'job _fail',
|
|
592
|
+
summary: 'internal: mark a job failed if it has not already been submitted (called by wrapper shell)',
|
|
593
|
+
params: [
|
|
594
|
+
{ kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id. If result.json already exists, this is a no-op.' },
|
|
595
|
+
],
|
|
596
|
+
output: [
|
|
597
|
+
{ name: 'recorded', type: 'boolean', required: true, constraint: 'True if failure was recorded; false if result.json already existed (no-op).' },
|
|
598
|
+
],
|
|
599
|
+
outputKind: 'object',
|
|
600
|
+
effects: [
|
|
601
|
+
'Writes result.json with status "failed" if not already present.',
|
|
602
|
+
'Updates meta.json status to failed.',
|
|
603
|
+
],
|
|
604
|
+
},
|
|
605
|
+
run: async (input) => {
|
|
606
|
+
const jobId = input['job_id'];
|
|
607
|
+
// No-op if result.json already exists (worker submitted successfully).
|
|
608
|
+
try {
|
|
609
|
+
const existing = await jobsReadResult(jobId, { waitMs: 0 });
|
|
610
|
+
if (existing.status !== 'timeout') {
|
|
611
|
+
return { recorded: false };
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
catch {
|
|
615
|
+
// job dir not found — still try to write to surface the failure
|
|
616
|
+
}
|
|
617
|
+
try {
|
|
618
|
+
writeResult(jobId, { reason: 'worker exited without submitting' }, 'failed');
|
|
619
|
+
return { recorded: true };
|
|
620
|
+
}
|
|
621
|
+
catch {
|
|
622
|
+
return { recorded: false };
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
});
|
|
626
|
+
// ---------------------------------------------------------------------------
|
|
627
|
+
// cancel
|
|
628
|
+
// ---------------------------------------------------------------------------
|
|
629
|
+
const jobCancel = defineLeaf({
|
|
630
|
+
name: 'cancel',
|
|
631
|
+
help: {
|
|
632
|
+
name: 'job cancel',
|
|
633
|
+
summary: 'send a best-effort cancellation signal to a running job',
|
|
634
|
+
params: [
|
|
635
|
+
{ kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id from a `job start *` call.' },
|
|
636
|
+
],
|
|
637
|
+
output: [
|
|
638
|
+
{ name: 'canceled', type: 'boolean', required: true, constraint: 'True if a signal was delivered or the job was already terminal; false if the job was not live.' },
|
|
639
|
+
],
|
|
640
|
+
outputKind: 'object',
|
|
641
|
+
effects: ['Best-effort: delivers SIGTERM to the worker process and marks meta.json canceled.'],
|
|
642
|
+
},
|
|
643
|
+
run: async (input) => {
|
|
644
|
+
const jobId = input['job_id'];
|
|
645
|
+
const result = cancelJob(jobId);
|
|
646
|
+
return { canceled: result.canceled };
|
|
647
|
+
},
|
|
648
|
+
});
|
|
649
|
+
// ---------------------------------------------------------------------------
|
|
650
|
+
// root branch
|
|
651
|
+
// ---------------------------------------------------------------------------
|
|
652
|
+
export function registerJob() {
|
|
653
|
+
return defineBranch({
|
|
654
|
+
name: 'job',
|
|
655
|
+
help: {
|
|
656
|
+
name: 'job',
|
|
657
|
+
summary: 'spawn, monitor, and collect results from running agent workers',
|
|
658
|
+
model: 'Jobs are running or completed agent workers. Status: live | done | failed | canceled.',
|
|
659
|
+
children: [
|
|
660
|
+
{ name: 'start', desc: 'spawn agent workers', useWhen: 'launching a new agent job' },
|
|
661
|
+
{ name: 'read', desc: 'read status, logs, or results', useWhen: 'monitoring or collecting from a running or completed job' },
|
|
662
|
+
{ name: 'submit', desc: 'deliver result from inside a spawned pane', useWhen: 'worker is ready to return its output' },
|
|
663
|
+
{ name: '_fail', desc: 'internal: mark job failed on unsubmitted exit', useWhen: 'called by the wrapper shell, not manually' },
|
|
664
|
+
{ name: 'cancel', desc: 'best-effort cancel a live job', useWhen: 'stopping a job that is no longer needed' },
|
|
665
|
+
],
|
|
666
|
+
},
|
|
667
|
+
children: [startBranch, readBranch, jobSubmit, jobFail, jobCancel],
|
|
668
|
+
});
|
|
669
|
+
}
|