@crouton-kit/crouter 0.3.8 → 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 (46) hide show
  1. package/dist/cli.js +14 -24
  2. package/dist/commands/agent.d.ts +4 -0
  3. package/dist/commands/agent.js +444 -243
  4. package/dist/commands/debug.d.ts +1 -1
  5. package/dist/commands/debug.js +20 -7
  6. package/dist/commands/human.js +51 -19
  7. package/dist/commands/job.d.ts +9 -0
  8. package/dist/commands/job.js +50 -10
  9. package/dist/commands/mode.d.ts +2 -0
  10. package/dist/commands/mode.js +231 -0
  11. package/dist/commands/pkg.js +5 -0
  12. package/dist/commands/plan.d.ts +1 -1
  13. package/dist/commands/plan.js +24 -11
  14. package/dist/commands/skill.js +20 -4
  15. package/dist/commands/spec.d.ts +1 -1
  16. package/dist/commands/spec.js +24 -11
  17. package/dist/commands/sys.js +5 -0
  18. package/dist/core/__tests__/job.test.js +11 -11
  19. package/dist/core/__tests__/jobs.test.js +33 -1
  20. package/dist/core/__tests__/resolver.test.js +69 -1
  21. package/dist/core/__tests__/spawn.test.d.ts +1 -0
  22. package/dist/core/__tests__/spawn.test.js +138 -0
  23. package/dist/core/__tests__/subagents.test.d.ts +1 -0
  24. package/dist/core/__tests__/subagents.test.js +75 -0
  25. package/dist/core/__tests__/unknown-path.test.d.ts +1 -0
  26. package/dist/core/__tests__/unknown-path.test.js +52 -0
  27. package/dist/core/bootstrap.d.ts +2 -0
  28. package/dist/core/bootstrap.js +66 -0
  29. package/dist/core/command.d.ts +58 -2
  30. package/dist/core/command.js +62 -14
  31. package/dist/core/frontmatter.d.ts +10 -0
  32. package/dist/core/frontmatter.js +24 -9
  33. package/dist/core/help.d.ts +39 -8
  34. package/dist/core/help.js +64 -32
  35. package/dist/core/jobs.d.ts +8 -2
  36. package/dist/core/jobs.js +109 -6
  37. package/dist/core/resolver.js +51 -1
  38. package/dist/core/spawn.d.ts +140 -23
  39. package/dist/core/spawn.js +392 -73
  40. package/dist/core/subagents.d.ts +18 -0
  41. package/dist/core/subagents.js +163 -0
  42. package/dist/prompts/agent.d.ts +10 -1
  43. package/dist/prompts/agent.js +34 -3
  44. package/dist/types.d.ts +21 -0
  45. package/dist/types.js +3 -0
  46. package/package.json +2 -2
