@crouton-kit/crouter 0.3.2 → 0.3.8

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 (36) 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 +6 -6
  5. package/dist/commands/__tests__/skill.test.js +24 -28
  6. package/dist/commands/{flow.d.ts → agent.d.ts} +1 -1
  7. package/dist/commands/agent.js +384 -0
  8. package/dist/commands/debug.d.ts +1 -1
  9. package/dist/commands/debug.js +7 -7
  10. package/dist/commands/human.js +6 -24
  11. package/dist/commands/job.js +54 -379
  12. package/dist/commands/plan.d.ts +1 -1
  13. package/dist/commands/plan.js +11 -11
  14. package/dist/commands/skill.js +114 -107
  15. package/dist/commands/spec.d.ts +1 -1
  16. package/dist/commands/spec.js +11 -11
  17. package/dist/core/__tests__/job.test.js +38 -74
  18. package/dist/core/__tests__/jobs.test.d.ts +1 -0
  19. package/dist/core/__tests__/jobs.test.js +66 -0
  20. package/dist/core/__tests__/resolver.test.d.ts +1 -0
  21. package/dist/core/__tests__/resolver.test.js +113 -0
  22. package/dist/core/config.js +20 -2
  23. package/dist/core/jobs.d.ts +26 -12
  24. package/dist/core/jobs.js +151 -42
  25. package/dist/core/resolver.d.ts +1 -2
  26. package/dist/core/resolver.js +60 -46
  27. package/dist/core/spawn.d.ts +26 -3
  28. package/dist/core/spawn.js +144 -11
  29. package/dist/prompts/agent.d.ts +3 -3
  30. package/dist/prompts/agent.js +20 -18
  31. package/dist/prompts/debug.js +14 -7
  32. package/dist/prompts/skill.js +16 -16
  33. package/dist/types.d.ts +1 -1
  34. package/dist/types.js +2 -2
  35. package/package.json +2 -2
  36. package/dist/commands/flow.js +0 -24
@@ -21,7 +21,7 @@ import { readConfig } from '../core/config.js';
21
21
  import { mkdirSync, existsSync } from 'node:fs';
22
22
  import { join, resolve } from 'node:path';
23
23
  import { randomBytes } from 'node:crypto';
24
- import { ask, launchReview, display, inbox, scanInbox, validateDeck, parseDeck, deckPath, atomicWriteJson, readJson, } from '@crouton-kit/humanloop';
24
+ import { ask, launchReview, display, inbox, scanInbox, validateDeck, approveDeck, notifyDeck, parseDeck, deckPath, atomicWriteJson, readJson, } from '@crouton-kit/humanloop';
25
25
  const DECK_SCHEMA_HINT = 'Deck must match the humanloop deck schema: {title?, ' +
26
26
  'source?:{sessionName?,askedBy?,blockedSince?}, ' +
27
27
  'interactions:[{id,title,subtitle?,(body?|bodyPath?),options:[{id,label,' +
