@welluable/orch 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -51,7 +51,10 @@ npm install -g @welluable/orch
51
51
 
52
52
  Make sure an agent CLI is on your `PATH` — orch defaults to `--agent cursor`
53
53
  (the Cursor Agent CLI, command `agent`); `claude` and `agn` are also
54
- supported. See [Requirements](#requirements) for details.
54
+ supported. Pin a default with `orch config --agent <name>` so you don't need
55
+ `--agent` on every run (local `.orch/config` overrides global
56
+ `~/.orch/config`; CLI `--agent` still wins). See [Requirements](#requirements)
57
+ for details.
55
58
 
56
59
  ```bash
57
60
  orch "fix the typo in the README"
@@ -123,6 +126,8 @@ you invoked `orch`, plus a persistent sibling git worktree and branch:
123
126
  research.md
124
127
  task.md
125
128
  status.md
129
+ run.json # job state (state, phase, pid, ...); written for every run
130
+ orch.log # full stdout/stderr of the run; --detach only
126
131
 
127
132
  <parent-of-repo>/<repo-name>-<slug> # worktree
128
133
  orch/<slug> # branch
@@ -134,6 +139,11 @@ test-runner) run inside the worktree instead. The worktree is never deleted
134
139
  automatically — it's left in place after the run so you can inspect,
135
140
  continue, or merge the work whenever you're ready.
136
141
 
142
+ `run.json` is written for every invocation — default, `--quick`, `--ask`, and
143
+ `--detach` alike — so `orch list`/`status` always have something to show; see
144
+ [Headless runs](#headless-runs). `orch.log` is only written for `--detach`
145
+ runs, since foreground runs already stream their output to your terminal.
146
+
137
147
  ## Architecture
138
148
 
139
149
  ```text
@@ -161,14 +171,22 @@ agent CLI does all the actual reading and writing of files.
161
171
  | `--quick` | Skips triage, runs `quick-fix` directly in the current tree; no artifacts, worktree, or commits. | You already know it's a small, direct edit. |
162
172
  | `--ask` | Skips triage and all write pipelines; one read-only agent answers and orch prints the reply. | You want an answer about the codebase, not a change. |
163
173
  | `--dry-run` | Checks the selected agent CLI is on `PATH` and exits without running the pipeline. | You want to sanity-check your setup before a real run. |
174
+ | `--detach` | Runs the pipeline in a background process and returns immediately, printing the run slug. Manage it with `orch list/status/pause/resume/stop/logs`. | You want to kick off a run and keep using your shell, or run several tasks concurrently. |
164
175
 
165
176
  For `--ask`, Cursor uses `--mode ask`, Claude uses `--permission-mode plan`,
166
177
  and `agn` is prompt-only best-effort (it has no dedicated read-only flag).
167
178
 
179
+ `--detach` only controls backgrounding, not whether a job record exists —
180
+ every non-`--dry-run` invocation gets one. Combining `--detach` with
181
+ `--ask`, `--quick`, or `--dry-run` is rejected outright (non-zero exit)
182
+ because there's no way to background those modes, not because they'd lack a
183
+ job record. Multiple `--detach` runs can execute concurrently in the same
184
+ directory, each with its own slug.
185
+
168
186
  ## CLI Reference
169
187
 
170
188
  ```text
171
- Usage: orch [options] <task...>
189
+ Usage: orch [options] [command] <task...>
172
190
  ```
173
191
 
174
192
  - `<task...>` — task description to use as the prompt (mention a file path
@@ -182,12 +200,50 @@ Usage: orch [options] <task...>
182
200
  and exits (skips triage and all write pipelines).
183
201
  - `--quick` — skips triage, runs `quick-fix` directly in the current working
184
202
  tree; creates no artifacts, worktrees, or commits.
203
+ - `--detach` — runs the pipeline in the background and returns immediately,
204
+ printing `started <slug> (pid <pid>)`; rejects `--ask`/`--quick`/`--dry-run`.
185
205
  - `--max-rounds <n>` — max writer⇄critic and writer⇄runner iterations per
186
206
  implementer loop; defaults to `5`, ignored with `--ask` and `--quick`.
207
+ - `--fan-out` — decomposes the task into parallel workers coordinated by this
208
+ process instead of running the single-worktree pipeline (see
209
+ [Fan-out](#fan-out)); rejects `--ask`/`--quick`/`--dry-run`.
210
+ - `--max-workers <n>` — max number of parallel fan-out workers; defaults to
211
+ `4`; only meaningful with `--fan-out`.
212
+ - `--max-concurrency <n>` — optional hard ceiling on in-flight fan-out workers
213
+ at once; omit to let the coordinator choose (typically the current layer's
214
+ size); only meaningful with `--fan-out`.
187
215
  - `--agent <cursor|claude|agn>` — selects the backend for the whole pipeline;
188
- defaults to `cursor`.
216
+ when omitted, uses local `.orch/config`, then global `~/.orch/config`, else
217
+ `cursor`.
189
218
  - `-h, --help` — displays help for the command.
190
219
 
220
+ Config:
221
+
222
+ - `orch config` — prints the effective agent and which file(s) contributed
223
+ (local / global / default). Does not prompt.
224
+ - `orch config --agent <cursor|claude|agn> [--global|--local]` — writes the
225
+ default agent. Bare `--agent` (and `--global`) write `~/.orch/config`;
226
+ `--local` writes `<cwd>/.orch/config`. There is no `orch init`.
227
+
228
+ Job-control subcommands (see [Headless runs](#headless-runs)):
229
+
230
+ - `orch list` — lists all runs tracked under `.orch/` in the current directory.
231
+ - `orch status [slug]` — shows full status for a run; defaults to the most
232
+ recently started run.
233
+ - `orch pause <slug>` — requests a pause at the run's next stage-boundary
234
+ checkpoint.
235
+ - `orch resume <slug>` — unpauses a paused (or pausing) run.
236
+ - `orch continue <slug> "new task"` — starts a new complex pipeline on a
237
+ finished run's existing worktree/branch (same slug). Carries prior
238
+ stage/failure outcome into research/plan. For fan-out workers, continue the
239
+ worker slug, then `orch --integrate <parent>`. Not the same as `resume`.
240
+ - `orch stop <slug>` — sends `SIGTERM` to a running job (or reconciles a dead
241
+ one to `crashed`).
242
+ - `orch logs <slug> [-f]` — prints a run's `orch.log`; `-f` follows it until
243
+ the job reaches a terminal state.
244
+ - `orch jobs clean` — deletes every run tracked under `.orch/` in the current
245
+ directory, after a `y/N` confirmation prompt.
246
+
191
247
  Examples:
192
248
 
193
249
  ```bash
@@ -197,8 +253,167 @@ orch "implement the local spec" --agent agn -v
197
253
  orch --ask "where is the CLI entrypoint?" --agent claude
198
254
  orch --quick "fix the typo in the README" --agent claude
199
255
  orch "noop" --dry-run --agent cursor
256
+ orch config
257
+ orch config --agent claude
258
+ orch config --agent agn --local
259
+ ```
260
+
261
+ ## Headless runs
262
+
263
+ `--detach` starts the full pipeline in a background process and returns your
264
+ shell immediately:
265
+
266
+ ```bash
267
+ orch "implement the local spec" --agent claude --detach
268
+ # started swift-lagoon-49ea (pid 12345)
200
269
  ```
201
270
 
271
+ The parent process validates the agent binary and flag combination, eagerly
272
+ allocates `.orch/<slug>/`, writes an initial `run.json`, then re-invokes
273
+ itself (without `--detach`) as a detached child with `ORCH_JOB_SLUG` set; the
274
+ child runs the actual pipeline and keeps `run.json` up to date as it
275
+ progresses. Manage it with the subcommands below, all scoped to the current
276
+ directory's `.orch/`:
277
+
278
+ ```bash
279
+ orch list # SLUG ROLE STATE PHASE AGENT STARTED DURATION PID
280
+ orch status swift-lagoon-49ea # full record: state, phase, branch, worktree, exit code, ...
281
+ orch pause swift-lagoon-49ea # request a pause at the next stage-boundary checkpoint
282
+ orch resume swift-lagoon-49ea # unpause a paused/pausing run
283
+ orch continue swift-lagoon-49ea "follow-up polish" # new work on the same worktree
284
+ orch logs swift-lagoon-49ea -f # follow orch.log until the run finishes
285
+ orch stop swift-lagoon-49ea # SIGTERM the run
286
+ orch jobs clean # delete every tracked run under .orch/ (asks to confirm)
287
+ ```
288
+
289
+ Pausing is cooperative and happens at stage boundaries (before the first
290
+ agent stage, and after each individual agent invocation) — it is not an OS
291
+ suspend, and a pause requested mid-stage takes effect only once that stage's
292
+ agent finishes. This means pause is not atomic across a writer⇄critic or
293
+ writer⇄runner pair: e.g. pausing during `test-writer` still lets
294
+ `test-writer` finish before the run actually pauses, ahead of `test-critic`.
295
+
296
+ `--detach` is rejected outright when combined with `--ask`, `--quick`, or
297
+ `--dry-run` — those modes have nothing to background — but that's a
298
+ restriction on backgrounding, not on job records: every non-`--dry-run`
299
+ invocation gets a `run.json`, `--detach` or not. Multiple `--detach` runs can
300
+ execute concurrently against the same directory, each tracked under its own
301
+ slug.
302
+
303
+ ## Fan-out
304
+
305
+ `--fan-out` decomposes a complex task into independent workers that each run
306
+ the full pipeline (minus triage) in their own worktree, then hands the green
307
+ branches to a dedicated integration session:
308
+
309
+ ```bash
310
+ orch "implement the billing module" --fan-out --agent claude
311
+ ```
312
+
313
+ ```text
314
+ triage: complex — fan-out requested
315
+ boundaries: partitionable into billing scaffold + 3 endpoint workers
316
+ decomposer: split into 4 workers (1 scaffold, 3 parallel)
317
+ schedule: concurrency 3
318
+
319
+ [01-scaffold rapid-fox-x7q2] done — commit b2c3d4e
320
+ [02-invoices merry-elk-r4b1] running
321
+ [03-charges wise-owl-k1a8] running
322
+ [02-invoices merry-elk-r4b1] done — commit c3d4e5f
323
+ [04-webhooks quirky-cedar-p8w3] done — commit d4e5f60
324
+
325
+ overlaps: none
326
+ [integrate tidy-heron-m2p9] merged 3 branches, full suite passed
327
+ commit: e5f6071 on orch/wise-pine-e904
328
+ merge: git merge orch/wise-pine-e904
329
+ ```
330
+
331
+ The flow, in order:
332
+
333
+ 1. **Triage** runs once, same as any run. If it routes to quick-fix, fan-out
334
+ is skipped entirely — triage never opts into fan-out on its own.
335
+ 2. **`boundaries`** (new agent) researches only how the work can be split —
336
+ what can run in parallel, where the coarse boundaries are, whether shared
337
+ scaffolding (types, registries, barrels) needs to land first — and writes
338
+ `boundaries.md`. It never plans implementation steps.
339
+ 3. **`decomposer`** (new agent) reads `boundaries.md` and the task and emits
340
+ a worker list (or declines with a reason). orch validates the decomposition
341
+ itself — worker count, ownership, dependency graph, layer overlap, at most
342
+ one `scaffold` worker — and feeds validation failures back to the
343
+ decomposer for up to two repair attempts before giving up.
344
+ 4. **Decline path.** If the decomposer declines, or validation still fails
345
+ after repairs, orch falls through to today's single-worktree pipeline
346
+ (research → plan → worktree → test loop → code loop → commit) — no
347
+ `fanout.json`, no workers scheduled.
348
+ 5. **Scaffold first.** If one worker is marked `scaffold`, it runs alone to
349
+ completion before anything else starts; its commit becomes the base SHA
350
+ every other worker (and the integration session) branches from. A failed
351
+ scaffold aborts the whole fan-out before any parallel worker spawns.
352
+ 6. **Parallel workers.** Each remaining worker is a full, cold orch pipeline
353
+ (research → plan → worktree → test loop → code loop → commit) running as
354
+ its own detached process in its own `.orch/<worker-slug>/` directory and
355
+ worktree — a sibling `orch` invocation, not in-process work. The
356
+ coordinator chooses how many run at once (capped by `--max-concurrency`
357
+ when set) based on the dependency layer currently in flight; a worker
358
+ whose dependency failed is recorded `skipped` and never started.
359
+ 7. **Integration.** Once every worker has settled, the coordinator detects
360
+ file overlaps between the `done` workers and spawns a specialized
361
+ integration session (`--integrate`, a hidden flag) that merges their
362
+ branches in order, resolves any conflicts with a dedicated `integrator`
363
+ agent, then runs a runner-first verify loop (test-runner first,
364
+ code-writer only on failure) before committing to `orch/<parent-slug>`. If
365
+ no worker reached `done`, integration is skipped and the run exits
366
+ non-zero.
367
+
368
+ Artifacts, in addition to the coordinator's own `run.json`/`orch.log`:
369
+
370
+ ```text
371
+ <invocation-cwd>/.orch/<parent-slug>/
372
+ boundaries.md # partitionability research only
373
+ fanout.json # decomposition + scheduling state
374
+
375
+ <invocation-cwd>/.orch/<worker-slug>/
376
+ research.md, task.md, status.md, run.json, orch.log # a normal run, per worker
377
+
378
+ <invocation-cwd>/.orch/<integration-slug>/
379
+ integration.md # merge log, overlaps, conflict repairs, final verdict
380
+ ```
381
+
382
+ The coordinator never creates its own worktree and never runs the
383
+ implementer stages itself outside of the decline path — after decomposition
384
+ it is pure orchestration: spawn, poll, report. Exit code is `0` only when
385
+ every worker succeeded and integration committed; any worker failure forces a
386
+ non-zero exit even if integration still commits on the green subset.
387
+
388
+ `orch list` renders an indented job tree: top-level rows are jobs with no
389
+ `parent` (coordinators and ordinary runs), children indent two spaces under
390
+ their coordinator with a `ROLE` column (`coordinator` / `worker` /
391
+ `integrate` / `-`). `orch status <parent>` expands each child's
392
+ state/phase/branch in the same tree order; `orch status <child>` shows a
393
+ `parent:` line and does not list siblings. Default `orch status` (no slug)
394
+ still picks the most recent job and uses the parent-vs-child view as
395
+ appropriate. `orch logs <parent>` tails the coordinator log only.
396
+
397
+ Cascade control is parent → children only: `orch pause|resume|stop <parent>`
398
+ applies to the coordinator and every live child (pause reports how many
399
+ children were signaled). `orch pause|resume|stop <child>` is leaf-only — no
400
+ upward cascade. While a parent is paused, the coordinator stops spawning at
401
+ its schedule checkpoints and does not kill live children; on resume it
402
+ re-attaches to still-live workers and spawns only still-pending ones (never
403
+ re-decomposing or duplicating finished/live work). A paused leaf is waited
404
+ on without failing it or starting dependents; a stopped/crashed/failed leaf
405
+ is marked `failed` in `fanout.json`, its dependents are skipped, and the
406
+ schedule continues. `SIGINT`/`SIGHUP`/`SIGTERM` on the coordinator (and
407
+ `orch stop <parent>`) cascade a `SIGTERM` to every live child it recorded —
408
+ the same as [Headless runs](#headless-runs)'s interrupt handling, just
409
+ extended to the whole fan-out tree; worktrees and branches are never
410
+ removed automatically.
411
+
412
+ Depth is capped at 1: every worker and the integration session run with
413
+ `ORCH_FANOUT_DEPTH=1` set, and `--fan-out` is rejected outright when that
414
+ variable is already present — a worker or integration session never fans out
415
+ again.
416
+
202
417
  ## Project structure
203
418
 
204
419
  Complex runs create a run directory and a sibling worktree, reusing the
@@ -209,13 +424,16 @@ layout shown in [Artifacts and worktrees](#artifacts-and-worktrees):
209
424
  research.md
210
425
  task.md
211
426
  status.md
427
+ run.json # written for every run
428
+ orch.log # --detach only
212
429
 
213
430
  <parent-of-repo>/<repo-name>-<slug> # worktree
214
431
  orch/<slug> # branch
215
432
  ```
216
433
 
217
- Default quick fixes, `--quick`, and `--ask` runs create none of this — no
218
- `.orch/` directory, no worktree, and no commits.
434
+ Default quick-fixes, `--quick`, and `--ask` runs still get a `.orch/<slug>/`
435
+ directory with a `run.json` job record (so `orch list`/`status` can see them),
436
+ but no `research.md`/`task.md`/`status.md`, no worktree, and no commits.
219
437
 
220
438
  ## Interrupts
221
439
 
@@ -234,7 +452,7 @@ pkill -f 'agn ' # --agent agn
234
452
 
235
453
  | Backend | `--agent` value | Status | Notes |
236
454
  | --- | --- | --- | --- |
237
- | Cursor Agent CLI | `cursor` | Supported (default) | Command `agent` on `PATH`. |
455
+ | Cursor Agent CLI | `cursor` | Supported (builtin default) | Command `agent` on `PATH`. Override via `orch config` or `--agent`. |
238
456
  | Claude Code CLI | `claude` | Supported | Command `claude` on `PATH`. |
239
457
  | agn | `agn` | Supported | Requires `npm install -g @welluable/agn-cli` (`>= 0.0.12`) and `agn init`. |
240
458
 
@@ -0,0 +1,26 @@
1
+ export function boundariesAgentArgs({ prompt, cwd, boundariesPath }) {
2
+ return {
3
+ name: 'boundaries',
4
+ instructions: `
5
+ You are a Boundaries Agent.
6
+
7
+ * Research the codebase rooted at ${cwd} enough to answer: what pieces of
8
+ the user's request can be worked on in parallel, where the coarse
9
+ boundaries between them are, and whether shared scaffolding (types,
10
+ registries, barrels) must land before parallel work can start.
11
+ * Do not plan implementation steps and do not write a task checklist —
12
+ forbid implementation planning entirely. That is each worker's own job
13
+ later in the pipeline.
14
+ * Do not write any code. Write your findings only to the exact path:
15
+ ${boundariesPath}
16
+ * Before the summary marker below, your message must contain only the
17
+ exact path: ${boundariesPath}
18
+ * After the path above, on a new line write the exact marker
19
+ \`<<<SUMMARY>>>\`, followed by one paragraph in natural, human-readable
20
+ language explaining what you did in this step and what happened — no
21
+ lists, no headers, just prose.
22
+ `,
23
+ prompt,
24
+ options: { cwd },
25
+ };
26
+ }
@@ -47,6 +47,11 @@ export function codeWriterAgentArgs({
47
47
  characters, with no spaces), followed by one paragraph in natural,
48
48
  human-readable language explaining what you did in this step and
49
49
  what happened — no lists, no headers, just prose.
50
+ * If you changed any files, after that paragraph add a line reading
51
+ exactly "Files:" followed by one line per changed file formatted
52
+ as "<path>: <one-line description>" (e.g. "lib/agent.js: wired the
53
+ file tracker into onToolEvent"). Omit this section if you changed
54
+ nothing.
50
55
  ${feedbackBlock}
51
56
  `,
52
57
  prompt,
@@ -0,0 +1,62 @@
1
+ function feedbackBlock(feedback) {
2
+ if (!Array.isArray(feedback) || feedback.length === 0) return '';
3
+ const violations = feedback.map((violation) => `- ${violation}`).join('\n');
4
+ return `
5
+
6
+ [Validation Feedback]
7
+ Your previous decomposition was rejected for these reasons. Fix them and
8
+ try again:
9
+ ${violations}
10
+ [/Validation Feedback]`;
11
+ }
12
+
13
+ export function decomposerAgentArgs({ prompt, cwd, boundariesOutput, maxWorkers, feedback }) {
14
+ return {
15
+ name: 'decomposer',
16
+ instructions: `
17
+ You are a Decomposer Agent.
18
+
19
+ * Read the boundaries research below and the original task, then decide
20
+ whether the work can be split into independent workers.
21
+ * At most ${maxWorkers} workers — that is a hard ceiling, not a target.
22
+ * "decomposable": false (with a "why") is a valid, expected answer when
23
+ you cannot find independent seams — do not force a split.
24
+ * Your final message MUST be valid JSON only — no markdown, no prose
25
+ outside JSON:
26
+
27
+ {
28
+ "decomposable": true,
29
+ "why": "short reason",
30
+ "workers": [
31
+ {
32
+ "id": "01-scaffold",
33
+ "title": "short title",
34
+ "subtask": "what this worker implements",
35
+ "area": "coarse directory or feature name",
36
+ "owns": ["path/prefix/or/file"],
37
+ "dependsOn": [],
38
+ "scaffold": true
39
+ }
40
+ ]
41
+ }
42
+
43
+ or, when declining:
44
+
45
+ {
46
+ "decomposable": false,
47
+ "why": "short reason"
48
+ }
49
+ * After the JSON above, on a new line write the exact marker
50
+ \`<<<SUMMARY>>>\`, followed by one paragraph in natural, human-readable
51
+ language explaining what you did in this step and what happened — no
52
+ lists, no headers, just prose. The JSON itself must stay exactly as
53
+ specified above, before the summary marker.
54
+
55
+ [Boundaries Agent Output]
56
+ ${boundariesOutput}
57
+ [/Boundaries Agent Output]${feedbackBlock(feedback)}
58
+ `,
59
+ prompt,
60
+ options: { cwd },
61
+ };
62
+ }
package/agents/index.js CHANGED
@@ -7,3 +7,6 @@ export { testWriterAgentArgs } from './test-writer.js';
7
7
  export { testCriticAgentArgs } from './test-critic.js';
8
8
  export { codeWriterAgentArgs } from './code-writer.js';
9
9
  export { testRunnerAgentArgs } from './test-runner.js';
10
+ export { integratorAgentArgs } from './integrator.js';
11
+ export { boundariesAgentArgs } from './boundaries.js';
12
+ export { decomposerAgentArgs } from './decomposer.js';
@@ -0,0 +1,41 @@
1
+ /** Conflict-repair-only agent for the `--integrate` driver: resolves merge conflict
2
+ * markers in the given files using the merge output and involved workers' subtask/area
3
+ * as context. Never touches git itself — orch owns the merge commit. */
4
+ export function integratorAgentArgs({ prompt, cwd, conflictedFiles, mergeOutput, involvedWorkers }) {
5
+ const fileList = (conflictedFiles || []).map((file) => `- ${file}`).join('\n');
6
+ const workerContext = (involvedWorkers || [])
7
+ .map((worker) => `- ${worker.title ?? worker.id}: ${worker.subtask} (area: ${worker.area})`)
8
+ .join('\n');
9
+
10
+ return {
11
+ name: 'integrator',
12
+ instructions: `
13
+ You are an Integrator Agent.
14
+
15
+ * A merge is in progress in this worktree with unresolved conflict
16
+ markers in exactly these files — resolve markers only in these
17
+ files, and touch no other files:
18
+ ${fileList}
19
+ * Combine what the involved workers already built; do not redesign or
20
+ reimplement anything beyond what is strictly needed to resolve the
21
+ conflict.
22
+ * Workers involved in this conflict:
23
+ ${workerContext}
24
+ * Do not run \`git\` yourself — no \`git add\`, \`git commit\`,
25
+ \`git merge --continue\`, \`git merge --abort\`, or any other git
26
+ command. orch completes the merge commit itself; you only edit files.
27
+ * Do not report a pass/fail judgment of any kind; orch checks whether
28
+ the conflict markers are gone itself.
29
+ * After you finish editing, on a new line write the exact marker
30
+ \`<<<SUMMARY>>>\`, followed by one paragraph in natural,
31
+ human-readable language explaining what you did in this step and
32
+ what happened — no lists, no headers, just prose.
33
+
34
+ [Merge Output]
35
+ ${mergeOutput}
36
+ [/Merge Output]
37
+ `,
38
+ prompt,
39
+ options: { cwd },
40
+ };
41
+ }
@@ -22,6 +22,10 @@ export function quickFixAgentArgs({ prompt, cwd, fix_plan }) {
22
22
  characters, with no spaces), followed by one paragraph in
23
23
  natural, human-readable language explaining what you did in this
24
24
  step and what happened — no lists, no headers, just prose.
25
+ * If you changed any files, after that paragraph add a line reading
26
+ exactly "Files:" followed by one line per changed file formatted
27
+ as "<path>: <one-line description>" (e.g. "README.md: documented
28
+ the new --files flag"). Omit this section if you changed nothing.
25
29
  ${fixPlan}
26
30
  `,
27
31
  prompt,
@@ -42,6 +42,10 @@ export function testWriterAgentArgs({
42
42
  characters, with no spaces), followed by one paragraph in natural,
43
43
  human-readable language explaining what you did in this step and
44
44
  what happened — no lists, no headers, just prose.
45
+ * If you changed any files, after that paragraph add a line reading
46
+ exactly "Files:" followed by one line per changed file formatted
47
+ as "<path>: <one-line description>" (e.g. "test/agent.test.js:
48
+ added the dedupe case"). Omit this section if you changed nothing.
45
49
  ${criticBlock}
46
50
  `,
47
51
  prompt,
package/lib/agent.js CHANGED
@@ -2,6 +2,7 @@ import { spawn } from 'child_process';
2
2
  import path from 'path';
3
3
  import ora from 'ora';
4
4
  import { formatActiveTools } from './tool-status.js';
5
+ import { patchJob, buildLastOutcome } from './jobs.js';
5
6
 
6
7
  /**
7
8
  * Every child process spawned by an agent, tracked so we can reap them all
@@ -29,6 +30,19 @@ function killChildGroup(child, signal) {
29
30
 
30
31
  let shuttingDown = false;
31
32
 
33
+ /**
34
+ * The in-process active job slug for foreground (non-detached) runs, which
35
+ * never spawn a separate process to carry `ORCH_JOB_SLUG` as an env var. Set
36
+ * by main.js via `setJobSlug(slug)` once the Commander action allocates a
37
+ * job for a non-detached invocation.
38
+ */
39
+ let activeJobSlug = null;
40
+
41
+ /** Sets the in-process active job slug (see `activeJobSlug`). */
42
+ export function setJobSlug(slug) {
43
+ activeJobSlug = slug;
44
+ }
45
+
32
46
  /** Conventional shell status: 128 + signal number. */
33
47
  export function exitCodeForSignal(signal) {
34
48
  if (signal === 'SIGINT') return 130;
@@ -38,12 +52,44 @@ export function exitCodeForSignal(signal) {
38
52
 
39
53
  /**
40
54
  * Reap every tracked child group, then exit. `exit` is injectable so tests can
41
- * assert without tearing down the runner.
55
+ * assert without tearing down the runner. Persists terminal job state to
56
+ * `run.json` for whichever job is active — `process.env.ORCH_JOB_SLUG` (set
57
+ * on a detached child's own env) or the in-process `activeJobSlug` (set for a
58
+ * foreground run via `setJobSlug`), env taking precedence when both are set —
59
+ * before reaping children, so a concurrent `orch pause`/`resume`/`status`
60
+ * write stays lock-safe. `jobCwd` is a test seam only; real callers never
61
+ * pass it — it always resolves to the actual `process.cwd()`.
42
62
  */
43
- export function shutdown(signal, { exit = (code) => process.exit(code) } = {}) {
63
+ export function shutdown(signal, { exit = (code) => process.exit(code), jobCwd = process.cwd() } = {}) {
44
64
  if (shuttingDown) return;
45
65
  shuttingDown = true;
46
66
 
67
+ const jobSlug = process.env.ORCH_JOB_SLUG ?? activeJobSlug;
68
+ if (jobSlug) {
69
+ try {
70
+ const exitCode = exitCodeForSignal(signal);
71
+ const finishedAt = new Date().toISOString();
72
+ patchJob(jobCwd, jobSlug, (current) => ({
73
+ state: 'stopped',
74
+ exitCode,
75
+ finishedAt,
76
+ lastOutcome: buildLastOutcome({
77
+ state: 'stopped',
78
+ phase: current.phase,
79
+ stage: current.stage,
80
+ round: current.round,
81
+ exitCode,
82
+ finishedAt,
83
+ task: current.task,
84
+ summary: '',
85
+ error: null,
86
+ }),
87
+ }));
88
+ } catch {
89
+ // Best-effort: don't let a job-state write failure block shutdown.
90
+ }
91
+ }
92
+
47
93
  for (const child of liveChildren) killChildGroup(child, 'SIGTERM');
48
94
 
49
95
  const exitCode = exitCodeForSignal(signal);
@@ -74,10 +120,11 @@ export function trackLiveChild(child) {
74
120
  child.once('error', () => liveChildren.delete(child));
75
121
  }
76
122
 
77
- /** Reset shutdown latch + child set between tests. */
123
+ /** Reset shutdown latch + child set + in-process active job slug between tests. */
78
124
  export function resetShutdownState() {
79
125
  shuttingDown = false;
80
126
  liveChildren.clear();
127
+ activeJobSlug = null;
81
128
  }
82
129
 
83
130
  /**
@@ -124,21 +171,30 @@ function registerShutdownHandlers() {
124
171
  });
125
172
  }
126
173
 
127
- /** Format milliseconds as `{s}s` or `{m}m {s}s` (whole seconds, no leading 0m). */
174
+ /** Format milliseconds as `{s}s`, `{m}m {s}s`, or `{h}h {m}m` (whole seconds, no leading 0m/0h). */
128
175
  export function formatElapsed(ms) {
129
176
  const totalSec = Math.floor(ms / 1000);
130
- const m = Math.floor(totalSec / 60);
177
+ const h = Math.floor(totalSec / 3600);
178
+ const m = Math.floor((totalSec % 3600) / 60);
131
179
  const s = totalSec % 60;
180
+ if (h > 0) return `${h}h ${m}m`;
132
181
  return m > 0 ? `${m}m ${s}s` : `${s}s`;
133
182
  }
134
183
 
135
184
  export class Agent {
136
- constructor(name, instructions, prompt, { cwd, readOnly = false } = {}) {
185
+ constructor(name, instructions, prompt, {
186
+ cwd,
187
+ readOnly = false,
188
+ fileTracker = null,
189
+ onFileChange = null,
190
+ } = {}) {
137
191
  this.name = name;
138
192
  this.instructions = instructions;
139
193
  this.prompt = prompt;
140
194
  this.cwd = cwd ? path.resolve(cwd) : process.cwd();
141
195
  this.readOnly = Boolean(readOnly);
196
+ this.fileTracker = fileTracker;
197
+ this.onFileChange = onFileChange;
142
198
  this.process = null;
143
199
  this.spinner = null;
144
200
  this.startedAt = 0;
@@ -157,8 +213,28 @@ export class Agent {
157
213
  onToolEvent({ name, args, phase, callId }) {
158
214
  if (phase === 'started') {
159
215
  this.activeTools.set(callId, { name, args });
216
+ this.fileTracker?.record({ name, args, phase, callId });
160
217
  } else if (phase === 'completed') {
218
+ // Recall before delete — Claude/agn completions often have empty args.
219
+ const prior = this.activeTools.get(callId);
161
220
  this.activeTools.delete(callId);
221
+
222
+ if (this.fileTracker) {
223
+ const hasPath = Boolean(args?.path || args?.file_path);
224
+ const entry = this.fileTracker.record({
225
+ name: name || prior?.name || '',
226
+ args: hasPath ? args : (prior?.args ?? args ?? {}),
227
+ phase,
228
+ callId,
229
+ });
230
+ if (entry?.isNew) {
231
+ const wasSpinning = this.spinner?.isSpinning;
232
+ if (wasSpinning) this.spinner.stop();
233
+ console.log(` ${entry.marker} ${entry.path}`);
234
+ if (wasSpinning) this.spinner.start();
235
+ this.onFileChange?.(entry);
236
+ }
237
+ }
162
238
  }
163
239
  this.refreshToolStatus();
164
240
  }