@@ -1,3 +1,3 @@
1
- export declare const FLOW_DEBUG_GUIDE = "## Debug workflow \u2014 reproduce first\n\nAudience: the agent that ran `crtr agent debug`. A reproduction agent is\nalready spawned in a sibling pane. It writes ONE failing integration test and\nnever fixes anything. You do everything after: gate on the repro, root-cause,\nfix, verify against that same test.\n\n### Phase 0: Await the repro agent\n\nRun `crtr job read result <job_id> --wait` (10-min budget).\nOn status:\"timeout\": re-issue the wait, or run `crtr job read logs <job_id> --follow`\nuntil the job is terminal.\n\n### Phase 1: Gate on reproduction\n\n`reproduces:true`: read `test_path`, run `test_command` YOURSELF, confirm\nit fails for the stated reason. Do not trust the agent's claim \u2014 if it passes\nor fails differently, treat repro as NOT achieved. This test is the regression\ngate; it stays in the suite after the fix.\n`status:\"failed\"` / `reproduces:false` / your run disproves it: no repro\nharness. Continue, but record \"no reproduction \u2014 fix unverified; do not claim\nverified-fixed.\"\n\n### Phase 2: Reconnaissance\n\nRead the key files yourself \u2014 entry point, failure point, the data flow\nbetween. `git log` / `git blame` near the failure: recent changes are\nhigh-signal.\n\n### Phase 3: Assess difficulty, scale investigators\n\nSimple \u2192 solo (Explore subagents for tracing if the area is large).\nMedium \u2192 2\u20133 parallel `devcore:senior-advisor`: data-flow tracer, assumption\nauditor, change investigator.\nHard (intermittent, races, \"been stuck\", many modules) \u2192 3\u20135 parallel:\nend-to-end tracer, assumption breaker, git archaeologist, boundary inspector.\nGive investigators file paths, observed behavior, and concrete tasks \u2014 never\nyour hypotheses. Challenge theories against each other; the one that survives\ndisconfirmation wins.\n\n### Phase 4: Fix\n\nMinimal root-cause fix. No scope expansion, no drive-by refactor.\n\n### Phase 5: Verify\n\nRe-run `test_command`: it MUST now pass. Run the broader suite for\nregressions. If there was no repro test, state the fix is unverified by\nreproduction and recommend explicit manual verification.\n\n### Phase 6: Report\n\nRoot cause (exact line + why), evidence, the now-passing repro test path,\nconfidence (High/Medium/Low; if not High, name what is uncertain).\n\n### Constraints\n\nThe repro test is the regression guard \u2014 it stays; a fix-agent must never\nweaken it. Investigators run in forked contexts; they return summaries, not\nraw output. No code changes during Phases 2\u20133 except the repro test.";
1
+ export declare const FLOW_DEBUG_GUIDE = "## Debug workflow \u2014 reproduce first\n\nAudience: the agent that ran `crtr mode debug`. A reproduction agent is\nalready spawned in a sibling pane. It writes ONE failing integration test and\nnever fixes anything. You do everything after: gate on the repro, root-cause,\nfix, verify against that same test.\n\n### Phase 0: Await the repro agent\n\nRun `crtr job read result <job_id> --wait` (10-min budget).\nOn status:\"timeout\": re-issue the wait, or run `crtr job read logs <job_id> --follow`\nuntil the job is terminal.\n\n### Phase 1: Gate on reproduction\n\n`reproduces:true`: read `test_path`, run `test_command` YOURSELF, confirm\nit fails for the stated reason. Do not trust the agent's claim \u2014 if it passes\nor fails differently, treat repro as NOT achieved. This test is the regression\ngate; it stays in the suite after the fix.\n`status:\"failed\"` / `reproduces:false` / your run disproves it: no repro\nharness. Continue, but record \"no reproduction \u2014 fix unverified; do not claim\nverified-fixed.\"\n\n### Phase 2: Reconnaissance\n\nRead the key files yourself \u2014 entry point, failure point, the data flow\nbetween. `git log` / `git blame` near the failure: recent changes are\nhigh-signal.\n\n### Phase 3: Assess difficulty, scale investigators\n\nSimple \u2192 solo (Explore subagents for tracing if the area is large).\nMedium \u2192 2\u20133 parallel `devcore:senior-advisor`: data-flow tracer, assumption\nauditor, change investigator.\nHard (intermittent, races, \"been stuck\", many modules) \u2192 3\u20135 parallel:\nend-to-end tracer, assumption breaker, git archaeologist, boundary inspector.\nGive investigators file paths, observed behavior, and concrete tasks \u2014 never\nyour hypotheses. Challenge theories against each other; the one that survives\ndisconfirmation wins.\n\n### Phase 4: Fix\n\nMinimal root-cause fix. No scope expansion, no drive-by refactor.\n\n### Phase 5: Verify\n\nRe-run `test_command`: it MUST now pass. Run the broader suite for\nregressions. If there was no repro test, state the fix is unverified by\nreproduction and recommend explicit manual verification.\n\n### Phase 6: Report\n\nRoot cause (exact line + why), evidence, the now-passing repro test path,\nconfidence (High/Medium/Low; if not High, name what is uncertain).\n\n### Constraints\n\nThe repro test is the regression guard \u2014 it stays; a fix-agent must never\nweaken it. Investigators run in forked contexts; they return summaries, not\nraw output. No code changes during Phases 2\u20133 except the repro test.";
2
2
  import type { LeafDef } from '../core/command.js';
