td-barrage 0.1.1 → 0.1.3

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
@@ -80,14 +80,47 @@ npm run dev -- --tasks queue.json
80
80
  npm run dev -- queue.json --max-attempts 3 --cwd /path/to/work --results-dir .queue/results
81
81
  npm run dev -- --tasks queue.json --dry-run
82
82
  npm run dev -- --tasks queue.json --json
83
+ npm run dev -- --tasks queue.json --verbose --timestamps
83
84
  ```
84
85
 
86
+ Options:
87
+
88
+ - `--tasks, -t <path>`: task file (also accepted as a positional argument)
89
+ - `--max-attempts <n>`: global retry limit (per-task `maxAttempts` overrides it)
90
+ - `--cwd <path>`: working directory for tasks that do not set their own
91
+ - `--results-dir <path>`: where result files are expected, default `.queue/results`
92
+ - `--dry-run`: print the topological plan (one id per line) and exit without running
93
+ - `--no-commit`: skip the git commit stage that otherwise runs after each successful task
94
+ - `--json`: emit newline-delimited JSON events instead of human output
95
+
85
96
  Exit codes:
86
97
 
87
98
  - `0`: all reachable tasks completed
88
99
  - `1`: at least one task failed or became blocked
89
100
  - `2`: CLI usage or missing task-file error
90
101
 
102
+ ## Telemetry & output
103
+
104
+ By default the runner is deliberately verbose, narrating progress as it happens:
105
+ a startup header (version, task file, cwd, results dir, limits), the resolved
106
+ execution plan, a line as each task starts, and a line as each task finishes with
107
+ its duration and overall progress (`2/5 complete`). Failures, retries, and blocks
108
+ are always reported, along with a closing summary and total elapsed time.
109
+
110
+ Verbosity flags:
111
+
112
+ - `--verbose, -v`: add per-step traces — the OpenCode session id and the
113
+ success-check result for every task
114
+ - `--quiet, -q`: show only failures, blocks, and the final summary
115
+ - `--timestamps`: prefix each line with a wall-clock time
116
+ - `--color` / `--no-color`: force or disable ANSI color (auto-detected from the
117
+ TTY otherwise; the `NO_COLOR` and `FORCE_COLOR` environment variables are honored)
118
+
119
+ `--json` emits every event — including the enriched fields above — as one JSON
120
+ object per line, regardless of verbosity, which is the format to pipe into log
121
+ aggregators or `jq`. `--dry-run` always prints a bare, uncolored id list so it
122
+ stays easy to pipe.
123
+
91
124
  ## Success Checks
92
125
 
93
126
  `default` succeeds unconditionally.
@@ -96,6 +129,14 @@ Exit codes:
96
129
 
97
130
  `result_json` succeeds when the configured JSON file parses and contains `"status": "ok"`.
98
131
 
132
+ ## Commits
133
+
134
+ After a task passes its success check, the orchestrator stages everything with
135
+ `git add -A` and commits it in the task's cwd with the message `feat: <id>`. A
136
+ clean working tree (nothing to commit) is a quiet no-op, and a commit that fails
137
+ outright is reported as a warning but never flips the task back to failed — the
138
+ task already succeeded. Pass `--no-commit` to skip this stage entirely.
139
+
99
140
  ## Recovery
100
141
 
101
142
  On startup, any task persisted as `running` is reset to `pending`, annotated in `recoveryNotes`, and retried. The `attempts` counter is preserved, so a task that crashed mid-run still counts that attempt.
package/dist/git.d.ts ADDED
@@ -0,0 +1,28 @@
1
+ export interface GitExecResult {
2
+ code: number;
3
+ stdout: string;
4
+ stderr: string;
5
+ }
6
+ /** Runs `git <args>` in `cwd` and resolves with its exit code and output (never rejects). */
7
+ export type GitExec = (args: string[], cwd: string) => Promise<GitExecResult>;
8
+ export interface CommitInput {
9
+ cwd: string;
10
+ message: string;
11
+ }
12
+ export interface CommitResult {
13
+ /** "committed" — a new commit was made; "nothing" — clean tree, nothing staged; "error" — git failed. */
14
+ status: "committed" | "nothing" | "error";
15
+ /** Short SHA of the new commit, when status is "committed". */
16
+ commit?: string;
17
+ /** Human-readable detail, primarily for the "error" status. */
18
+ detail?: string;
19
+ }
20
+ export interface GitCommitter {
21
+ commit(input: CommitInput): Promise<CommitResult>;
22
+ }
23
+ /**
24
+ * Stages every change with `git add -A` and commits it. A clean working tree
25
+ * (nothing to commit) is reported as "nothing" rather than an error, since a
26
+ * task can legitimately succeed without touching tracked files.
27
+ */
28
+ export declare function createGitCommitter(exec?: GitExec): GitCommitter;
package/dist/git.js ADDED
@@ -0,0 +1,47 @@
1
+ import { execFile } from "node:child_process";
2
+ /**
3
+ * Stages every change with `git add -A` and commits it. A clean working tree
4
+ * (nothing to commit) is reported as "nothing" rather than an error, since a
5
+ * task can legitimately succeed without touching tracked files.
6
+ */
7
+ export function createGitCommitter(exec = defaultExec) {
8
+ return {
9
+ async commit({ cwd, message }) {
10
+ const add = await exec(["add", "-A"], cwd);
11
+ if (add.code !== 0) {
12
+ return { status: "error", detail: detailOf(add) || "git add failed" };
13
+ }
14
+ const commit = await exec(["commit", "-m", message], cwd);
15
+ if (commit.code === 0) {
16
+ const head = await exec(["rev-parse", "--short", "HEAD"], cwd);
17
+ return { status: "committed", commit: head.code === 0 ? head.stdout.trim() : undefined };
18
+ }
19
+ if (isNothingToCommit(commit)) {
20
+ return { status: "nothing" };
21
+ }
22
+ return { status: "error", detail: detailOf(commit) || `git commit exited with code ${commit.code}` };
23
+ },
24
+ };
25
+ }
26
+ // `git commit` exits non-zero with this phrasing when the index has no staged
27
+ // changes — a no-op we treat as success, not failure.
28
+ function isNothingToCommit(result) {
29
+ const text = `${result.stdout}\n${result.stderr}`;
30
+ return /nothing to commit|no changes added to commit|nothing added to commit/i.test(text);
31
+ }
32
+ function detailOf(result) {
33
+ return (result.stderr.trim() || result.stdout.trim()).split("\n")[0] ?? "";
34
+ }
35
+ const defaultExec = (args, cwd) => new Promise((resolve) => {
36
+ execFile("git", args, { cwd }, (error, stdout, stderr) => {
37
+ if (!error) {
38
+ resolve({ code: 0, stdout: String(stdout), stderr: String(stderr) });
39
+ return;
40
+ }
41
+ // A process that exits non-zero surfaces a numeric `code`; a spawn
42
+ // failure (e.g. git not installed) surfaces a string code like "ENOENT",
43
+ // in which case we fall back to 1 and lean on the error message.
44
+ const code = typeof error.code === "number" ? error.code : 1;
45
+ resolve({ code, stdout: String(stdout), stderr: String(stderr) || error.message });
46
+ });
47
+ });
package/dist/index.d.ts CHANGED
@@ -1,9 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs/promises";
3
+ import { type GitCommitter } from "./git.js";
3
4
  import { type OpenCodeClient } from "./orchestrator.js";
4
5
  export interface MainDeps {
5
6
  fs?: typeof fs;
6
7
  client?: OpenCodeClient;
8
+ git?: GitCommitter;
7
9
  stdout?: Pick<NodeJS.WritableStream, "write">;
8
10
  stderr?: Pick<NodeJS.WritableStream, "write">;
9
11
  }
package/dist/index.js CHANGED
@@ -1,11 +1,13 @@
1
1
  #!/usr/bin/env node
2
2
  import fs from "node:fs/promises";
3
3
  import { realpathSync } from "node:fs";
4
+ import { createRequire } from "node:module";
4
5
  import process from "node:process";
5
6
  import { pathToFileURL } from "node:url";
6
7
  import { parseArgs } from "node:util";
7
8
  import { createOpenCodeClient } from "./client.js";
8
9
  import { topologicalOrder } from "./deps.js";
10
+ import { createGitCommitter } from "./git.js";
9
11
  import { createLogger } from "./log.js";
10
12
  import { defaultResultsDir, runOrchestrator } from "./orchestrator.js";
11
13
  import { loadTasks } from "./tasks.js";
@@ -22,6 +24,12 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
22
24
  "results-dir": { type: "string" },
23
25
  json: { type: "boolean", default: false },
24
26
  "dry-run": { type: "boolean", default: false },
27
+ "no-commit": { type: "boolean", default: false },
28
+ verbose: { type: "boolean", short: "v", default: false },
29
+ quiet: { type: "boolean", short: "q", default: false },
30
+ color: { type: "boolean", default: false },
31
+ "no-color": { type: "boolean", default: false },
32
+ timestamps: { type: "boolean", default: false },
25
33
  },
26
34
  });
27
35
  const tasksPath = parsed.values.tasks ?? parsed.positionals[0];
@@ -31,7 +39,17 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
31
39
  }
32
40
  const queueFs = deps.fs ?? fs;
33
41
  const cwd = parsed.values.cwd ?? process.cwd();
34
- const logger = createLogger(Boolean(parsed.values.json), stdout);
42
+ if (parsed.values.quiet && parsed.values.verbose) {
43
+ stderr.write("--quiet and --verbose cannot be combined.\n");
44
+ return 2;
45
+ }
46
+ const level = parsed.values.quiet ? 0 : parsed.values.verbose ? 2 : 1;
47
+ const logger = createLogger({
48
+ json: Boolean(parsed.values.json),
49
+ level,
50
+ color: resolveColor(parsed.values, stdout),
51
+ timestamps: Boolean(parsed.values.timestamps),
52
+ }, stdout);
35
53
  const maxAttempts = parsed.values["max-attempts"] ? Number(parsed.values["max-attempts"]) : undefined;
36
54
  const resultsDir = parsed.values["results-dir"] ?? defaultResultsDir(cwd);
37
55
  if (maxAttempts !== undefined && (!Number.isInteger(maxAttempts) || maxAttempts < 1)) {
@@ -46,6 +64,7 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
46
64
  return 0;
47
65
  }
48
66
  const client = deps.client ?? (await createOpenCodeClient());
67
+ const git = parsed.values["no-commit"] ? undefined : deps.git ?? createGitCommitter();
49
68
  const summary = await runOrchestrator({
50
69
  tasksPath,
51
70
  client,
@@ -54,6 +73,8 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
54
73
  cwd,
55
74
  resultsDir,
56
75
  maxAttempts,
76
+ git,
77
+ version: readVersion(),
57
78
  });
58
79
  return summary.failed > 0 || summary.blocked > 0 ? 1 : 0;
59
80
  }
@@ -63,6 +84,25 @@ export async function main(argv = process.argv.slice(2), deps = {}) {
63
84
  return code;
64
85
  }
65
86
  }
87
+ // Color is on when the stream is a TTY, unless overridden. NO_COLOR disables and
88
+ // FORCE_COLOR / --color force it on, matching the conventions other CLIs follow.
89
+ function resolveColor(values, stdout) {
90
+ if (values["no-color"] || process.env.NO_COLOR)
91
+ return false;
92
+ if (values.color || process.env.FORCE_COLOR)
93
+ return true;
94
+ return Boolean(stdout.isTTY);
95
+ }
96
+ function readVersion() {
97
+ try {
98
+ const require = createRequire(import.meta.url);
99
+ const pkg = require("../package.json");
100
+ return pkg.version ?? "";
101
+ }
102
+ catch {
103
+ return "";
104
+ }
105
+ }
66
106
  function isMissingFile(error) {
67
107
  return Boolean(error &&
68
108
  typeof error === "object" &&
package/dist/log.d.ts CHANGED
@@ -1,21 +1,69 @@
1
+ import type { Summary } from "./orchestrator.js";
2
+ export interface RunInfo {
3
+ version: string;
4
+ tasksPath: string;
5
+ total: number;
6
+ maxAttempts: number;
7
+ timeoutMs: number;
8
+ cwd: string;
9
+ resultsDir: string;
10
+ }
1
11
  export type LogEvent = {
12
+ type: "run_started";
13
+ info: RunInfo;
14
+ } | {
15
+ type: "plan";
16
+ taskIds: string[];
17
+ } | {
2
18
  type: "task_started";
3
19
  taskId: string;
4
20
  attempt: number;
21
+ maxAttempts: number;
22
+ cwd: string;
23
+ timeoutMs: number;
24
+ } | {
25
+ type: "task_session";
26
+ taskId: string;
27
+ sessionId: string;
28
+ } | {
29
+ type: "task_check";
30
+ taskId: string;
31
+ strategy: string;
32
+ ok: boolean;
5
33
  } | {
6
34
  type: "task_done";
7
35
  taskId: string;
36
+ durationMs: number;
37
+ done: number;
38
+ total: number;
39
+ } | {
40
+ type: "task_committed";
41
+ taskId: string;
42
+ message: string;
43
+ status: "committed" | "nothing";
44
+ commit?: string;
45
+ } | {
46
+ type: "task_commit_failed";
47
+ taskId: string;
48
+ message: string;
49
+ detail: string;
8
50
  } | {
9
51
  type: "task_failed";
10
52
  taskId: string;
11
53
  error: string;
54
+ attempt: number;
55
+ maxAttempts: number;
56
+ willRetry: boolean;
57
+ durationMs: number;
12
58
  } | {
13
59
  type: "task_blocked";
14
60
  taskId: string;
15
61
  reason: string;
62
+ blockedBy?: string;
16
63
  } | {
17
64
  type: "summary";
18
- summary: unknown;
65
+ summary: Summary;
66
+ durationMs: number;
19
67
  } | {
20
68
  type: "dry_run";
21
69
  taskIds: string[];
@@ -26,4 +74,15 @@ export type LogEvent = {
26
74
  export interface Logger {
27
75
  event(event: LogEvent): void;
28
76
  }
29
- export declare function createLogger(json: boolean, stream?: Pick<NodeJS.WritableStream, "write">): Logger;
77
+ /** 0 = quiet (problems + summary only), 1 = normal, 2 = verbose. */
78
+ export type Verbosity = 0 | 1 | 2;
79
+ export interface LoggerOptions {
80
+ json?: boolean;
81
+ level?: Verbosity;
82
+ color?: boolean;
83
+ timestamps?: boolean;
84
+ now?: () => Date;
85
+ }
86
+ export declare function createLogger(options?: LoggerOptions, stream?: Pick<NodeJS.WritableStream, "write"> & {
87
+ isTTY?: boolean;
88
+ }): Logger;
package/dist/log.js CHANGED
@@ -1,26 +1,263 @@
1
- export function createLogger(json, stream = process.stdout) {
1
+ import { intro, outro, note, log as clack, spinner } from "@clack/prompts";
2
+ // Lowest verbosity level at which each event is shown in human mode.
3
+ // Level 0 events (failures, blocks, summary, dry-run) always surface.
4
+ const EVENT_LEVEL = {
5
+ run_started: 1,
6
+ plan: 1,
7
+ task_started: 1,
8
+ task_session: 2,
9
+ task_check: 2,
10
+ task_done: 1,
11
+ task_committed: 1,
12
+ task_commit_failed: 0,
13
+ task_failed: 0,
14
+ task_blocked: 0,
15
+ summary: 0,
16
+ dry_run: 0,
17
+ server: 1,
18
+ };
19
+ export function createLogger(options = {}, stream = process.stdout) {
20
+ const json = Boolean(options.json);
21
+ const level = options.level ?? 1;
22
+ const styles = makeStyles(Boolean(options.color));
23
+ const now = options.now ?? (() => new Date());
24
+ // The clack treatment is reserved for an interactive, colored terminal: it
25
+ // animates spinners and draws connecting gutters that only make sense live.
26
+ // Pipes, CI, --no-color, --timestamps, and tests fall through to the plain
27
+ // line renderer so output stays deterministic and grep/pipe-friendly.
28
+ const pretty = !json && !options.timestamps && Boolean(options.color) && Boolean(stream.isTTY);
29
+ const renderPretty = pretty ? createPrettyRenderer(stream, styles) : null;
2
30
  return {
3
31
  event(event) {
4
32
  if (json) {
5
- stream.write(`${JSON.stringify({ time: new Date().toISOString(), ...event })}\n`);
33
+ stream.write(`${JSON.stringify({ time: now().toISOString(), ...event })}\n`);
6
34
  return;
7
35
  }
8
- stream.write(`${formatHuman(event)}\n`);
36
+ if (EVENT_LEVEL[event.type] > level)
37
+ return;
38
+ // The plan preview is meant to be piped, so it stays bare even in pretty mode.
39
+ if (renderPretty && event.type !== "dry_run") {
40
+ renderPretty(event);
41
+ return;
42
+ }
43
+ const text = formatHuman(event, styles);
44
+ if (text === null)
45
+ return;
46
+ const stamp = options.timestamps ? `${styles.gray(formatClock(now()))} ` : "";
47
+ const out = text
48
+ .split("\n")
49
+ .map((line) => `${stamp}${line}`)
50
+ .join("\n");
51
+ stream.write(`${out}\n`);
9
52
  },
10
53
  };
11
54
  }
12
- function formatHuman(event) {
13
- if (event.type === "task_started")
14
- return `starting ${event.taskId} (attempt ${event.attempt})`;
15
- if (event.type === "task_done")
16
- return `done ${event.taskId}`;
17
- if (event.type === "task_failed")
18
- return `failed ${event.taskId}: ${event.error}`;
19
- if (event.type === "task_blocked")
20
- return `blocked ${event.taskId}: ${event.reason}`;
21
- if (event.type === "summary")
22
- return `summary ${JSON.stringify(event.summary)}`;
23
- if (event.type === "dry_run")
24
- return event.taskIds.join("\n");
25
- return event.message;
55
+ /**
56
+ * Renders events through @clack/prompts: an `intro` banner, a boxed `note` of
57
+ * run details, a live spinner for the task in flight (the orchestrator runs
58
+ * tasks sequentially, so at most one is active), and an `outro` summary.
59
+ */
60
+ function createPrettyRenderer(output, s) {
61
+ const opts = { output };
62
+ let active = null;
63
+ return (event) => {
64
+ switch (event.type) {
65
+ case "run_started": {
66
+ const i = event.info;
67
+ intro(s.bold(`td-barrage ${i.version}`), opts);
68
+ const details = [
69
+ `${s.dim("tasks ")} ${i.tasksPath} ${s.dim(`(${i.total} ${plural(i.total, "task")})`)}`,
70
+ `${s.dim("cwd ")} ${i.cwd}`,
71
+ `${s.dim("results")} ${i.resultsDir}`,
72
+ `${s.dim("limits ")} ${i.maxAttempts} ${plural(i.maxAttempts, "attempt")} ${s.dim("·")} ${fmtDuration(i.timeoutMs)} timeout`,
73
+ ].join("\n");
74
+ note(details, s.dim("run details"), opts);
75
+ return;
76
+ }
77
+ case "plan": {
78
+ const body = event.taskIds.length === 0
79
+ ? s.dim("(no tasks)")
80
+ : event.taskIds.map((id) => s.cyan(id)).join(s.dim(" → "));
81
+ clack.step(`${s.dim("plan")} ${body}`, opts);
82
+ return;
83
+ }
84
+ case "task_started":
85
+ active = spinner(opts);
86
+ active.start(`${s.bold(event.taskId)} ${s.dim(`running (attempt ${event.attempt}/${event.maxAttempts})`)}`);
87
+ return;
88
+ case "task_session":
89
+ if (active)
90
+ active.message(s.dim(`${event.taskId} · session ${event.sessionId}`));
91
+ else
92
+ clack.message(s.dim(`↳ session ${event.sessionId}`), opts);
93
+ return;
94
+ case "task_check":
95
+ if (active)
96
+ active.message(s.dim(`${event.taskId} · check ${event.strategy} → ${event.ok ? "ok" : "fail"}`));
97
+ else
98
+ clack.message(s.dim(`↳ check ${event.strategy} → ${event.ok ? "ok" : "fail"}`), opts);
99
+ return;
100
+ case "task_done": {
101
+ const msg = `${s.bold(event.taskId)} ${s.green("done")} ${s.dim(`in ${fmtDuration(event.durationMs)} · ${event.done}/${event.total} complete`)}`;
102
+ if (active) {
103
+ active.stop(msg);
104
+ active = null;
105
+ }
106
+ else
107
+ clack.success(msg, opts);
108
+ return;
109
+ }
110
+ case "task_committed": {
111
+ const detail = event.status === "nothing"
112
+ ? s.dim("no changes to commit")
113
+ : `${s.dim("committed")} ${event.commit ? s.cyan(event.commit) : ""} ${s.dim(event.message)}`;
114
+ clack.message(detail, opts);
115
+ return;
116
+ }
117
+ case "task_commit_failed":
118
+ clack.warn(`${s.bold(event.taskId)} ${s.yellow("commit failed")} ${s.dim(event.detail)}`, opts);
119
+ return;
120
+ case "task_failed": {
121
+ const tail = event.willRetry
122
+ ? s.yellow(`will retry (attempt ${event.attempt}/${event.maxAttempts})`)
123
+ : s.red(`gave up after ${event.attempt} ${plural(event.attempt, "attempt")}`);
124
+ const msg = `${s.bold(event.taskId)} ${s.red("failed")} ${s.dim(`in ${fmtDuration(event.durationMs)}`)}: ${event.error} ${tail}`;
125
+ if (active) {
126
+ // cancel() finalizes the spinner with a red ■ — distinct from the
127
+ // ▲ clack uses for a blocked task, and matching clack.error's glyph
128
+ // below. Called directly it only selects the symbol (no onCancel).
129
+ active.cancel(msg);
130
+ active = null;
131
+ }
132
+ else
133
+ clack.error(msg, opts);
134
+ return;
135
+ }
136
+ case "task_blocked": {
137
+ const by = event.blockedBy ? s.dim(` (by ${event.blockedBy})`) : "";
138
+ clack.warn(`${s.bold(event.taskId)} ${s.yellow("blocked")}${by} ${s.dim(event.reason)}`, opts);
139
+ return;
140
+ }
141
+ case "summary": {
142
+ if (active) {
143
+ active.stop();
144
+ active = null;
145
+ }
146
+ const sm = event.summary;
147
+ const parts = [
148
+ s.green(`${sm.done} done`),
149
+ count(sm.failed, "failed", s.red, s),
150
+ count(sm.blocked, "blocked", s.yellow, s),
151
+ count(sm.pending, "pending", s.yellow, s),
152
+ ];
153
+ const ok = sm.failed === 0 && sm.blocked === 0;
154
+ const head = ok ? s.green("done") : s.red("finished with issues");
155
+ outro(`${head} ${parts.join(s.dim(" · "))} ${s.dim(`(${fmtDuration(event.durationMs)})`)}`, opts);
156
+ return;
157
+ }
158
+ case "server":
159
+ if (active)
160
+ active.message(s.dim(event.message));
161
+ else
162
+ clack.info(s.dim(event.message), opts);
163
+ return;
164
+ case "dry_run":
165
+ // Handled before reaching the pretty renderer; kept exhaustive for the type checker.
166
+ output.write(`${event.taskIds.join("\n")}\n`);
167
+ return;
168
+ }
169
+ };
170
+ }
171
+ function formatHuman(event, s) {
172
+ switch (event.type) {
173
+ case "run_started": {
174
+ const i = event.info;
175
+ const lines = [
176
+ s.bold(`▶ td-barrage ${i.version}`),
177
+ ` ${s.dim("tasks ")} ${i.tasksPath} ${s.dim(`(${i.total} ${plural(i.total, "task")})`)}`,
178
+ ` ${s.dim("cwd ")} ${i.cwd}`,
179
+ ` ${s.dim("results")} ${i.resultsDir}`,
180
+ ` ${s.dim("limits ")} ${i.maxAttempts} ${plural(i.maxAttempts, "attempt")} ${s.dim("·")} ${fmtDuration(i.timeoutMs)} timeout`,
181
+ ];
182
+ return lines.join("\n");
183
+ }
184
+ case "plan":
185
+ if (event.taskIds.length === 0)
186
+ return s.dim("plan (no tasks)");
187
+ return `${s.dim("plan ")} ${event.taskIds.map((id) => s.cyan(id)).join(s.dim(" → "))}`;
188
+ case "task_started":
189
+ return `${s.blue("▶")} ${s.bold(event.taskId)} ${s.dim(`starting (attempt ${event.attempt}/${event.maxAttempts})`)}`;
190
+ case "task_session":
191
+ return s.dim(` ↳ session ${event.sessionId}`);
192
+ case "task_check":
193
+ return s.dim(` ↳ check ${event.strategy} → ${event.ok ? "ok" : "fail"}`);
194
+ case "task_done":
195
+ return `${s.green("✔")} ${s.bold(event.taskId)} ${s.green("done")} ${s.dim(`in ${fmtDuration(event.durationMs)} · ${event.done}/${event.total} complete`)}`;
196
+ case "task_committed":
197
+ return event.status === "nothing"
198
+ ? s.dim(" ↳ no changes to commit")
199
+ : s.dim(` ↳ committed ${event.commit ? `${event.commit} ` : ""}${event.message}`);
200
+ case "task_commit_failed":
201
+ return `${s.yellow("⚠")} ${s.bold(event.taskId)} ${s.yellow("commit failed")} ${s.dim(event.detail)}`;
202
+ case "task_failed": {
203
+ const tail = event.willRetry
204
+ ? s.yellow(`will retry (attempt ${event.attempt}/${event.maxAttempts})`)
205
+ : s.red(`gave up after ${event.attempt} ${plural(event.attempt, "attempt")}`);
206
+ return `${s.red("✖")} ${s.bold(event.taskId)} ${s.red("failed")} ${s.dim(`in ${fmtDuration(event.durationMs)}`)}: ${event.error} ${tail}`;
207
+ }
208
+ case "task_blocked": {
209
+ const by = event.blockedBy ? s.dim(` (by ${event.blockedBy})`) : "";
210
+ return `${s.yellow("⊘")} ${s.bold(event.taskId)} ${s.yellow("blocked")}${by} ${s.dim(event.reason)}`;
211
+ }
212
+ case "summary": {
213
+ const sm = event.summary;
214
+ const parts = [
215
+ s.green(`${sm.done} done`),
216
+ count(sm.failed, "failed", s.red, s),
217
+ count(sm.blocked, "blocked", s.yellow, s),
218
+ count(sm.pending, "pending", s.yellow, s),
219
+ ];
220
+ const ok = sm.failed === 0 && sm.blocked === 0;
221
+ const head = ok ? s.green("✔ done") : s.red("✖ finished with issues");
222
+ return `${head} ${parts.join(s.dim(" · "))} ${s.dim(`(${fmtDuration(event.durationMs)})`)}`;
223
+ }
224
+ case "dry_run":
225
+ // Plain, uncolored, pipeable plan — one task id per line.
226
+ return event.taskIds.join("\n");
227
+ case "server":
228
+ return s.dim(`• ${event.message}`);
229
+ }
230
+ }
231
+ function count(n, label, color, s) {
232
+ const text = `${n} ${label}`;
233
+ return n > 0 ? color(text) : s.dim(text);
234
+ }
235
+ function plural(n, word) {
236
+ return n === 1 ? word : `${word}s`;
237
+ }
238
+ function fmtDuration(ms) {
239
+ if (ms < 1000)
240
+ return `${Math.round(ms)}ms`;
241
+ const seconds = ms / 1000;
242
+ if (seconds < 60)
243
+ return `${seconds.toFixed(1)}s`;
244
+ const minutes = Math.floor(seconds / 60);
245
+ const rest = Math.round(seconds % 60);
246
+ return `${minutes}m${rest.toString().padStart(2, "0")}s`;
247
+ }
248
+ function formatClock(date) {
249
+ return date.toTimeString().slice(0, 8);
250
+ }
251
+ function makeStyles(enabled) {
252
+ const wrap = (open, close) => (text) => enabled ? `[${open}m${text}[${close}m` : text;
253
+ return {
254
+ bold: wrap(1, 22),
255
+ dim: wrap(2, 22),
256
+ red: wrap(31, 39),
257
+ green: wrap(32, 39),
258
+ yellow: wrap(33, 39),
259
+ blue: wrap(34, 39),
260
+ cyan: wrap(36, 39),
261
+ gray: wrap(90, 39),
262
+ };
26
263
  }
@@ -1,3 +1,4 @@
1
+ import type { GitCommitter } from "./git.js";
1
2
  import { type SuccessFs } from "./success.js";
2
3
  import { type TaskFs } from "./tasks.js";
3
4
  import type { Logger } from "./log.js";
@@ -33,6 +34,11 @@ export interface OrchestratorOptions {
33
34
  resultsDir?: string;
34
35
  maxAttempts?: number;
35
36
  timeoutMs?: number;
37
+ version?: string;
38
+ /** Commit the working tree after each task succeeds. Defaults to true. */
39
+ commit?: boolean;
40
+ /** Commits the working tree after each successful task. Omit to skip committing entirely. */
41
+ git?: GitCommitter;
36
42
  }
37
43
  export interface Summary {
38
44
  total: number;
@@ -1,6 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { dependencyState } from "./deps.js";
3
+ import { dependencyState, topologicalOrder } from "./deps.js";
4
4
  import { checkSuccess } from "./success.js";
5
5
  import { loadTasks, saveTasks } from "./tasks.js";
6
6
  export const DEFAULT_TIMEOUT_MS = 10 * 60 * 1000;
@@ -12,8 +12,34 @@ const defaultClock = {
12
12
  };
13
13
  export async function runOrchestrator(options) {
14
14
  const queueFs = options.fs ?? fs;
15
+ const clock = options.clock ?? defaultClock;
16
+ const runStart = clock.now().getTime();
15
17
  const taskFile = await loadTasks(options.tasksPath, queueFs);
16
18
  await saveTasks(options.tasksPath, taskFile, queueFs);
19
+ if (options.log) {
20
+ const cwd = options.cwd ?? process.cwd();
21
+ options.log.event({
22
+ type: "run_started",
23
+ info: {
24
+ version: options.version ?? "",
25
+ tasksPath: options.tasksPath,
26
+ total: taskFile.tasks.length,
27
+ maxAttempts: options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
28
+ timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
29
+ cwd,
30
+ resultsDir: options.resultsDir ?? defaultResultsDir(cwd),
31
+ },
32
+ });
33
+ // The plan is a courtesy preview; a dependency cycle is handled gracefully
34
+ // by the loop below, so never let topological ordering abort the run.
35
+ try {
36
+ const ordered = topologicalOrder(taskFile.tasks);
37
+ options.log.event({ type: "plan", taskIds: ordered.map((task) => task.id) });
38
+ }
39
+ catch {
40
+ // Cycle or missing dependency — the loop reports it as a deadlock/block.
41
+ }
42
+ }
17
43
  while (true) {
18
44
  let progressed = false;
19
45
  for (const task of taskFile.tasks) {
@@ -26,7 +52,12 @@ export async function runOrchestrator(options) {
26
52
  task.status = "blocked";
27
53
  task.error = "Dependency failed, blocked, or missing.";
28
54
  task.finishedAt = nowIso(options.clock);
29
- options.log?.event({ type: "task_blocked", taskId: task.id, reason: task.error });
55
+ options.log?.event({
56
+ type: "task_blocked",
57
+ taskId: task.id,
58
+ reason: task.error,
59
+ blockedBy: blockingDependency(task, taskFile.tasks),
60
+ });
30
61
  await saveTasks(options.tasksPath, taskFile, queueFs);
31
62
  progressed = true;
32
63
  continue;
@@ -38,7 +69,7 @@ export async function runOrchestrator(options) {
38
69
  break;
39
70
  }
40
71
  const summary = summarize(taskFile.tasks);
41
- options.log?.event({ type: "summary", summary });
72
+ options.log?.event({ type: "summary", summary, durationMs: clock.now().getTime() - runStart });
42
73
  return summary;
43
74
  }
44
75
  async function runTask(task, tasks, options, queueFs) {
@@ -48,30 +79,102 @@ async function runTask(task, tasks, options, queueFs) {
48
79
  const maxAttempts = task.maxAttempts ?? options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS;
49
80
  task.status = "running";
50
81
  task.attempts += 1;
82
+ const startMs = clock.now().getTime();
51
83
  task.startedAt = clock.now().toISOString();
52
84
  task.finishedAt = undefined;
53
85
  task.error = undefined;
54
- options.log?.event({ type: "task_started", taskId: task.id, attempt: task.attempts });
86
+ options.log?.event({
87
+ type: "task_started",
88
+ taskId: task.id,
89
+ attempt: task.attempts,
90
+ maxAttempts,
91
+ cwd,
92
+ timeoutMs,
93
+ });
55
94
  await saveTasks(options.tasksPath, { tasks }, queueFs);
56
95
  try {
57
96
  const session = await options.client.session.create({ cwd });
58
97
  const sessionId = typeof session === "string" ? session : session.id;
98
+ options.log?.event({ type: "task_session", taskId: task.id, sessionId });
59
99
  const controller = new AbortController();
60
100
  await withTimeout(options.client.session.prompt(sessionId, { prompt: task.prompt, signal: controller.signal }), timeoutMs, clock, controller);
101
+ const strategy = task.success?.strategy ?? "default";
61
102
  const success = await checkSuccess(task.success, { fs: queueFs, cwd });
103
+ options.log?.event({ type: "task_check", taskId: task.id, strategy, ok: success });
62
104
  if (!success)
63
105
  throw new Error("Success check failed.");
64
106
  task.status = "done";
65
107
  task.finishedAt = clock.now().toISOString();
66
- options.log?.event({ type: "task_done", taskId: task.id });
108
+ options.log?.event({
109
+ type: "task_done",
110
+ taskId: task.id,
111
+ durationMs: clock.now().getTime() - startMs,
112
+ done: countDone(tasks),
113
+ total: tasks.length,
114
+ });
67
115
  }
68
116
  catch (error) {
69
117
  task.error = error instanceof Error ? error.message : String(error);
70
118
  task.status = task.attempts >= maxAttempts ? "failed" : "pending";
71
119
  task.finishedAt = clock.now().toISOString();
72
- options.log?.event({ type: "task_failed", taskId: task.id, error: task.error });
120
+ options.log?.event({
121
+ type: "task_failed",
122
+ taskId: task.id,
123
+ error: task.error,
124
+ attempt: task.attempts,
125
+ maxAttempts,
126
+ willRetry: task.status === "pending",
127
+ durationMs: clock.now().getTime() - startMs,
128
+ });
73
129
  }
74
130
  await saveTasks(options.tasksPath, { tasks }, queueFs);
131
+ // Commit the work the task produced. This runs after the save above so the
132
+ // commit captures the task file in its final "done" state. A failed commit
133
+ // is reported but never flips the task back to failed — the task itself
134
+ // already passed its success check. Skipped when no committer is wired in.
135
+ if (task.status === "done" && options.commit !== false && options.git) {
136
+ await commitTask(task, options.git, options.log, cwd);
137
+ }
138
+ }
139
+ async function commitTask(task, committer, log, cwd) {
140
+ const message = `feat: ${task.id}`;
141
+ try {
142
+ const result = await committer.commit({ cwd, message });
143
+ if (result.status === "error") {
144
+ log?.event({ type: "task_commit_failed", taskId: task.id, message, detail: result.detail ?? "" });
145
+ return;
146
+ }
147
+ log?.event({
148
+ type: "task_committed",
149
+ taskId: task.id,
150
+ message,
151
+ status: result.status,
152
+ commit: result.commit,
153
+ });
154
+ }
155
+ catch (error) {
156
+ log?.event({
157
+ type: "task_commit_failed",
158
+ taskId: task.id,
159
+ message,
160
+ detail: error instanceof Error ? error.message : String(error),
161
+ });
162
+ }
163
+ }
164
+ function countDone(tasks) {
165
+ return tasks.reduce((total, task) => (task.status === "done" ? total + 1 : total), 0);
166
+ }
167
+ // Names the first dependency that is failed, blocked, or missing, for telemetry.
168
+ function blockingDependency(task, tasks) {
169
+ const byId = new Map(tasks.map((candidate) => [candidate.id, candidate]));
170
+ for (const depId of task.dependsOn) {
171
+ const dep = byId.get(depId);
172
+ if (!dep)
173
+ return `${depId} (missing)`;
174
+ if (dep.status === "failed" || dep.status === "blocked")
175
+ return `${depId} (${dep.status})`;
176
+ }
177
+ return undefined;
75
178
  }
76
179
  function isRunnable(task, globalMaxAttempts = DEFAULT_MAX_ATTEMPTS) {
77
180
  if (task.status === "pending")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "td-barrage",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "TypeScript queue runner that loads a JSON task file, resolves dependencies, runs tasks through the OpenCode SDK, and resumes interrupted work.",
5
5
  "type": "module",
6
6
  "engines": {
@@ -34,6 +34,7 @@
34
34
  "prepublishOnly": "npm run typecheck && npm test && npm run build"
35
35
  },
36
36
  "dependencies": {
37
+ "@clack/prompts": "^1.4.0",
37
38
  "@opencode-ai/sdk": "^1.15.10",
38
39
  "zod": "^3.25.0"
39
40
  },