@@ -146,20 +146,10 @@ const humanApprove = defineLeaf({
146
146
  const title = input['title'];
147
147
  const subtitle = input['subtitle'];
148
148
  const body = input['body'];
149
- const interaction = {
150
- id: 'approve',
151
- title,
152
- kind: 'validation',
153
- options: [
154
- { id: 'yes', label: 'Yes' },
155
- { id: 'no', label: 'No' },
156
- ],
157
- };
158
- if (subtitle !== undefined)
159
- interaction['subtitle'] = subtitle;
160
- if (body !== undefined)
161
- interaction['body'] = body;
162
- const deck = validateDeck({ interactions: [interaction] });
149
+ const deck = approveDeck(title, {
150
+ ...(subtitle !== undefined ? { subtitle } : {}),
151
+ ...(body !== undefined ? { body } : {}),
152
+ });
163
153
  const cwd = process.cwd();
164
154
  const { jobId } = createJob('human', { cwd });
165
155
  const idir = interactionDir(jobId, cwd);
@@ -250,15 +240,7 @@ const humanNotify = defineLeaf({
250
240
  run: async (input) => {
251
241
  const title = input['title'];
252
242
  const body = input['body'];
253
- const interaction = {
254
- id: 'notify',
255
- title,
256
- kind: 'notify',
257
- options: [{ id: 'ack', label: 'OK' }],
258
- };
259
- if (body !== undefined)
260
- interaction['body'] = body;
261
- const deck = validateDeck({ interactions: [interaction] });
243
+ const deck = notifyDeck(title, body !== undefined ? { body } : {});
262
244
  const cwd = process.cwd();
263
245
  const id = `nfy-${randomBytes(4).toString('hex')}`;
264
246
  const idir = interactionDir(id, cwd);
@@ -1,7 +1,9 @@
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
9
  // Worker calls `crtr job submit` → jobs.writeResult(jobId, result, 'done').
@@ -13,356 +15,12 @@
13
15
  import { defineBranch, defineLeaf } from '../core/command.js';
14
16
  import { emitLine } from '../core/io.js';
15
17
  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';
18
+ import { writeMarkdownResult, readResult as jobsReadResult, jobStatus, listJobs, readLog, cancelJob, } from '../core/jobs.js';
19
+ import { scheduleKillCurrentPane } from '../core/spawn.js';
20
20
  import { paginate } from '../core/pagination.js';
21
- import { existsSync } from 'node:fs';
22
21
  const WAIT_BUDGET_MS = 10 * 60 * 1000;
23
22
  const FOLLOW_POLL_MS = 1000;
24
23
  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
24
  // ---------------------------------------------------------------------------
367
25
  // read sub-branch
368
26
  // ---------------------------------------------------------------------------
@@ -406,7 +64,7 @@ const readStatus = defineLeaf({
406
64
  name: 'job read status',
407
65
  summary: 'read the current status of a job',
408
66
  params: [
409
- { kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id from a `job start *` call.' },
67
+ { kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id from a producer (e.g. `crtr agent new *`).' },
410
68
  ],
411
69
  output: [
412
70
  { name: 'job_id', type: 'string', required: true, constraint: 'Echo of input.' },
@@ -434,7 +92,7 @@ const readLogs = defineLeaf({
434
92
  name: 'job read logs',
435
93
  summary: 'read log events from a job; emits JSONL — one event object per line',
436
94
  params: [
437
- { kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id from a `job start *` call.' },
95
+ { kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id from a producer (e.g. `crtr agent new *`).' },
438
96
  { kind: 'flag', name: 'since', type: 'string', required: false, constraint: 'ISO 8601 timestamp. Only emit events at or after this time.' },
439
97
  { kind: 'flag', name: 'until', type: 'string', required: false, constraint: 'ISO 8601 timestamp. Only emit events before this time.' },
440
98
  { kind: 'flag', name: 'level', type: 'enum', choices: ['debug', 'info', 'warn', 'error'], required: false, default: 'info', constraint: 'Minimum severity. Default: info.' },
@@ -478,9 +136,7 @@ const readLogs = defineLeaf({
478
136
  const terminalStates = new Set(['done', 'failed', 'canceled']);
479
137
  await new Promise((resolve) => {
480
138
  const poll = () => {
481
- // Check terminal state first.
482
139
  const status = jobStatus(jobId);
483
- // Emit any new events since lastTs.
484
140
  const newEvents = readLog(jobId, { sinceTs: lastTs !== new Date(0).toISOString() ? lastTs : undefined, minLevel });
485
141
  for (const ev of newEvents) {
486
142
  const e = ev;
@@ -505,13 +161,15 @@ const readResult = defineLeaf({
505
161
  name: 'job read result',
506
162
  summary: 'read the final result of a completed job',
507
163
  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).' },
164
+ { kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id from a producer (e.g. `crtr agent new *`).' },
165
+ { kind: 'flag', name: 'wait', type: 'bool', required: false, constraint: 'When present, blocks until a result file appears (up to 10 min).' },
510
166
  ],
511
167
  output: [
512
168
  { name: 'job_id', type: 'string', required: true, constraint: 'Echo of input.' },
513
169
  { 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.' },
170
+ { 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.' },
171
+ { 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.' },
172
+ { name: 'reason', type: 'string', required: false, constraint: 'Failure reason from frontmatter when status is failed and the agent submit path was used.' },
515
173
  ],
516
174
  outputKind: 'object',
517
175
  effects: ['None. Read-only.'],
@@ -524,6 +182,12 @@ const readResult = defineLeaf({
524
182
  if (r.result !== undefined) {
525
183
  out['result'] = r.result;
526
184
  }
185
+ if (r.result_md !== undefined) {
186
+ out['result_md'] = r.result_md;
187
+ }
188
+ if (r.reason !== undefined) {
189
+ out['reason'] = r.reason;
190
+ }
527
191
  return out;
528
192
  },
529
193
  });
@@ -542,16 +206,19 @@ const readBranch = defineBranch({
542
206
  children: [readList, readStatus, readLogs, readResult],
543
207
  });
544
208
  // ---------------------------------------------------------------------------
545
- // submit — called by the worker inside its pane
209
+ // submit — called by the worker inside its pane (or by any producer that
210
+ // writes a result programmatically)
546
211
  // ---------------------------------------------------------------------------
547
212
  const jobSubmit = defineLeaf({
548
213
  name: 'submit',
549
214
  help: {
550
215
  name: 'job submit',
551
- summary: 'inside a crtr-spawned pane, deliver the result back to the job record',
216
+ summary: 'deliver a markdown result back to a job record (called by workers, or any producer writing the terminal value)',
552
217
  params: [
553
218
  { 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.` },
219
+ { 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.' },
220
+ { kind: 'flag', name: 'status', type: 'enum', choices: ['done', 'failed'], required: false, default: 'done', constraint: 'Terminal status to record. Default: done.' },
221
+ { kind: 'flag', name: 'reason', type: 'string', required: false, constraint: 'Short failure reason. Required when --status failed; ignored otherwise.' },
555
222
  { 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
223
  ],
557
224
  output: [
@@ -560,24 +227,34 @@ const jobSubmit = defineLeaf({
560
227
  ],
561
228
  outputKind: 'object',
562
229
  effects: [
563
- 'Writes result.json atomically for the job, marking it done.',
564
- 'Updates meta.json status to done.',
230
+ 'Writes <jobdir>/result.md atomically (YAML frontmatter + body), marking the job done or failed.',
231
+ 'Updates meta.json status to match.',
565
232
  `When --kill-pane is set, schedules \`tmux kill-pane\` on $TMUX_PANE after ${DEFAULT_KILL_SECS}s (detached; submit still returns cleanly).`,
566
233
  ],
567
234
  },
568
235
  run: async (input) => {
569
236
  const jobId = input['job_id'];
570
- const result = input['result'];
571
- if (result === undefined || result === null || typeof result !== 'object' || Array.isArray(result)) {
237
+ const status = (typeof input['status'] === 'string' ? input['status'] : 'done');
238
+ const body = typeof input['body'] === 'string' ? input['body'] : '';
239
+ const reason = typeof input['reason'] === 'string' ? input['reason'] : '';
240
+ const killPane = input['killPane'] === true;
241
+ if (status === 'done' && body.trim() === '') {
572
242
  throw new InputError({
573
243
  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.',
244
+ message: '--status done requires a markdown body on stdin.',
245
+ field: 'body',
246
+ next: `Pipe the markdown result on stdin, e.g. \`crtr job submit ${jobId} <<'MD' ... MD\`. For failures, use \`--status failed --reason "<why>"\`.`,
577
247
  });
578
248
  }
579
- const killPane = input['killPane'] === true;
580
- writeResult(jobId, result, 'done');
249
+ if (status === 'failed' && reason.trim() === '') {
250
+ throw new InputError({
251
+ error: 'invalid_field',
252
+ message: '--status failed requires --reason "<text>".',
253
+ field: 'reason',
254
+ next: 'Pass --reason explaining why the task could not complete.',
255
+ });
256
+ }
257
+ writeMarkdownResult(jobId, body, status, status === 'failed' ? reason : undefined);
581
258
  const paneKillScheduled = killPane ? scheduleKillCurrentPane(DEFAULT_KILL_SECS) : false;
582
259
  return { submitted: true, pane_kill_scheduled: paneKillScheduled };
583
260
  },
@@ -591,20 +268,19 @@ const jobFail = defineLeaf({
591
268
  name: 'job _fail',
592
269
  summary: 'internal: mark a job failed if it has not already been submitted (called by wrapper shell)',
593
270
  params: [
594
- { kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id. If result.json already exists, this is a no-op.' },
271
+ { kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id. If a result file already exists, this is a no-op.' },
595
272
  ],
596
273
  output: [
597
- { name: 'recorded', type: 'boolean', required: true, constraint: 'True if failure was recorded; false if result.json already existed (no-op).' },
274
+ { name: 'recorded', type: 'boolean', required: true, constraint: 'True if failure was recorded; false if a result file already existed (no-op).' },
598
275
  ],
599
276
  outputKind: 'object',
600
277
  effects: [
601
- 'Writes result.json with status "failed" if not already present.',
278
+ 'Writes result.md with status "failed" and a reason if no result file is present.',
602
279
  'Updates meta.json status to failed.',
603
280
  ],
604
281
  },
605
282
  run: async (input) => {
606
283
  const jobId = input['job_id'];
607
- // No-op if result.json already exists (worker submitted successfully).
608
284
  try {
609
285
  const existing = await jobsReadResult(jobId, { waitMs: 0 });
610
286
  if (existing.status !== 'timeout') {
@@ -615,7 +291,7 @@ const jobFail = defineLeaf({
615
291
  // job dir not found — still try to write to surface the failure
616
292
  }
617
293
  try {
618
- writeResult(jobId, { reason: 'worker exited without submitting' }, 'failed');
294
+ writeMarkdownResult(jobId, '', 'failed', 'worker exited without submitting');
619
295
  return { recorded: true };
620
296
  }
621
297
  catch {
@@ -632,7 +308,7 @@ const jobCancel = defineLeaf({
632
308
  name: 'job cancel',
633
309
  summary: 'send a best-effort cancellation signal to a running job',
634
310
  params: [
635
- { kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id from a `job start *` call.' },
311
+ { kind: 'positional', name: 'job_id', type: 'string', required: true, constraint: 'Job id from a producer (e.g. `crtr agent new *`).' },
636
312
  ],
637
313
  output: [
638
314
  { 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.' },
@@ -654,16 +330,15 @@ export function registerJob() {
654
330
  name: 'job',
655
331
  help: {
656
332
  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.',
333
+ summary: 'monitor and collect results from any ongoing task',
334
+ 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.',
659
335
  children: [
660
- { name: 'start', desc: 'spawn agent workers', useWhen: 'launching a new agent job' },
661
336
  { 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' },
337
+ { name: 'submit', desc: 'deliver result from inside a worker pane or any producer', useWhen: 'a worker is ready to return its output' },
663
338
  { name: '_fail', desc: 'internal: mark job failed on unsubmitted exit', useWhen: 'called by the wrapper shell, not manually' },
664
339
  { name: 'cancel', desc: 'best-effort cancel a live job', useWhen: 'stopping a job that is no longer needed' },
665
340
  ],
666
341
  },
667
- children: [startBranch, readBranch, jobSubmit, jobFail, jobCancel],
342
+ children: [readBranch, jobSubmit, jobFail, jobCancel],
668
343
  });
669
344
  }
@@ -1,4 +1,4 @@
1
- export declare const PLAN_NEW_GUIDE = "## Planning workflow\n\nBuild and save an implementation plan: a map another agent can execute without\nre-discovering context. Work through these phases before saving.\n\n### Phase 1: Understand\n\nBuild a full picture of the request and the code. Search for reusable\nfunctions, patterns, and existing implementations before proposing new ones.\n\nLaunch up to 3 Explore subagents IN PARALLEL (single message, multiple tool\ncalls). Use 1 agent for isolated, small-scope tasks; use more when scope is\nuncertain or multiple subsystems are involved. Give each a distinct focus so\nthey don't duplicate work.\n\n### Phase 2: Design\n\nDesign the implementation from Phase 1 findings. Default to launching at least\n1 Plan agent to validate your understanding and surface alternatives. Skip only\nfor trivially small tasks (typo fixes, single-line renames). Use up to 3 agents\nfor large refactors or architectural changes.\n\nIn the Plan agent prompt: include file paths, code-path traces, requirements,\nand constraints from Phase 1. Request a detailed implementation plan.\n\n### Phase 3: Review findings\n\nRead the critical files identified by agents. Confirm the plan aligns with the\nuser's request. Use AskUserQuestion ONLY to clarify requirements or choose\nbetween approaches \u2014 never to ask \"is this okay?\" or \"should I proceed?\".\n\n### Phase 4: Compose the plan body\n\nQuality bar \u2014 every item below is cheap to satisfy and saves the implementer\nfrom re-deciding:\n\n- Every decision pinned. No \"if X then Y\" branches, no \"investigate whether\u2026\",\n no deferred choices. If you don't know, find out or ask now.\n- No timelines, no fallbacks, no magic values, no \"for now\" shortcuts.\n- Where the plan creates a new interface, schema, or contract, write the actual\n shape, not \"design a Foo type.\"\n\nRequired sections:\n\n # Plan: <one-line title>\n\n ## Context\n <why this change is being made \u2014 the problem, what prompted it, intended outcome>\n\n ## Recommended approach\n <your chosen approach only. Concise enough to scan, detailed enough to execute.>\n\n ## Files to modify / create\n - `path/to/file.ts` \u2014 <what changes>\n\n ## Existing utilities to reuse\n - `functionName` from `path/to/file.ts:LL` \u2014 <why it fits>\n\n ## Verification\n <how to test end-to-end \u2014 run the code, run tests, etc.>\n\nFor plans touching 4+ files across distinct concerns, structure parallel tasks:\n\n ## Tasks\n - **Task 1**: <name>\n - Files: `a.ts`, `b.ts` (disjoint from other tasks)\n - Depends on: (none) | Task N\n - Integration: <shared types/APIs with exact shape>\n - Changes: <bullets>\n\nSkip the Tasks structure for small plans; it's noise when there's no\nparallelism to unlock.\n\n### Phase 5: Save\n\nRun `crtr flow plan new`:\n\n echo '<plan markdown>' | crtr flow plan new <kebab-case-name> [--spec <spec-name>]\n\n- NAME: short kebab-case slug. Nested names become subdirectories\n (e.g. `auth/jwt-refresh`).\n- Pipe the full plan markdown composed in Phase 4 on stdin.\n- `--spec` (optional): name of the spec this plan implements. Enables alignment\n check by the reviewer.\n\nOutput: `{path, follow_up}`. The `follow_up` field names the exact next call\n\u2014 run it.\n\n### Phase 6: Oversize check\n\nIf `follow_up` contains an oversize advisory (plan exceeds 200 lines), split\ninto a short index plan plus nested part plans, each under the threshold.\nRe-save. The implementer executes parts one at a time; long monolithic plans\nare under-decomposed.\n\n### Phase 7: Done\n\nAfter the reviewer approves the plan, your turn ends. Do not summarize in chat.\nFor a human gate, optionally put the plan in front of a person with `crtr\nhuman review` (anchored comments) and gate the handoff with `crtr human\napprove`. This complements \u2014 it does not replace \u2014 `crtr job start reviewer`.\nIf the user is ready to build, ask once whether to hand off; if yes, run:\n`crtr job start implementer` with the plan path.";
1
+ export declare const PLAN_NEW_GUIDE = "## Planning workflow\n\nBuild and save an implementation plan: a map another agent can execute without\nre-discovering context. Work through these phases before saving.\n\n### Phase 1: Understand\n\nBuild a full picture of the request and the code. Search for reusable\nfunctions, patterns, and existing implementations before proposing new ones.\n\nLaunch up to 3 Explore subagents IN PARALLEL (single message, multiple tool\ncalls). Use 1 agent for isolated, small-scope tasks; use more when scope is\nuncertain or multiple subsystems are involved. Give each a distinct focus so\nthey don't duplicate work.\n\n### Phase 2: Design\n\nDesign the implementation from Phase 1 findings. Default to launching at least\n1 Plan agent to validate your understanding and surface alternatives. Skip only\nfor trivially small tasks (typo fixes, single-line renames). Use up to 3 agents\nfor large refactors or architectural changes.\n\nIn the Plan agent prompt: include file paths, code-path traces, requirements,\nand constraints from Phase 1. Request a detailed implementation plan.\n\n### Phase 3: Review findings\n\nRead the critical files identified by agents. Confirm the plan aligns with the\nuser's request. Use AskUserQuestion ONLY to clarify requirements or choose\nbetween approaches \u2014 never to ask \"is this okay?\" or \"should I proceed?\".\n\n### Phase 4: Compose the plan body\n\nQuality bar \u2014 every item below is cheap to satisfy and saves the implementer\nfrom re-deciding:\n\n- Every decision pinned. No \"if X then Y\" branches, no \"investigate whether\u2026\",\n no deferred choices. If you don't know, find out or ask now.\n- No timelines, no fallbacks, no magic values, no \"for now\" shortcuts.\n- Where the plan creates a new interface, schema, or contract, write the actual\n shape, not \"design a Foo type.\"\n\nRequired sections:\n\n # Plan: <one-line title>\n\n ## Context\n <why this change is being made \u2014 the problem, what prompted it, intended outcome>\n\n ## Recommended approach\n <your chosen approach only. Concise enough to scan, detailed enough to execute.>\n\n ## Files to modify / create\n - `path/to/file.ts` \u2014 <what changes>\n\n ## Existing utilities to reuse\n - `functionName` from `path/to/file.ts:LL` \u2014 <why it fits>\n\n ## Verification\n <how to test end-to-end \u2014 run the code, run tests, etc.>\n\nFor plans touching 4+ files across distinct concerns, structure parallel tasks:\n\n ## Tasks\n - **Task 1**: <name>\n - Files: `a.ts`, `b.ts` (disjoint from other tasks)\n - Depends on: (none) | Task N\n - Integration: <shared types/APIs with exact shape>\n - Changes: <bullets>\n\nSkip the Tasks structure for small plans; it's noise when there's no\nparallelism to unlock.\n\n### Phase 5: Save\n\nRun `crtr agent plan new`:\n\n echo '<plan markdown>' | crtr agent plan new <kebab-case-name> [--spec <spec-name>]\n\n- NAME: short kebab-case slug. Nested names become subdirectories\n (e.g. `auth/jwt-refresh`).\n- Pipe the full plan markdown composed in Phase 4 on stdin.\n- `--spec` (optional): name of the spec this plan implements. Enables alignment\n check by the reviewer.\n\nOutput: `{path, follow_up}`. The `follow_up` field names the exact next call\n\u2014 run it.\n\n### Phase 6: Oversize check\n\nIf `follow_up` contains an oversize advisory (plan exceeds 200 lines), split\ninto a short index plan plus nested part plans, each under the threshold.\nRe-save. The implementer executes parts one at a time; long monolithic plans\nare under-decomposed.\n\n### Phase 7: Done\n\nAfter the reviewer approves the plan, your turn ends. Do not summarize in chat.\nFor a human gate, optionally put the plan in front of a person with `crtr\nhuman review` (anchored comments) and gate the handoff with `crtr human\napprove`. This complements \u2014 it does not replace \u2014 `crtr agent new reviewer`.\nIf the user is ready to build, ask once whether to hand off; if yes, run:\n`crtr agent new implementer` with the plan path.";
2
2
  export declare const PLAN_SHOW_GUIDE = "";
3
3
  import type { BranchDef } from '../core/command.js';
4
4
  export declare function registerPlan(): BranchDef;