3
3
  export declare function registerDebug(): LeafDef;
@@ -1,14 +1,14 @@
1
- // `crtr agent debug` leaf — reproduce-first root-cause workflow.
1
+ // `crtr mode debug` leaf — reproduce-first root-cause workflow.
2
2
  //
3
3
  // Running it spawns a reproduction-only agent in a sibling tmux pane (the same
4
- // spawn + job-handle shape as `crtr agent new prompt`) and returns a job handle
4
+ // spawn + job-handle shape as `crtr agent new`) and returns a job handle
5
5
  // plus a follow_up. The orchestrator-side methodology lives in FLOW_DEBUG_GUIDE
6
- // (the leaf's help.guide), loaded via `crtr agent debug -h` after the repro
6
+ // (the leaf's help.guide), loaded via `crtr mode debug -h` after the repro
7
7
  // agent returns. Methodology stays in the CLI guide field, like PLAN_NEW_GUIDE;
8
8
  // no builtin skill.
9
9
  export const FLOW_DEBUG_GUIDE = `## Debug workflow — reproduce first
10
10
 
11
- Audience: the agent that ran \`crtr agent debug\`. A reproduction agent is
11
+ Audience: the agent that ran \`crtr mode debug\`. A reproduction agent is
12
12
  already spawned in a sibling pane. It writes ONE failing integration test and
13
13
  never fixes anything. You do everything after: gate on the repro, root-cause,
14
14
  fix, verify against that same test.
@@ -82,7 +82,7 @@ function assertTmux() {
82
82
  if (!isInTmux()) {
83
83
  throw new InputError({
84
84
  error: 'not_in_tmux',
85
- message: 'crtr agent debug requires tmux (TMUX env var not set).',
85
+ message: 'crtr mode debug requires tmux (TMUX env var not set).',
86
86
  next: 'Run inside a tmux session.',
87
87
  });
88
88
  }
@@ -91,7 +91,7 @@ export function registerDebug() {
91
91
  return defineLeaf({
92
92
  name: 'debug',
93
93
  help: {
94
- name: 'agent debug',
94
+ name: 'mode debug',
95
95
  summary: 'reproduce-first root-cause workflow: spawns a reproduction agent, then you root-cause and fix',
96
96
  guide: FLOW_DEBUG_GUIDE,
97
97
  params: [
@@ -137,6 +137,19 @@ export function registerDebug() {
137
137
  'On completion, result writes atomically to result.json.',
138
138
  ],
139
139
  },
140
+ slash: {
141
+ name: 'debug',
142
+ description: 'Debug mode — reproduce-first root-cause investigation.',
143
+ argumentHint: '<symptom or failing test>',
144
+ body: `You are entering **debug mode**: a reproduce-first, root-cause investigation.
145
+
146
+ 1. Run \`crtr mode debug -h\` to load the debugging workflow and output schema.
147
+ 2. Follow it — reproduce the failure first, then isolate the root cause before proposing a fix.
148
+
149
+ The issue: $ARGUMENTS
150
+
151
+ If no issue was given, ask the user for the symptom or failing test before starting.`,
152
+ },
140
153
  run: async (input) => {
141
154
  assertTmux();
142
155
  const stepsToReproduce = input['steps_to_reproduce'];
@@ -172,7 +185,7 @@ export function registerDebug() {
172
185
  });
173
186
  return {
174
187
  job_id: jobId,
175
- follow_up: `Await the reproduction agent: crtr job read result ${jobId} --wait. Then run \`crtr agent debug -h\` and follow the workflow from Phase 1.`,
188
+ follow_up: `Await the reproduction agent: crtr job read result ${jobId} --wait. Then run \`crtr mode debug -h\` and follow the workflow from Phase 1.`,
176
189
  };
177
190
  },
178
191
  });
@@ -13,7 +13,7 @@
13
13
  // run.json, never stdin.
14
14
  import { defineBranch, defineLeaf } from '../core/command.js';
15
15
  import { InputError } from '../core/io.js';
