@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.
Files changed (57) hide show
  1. package/README.md +2 -2
  2. package/dist/builtin-skills/skills/crouter-development/marketplaces/SKILL.md +1 -1
  3. package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +3 -3
  4. package/dist/cli.js +16 -26
  5. package/dist/commands/__tests__/skill.test.js +24 -28
  6. package/dist/commands/agent.d.ts +6 -0
  7. package/dist/commands/agent.js +585 -0
  8. package/dist/commands/debug.d.ts +1 -1
  9. package/dist/commands/debug.js +20 -7
  10. package/dist/commands/human.js +51 -19
  11. package/dist/commands/job.d.ts +9 -0
  12. package/dist/commands/job.js +100 -385
  13. package/dist/commands/{flow.d.ts → mode.d.ts} +1 -1
  14. package/dist/commands/mode.js +231 -0
  15. package/dist/commands/pkg.js +5 -0
  16. package/dist/commands/plan.d.ts +1 -1
  17. package/dist/commands/plan.js +24 -11
  18. package/dist/commands/skill.js +130 -107
  19. package/dist/commands/spec.d.ts +1 -1
  20. package/dist/commands/spec.js +24 -11
  21. package/dist/commands/sys.js +5 -0
  22. package/dist/core/__tests__/job.test.js +38 -74
  23. package/dist/core/__tests__/jobs.test.d.ts +1 -0
  24. package/dist/core/__tests__/jobs.test.js +98 -0
  25. package/dist/core/__tests__/resolver.test.d.ts +1 -0
  26. package/dist/core/__tests__/resolver.test.js +181 -0
  27. package/dist/core/__tests__/spawn.test.d.ts +1 -0
  28. package/dist/core/__tests__/spawn.test.js +138 -0
  29. package/dist/core/__tests__/subagents.test.d.ts +1 -0
  30. package/dist/core/__tests__/subagents.test.js +75 -0
  31. package/dist/core/__tests__/unknown-path.test.d.ts +1 -0
  32. package/dist/core/__tests__/unknown-path.test.js +52 -0
  33. package/dist/core/bootstrap.d.ts +2 -0
  34. package/dist/core/bootstrap.js +66 -0
  35. package/dist/core/command.d.ts +58 -2
  36. package/dist/core/command.js +62 -14
  37. package/dist/core/config.js +20 -2
  38. package/dist/core/frontmatter.d.ts +10 -0
  39. package/dist/core/frontmatter.js +24 -9
  40. package/dist/core/help.d.ts +39 -8
  41. package/dist/core/help.js +64 -32
  42. package/dist/core/jobs.d.ts +33 -13
  43. package/dist/core/jobs.js +259 -47
  44. package/dist/core/resolver.d.ts +1 -2
  45. package/dist/core/resolver.js +111 -47
  46. package/dist/core/spawn.d.ts +150 -10
  47. package/dist/core/spawn.js +493 -41
  48. package/dist/core/subagents.d.ts +18 -0
  49. package/dist/core/subagents.js +163 -0
  50. package/dist/prompts/agent.d.ts +12 -3
  51. package/dist/prompts/agent.js +51 -18
  52. package/dist/prompts/debug.js +14 -7
  53. package/dist/prompts/skill.js +16 -16
  54. package/dist/types.d.ts +22 -1
  55. package/dist/types.js +5 -2
  56. package/package.json +2 -2
  57. package/dist/commands/flow.js +0 -24
@@ -1,368 +1,50 @@
1
- // `crtr job` subtree — spawn/worker model backed by jobs.ts persistence.
1
+ // `crtr job` subtree — universal monitoring registry for any ongoing task.
2
2
  //
3
- // Sub-branches: start {prompt,fork,planner,implementer,reviewer},
4
- // read {list,status,logs,result}, submit, _fail, cancel.
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 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.
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 { 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 { 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 { existsSync } from 'node:fs';
23
+ import { stateBlock } from '../core/help.js';
22
24
  const WAIT_BUDGET_MS = 10 * 60 * 1000;
23
25
  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
- });
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
- // 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
- });
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 `job start *` call.' },
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 `job start *` call.' },
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 `job start *` call.' },
509
- { kind: 'flag', name: 'wait', type: 'bool', required: false, constraint: 'When present, blocks until result.json appears (up to 10 min).' },
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: 'result', type: 'object', required: false, constraint: 'The result object submitted by the worker. Present when status is done or failed.' },
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: 'inside a crtr-spawned pane, deliver the result back to the job record',
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: 'context-file', name: 'result', required: true, constraint: `Result payload JSON file. Must be a JSON object. Becomes the result.json content.` },
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.json atomically for the job, marking it done.',
564
- 'Updates meta.json status to done.',
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 result = input['result'];
571
- if (result === undefined || result === null || typeof result !== 'object' || Array.isArray(result)) {
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: '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.',
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
- const killPane = input['killPane'] === true;
580
- writeResult(jobId, result, 'done');
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.json already exists, this is a no-op.' },
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.json already existed (no-op).' },
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.json with status "failed" if not already present.',
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
- writeResult(jobId, { reason: 'worker exited without submitting' }, 'failed');
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 `job start *` call.' },
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: 'spawn, monitor, and collect results from running agent workers',
658
- model: 'Jobs are running or completed agent workers. Status: live | done | failed | canceled.',
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 spawned pane', useWhen: 'worker is ready to return its output' },
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: [startBranch, readBranch, jobSubmit, jobFail, jobCancel],
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 registerFlow(): BranchDef;
2
+ export declare function registerMode(): BranchDef;