@crouton-kit/crouter 0.3.3 → 0.3.11
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/README.md +2 -2
- package/dist/builtin-skills/skills/crouter-development/marketplaces/SKILL.md +1 -1
- package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +3 -3
- package/dist/cli.js +16 -26
- package/dist/commands/__tests__/skill.test.js +24 -28
- package/dist/commands/agent.d.ts +6 -0
- package/dist/commands/agent.js +585 -0
- package/dist/commands/debug.d.ts +1 -1
- package/dist/commands/debug.js +20 -7
- package/dist/commands/human.js +51 -19
- package/dist/commands/job.d.ts +9 -0
- package/dist/commands/job.js +100 -385
- package/dist/commands/{flow.d.ts → mode.d.ts} +1 -1
- package/dist/commands/mode.js +231 -0
- package/dist/commands/pkg.js +5 -0
- package/dist/commands/plan.d.ts +1 -1
- package/dist/commands/plan.js +24 -11
- package/dist/commands/skill.js +130 -107
- package/dist/commands/spec.d.ts +1 -1
- package/dist/commands/spec.js +24 -11
- package/dist/commands/sys.js +5 -0
- package/dist/core/__tests__/job.test.js +38 -74
- package/dist/core/__tests__/jobs.test.d.ts +1 -0
- package/dist/core/__tests__/jobs.test.js +98 -0
- package/dist/core/__tests__/resolver.test.d.ts +1 -0
- package/dist/core/__tests__/resolver.test.js +181 -0
- package/dist/core/__tests__/spawn.test.d.ts +1 -0
- package/dist/core/__tests__/spawn.test.js +138 -0
- package/dist/core/__tests__/subagents.test.d.ts +1 -0
- package/dist/core/__tests__/subagents.test.js +75 -0
- package/dist/core/__tests__/unknown-path.test.d.ts +1 -0
- package/dist/core/__tests__/unknown-path.test.js +52 -0
- package/dist/core/bootstrap.d.ts +2 -0
- package/dist/core/bootstrap.js +66 -0
- package/dist/core/command.d.ts +58 -2
- package/dist/core/command.js +62 -14
- package/dist/core/config.js +20 -2
- package/dist/core/frontmatter.d.ts +10 -0
- package/dist/core/frontmatter.js +24 -9
- package/dist/core/help.d.ts +39 -8
- package/dist/core/help.js +64 -32
- package/dist/core/jobs.d.ts +33 -13
- package/dist/core/jobs.js +259 -47
- package/dist/core/resolver.d.ts +1 -2
- package/dist/core/resolver.js +111 -47
- package/dist/core/spawn.d.ts +150 -10
- package/dist/core/spawn.js +493 -41
- package/dist/core/subagents.d.ts +18 -0
- package/dist/core/subagents.js +163 -0
- package/dist/prompts/agent.d.ts +12 -3
- package/dist/prompts/agent.js +51 -18
- package/dist/prompts/debug.js +14 -7
- package/dist/prompts/skill.js +16 -16
- package/dist/types.d.ts +22 -1
- package/dist/types.js +5 -2
- package/package.json +2 -2
- package/dist/commands/flow.js +0 -24
package/dist/commands/job.js
CHANGED
|
@@ -1,368 +1,50 @@
|
|
|
1
|
-
// `crtr job` subtree —
|
|
1
|
+
// `crtr job` subtree — universal monitoring registry for any ongoing task.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// Producers (agent spawns, future task systems) register jobs and write
|
|
4
|
+
// results; this subtree is the read/cancel/submit surface shared across all
|
|
5
|
+
// producers. Sub-branches: read {list, status, logs, result}, submit, _fail,
|
|
6
|
+
// cancel.
|
|
5
7
|
//
|
|
6
8
|
// Terminal-write contract:
|
|
7
|
-
// Worker
|
|
8
|
-
// If claude exits without submitting, the wrapper shell
|
|
9
|
-
//
|
|
10
|
-
//
|
|
9
|
+
// Worker MAY call `crtr job submit` → writes result.md (done|failed).
|
|
10
|
+
// If claude exits without submitting, the wrapper shell's `crtr job _fail`
|
|
11
|
+
// marks it failed IF no result file exists yet.
|
|
12
|
+
// If the worker's tmux pane is closed, SIGHUP skips `_fail`; the jobs layer
|
|
13
|
+
// then reaps the job by detecting that its recorded pane has vanished.
|
|
14
|
+
// `job read result` watches result file appearance and polls for pane death.
|
|
11
15
|
//
|
|
12
16
|
// `job read logs` is the only JSONL leaf.
|
|
13
17
|
import { defineBranch, defineLeaf } from '../core/command.js';
|
|
14
18
|
import { emitLine } from '../core/io.js';
|
|
15
19
|
import { InputError } from '../core/io.js';
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
import { readConfig } from '../core/config.js';
|
|
19
|
-
import { planHandoffPrompt, implementHandoffPrompt, reviewerHandoffPrompt } from '../prompts/agent.js';
|
|
20
|
+
import { writeMarkdownResult, readResult as jobsReadResult, jobStatus, listJobs, readLog, cancelJob, appendEvent, } from '../core/jobs.js';
|
|
21
|
+
import { scheduleKillCurrentPane } from '../core/spawn.js';
|
|
20
22
|
import { paginate } from '../core/pagination.js';
|
|
21
|
-
import {
|
|
23
|
+
import { stateBlock } from '../core/help.js';
|
|
22
24
|
const WAIT_BUDGET_MS = 10 * 60 * 1000;
|
|
23
25
|
const FOLLOW_POLL_MS = 1000;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
});
|
|
26
|
+
/** Count of jobs currently in the live state, or null when listing fails.
|
|
27
|
+
* Backs the always-on "Workers running" signal on root -h so an agent never
|
|
28
|
+
* forgets it has in-flight workers to collect. */
|
|
29
|
+
export function liveJobCount() {
|
|
30
|
+
try {
|
|
31
|
+
return listJobs().filter((j) => j.state === 'live').length;
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
return null;
|
|
39
35
|
}
|
|
40
36
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
});
|
|
37
|
+
/** The job subtree's root-level dynamic block. A bounded aggregate (running
|
|
38
|
+
* count + how to collect), never an enumeration: live jobs are volatile and
|
|
39
|
+
* unbounded, so listing them in root -h would balloon (cli-design rule 15).
|
|
40
|
+
* Omitted when nothing is running. */
|
|
41
|
+
export function buildJobRootBlock() {
|
|
42
|
+
const n = liveJobCount();
|
|
43
|
+
if (n === null || n === 0)
|
|
44
|
+
return null;
|
|
45
|
+
return stateBlock('workers', { count: n }, '`crtr job read list` to see them; `crtr job read result ID` to collect');
|
|
46
|
+
}
|
|
47
|
+
const DEFAULT_KILL_SECS = 2;
|
|
366
48
|
// ---------------------------------------------------------------------------
|
|
367
49
|
// read sub-branch
|
|
368
50
|
// ---------------------------------------------------------------------------
|
|
@@ -406,11 +88,11 @@ const readStatus = defineLeaf({
|
|
|
406
88
|
name: 'job read status',
|
|
407
89
|
summary: 'read the current status of a job',
|
|
408
90
|
params: [
|
|
409
|
-
{ kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id from a `
|
|
91
|
+
{ kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id from a producer (e.g. `crtr agent new *`).' },
|
|
410
92
|
],
|
|
411
93
|
output: [
|
|
412
94
|
{ 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.' },
|
|
95
|
+
{ name: 'state', type: 'string', required: true, constraint: 'One of: live, done, failed, canceled, closed (worker pane closed with no submitted result).' },
|
|
414
96
|
{ name: 'age_s', type: 'number', required: true, constraint: 'Seconds since job creation.' },
|
|
415
97
|
{ name: 'last_event', type: 'object | null', required: true, constraint: 'Most recent log event {event, ts}, or null if no events yet.' },
|
|
416
98
|
],
|
|
@@ -434,7 +116,7 @@ const readLogs = defineLeaf({
|
|
|
434
116
|
name: 'job read logs',
|
|
435
117
|
summary: 'read log events from a job; emits JSONL — one event object per line',
|
|
436
118
|
params: [
|
|
437
|
-
{ kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id from a `
|
|
119
|
+
{ kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id from a producer (e.g. `crtr agent new *`).' },
|
|
438
120
|
{ kind: 'flag', name: 'since', type: 'string', required: false, constraint: 'ISO 8601 timestamp. Only emit events at or after this time.' },
|
|
439
121
|
{ kind: 'flag', name: 'until', type: 'string', required: false, constraint: 'ISO 8601 timestamp. Only emit events before this time.' },
|
|
440
122
|
{ kind: 'flag', name: 'level', type: 'enum', choices: ['debug', 'info', 'warn', 'error'], required: false, default: 'info', constraint: 'Minimum severity. Default: info.' },
|
|
@@ -475,12 +157,10 @@ const readLogs = defineLeaf({
|
|
|
475
157
|
lastTs = e['ts'];
|
|
476
158
|
}
|
|
477
159
|
}
|
|
478
|
-
const terminalStates = new Set(['done', 'failed', 'canceled']);
|
|
160
|
+
const terminalStates = new Set(['done', 'failed', 'canceled', 'closed']);
|
|
479
161
|
await new Promise((resolve) => {
|
|
480
162
|
const poll = () => {
|
|
481
|
-
// Check terminal state first.
|
|
482
163
|
const status = jobStatus(jobId);
|
|
483
|
-
// Emit any new events since lastTs.
|
|
484
164
|
const newEvents = readLog(jobId, { sinceTs: lastTs !== new Date(0).toISOString() ? lastTs : undefined, minLevel });
|
|
485
165
|
for (const ev of newEvents) {
|
|
486
166
|
const e = ev;
|
|
@@ -505,13 +185,15 @@ const readResult = defineLeaf({
|
|
|
505
185
|
name: 'job read result',
|
|
506
186
|
summary: 'read the final result of a completed job',
|
|
507
187
|
params: [
|
|
508
|
-
{ kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id from a `
|
|
509
|
-
{ kind: 'flag', name: 'wait', type: 'bool', required: false, constraint: 'When present, blocks until result
|
|
188
|
+
{ kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id from a producer (e.g. `crtr agent new *`).' },
|
|
189
|
+
{ kind: 'flag', name: 'wait', type: 'bool', required: false, constraint: 'When present, blocks until a result file appears (up to 10 min).' },
|
|
510
190
|
],
|
|
511
191
|
output: [
|
|
512
192
|
{ 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: '
|
|
193
|
+
{ name: 'status', type: 'string', required: true, constraint: 'One of: done, failed, canceled, closed, timeout. closed = the worker pane went away before submitting a result.' },
|
|
194
|
+
{ name: 'result_md', type: 'string', required: false, constraint: 'Markdown body submitted by an agent via `crtr job submit`. Present when the job used the agent submit path.' },
|
|
195
|
+
{ name: 'result', type: 'object', required: false, constraint: 'Structured object submitted by a programmatic caller (human/sys). Present when the job used the programmatic submit path.' },
|
|
196
|
+
{ name: 'reason', type: 'string', required: false, constraint: 'Short explanation from frontmatter. Present when status is failed (agent-reported error) or closed (worker pane closed before submitting).' },
|
|
515
197
|
],
|
|
516
198
|
outputKind: 'object',
|
|
517
199
|
effects: ['None. Read-only.'],
|
|
@@ -524,6 +206,12 @@ const readResult = defineLeaf({
|
|
|
524
206
|
if (r.result !== undefined) {
|
|
525
207
|
out['result'] = r.result;
|
|
526
208
|
}
|
|
209
|
+
if (r.result_md !== undefined) {
|
|
210
|
+
out['result_md'] = r.result_md;
|
|
211
|
+
}
|
|
212
|
+
if (r.reason !== undefined) {
|
|
213
|
+
out['reason'] = r.reason;
|
|
214
|
+
}
|
|
527
215
|
return out;
|
|
528
216
|
},
|
|
529
217
|
});
|
|
@@ -542,16 +230,19 @@ const readBranch = defineBranch({
|
|
|
542
230
|
children: [readList, readStatus, readLogs, readResult],
|
|
543
231
|
});
|
|
544
232
|
// ---------------------------------------------------------------------------
|
|
545
|
-
// submit — called by the worker inside its pane
|
|
233
|
+
// submit — called by the worker inside its pane (or by any producer that
|
|
234
|
+
// writes a result programmatically)
|
|
546
235
|
// ---------------------------------------------------------------------------
|
|
547
236
|
const jobSubmit = defineLeaf({
|
|
548
237
|
name: 'submit',
|
|
549
238
|
help: {
|
|
550
239
|
name: 'job submit',
|
|
551
|
-
summary: '
|
|
240
|
+
summary: 'deliver a markdown result back to a job record (called by workers, or any producer writing the terminal value)',
|
|
552
241
|
params: [
|
|
553
242
|
{ kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id injected as $CRTR_JOB_ID in the spawned pane.' },
|
|
554
|
-
{ kind: '
|
|
243
|
+
{ kind: 'stdin', name: 'body', required: false, constraint: 'Markdown body of the result, piped on stdin. Required when --status is done (the default). When --status failed, stdin is optional; --reason carries the explanation.' },
|
|
244
|
+
{ kind: 'flag', name: 'status', type: 'enum', choices: ['done', 'failed'], required: false, default: 'done', constraint: 'Terminal status to record. Default: done.' },
|
|
245
|
+
{ kind: 'flag', name: 'reason', type: 'string', required: false, constraint: 'Short failure reason. Required when --status failed; ignored otherwise.' },
|
|
555
246
|
{ 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
247
|
],
|
|
557
248
|
output: [
|
|
@@ -560,24 +251,39 @@ const jobSubmit = defineLeaf({
|
|
|
560
251
|
],
|
|
561
252
|
outputKind: 'object',
|
|
562
253
|
effects: [
|
|
563
|
-
'Writes result.
|
|
564
|
-
'Updates meta.json status to
|
|
254
|
+
'Writes <jobdir>/result.md atomically (YAML frontmatter + body), marking the job done or failed.',
|
|
255
|
+
'Updates meta.json status to match.',
|
|
565
256
|
`When --kill-pane is set, schedules \`tmux kill-pane\` on $TMUX_PANE after ${DEFAULT_KILL_SECS}s (detached; submit still returns cleanly).`,
|
|
566
257
|
],
|
|
567
258
|
},
|
|
568
259
|
run: async (input) => {
|
|
569
260
|
const jobId = input['job_id'];
|
|
570
|
-
const
|
|
571
|
-
|
|
261
|
+
const status = (typeof input['status'] === 'string' ? input['status'] : 'done');
|
|
262
|
+
const body = typeof input['body'] === 'string' ? input['body'] : '';
|
|
263
|
+
const reason = typeof input['reason'] === 'string' ? input['reason'] : '';
|
|
264
|
+
const killPane = input['killPane'] === true;
|
|
265
|
+
if (status === 'done' && body.trim() === '') {
|
|
572
266
|
throw new InputError({
|
|
573
267
|
error: 'invalid_field',
|
|
574
|
-
message: '
|
|
575
|
-
field: '
|
|
576
|
-
next:
|
|
268
|
+
message: '--status done requires a markdown body on stdin.',
|
|
269
|
+
field: 'body',
|
|
270
|
+
next: `Pipe the markdown result on stdin, e.g. \`crtr job submit ${jobId} <<'MD' ... MD\`. For failures, use \`--status failed --reason "<why>"\`.`,
|
|
577
271
|
});
|
|
578
272
|
}
|
|
579
|
-
|
|
580
|
-
|
|
273
|
+
if (status === 'failed' && reason.trim() === '') {
|
|
274
|
+
throw new InputError({
|
|
275
|
+
error: 'invalid_field',
|
|
276
|
+
message: '--status failed requires --reason "<text>".',
|
|
277
|
+
field: 'reason',
|
|
278
|
+
next: 'Pass --reason explaining why the task could not complete.',
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
writeMarkdownResult(jobId, body, status, status === 'failed' ? reason : undefined);
|
|
282
|
+
appendEvent(jobId, {
|
|
283
|
+
level: status === 'failed' ? 'error' : 'info',
|
|
284
|
+
event: 'worker_finished',
|
|
285
|
+
message: status === 'failed' ? `worker failed: ${reason}` : 'worker submitted result',
|
|
286
|
+
});
|
|
581
287
|
const paneKillScheduled = killPane ? scheduleKillCurrentPane(DEFAULT_KILL_SECS) : false;
|
|
582
288
|
return { submitted: true, pane_kill_scheduled: paneKillScheduled };
|
|
583
289
|
},
|
|
@@ -591,20 +297,19 @@ const jobFail = defineLeaf({
|
|
|
591
297
|
name: 'job _fail',
|
|
592
298
|
summary: 'internal: mark a job failed if it has not already been submitted (called by wrapper shell)',
|
|
593
299
|
params: [
|
|
594
|
-
{ kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id. If result
|
|
300
|
+
{ kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id. If a result file already exists, this is a no-op.' },
|
|
595
301
|
],
|
|
596
302
|
output: [
|
|
597
|
-
{ name: 'recorded', type: 'boolean', required: true, constraint: 'True if failure was recorded; false if result
|
|
303
|
+
{ name: 'recorded', type: 'boolean', required: true, constraint: 'True if failure was recorded; false if a result file already existed (no-op).' },
|
|
598
304
|
],
|
|
599
305
|
outputKind: 'object',
|
|
600
306
|
effects: [
|
|
601
|
-
'Writes result.
|
|
307
|
+
'Writes result.md with status "failed" and a reason if no result file is present.',
|
|
602
308
|
'Updates meta.json status to failed.',
|
|
603
309
|
],
|
|
604
310
|
},
|
|
605
311
|
run: async (input) => {
|
|
606
312
|
const jobId = input['job_id'];
|
|
607
|
-
// No-op if result.json already exists (worker submitted successfully).
|
|
608
313
|
try {
|
|
609
314
|
const existing = await jobsReadResult(jobId, { waitMs: 0 });
|
|
610
315
|
if (existing.status !== 'timeout') {
|
|
@@ -615,7 +320,12 @@ const jobFail = defineLeaf({
|
|
|
615
320
|
// job dir not found — still try to write to surface the failure
|
|
616
321
|
}
|
|
617
322
|
try {
|
|
618
|
-
|
|
323
|
+
writeMarkdownResult(jobId, '', 'failed', 'worker exited without submitting');
|
|
324
|
+
appendEvent(jobId, {
|
|
325
|
+
level: 'error',
|
|
326
|
+
event: 'worker_finished',
|
|
327
|
+
message: 'worker exited without submitting',
|
|
328
|
+
});
|
|
619
329
|
return { recorded: true };
|
|
620
330
|
}
|
|
621
331
|
catch {
|
|
@@ -632,7 +342,7 @@ const jobCancel = defineLeaf({
|
|
|
632
342
|
name: 'job cancel',
|
|
633
343
|
summary: 'send a best-effort cancellation signal to a running job',
|
|
634
344
|
params: [
|
|
635
|
-
{ kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id from a `
|
|
345
|
+
{ kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id from a producer (e.g. `crtr agent new *`).' },
|
|
636
346
|
],
|
|
637
347
|
output: [
|
|
638
348
|
{ 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.' },
|
|
@@ -652,18 +362,23 @@ const jobCancel = defineLeaf({
|
|
|
652
362
|
export function registerJob() {
|
|
653
363
|
return defineBranch({
|
|
654
364
|
name: 'job',
|
|
365
|
+
rootEntry: {
|
|
366
|
+
concept: 'producer-agnostic record of any ongoing task — its logs and result',
|
|
367
|
+
desc: 'monitor and collect from any ongoing task',
|
|
368
|
+
useWhen: 'reading status, logs, or result of a job started by any producer',
|
|
369
|
+
dynamicState: buildJobRootBlock,
|
|
370
|
+
},
|
|
655
371
|
help: {
|
|
656
372
|
name: 'job',
|
|
657
|
-
summary: '
|
|
658
|
-
model: '
|
|
373
|
+
summary: 'monitor and collect results from any ongoing task',
|
|
374
|
+
model: 'A job is a producer-agnostic record of an ongoing task: state, logs, terminal result. Producers (`crtr agent new *`, future task systems) create jobs; this subtree is the shared read/cancel/submit surface. States: live | done | failed | canceled | closed (worker pane closed before submitting a result).',
|
|
659
375
|
children: [
|
|
660
|
-
{ name: 'start', desc: 'spawn agent workers', useWhen: 'launching a new agent job' },
|
|
661
376
|
{ 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
|
|
377
|
+
{ name: 'submit', desc: 'deliver result from inside a worker pane or any producer', useWhen: 'a worker is ready to return its output' },
|
|
663
378
|
{ name: '_fail', desc: 'internal: mark job failed on unsubmitted exit', useWhen: 'called by the wrapper shell, not manually' },
|
|
664
379
|
{ name: 'cancel', desc: 'best-effort cancel a live job', useWhen: 'stopping a job that is no longer needed' },
|
|
665
380
|
],
|
|
666
381
|
},
|
|
667
|
-
children: [
|
|
382
|
+
children: [readBranch, jobSubmit, jobFail, jobCancel],
|
|
668
383
|
});
|
|
669
384
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { BranchDef } from '../core/command.js';
|
|
2
|
-
export declare function
|
|
2
|
+
export declare function registerMode(): BranchDef;
|