@welluable/orch 1.1.0 → 1.2.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
@@ -123,6 +123,8 @@ you invoked `orch`, plus a persistent sibling git worktree and branch:
123
123
  research.md
124
124
  task.md
125
125
  status.md
126
+ run.json # job state (state, phase, pid, ...); written for every run
127
+ orch.log # full stdout/stderr of the run; --detach only
126
128
 
127
129
  <parent-of-repo>/<repo-name>-<slug> # worktree
128
130
  orch/<slug> # branch
@@ -134,6 +136,11 @@ test-runner) run inside the worktree instead. The worktree is never deleted
134
136
  automatically — it's left in place after the run so you can inspect,
135
137
  continue, or merge the work whenever you're ready.
136
138
 
139
+ `run.json` is written for every invocation — default, `--quick`, `--ask`, and
140
+ `--detach` alike — so `orch list`/`status` always have something to show; see
141
+ [Headless runs](#headless-runs). `orch.log` is only written for `--detach`
142
+ runs, since foreground runs already stream their output to your terminal.
143
+
137
144
  ## Architecture
138
145
 
139
146
  ```text
@@ -161,14 +168,22 @@ agent CLI does all the actual reading and writing of files.
161
168
  | `--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
169
  | `--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
170
  | `--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. |
171
+ | `--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
172
 
165
173
  For `--ask`, Cursor uses `--mode ask`, Claude uses `--permission-mode plan`,
166
174
  and `agn` is prompt-only best-effort (it has no dedicated read-only flag).
167
175
 
176
+ `--detach` only controls backgrounding, not whether a job record exists —
177
+ every non-`--dry-run` invocation gets one. Combining `--detach` with
178
+ `--ask`, `--quick`, or `--dry-run` is rejected outright (non-zero exit)
179
+ because there's no way to background those modes, not because they'd lack a
180
+ job record. Multiple `--detach` runs can execute concurrently in the same
181
+ directory, each with its own slug.
182
+
168
183
  ## CLI Reference
169
184
 
170
185
  ```text
171
- Usage: orch [options] <task...>
186
+ Usage: orch [options] [command] <task...>
172
187
  ```
173
188
 
174
189
  - `<task...>` — task description to use as the prompt (mention a file path
@@ -182,12 +197,37 @@ Usage: orch [options] <task...>
182
197
  and exits (skips triage and all write pipelines).
183
198
  - `--quick` — skips triage, runs `quick-fix` directly in the current working
184
199
  tree; creates no artifacts, worktrees, or commits.
200
+ - `--detach` — runs the pipeline in the background and returns immediately,
201
+ printing `started <slug> (pid <pid>)`; rejects `--ask`/`--quick`/`--dry-run`.
185
202
  - `--max-rounds <n>` — max writer⇄critic and writer⇄runner iterations per
186
203
  implementer loop; defaults to `5`, ignored with `--ask` and `--quick`.
204
+ - `--fan-out` — decomposes the task into parallel workers coordinated by this
205
+ process instead of running the single-worktree pipeline (see
206
+ [Fan-out](#fan-out)); rejects `--ask`/`--quick`/`--dry-run`.
207
+ - `--max-workers <n>` — max number of parallel fan-out workers; defaults to
208
+ `4`; only meaningful with `--fan-out`.
209
+ - `--max-concurrency <n>` — optional hard ceiling on in-flight fan-out workers
210
+ at once; omit to let the coordinator choose (typically the current layer's
211
+ size); only meaningful with `--fan-out`.
187
212
  - `--agent <cursor|claude|agn>` — selects the backend for the whole pipeline;
188
213
  defaults to `cursor`.
189
214
  - `-h, --help` — displays help for the command.
190
215
 
216
+ Job-control subcommands (see [Headless runs](#headless-runs)):
217
+
218
+ - `orch list` — lists all runs tracked under `.orch/` in the current directory.
219
+ - `orch status [slug]` — shows full status for a run; defaults to the most
220
+ recently started run.
221
+ - `orch pause <slug>` — requests a pause at the run's next stage-boundary
222
+ checkpoint.
223
+ - `orch resume <slug>` — resumes a paused (or pausing) run.
224
+ - `orch stop <slug>` — sends `SIGTERM` to a running job (or reconciles a dead
225
+ one to `crashed`).
226
+ - `orch logs <slug> [-f]` — prints a run's `orch.log`; `-f` follows it until
227
+ the job reaches a terminal state.
228
+ - `orch jobs clean` — deletes every run tracked under `.orch/` in the current
229
+ directory, after a `y/N` confirmation prompt.
230
+
191
231
  Examples:
192
232
 
193
233
  ```bash
@@ -199,6 +239,161 @@ orch --quick "fix the typo in the README" --agent claude
199
239
  orch "noop" --dry-run --agent cursor
200
240
  ```
201
241
 
242
+ ## Headless runs
243
+
244
+ `--detach` starts the full pipeline in a background process and returns your
245
+ shell immediately:
246
+
247
+ ```bash
248
+ orch "implement the local spec" --agent claude --detach
249
+ # started swift-lagoon-49ea (pid 12345)
250
+ ```
251
+
252
+ The parent process validates the agent binary and flag combination, eagerly
253
+ allocates `.orch/<slug>/`, writes an initial `run.json`, then re-invokes
254
+ itself (without `--detach`) as a detached child with `ORCH_JOB_SLUG` set; the
255
+ child runs the actual pipeline and keeps `run.json` up to date as it
256
+ progresses. Manage it with the subcommands below, all scoped to the current
257
+ directory's `.orch/`:
258
+
259
+ ```bash
260
+ orch list # SLUG ROLE STATE PHASE AGENT STARTED DURATION PID
261
+ orch status swift-lagoon-49ea # full record: state, phase, branch, worktree, exit code, ...
262
+ orch pause swift-lagoon-49ea # request a pause at the next stage-boundary checkpoint
263
+ orch resume swift-lagoon-49ea # resume a paused/pausing run
264
+ orch logs swift-lagoon-49ea -f # follow orch.log until the run finishes
265
+ orch stop swift-lagoon-49ea # SIGTERM the run
266
+ orch jobs clean # delete every tracked run under .orch/ (asks to confirm)
267
+ ```
268
+
269
+ Pausing is cooperative and happens at stage boundaries (before the first
270
+ agent stage, and after each individual agent invocation) — it is not an OS
271
+ suspend, and a pause requested mid-stage takes effect only once that stage's
272
+ agent finishes. This means pause is not atomic across a writer⇄critic or
273
+ writer⇄runner pair: e.g. pausing during `test-writer` still lets
274
+ `test-writer` finish before the run actually pauses, ahead of `test-critic`.
275
+
276
+ `--detach` is rejected outright when combined with `--ask`, `--quick`, or
277
+ `--dry-run` — those modes have nothing to background — but that's a
278
+ restriction on backgrounding, not on job records: every non-`--dry-run`
279
+ invocation gets a `run.json`, `--detach` or not. Multiple `--detach` runs can
280
+ execute concurrently against the same directory, each tracked under its own
281
+ slug.
282
+
283
+ ## Fan-out
284
+
285
+ `--fan-out` decomposes a complex task into independent workers that each run
286
+ the full pipeline (minus triage) in their own worktree, then hands the green
287
+ branches to a dedicated integration session:
288
+
289
+ ```bash
290
+ orch "implement the billing module" --fan-out --agent claude
291
+ ```
292
+
293
+ ```text
294
+ triage: complex — fan-out requested
295
+ boundaries: partitionable into billing scaffold + 3 endpoint workers
296
+ decomposer: split into 4 workers (1 scaffold, 3 parallel)
297
+ schedule: concurrency 3
298
+
299
+ [01-scaffold rapid-fox-x7q2] done — commit b2c3d4e
300
+ [02-invoices merry-elk-r4b1] running
301
+ [03-charges wise-owl-k1a8] running
302
+ [02-invoices merry-elk-r4b1] done — commit c3d4e5f
303
+ [04-webhooks quirky-cedar-p8w3] done — commit d4e5f60
304
+
305
+ overlaps: none
306
+ [integrate tidy-heron-m2p9] merged 3 branches, full suite passed
307
+ commit: e5f6071 on orch/wise-pine-e904
308
+ merge: git merge orch/wise-pine-e904
309
+ ```
310
+
311
+ The flow, in order:
312
+
313
+ 1. **Triage** runs once, same as any run. If it routes to quick-fix, fan-out
314
+ is skipped entirely — triage never opts into fan-out on its own.
315
+ 2. **`boundaries`** (new agent) researches only how the work can be split —
316
+ what can run in parallel, where the coarse boundaries are, whether shared
317
+ scaffolding (types, registries, barrels) needs to land first — and writes
318
+ `boundaries.md`. It never plans implementation steps.
319
+ 3. **`decomposer`** (new agent) reads `boundaries.md` and the task and emits
320
+ a worker list (or declines with a reason). orch validates the decomposition
321
+ itself — worker count, ownership, dependency graph, layer overlap, at most
322
+ one `scaffold` worker — and feeds validation failures back to the
323
+ decomposer for up to two repair attempts before giving up.
324
+ 4. **Decline path.** If the decomposer declines, or validation still fails
325
+ after repairs, orch falls through to today's single-worktree pipeline
326
+ (research → plan → worktree → test loop → code loop → commit) — no
327
+ `fanout.json`, no workers scheduled.
328
+ 5. **Scaffold first.** If one worker is marked `scaffold`, it runs alone to
329
+ completion before anything else starts; its commit becomes the base SHA
330
+ every other worker (and the integration session) branches from. A failed
331
+ scaffold aborts the whole fan-out before any parallel worker spawns.
332
+ 6. **Parallel workers.** Each remaining worker is a full, cold orch pipeline
333
+ (research → plan → worktree → test loop → code loop → commit) running as
334
+ its own detached process in its own `.orch/<worker-slug>/` directory and
335
+ worktree — a sibling `orch` invocation, not in-process work. The
336
+ coordinator chooses how many run at once (capped by `--max-concurrency`
337
+ when set) based on the dependency layer currently in flight; a worker
338
+ whose dependency failed is recorded `skipped` and never started.
339
+ 7. **Integration.** Once every worker has settled, the coordinator detects
340
+ file overlaps between the `done` workers and spawns a specialized
341
+ integration session (`--integrate`, a hidden flag) that merges their
342
+ branches in order, resolves any conflicts with a dedicated `integrator`
343
+ agent, then runs a runner-first verify loop (test-runner first,
344
+ code-writer only on failure) before committing to `orch/<parent-slug>`. If
345
+ no worker reached `done`, integration is skipped and the run exits
346
+ non-zero.
347
+
348
+ Artifacts, in addition to the coordinator's own `run.json`/`orch.log`:
349
+
350
+ ```text
351
+ <invocation-cwd>/.orch/<parent-slug>/
352
+ boundaries.md # partitionability research only
353
+ fanout.json # decomposition + scheduling state
354
+
355
+ <invocation-cwd>/.orch/<worker-slug>/
356
+ research.md, task.md, status.md, run.json, orch.log # a normal run, per worker
357
+
358
+ <invocation-cwd>/.orch/<integration-slug>/
359
+ integration.md # merge log, overlaps, conflict repairs, final verdict
360
+ ```
361
+
362
+ The coordinator never creates its own worktree and never runs the
363
+ implementer stages itself outside of the decline path — after decomposition
364
+ it is pure orchestration: spawn, poll, report. Exit code is `0` only when
365
+ every worker succeeded and integration committed; any worker failure forces a
366
+ non-zero exit even if integration still commits on the green subset.
367
+
368
+ `orch list` renders an indented job tree: top-level rows are jobs with no
369
+ `parent` (coordinators and ordinary runs), children indent two spaces under
370
+ their coordinator with a `ROLE` column (`coordinator` / `worker` /
371
+ `integrate` / `-`). `orch status <parent>` expands each child's
372
+ state/phase/branch in the same tree order; `orch status <child>` shows a
373
+ `parent:` line and does not list siblings. Default `orch status` (no slug)
374
+ still picks the most recent job and uses the parent-vs-child view as
375
+ appropriate. `orch logs <parent>` tails the coordinator log only.
376
+
377
+ Cascade control is parent → children only: `orch pause|resume|stop <parent>`
378
+ applies to the coordinator and every live child (pause reports how many
379
+ children were signaled). `orch pause|resume|stop <child>` is leaf-only — no
380
+ upward cascade. While a parent is paused, the coordinator stops spawning at
381
+ its schedule checkpoints and does not kill live children; on resume it
382
+ re-attaches to still-live workers and spawns only still-pending ones (never
383
+ re-decomposing or duplicating finished/live work). A paused leaf is waited
384
+ on without failing it or starting dependents; a stopped/crashed/failed leaf
385
+ is marked `failed` in `fanout.json`, its dependents are skipped, and the
386
+ schedule continues. `SIGINT`/`SIGHUP`/`SIGTERM` on the coordinator (and
387
+ `orch stop <parent>`) cascade a `SIGTERM` to every live child it recorded —
388
+ the same as [Headless runs](#headless-runs)'s interrupt handling, just
389
+ extended to the whole fan-out tree; worktrees and branches are never
390
+ removed automatically.
391
+
392
+ Depth is capped at 1: every worker and the integration session run with
393
+ `ORCH_FANOUT_DEPTH=1` set, and `--fan-out` is rejected outright when that
394
+ variable is already present — a worker or integration session never fans out
395
+ again.
396
+
202
397
  ## Project structure
203
398
 
204
399
  Complex runs create a run directory and a sibling worktree, reusing the
@@ -209,13 +404,16 @@ layout shown in [Artifacts and worktrees](#artifacts-and-worktrees):
209
404
  research.md
210
405
  task.md
211
406
  status.md
407
+ run.json # written for every run
408
+ orch.log # --detach only
212
409
 
213
410
  <parent-of-repo>/<repo-name>-<slug> # worktree
214
411
  orch/<slug> # branch
215
412
  ```
216
413
 
217
- Default quick fixes, `--quick`, and `--ask` runs create none of this — no
218
- `.orch/` directory, no worktree, and no commits.
414
+ Default quick-fixes, `--quick`, and `--ask` runs still get a `.orch/<slug>/`
415
+ directory with a `run.json` job record (so `orch list`/`status` can see them),
416
+ but no `research.md`/`task.md`/`status.md`, no worktree, and no commits.
219
417
 
220
418
  ## Interrupts
221
419
 
@@ -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 } 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,31 @@ 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
+ patchJob(jobCwd, jobSlug, {
71
+ state: 'stopped',
72
+ exitCode: exitCodeForSignal(signal),
73
+ finishedAt: new Date().toISOString(),
74
+ });
75
+ } catch {
76
+ // Best-effort: don't let a job-state write failure block shutdown.
77
+ }
78
+ }
79
+
47
80
  for (const child of liveChildren) killChildGroup(child, 'SIGTERM');