16
- import { createJob, writeResult, recordJobPane, appendEvent } from '../core/jobs.js';
16
+ import { createJob, writeResult, recordJobPane, appendEvent, readResult as jobsReadResult } from '../core/jobs.js';
17
17
  import { spawnAndDetach, shellQuote, isInTmux, countPanesInCurrentWindow } from '../core/spawn.js';
18
18
  import { interactionsRoot, interactionDir } from '../core/artifact.js';
19
19
  import { paginate } from '../core/pagination.js';
@@ -45,9 +45,10 @@ function followUpDrain(jobId) {
45
45
  }
46
46
  /**
47
47
  * Spawn the detached `_run` pane for a job-backed kickoff, record the pane for
48
- * cancellation, log the start, and return the appropriate follow_up. Degrades
49
- * to the inbox-drain follow_up (job still created) when not in tmux / spawn
50
- * fails — kickoffs are intentionally non-fatal off-tmux.
48
+ * cancellation, log the start, and return whether the pane spawned plus the
49
+ * appropriate follow_up. Degrades to the inbox-drain follow_up (job still
50
+ * created) when not in tmux / spawn fails — kickoffs are intentionally
51
+ * non-fatal off-tmux.
51
52
  */
52
53
  function spawnHumanJob(jobId, idir, cwd) {
53
54
  const spawn = spawnAndDetach({
@@ -59,7 +60,7 @@ function spawnHumanJob(jobId, idir, cwd) {
59
60
  failGuard: true,
60
61
  });
61
62
  if (spawn.status !== 'spawned') {
62
- return followUpDrain(jobId);
63
+ return { spawned: false, follow_up: followUpDrain(jobId) };
63
64
  }
64
65
  if (spawn.paneId !== undefined)
65
66
  recordJobPane(jobId, spawn.paneId);
@@ -69,7 +70,7 @@ function spawnHumanJob(jobId, idir, cwd) {
69
70
  event: 'worker_started',
70
71
  message: `human pane ${paneLabel} spawned`,
71
72
  });
72
- return followUpResult(jobId);
73
+ return { spawned: true, follow_up: followUpResult(jobId) };
73
74
  }
74
75
  // ---------------------------------------------------------------------------
75
76
  // ask