48
81
 
49
82
  const exitCode = exitCodeForSignal(signal);
@@ -74,10 +107,11 @@ export function trackLiveChild(child) {
74
107
  child.once('error', () => liveChildren.delete(child));
75
108
  }
76
109
 
77
- /** Reset shutdown latch + child set between tests. */
110
+ /** Reset shutdown latch + child set + in-process active job slug between tests. */
78
111
  export function resetShutdownState() {
79
112
  shuttingDown = false;
80
113
  liveChildren.clear();
114
+ activeJobSlug = null;
81
115
  }
82
116
 
83
117
  /**
@@ -124,21 +158,30 @@ function registerShutdownHandlers() {
124
158
  });
125
159
  }
126
160
 
127
- /** Format milliseconds as `{s}s` or `{m}m {s}s` (whole seconds, no leading 0m). */
161
+ /** Format milliseconds as `{s}s`, `{m}m {s}s`, or `{h}h {m}m` (whole seconds, no leading 0m/0h). */
128
162
  export function formatElapsed(ms) {
129
163
  const totalSec = Math.floor(ms / 1000);
130
- const m = Math.floor(totalSec / 60);
164
+ const h = Math.floor(totalSec / 3600);
165
+ const m = Math.floor((totalSec % 3600) / 60);
131
166
  const s = totalSec % 60;
167
+ if (h > 0) return `${h}h ${m}m`;
132
168
  return m > 0 ? `${m}m ${s}s` : `${s}s`;
133
169
  }