@@ -78,7 +79,8 @@ const humanAsk = defineLeaf({
78
79
  name: 'ask',
79
80
  help: {
80
81
  name: 'human ask',
81
- summary: 'put a humanloop decision deck in front of a person; returns a job handle immediately. Humans respond on human time (often >10 min)never block on the result.',
82
+ summary: 'put a humanloop decision deck in front of a person; returns a job handle immediately. This is the default, expected channel for posing ANY question or decision to the user reach for it instead of writing the question as prose in your reply.',
83
+ guide: 'Use this for quick, open-ended, and nuanced asks alike — not just "formal" multiple-choice. Set `allowFreetext: true` (with `freetextLabel`) when the answer is open-ended; offer a few `options` as starting points even for judgment calls. The kickoff is instant and NEVER blocks — "never block on the result" refers only to not busy-waiting on the job; the human answering on their own time is not a reason to avoid asking or to fall back to inline prose. The deck body is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see `termrender doc -h` for the directive set before authoring one.',
82
84
  params: [
83
85
  { kind: 'context-file', name: 'deck', required: true, constraint: 'Contains a humanloop deck. Validated before any job is created.', shape: DECK_SCHEMA_HINT },
84
86
  { kind: 'flag', name: 'wait', type: 'bool', required: false, constraint: 'Accepted for symmetry with the job contract; the kickoff never blocks.' },
@@ -114,7 +116,7 @@ const humanAsk = defineLeaf({
114
116
  atomicWriteJson(deckPath(idir), deck);
115
117
  const rc = { mode: 'ask', job_id: jobId };
116
118
  atomicWriteJson(join(idir, 'run.json'), rc);
117
- const follow_up = spawnHumanJob(jobId, idir, cwd);
119
+ const { follow_up } = spawnHumanJob(jobId, idir, cwd);
118
120
  return { job_id: jobId, dir: idir, follow_up };
119
121
  },
120
122
  });
@@ -125,7 +127,8 @@ const humanApprove = defineLeaf({
125
127
  name: 'approve',
126
128
  help: {
127
129
  name: 'human approve',
128
- summary: 'a Yes/No approval gate; returns a job handle immediately. Humans respond on human time (often >10 min)never block on the result.',
130
+ summary: 'a Yes/No approval gate; returns a job handle immediately. The standard way to gate a handoff on human sign-off. Kickoff never blockspeek at the result later rather than busy-waiting; the human answering on their own time is not a reason to skip the gate.',
131
+ guide: 'The body is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see `termrender doc -h` for the directive set before authoring one.',
129
132
  params: [
130
133
  { kind: 'positional', name: 'title', type: 'string', required: true, constraint: 'The question shown to the human.' },
131
134
  { kind: 'flag', name: 'subtitle', type: 'string', required: false, constraint: 'Optional one-line context.' },
@@ -157,7 +160,7 @@ const humanApprove = defineLeaf({
157
160
  atomicWriteJson(deckPath(idir), deck);
158
161
  const rc = { mode: 'approve', job_id: jobId, approve_iid: 'approve' };
159
162
  atomicWriteJson(join(idir, 'run.json'), rc);
160
- const follow_up = spawnHumanJob(jobId, idir, cwd);
163
+ const { follow_up } = spawnHumanJob(jobId, idir, cwd);
161
164
  return { job_id: jobId, dir: idir, follow_up };
162
165
  },
163
166
  });
@@ -168,20 +171,25 @@ const humanReview = defineLeaf({
168
171
  name: 'review',
169
172
  help: {
170
173
  name: 'human review',
171
- summary: 'open a .md in a read-only review editor for anchored comments; returns a job handle immediately. Humans respond on human time (often >10 min) — never block on the result.',
174
+ summary: 'open a .md in a read-only review editor for anchored comments; BLOCKS until the human submits the review. Humans respond on human time (often >10 min) — if you want to keep working, background this call (your harness will notify you when it finishes).',
175
+ guide: 'Unlike ask/approve, this call does not return a job handle and walk away — it blocks until the human finishes reviewing and submits (or closes the pane). Run it in the background when you have other work to do; the harness surfaces the result on completion. The returned `result` is the humanloop FeedbackResult (anchored comments). The .md you point at is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see `termrender doc -h` for the directive set before authoring one.',
172
176
  params: [
173
177
  { kind: 'positional', name: 'file', type: 'path', required: true, constraint: 'Absolute path to an existing .md file.' },
174
178
  { kind: 'flag', name: 'output', type: 'path', required: false, constraint: 'Where the FeedbackResult JSON is written. Default: <dir>/feedback.json.' },
175
179
  ],
176
180
  output: [
177
- { name: 'job_id', type: 'string', required: true, constraint: 'Poll with `crtr job read result`; result is the humanloop FeedbackResult.' },
181
+ { name: 'job_id', type: 'string', required: true, constraint: 'The kind:"human" job backing this review. Cancel with `crtr job cancel`.' },
178
182
  { name: 'output', type: 'string', required: true, constraint: 'Path the FeedbackResult JSON is autosaved to.' },
179
- { name: 'follow_up', type: 'string', required: true, constraint: 'A non-blocking status peek. The human may take minutes to hours never block waiting on this.' },
183
+ { name: 'status', type: 'string', required: true, constraint: 'Terminal state once the call unblocks: done (submitted), failed, canceled, or closed (pane went away before submit).' },
184
+ { name: 'result', type: 'object', required: false, constraint: 'The humanloop FeedbackResult (anchored comments). Present when status is done.' },
185
+ { name: 'reason', type: 'string', required: false, constraint: 'Short explanation when status is failed or closed.' },
186
+ { name: 'follow_up', type: 'string', required: false, constraint: 'Present only when off-tmux: a human must drain the review via `crtr human inbox`, then read the result.' },
180
187
  ],
181
188
  outputKind: 'object',
182
189
  effects: [
183
190
  'Creates a kind:"human" job and writes run.json to the interaction dir.',
184
191
  'Spawns a read-only nvim/vim review session in a detached tmux pane (when in tmux).',
192
+ 'Blocks the calling process until the human submits, the pane closes, or the job is canceled.',
185
193
  ],
186
194
  },
187
195
  run: async (input) => {
@@ -211,8 +219,22 @@ const humanReview = defineLeaf({
211
219
  const output = outputArg !== undefined ? outputArg : join(idir, 'feedback.json');
212
220
  const rc = { mode: 'review', job_id: jobId, file: abs, output };
213
221
  atomicWriteJson(join(idir, 'run.json'), rc);
214
- const follow_up = spawnHumanJob(jobId, idir, cwd);
215
- return { job_id: jobId, output, follow_up };
222
+ const { spawned, follow_up } = spawnHumanJob(jobId, idir, cwd);
223
+ // Off-tmux: no pane to block on — fall back to the non-blocking handle the
224
+ // way ask/approve do, so the review can still be drained from the inbox.
225
+ if (!spawned) {
226
+ return { job_id: jobId, output, status: 'live', follow_up };
227
+ }
228
+ // In tmux: block until the human submits, the pane closes, or the job is
229
+ // canceled. Infinity = no timeout (the human owns the clock); the poll in
230
+ // readResult still reaps a dead pane, so this never hangs on a closed pane.
231
+ const r = await jobsReadResult(jobId, { waitMs: Infinity });
232
+ const out = { job_id: jobId, output, status: r.status };
233
+ if (r.result !== undefined)
234
+ out['result'] = r.result;
235
+ if (r.reason !== undefined)
236
+ out['reason'] = r.reason;
237
+ return out;
216
238
  },
217
239
  });
218
240
  // ---------------------------------------------------------------------------
@@ -223,6 +245,7 @@ const humanNotify = defineLeaf({
223
245
  help: {
224
246
  name: 'human notify',
225
247
  summary: 'show a fire-and-forget acknowledgement; creates no job',
248
+ guide: 'The body is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see `termrender doc -h` for the directive set before authoring one.',
226
249
  params: [
227
250
  { kind: 'positional', name: 'title', type: 'string', required: true, constraint: 'The notification headline.' },
228
251
  { kind: 'flag', name: 'body', type: 'string', required: false, constraint: 'Optional markdown body.' },
@@ -270,9 +293,9 @@ const humanShow = defineLeaf({
270
293
  help: {
271
294
  name: 'human show',
272
295
  summary: 'put a file live on screen in a tmux pane via humanloop display',
296
+ guide: 'The pane always watches the file and live-updates on every save — a displayed doc is a live view by definition, so point it at a file something keeps rewriting (a status board, a running summary) and it stays current. The file is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see `termrender doc -h` for the directive set before authoring one.',
273
297
  params: [
274
298
  { kind: 'positional', name: 'path', type: 'path', required: true, constraint: 'Path to the file to render.' },
275
- { kind: 'flag', name: 'watch', type: 'bool', required: false, constraint: 'When present, live-update the pane on edits. Default off.' },
276
299
  { kind: 'flag', name: 'window', type: 'enum', choices: ['auto', 'split', 'new'], required: false, default: 'auto', constraint: 'Placement. Default auto.' },
277
300
  ],
278
301
  output: [
@@ -284,14 +307,13 @@ const humanShow = defineLeaf({
284
307
  },
285
308
  run: async (input) => {
286
309
  const path = input['path'];
287
- const watch = input['watch'] === true;
288
310
  const windowArg = input['window'];
289
311
  const window = windowArg !== undefined ? windowArg : 'auto';
290
312
  // `human show` must never fail the caller: any display error degrades to
291
313
  // {pane_id:null, reason} with exit 0 (matches humanloop display semantics).
292
314
  let paneId;
293
315
  try {
294
- const r = display(path, { watch, window, maxPanes: resolveMaxPanes() });
316
+ const r = display(path, { window, maxPanes: resolveMaxPanes() });
295
317
  paneId = r.paneId;
296
318
  }
297
319
  catch {
@@ -415,8 +437,13 @@ const humanRun = defineLeaf({
415
437
  // notify: no job — nothing to write
416
438
  }
417
439
  else if (rc.mode === 'review') {
440
+ // The _run worker is already its own dedicated tmux pane with a TTY, so
441
+ // run nvim directly in it (noTmux) instead of letting launchReview
442
+ // split off a SECOND pane and sit polling. This matches how ask/approve
443
+ // render in-place and avoids the redundant side pane.
418
444
  const res = await launchReview(rc.file, {
419
445
  output: rc.output,
446
+ noTmux: true,
420
447
  });
421
448
  writeResult(rc.job_id, res, 'done');
422
449
  }
@@ -434,10 +461,15 @@ const humanRun = defineLeaf({
434
461
  export function registerHuman() {
435
462
  return defineBranch({
436
463
  name: 'human',
464
+ rootEntry: {
465
+ concept: 'human-in-the-loop decisions, document review, and live display: ask puts a structured choice to a person, approve gates a handoff on a Yes/No sign-off, review collects anchored comments on a plan or spec, notify informs without blocking, show puts a file live on screen',
466
+ desc: 'ask, approve, review, notify, show, inbox, list',
467
+ useWhen: 'you have a question for the user or want their feedback — always reach for human instead of guessing or assuming when a person can decide',
468
+ },
437
469
  help: {
438
470
  name: 'human',
439
471
  summary: 'human-in-the-loop decisions, document review, and live display',
440
- model: "Kickoff leaves create kind:'human' jobs. Humans respond on human time (often >10 min) — never block waiting on the result; peek with `crtr job read result|status` (no `wait`). Cancel with `crtr job cancel`. notify/show create no job.",
472
+ model: "Reach for human whenever you have a question for the user or want their feedback — never guess or assume when a person can decide. ask puts a structured choice in front of them; approve gates a handoff on a Yes/No sign-off; review collects anchored comments on a plan or spec; notify informs without blocking; show puts a file live on screen. Every body and displayed file is directive-flavored markdown rendered by termrender (panels, columns, trees, callouts, mermaid) — see `termrender doc -h` for the directive set before authoring one. ask/approve/review are the DEFAULT channel for questions, sign-offs, and feedback — reach for them even for quick or open-ended asks (use `allowFreetext`), and don't substitute prose in your reply. ask and approve are kickoffs: they create kind:'human' jobs and return instantly, never blocking — peek later with `crtr job read result|status` (no `wait`). review is different: it BLOCKS until the human submits, so background the call if you want to keep working (your harness notifies you when it finishes). 'Humans respond on human time' describes response latency only — it is never a reason to avoid asking. Cancel with `crtr job cancel`. notify/show create no job.",
441
473
  children: [
442
474
  { name: 'ask', desc: 'put a decision deck to a person', useWhen: 'a structured choice needs a human' },
443
475
  { name: 'approve', desc: 'a Yes/No approval gate', useWhen: 'gating a handoff on human sign-off' },
@@ -1,2 +1,11 @@
1
1
  import type { BranchDef } from '../core/command.js';
2
+ /** Count of jobs currently in the live state, or null when listing fails.
3
+ * Backs the always-on "Workers running" signal on root -h so an agent never
4
+ * forgets it has in-flight workers to collect. */
5
+ export declare function liveJobCount(): number | null;
6
+ /** The job subtree's root-level dynamic block. A bounded aggregate (running
7
+ * count + how to collect), never an enumeration: live jobs are volatile and
8
+ * unbounded, so listing them in root -h would balloon (cli-design rule 15).
9
+ * Omitted when nothing is running. */
10
+ export declare function buildJobRootBlock(): string | null;
2
11
  export declare function registerJob(): BranchDef;
@@ -6,20 +6,44 @@
6
6
  // cancel.
7
7
  //
8
8
  // Terminal-write contract:
9
- // Worker calls `crtr job submit` → jobs.writeResult(jobId, result, 'done').
10
- // If claude exits without submitting, the wrapper shell calls `crtr job _fail`
11
- // jobs.writeResult(jobId, {}, 'failed') IF result.json does not yet exist.
12
- // `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.
13
15
  //
14
16
  // `job read logs` is the only JSONL leaf.
15
17
  import { defineBranch, defineLeaf } from '../core/command.js';
16
18
  import { emitLine } from '../core/io.js';
17
19
  import { InputError } from '../core/io.js';
18
- import { writeMarkdownResult, readResult as jobsReadResult, jobStatus, listJobs, readLog, cancelJob, } from '../core/jobs.js';
20
+ import { writeMarkdownResult, readResult as jobsReadResult, jobStatus, listJobs, readLog, cancelJob, appendEvent, } from '../core/jobs.js';
19
21
  import { scheduleKillCurrentPane } from '../core/spawn.js';
20
22
  import { paginate } from '../core/pagination.js';
23
+ import { stateBlock } from '../core/help.js';
21
24
  const WAIT_BUDGET_MS = 10 * 60 * 1000;
22
25
  const FOLLOW_POLL_MS = 1000;
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;
35
+ }
36
+ }
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
+ }
23
47
  const DEFAULT_KILL_SECS = 2;
24
48
  // ---------------------------------------------------------------------------
25
49
  // read sub-branch
@@ -68,7 +92,7 @@ const readStatus = defineLeaf({
68
92
  ],
69
93
  output: [
70
94
  { name: 'job_id', type: 'string', required: true, constraint: 'Echo of input.' },
71
- { 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).' },
72
96
  { name: 'age_s', type: 'number', required: true, constraint: 'Seconds since job creation.' },
73
97
  { name: 'last_event', type: 'object | null', required: true, constraint: 'Most recent log event {event, ts}, or null if no events yet.' },
74
98
  ],
@@ -133,7 +157,7 @@ const readLogs = defineLeaf({
133
157
  lastTs = e['ts'];
134
158
  }
135
159
  }
136
- const terminalStates = new Set(['done', 'failed', 'canceled']);
160
+ const terminalStates = new Set(['done', 'failed', 'canceled', 'closed']);
137
161
  await new Promise((resolve) => {
138
162
  const poll = () => {
139
163
  const status = jobStatus(jobId);
@@ -166,10 +190,10 @@ const readResult = defineLeaf({
166
190
  ],
167
191
  output: [
168
192
  { name: 'job_id', type: 'string', required: true, constraint: 'Echo of input.' },
169
- { name: 'status', type: 'string', required: true, constraint: 'One of: done, failed, canceled, timeout.' },
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.' },
170
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.' },
171
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.' },
172
- { name: 'reason', type: 'string', required: false, constraint: 'Failure reason from frontmatter when status is failed and the agent submit path was used.' },
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).' },
173
197
  ],
174
198
  outputKind: 'object',
175
199
  effects: ['None. Read-only.'],
@@ -255,6 +279,11 @@ const jobSubmit = defineLeaf({
255
279
  });
256
280
  }
257
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
+ });
258
287
  const paneKillScheduled = killPane ? scheduleKillCurrentPane(DEFAULT_KILL_SECS) : false;
259
288
  return { submitted: true, pane_kill_scheduled: paneKillScheduled };
260
289
  },
@@ -292,6 +321,11 @@ const jobFail = defineLeaf({
292
321
  }
293
322
  try {
294
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
+ });
295
329
  return { recorded: true };
296
330
  }
297
331
  catch {
@@ -328,10 +362,16 @@ const jobCancel = defineLeaf({
328
362
  export function registerJob() {
329
363
  return defineBranch({
330
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
+ },
331
371
  help: {
332
372
  name: 'job',
333
373
  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.',
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).',
335
375
  children: [
336
376
  { name: 'read', desc: 'read status, logs, or results', useWhen: 'monitoring or collecting from a running or completed job' },
337
377
  { name: 'submit', desc: 'deliver result from inside a worker pane or any producer', useWhen: 'a worker is ready to return its output' },
@@ -0,0 +1,2 @@
1
+ import type { BranchDef } from '../core/command.js';
2
+ export declare function registerMode(): BranchDef;