134
170
 
135
171
  export class Agent {
136
- constructor(name, instructions, prompt, { cwd, readOnly = false } = {}) {
172
+ constructor(name, instructions, prompt, {
173
+ cwd,
174
+ readOnly = false,
175
+ fileTracker = null,
176
+ onFileChange = null,
177
+ } = {}) {
137
178
  this.name = name;
138
179
  this.instructions = instructions;
139
180
  this.prompt = prompt;
140
181
  this.cwd = cwd ? path.resolve(cwd) : process.cwd();
141
182
  this.readOnly = Boolean(readOnly);
183
+ this.fileTracker = fileTracker;
184
+ this.onFileChange = onFileChange;
142
185
  this.process = null;
143
186
  this.spinner = null;
144
187
  this.startedAt = 0;
@@ -157,8 +200,28 @@ export class Agent {
157
200
  onToolEvent({ name, args, phase, callId }) {
158
201
  if (phase === 'started') {
159
202
  this.activeTools.set(callId, { name, args });
203
+ this.fileTracker?.record({ name, args, phase, callId });
160
204
  } else if (phase === 'completed') {
205
+ // Recall before delete — Claude/agn completions often have empty args.
206
+ const prior = this.activeTools.get(callId);
161
207
  this.activeTools.delete(callId);
208
+
209
+ if (this.fileTracker) {
210
+ const hasPath = Boolean(args?.path || args?.file_path);
211
+ const entry = this.fileTracker.record({
212
+ name: name || prior?.name || '',
213
+ args: hasPath ? args : (prior?.args ?? args ?? {}),
214
+ phase,
215
+ callId,
216
+ });
217
+ if (entry?.isNew) {
218
+ const wasSpinning = this.spinner?.isSpinning;
219
+ if (wasSpinning) this.spinner.stop();
220
+ console.log(` ${entry.marker} ${entry.path}`);
221
+ if (wasSpinning) this.spinner.start();
222
+ this.onFileChange?.(entry);
223
+ }
224
+ }
162
225
  }
163
226
  this.refreshToolStatus();
164
227
  }
package/lib/commit.js CHANGED
@@ -33,3 +33,73 @@ export function commitWorktree({ worktreePath, branch, message, execFile = defau
33
33
 
34
34
  return { committed: true, sha, branch };
35
35
  }
36
+
37
+ /**
38
+ * Stage the dirty worktree (so untracked files show as `A`) and return
39
+ * cached name-status + shortstat vs worktree `HEAD`. Returns null when clean
40
+ * or when git cannot run against the path (display-only rollup must not abort
41
+ * the pipeline on stub/missing worktrees).
42
+ *
43
+ * @param {{ worktreePath: string, execFile?: Function }} opts
44
+ * @returns {{ files: { status: string, path: string }[], shortstat: string }|null}
45
+ */
46
+ export function collectWorktreeChanges({ worktreePath, execFile = defaultExecFile }) {
47
+ try {
48
+ const status = runGit(execFile, ['-C', worktreePath, 'status', '--porcelain']);
49
+ if (status.trim() === '') {
50
+ return null;
51
+ }
52
+
53
+ runGit(execFile, ['-C', worktreePath, 'add', '-A']);
54
+ const nameStatus = runGit(execFile, [
55
+ '-C', worktreePath, 'diff', '--cached', '--name-status', 'HEAD',
56
+ ]);
57
+ const shortstatRaw = runGit(execFile, [
58
+ '-C', worktreePath, 'diff', '--cached', '--shortstat', 'HEAD',
59
+ ]);
60
+
61
+ const files = nameStatus
62
+ .split('\n')
63
+ .map((line) => line.trimEnd())
64
+ .filter(Boolean)
65
+ .map((line) => {
66
+ const tab = line.indexOf('\t');
67
+ if (tab === -1) return { status: line.trim(), path: '' };
68
+ return {
69
+ status: line.slice(0, tab).trim(),
70
+ path: line.slice(tab + 1),
71
+ };
72
+ });
73
+
74
+ return { files, shortstat: shortstatRaw.trim() };
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Print the titled `files changed` rollup block. No-op for null/empty.
82
+ *
83
+ * @param {{ files: { status: string, path: string }[], shortstat: string }|null|undefined} changes
84
+ * @param {{ log?: (line: string) => void }} [opts]
85
+ */
86
+ export function printFilesChanged(changes, { log = console.log } = {}) {
87
+ if (!changes || !Array.isArray(changes.files) || changes.files.length === 0) {
88
+ return;
89
+ }
90
+
91
+ const title = ' files changed ';
92
+ const rule = '─'.repeat(title.length);
93
+
94
+ log('');
95
+ log(rule);
96
+ log(title);
97
+ log(rule);
98
+ for (const { status, path: filePath } of changes.files) {
99
+ log(` ${status} ${filePath}`);
100
+ }
101
+ if (changes.shortstat) {
102
+ log(` ${changes.shortstat}`);
103
+ }
104
+ log('');
105
+ }