@gevezex/gdt 0.4.0 → 0.5.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
@@ -1,25 +1,37 @@
1
1
  # gdt
2
2
 
3
- **GitHub issues to merge-ready pull requests, with a developer, tester and reviewer agent.**
3
+ **A deterministic supervisor that takes a GitHub issue through develop → test → review, with a different model in each role.**
4
4
 
5
- > Status: early development (0.x), used daily on this repository (see
6
- > [docs/dogfooding.md](docs/dogfooding.md)). Background:
7
- > [docs/design.md](docs/design.md).
5
+ gdt (GitHub Development and Test) is a supervisor that makes every GitHub issue
6
+ follow the same path: **develop → test → review**. Each step is done by a
7
+ separate agent role, and you ideally give each role a different model, so one
8
+ model's blind spots don't end up in your code unchecked.
8
9
 
9
- gdt takes one GitHub issue and runs it through three independent agent roles
10
- until a single draft pull request is ready to merge, or until it needs your
11
- decision:
10
+ When the tester or reviewer finds problems, the issue goes back to the
11
+ developer, but only a limited number of times: 2 correction rounds by default,
12
+ shared between tester and reviewer. After that gdt stops and asks you, so an
13
+ issue can never bounce between the roles forever.
14
+
15
+ **Built to avoid burning tokens.** GitHub is the shared record: the roles don't
16
+ talk to each other or to a long-running chat session, they each leave a
17
+ structured comment on the pull request, and the supervisor reads those. The
18
+ supervisor itself is plain code, not a model, so waiting, polling and deciding
19
+ whose turn it is cost no tokens; a model only runs during a role's turn. It
20
+ also means the workflow survives when your chat session ends.
21
+
22
+ gdt currently supports **Claude Code, Codex, OpenCode, MCode, pi and omp**, both
23
+ for the roles and for the agent you drive gdt from; more harnesses are on the
24
+ way. You can watch the roles work in [herdr](https://herdr.dev). gdt stops at a
25
+ draft pull request that is ready to merge; **you always merge yourself**.
26
+
27
+ ![gdt in herdr: the developer, tester and reviewer roles working on one issue](docs/assets/gdt-animation.gif)
12
28
 
13
29
  - the **developer** implements the issue and opens a draft pull request;
14
30
  - the **tester** checks every acceptance criterion against the running code;
15
31
  - the **reviewer** reads the diff against the issue.
16
32
 
17
- A deterministic **supervisor** (plain code, no model) decides whose turn it is.
18
- Waiting, polling and deciding cost no model tokens; a model only runs during a
19
- role's turn. You drive gdt by talking to any coding agent (Claude Code, Codex,
20
- OpenCode, MCode, pi or omp) and can watch the roles work in
21
- [herdr](https://herdr.dev). **You always merge yourself**: gdt never merges,
22
- deploys or closes issues.
33
+ More background in [docs/design.md](docs/design.md); how we use gdt on this
34
+ repository itself is in [docs/dogfooding.md](docs/dogfooding.md).
23
35
 
24
36
  ## How it works
25
37
 
@@ -28,8 +40,8 @@ deploys or closes issues.
28
40
  ```text
29
41
  ┌──────────┐ "pick up issue 251 ┌──────────────────────┐
30
42
  │ you │ ──────────────────────► │ operator agent │ Claude Code, Codex,
31
- └──────────┘ with gdt" │ (your chat session) │ OpenCode, ...
32
- ▲ └──────────┬───────────┘
43
+ └──────────┘ with gdt" │ (your chat session) │ OpenCode, MCode,
44
+ ▲ └──────────┬───────────┘ pi or omp
33
45
  │ │ gdt start 251 (returns at once)
34
46
  │ │ gdt wait 251 (background, 0 tokens)
35
47
  │ ▼
@@ -122,6 +134,7 @@ evidence too.
122
134
  | `git` | the shared checkout the roles work in |
123
135
  | `gh`, logged in (`gh auth login`) | issues, pull requests, comments, checks |
124
136
  | at least one agent CLI | `claude`, `codex`, `opencode`, `mcode`, `pi` or `omp` (see below) |
137
+ | CI on pull requests | the target repository needs at least one CI check (for example a GitHub Actions job); `workflow.required_checks` lists its name as shown on the pull request and `gdt init` detects the names. Set `workflow.allow_no_required_checks = true` only as the explicit opt-out |
125
138
  | [herdr](https://herdr.dev) 0.9.1+ | the default way to watch the roles live, one tab per role; on machines without herdr set `workflow.terminal = "headless"` |
126
139
 
127
140
  ### Agent prerequisites
@@ -159,6 +172,12 @@ npm run build
159
172
  npm link # puts `gdt` on your PATH
160
173
  ```
161
174
 
175
+ A linked install runs `dist/` of that checkout, so gdt runs the code you built
176
+ there. A workflow that runs gdt on that same checkout (for example on gdt's own
177
+ repository) can rebuild `dist/` and change the running gdt mid-workflow. Use a
178
+ linked install only to develop gdt; to run workflows, install the published
179
+ package with `npm i -g @gevezex/gdt`.
180
+
162
181
  Then install the operator skill, so your coding agent knows how to drive gdt:
163
182
 
164
183
  ```bash
@@ -168,13 +187,34 @@ gdt install-skill
168
187
  It copies `skill/SKILL.md` into the skill directory of every agent CLI it finds
169
188
  (for example `~/.claude/skills/gdt`) and is safe to run again.
170
189
 
190
+ ## Security
191
+
192
+ Before the first `gdt start`, know what a role turn can do:
193
+
194
+ - Every role turn runs its agent CLI **without permission prompts** and with
195
+ shell access to the machine. The adapters pass, for example,
196
+ `--permission-mode bypassPermissions` for Claude Code and
197
+ `--dangerously-bypass-approvals-and-sandbox` for Codex, because nobody is
198
+ there to answer a prompt. Run gdt only where you accept that.
199
+ - Issue and comment text is **task data** for the roles, never instructions to
200
+ the supervisor. Treat an issue body or a comment as untrusted input.
201
+ - The tester and reviewer are **checked mechanically**: after their turn the
202
+ supervisor verifies that HEAD, branch and the tracked files are unchanged, and
203
+ blocks the workflow otherwise.
204
+ - Roles act with **your own `gh` login**. An agent-written comment is
205
+ indistinguishable from one you wrote; gdt never merges, deploys or closes
206
+ issues.
207
+
208
+ The full invocation for each agent is in [docs/agents.md](docs/agents.md); the
209
+ trust boundaries are in
210
+ [section 10 of docs/design.md](docs/design.md#10-security-and-trust-boundaries).
211
+
171
212
  ## Quick start
172
213
 
173
- ### 1. Configure the target repository
214
+ ### 1. Set up the user config once per machine
174
215
 
175
- In the repository you want gdt to work on, create `.gdt/config.toml` with
176
- `gdt init` instead of writing TOML by hand. Ask your coding agent, or run it
177
- yourself:
216
+ Roles belong to you, not to a repository, so they live in your user config. Ask
217
+ your coding agent, or run `gdt init` yourself:
178
218
 
179
219
  ```bash
180
220
  gdt init --developer opencode/deepseek/deepseek-v4-flash \
@@ -182,27 +222,35 @@ gdt init --developer opencode/deepseek/deepseek-v4-flash \
182
222
  --reviewer codex/gpt-5.6-luna
183
223
  ```
184
224
 
185
- `gdt init` writes the roles to your user config (`~/.config/gdt/config.toml`,
186
- or `$XDG_CONFIG_HOME/gdt/config.toml`), writes the project settings to
187
- `.gdt/config.toml`, runs `gdt doctor` and installs the operator skill. Without the
188
- three role options, it reuses the roles from your user config when they are all
189
- there; otherwise it only reports what it found (agents on `PATH`, the terminal,
190
- the detected CI checks) so your agent can discuss the roles with you first. It
191
- never overwrites an existing config without `--force`, and it requires at least
192
- one required check unless you pass `--allow-no-required-checks`.
225
+ `gdt init` writes the roles to the user config (`~/.config/gdt/config.toml`, or
226
+ `$XDG_CONFIG_HOME/gdt/config.toml`) and installs the operator skill. Run it from
227
+ one of your checkouts: it also creates that repository's `.gdt/config.toml`, so
228
+ that first repository is configured too. Without the three role options, it
229
+ reuses the roles from your user config when they are all there; otherwise it
230
+ only reports what it found (agents on `PATH`, the terminal, the detected CI
231
+ checks) so your agent can discuss the roles with you first. It never overwrites
232
+ an existing config without `--force`.
233
+
234
+ ### 2. Configure each target repository
193
235
 
194
- Then check your setup:
236
+ For every other repository you want gdt to work on, create `.gdt/config.toml`
237
+ with `gdt init` (no role options: it reuses the roles from your user config), or
238
+ commit a file based on [`examples/config.toml`](examples/config.toml). `gdt init`
239
+ requires at least one required check unless you pass
240
+ `--allow-no-required-checks`.
241
+
242
+ Then check your setup in that repository:
195
243
 
196
244
  ```bash
197
245
  gdt doctor
198
246
  ```
199
247
 
200
- `doctor` checks `git`, `gh` and its login, the agent CLIs, herdr (when used)
201
- and the config, and prints a `fix:` line for every problem. It also warns when
202
- developer and tester use the same model vendor, because the tester is less
203
- independent then.
248
+ `doctor` checks `git`, `gh` and its login, the agent CLIs, herdr (when used) and
249
+ the user, repository and local config, and prints a `fix:` line for every
250
+ problem. It also warns when developer and tester use the same model vendor,
251
+ because the tester is less independent then.
204
252
 
205
- ### 2. Write the issue as a contract
253
+ ### 3. Write the issue as a contract
206
254
 
207
255
  The issue body is the only specification. It needs fixed sections and numbered
208
256
  acceptance criteria, each with Given, When, Then and a concrete Example:
@@ -251,7 +299,7 @@ gdt check-issue --body-file body.md # a draft, without calling GitHub
251
299
  Your agent can help write the body with the issue-writer instructions in
252
300
  [`roles/issue-writer.md`](roles/issue-writer.md).
253
301
 
254
- ### 3. Start it from your agent
302
+ ### 4. Start it from your agent
255
303
 
256
304
  Just ask your coding agent, in your own language:
257
305
 
@@ -269,7 +317,7 @@ gdt wait 251 # blocks until the workflow needs attention
269
317
  gdt status 251 # one line plus the next step
270
318
  ```
271
319
 
272
- ### 4. Answer, steer, merge
320
+ ### 5. Answer, steer, merge
273
321
 
274
322
  | Situation | What you (or your agent) run |
275
323
  |---|---|
@@ -295,7 +343,7 @@ changes product behaviour makes the role ask for the issue body to be updated.
295
343
  | `awaiting_human` | a role asked a question | `gdt answer <n> <question-id> "<text>"` |
296
344
  | `blocked` | a gate failed or a role reported blocked; the reason says why | follow the hint, e.g. `gdt allow-round <n>` |
297
345
  | `contract_changed` | the issue body changed; evidence is reset | wait |
298
- | `failed` | an agent turn exited non-zero | `gdt retry <n>` |
346
+ | `failed` | an agent turn exited non-zero | `gdt retry <n>`, then `gdt start <n>` |
299
347
  | `paused` / `stopped` | you paused or stopped it | `gdt resume <n>` / `gdt start <n>` |
300
348
  | `ready_to_merge` | all gates passed | review and merge the PR |
301
349
 
@@ -306,7 +354,7 @@ Every command supports `--help`; `status` and `wait` also support `--json`.
306
354
  | Command | Effect |
307
355
  |---|---|
308
356
  | `gdt init` | Write the roles to the user config and `.gdt/config.toml`, install the operator skill |
309
- | `gdt doctor` | Check tools, GitHub login, agents, herdr and `.gdt/config.toml` |
357
+ | `gdt doctor` | Check tools, GitHub login, agents, herdr and the user, repository and local config |
310
358
  | `gdt check-issue <n>` | Validate an issue body against the contract |
311
359
  | `gdt start <n>` | Preflight, start the supervisor and workers, return |
312
360
  | `gdt status <n>` | Status, role, round, open findings and next step |
@@ -374,6 +422,26 @@ the three files empty so you can see where your rules go; commit them like
374
422
 
375
423
  How each agent CLI is invoked is documented in [docs/agents.md](docs/agents.md).
376
424
 
425
+ ## Upgrading
426
+
427
+ ### From 0.3 to 0.4
428
+
429
+ Since 0.4.0, `[roles.*]` in `.gdt/config.toml` is an error and `gdt start`
430
+ refuses to run. Roles now live in the user config. To upgrade:
431
+
432
+ 1. Move the three `[roles.*]` tables from `.gdt/config.toml` to the user config
433
+ at `~/.config/gdt/config.toml`, or `$XDG_CONFIG_HOME/gdt/config.toml` when
434
+ `XDG_CONFIG_HOME` is set.
435
+ 2. Remove the `[roles.*]` tables from `.gdt/config.toml`, so only the repository
436
+ settings (`[workflow]`, `[contract]`) remain.
437
+ 3. Run `gdt doctor` to check that the configuration is valid again.
438
+
439
+ Instead of steps 1 and 2 you can run `gdt init --force` with the three role
440
+ options (see [Quick start](#quick-start)). It writes the roles to the user
441
+ config, but it also replaces `.gdt/config.toml` in the current checkout with
442
+ freshly detected defaults, so any custom repository settings there are lost.
443
+ Use the manual move when you have changed `[workflow]` or `[contract]`.
444
+
377
445
  ## Watching it: herdr or headless
378
446
 
379
447
  ```text
@@ -391,6 +459,66 @@ Everything gdt keeps for an issue lives under `.git/gdt/issue-<n>/`: `state.json
391
459
  (the workflow state), `logs/` (one log per process) and `runs/` (the prompt and
392
460
  result of every turn). It is never committed.
393
461
 
462
+ ### What a role pane shows
463
+
464
+ During a turn a role pane (herdr) or role log (headless, `logs/<role>.log`)
465
+ shows what the agent CLI prints with the invocation in
466
+ [docs/agents.md](docs/agents.md):
467
+
468
+ | Agent | What the pane shows during a turn |
469
+ |---|---|
470
+ | `claude` | Live progress rendered by gdt from Claude Code's event stream: assistant text, tool calls (`→ Bash npm test`), tool results (`✓ ok` or `✗ error`) and the final result, while the turn runs |
471
+ | `codex` | What `codex exec` prints: its progress (messages and the commands it runs) while the turn runs, then the final message |
472
+ | `opencode` | What `opencode run` prints: messages and tool calls while the turn runs |
473
+ | `mcode` | What `mcode exec` prints in its default text output |
474
+ | `pi` | What `pi --print` prints: the final answer, when the turn ends |
475
+ | `omp` | What `omp --print` prints: the final answer, when the turn ends |
476
+
477
+ ### Notifications
478
+
479
+ When a workflow needs you, gdt notifies you through the first of
480
+ `terminal-notifier`, `osascript` (macOS) or `notify-send` (Linux) that is on
481
+ `PATH` and works. If none of them is available, the notification is only
482
+ written to `.git/gdt/issue-<n>/logs/supervisor.log`, and `gdt wait` is the way
483
+ to be told: it blocks until the workflow needs attention.
484
+
485
+ ## The checkout during and after a workflow
486
+
487
+ The roles work in the checkout where you run `gdt start`: the developer checks
488
+ out the feature branch there, and the tester and reviewer read that same working
489
+ tree. `gdt start` needs a **clean working tree** and refuses to run otherwise
490
+ ("Commit or stash before starting."), so commit or stash first. Do not edit that
491
+ checkout while a workflow runs; if you want to keep working, use a separate
492
+ clone for gdt.
493
+
494
+ After `ready_to_merge`, or after `gdt stop`, two things stay behind:
495
+
496
+ - the herdr workspace `gdt-<n>`, which you can close yourself in herdr;
497
+ - the state directory `.git/gdt/issue-<n>/`, which you may delete once the pull
498
+ request is merged and no gdt process for that issue is running.
499
+
500
+ ## Troubleshooting
501
+
502
+ When a turn fails or a workflow stops unexpectedly, start with:
503
+
504
+ ```bash
505
+ gdt status <n>
506
+ gdt doctor
507
+ ```
508
+
509
+ `gdt status <n>` shows the status, role, round and next step; `gdt doctor`
510
+ reports a `fix:` line for every configuration or tool problem.
511
+
512
+ Everything gdt keeps for an issue is under the state directory: one log per
513
+ process in `.git/gdt/issue-<n>/logs/`, and the prompt and result of every turn
514
+ in `.git/gdt/issue-<n>/runs/`.
515
+
516
+ - **exit code 78** means the configuration was invalid or the turn's prompt
517
+ could not be built. Run `gdt doctor`, fix what it reports, then
518
+ `gdt retry <n>` and `gdt start <n>`.
519
+ - **any other non-zero exit code** means the agent CLI itself failed; its log
520
+ under `.git/gdt/issue-<n>/logs/` shows why.
521
+
394
522
  ## Principles
395
523
 
396
524
  - **The issue body is the contract.** Work starts only when there are no open questions.
@@ -413,7 +541,7 @@ npm ci
413
541
  npm run build
414
542
  npm run lint
415
543
  npm test
416
- node dist/cli.js doctor # run inside a repository with .gdt/config.toml
544
+ node dist/cli.js doctor # run in a repository configured for gdt (user + repository + local config)
417
545
  ```
418
546
 
419
547
  Rules for agents working on this repository: [AGENTS.md](AGENTS.md).
@@ -0,0 +1,124 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { statSync } from "node:fs";
4
+ import { isAbsolute, join } from "node:path";
5
+ /** The CPU signal fires when the agent process group used at least this many more CPU seconds. */
6
+ export const CPU_SIGNAL_SECONDS = 1;
7
+ /** Parses a `ps` `time` value: `[dd-]hh:mm:ss` on Linux, `m:ss.cc` or `h:mm:ss.cc` on macOS. */
8
+ export function parseCpuTime(value) {
9
+ const [days, rest] = value.includes("-") ? value.split("-", 2) : ["0", value];
10
+ const parts = (rest ?? "").split(":").map(Number);
11
+ let seconds = 0;
12
+ for (const part of parts)
13
+ seconds = seconds * 60 + (Number.isFinite(part) ? part : 0);
14
+ return Number(days) * 86_400 + seconds;
15
+ }
16
+ /**
17
+ * The summed CPU time and number of live (non-zombie) processes of process group `pgid`. The count is
18
+ * null when `ps` fails, so an unreadable process table never looks like an exited agent.
19
+ */
20
+ export function groupUsage(pgid, env) {
21
+ if (pgid === null || pgid <= 0)
22
+ return { cpu: 0, processes: 0 };
23
+ const result = spawnSync("ps", ["-A", "-o", "pgid=", "-o", "stat=", "-o", "time="], { env, encoding: "utf8" });
24
+ if (result.status !== 0)
25
+ return { cpu: 0, processes: null };
26
+ let cpu = 0;
27
+ let processes = 0;
28
+ for (const line of result.stdout.split("\n")) {
29
+ const [group, stat, time] = line.trim().split(/\s+/);
30
+ if (Number(group) !== pgid || stat === undefined || time === undefined || stat.startsWith("Z"))
31
+ continue;
32
+ processes += 1;
33
+ cpu += parseCpuTime(time);
34
+ }
35
+ return { cpu, processes };
36
+ }
37
+ /** `HEAD`, `git status --porcelain` and the mtime of every listed file, hashed. */
38
+ export function treeFingerprint(root, env) {
39
+ const git = (...args) => spawnSync("git", args, { cwd: root, env, encoding: "utf8" });
40
+ const head = git("rev-parse", "HEAD");
41
+ const status = git("status", "--porcelain", "-z");
42
+ if (status.status !== 0)
43
+ return null;
44
+ const hash = createHash("sha256").update(head.status === 0 ? head.stdout : "").update("\0").update(status.stdout);
45
+ const entries = status.stdout.split("\0").filter((entry) => entry !== "");
46
+ for (let i = 0; i < entries.length; i++) {
47
+ const entry = entries[i] ?? "";
48
+ const path = entry.slice(3);
49
+ // A rename or copy is followed by its original path, which no longer exists as listed.
50
+ if (entry[0] === "R" || entry[0] === "C")
51
+ i += 1;
52
+ hash.update(`\0${path}\0${mtimeOf(join(root, path)) ?? "-"}`);
53
+ }
54
+ return hash.digest("hex");
55
+ }
56
+ function mtimeOf(path) {
57
+ try {
58
+ return statSync(path).mtimeMs;
59
+ }
60
+ catch {
61
+ return null;
62
+ }
63
+ }
64
+ function sizeOf(path) {
65
+ try {
66
+ return statSync(path).size;
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ /** The opencode session database: `$XDG_DATA_HOME/opencode/opencode.db`, by default under `~/.local/share`. */
73
+ export function opencodeDatabase(env, home) {
74
+ const xdg = env.XDG_DATA_HOME;
75
+ const base = xdg !== undefined && isAbsolute(xdg) ? xdg : join(home, ".local", "share");
76
+ return join(base, "opencode", "opencode.db");
77
+ }
78
+ /** The mtime of `opencode.db-wal`, or of `opencode.db` when there is no write-ahead log. */
79
+ export function opencodeMtime(database) {
80
+ return mtimeOf(`${database}-wal`) ?? mtimeOf(database);
81
+ }
82
+ /** Reads one activity sample with `ps`, `git` and file metadata only. */
83
+ export function sample(input) {
84
+ const usage = groupUsage(input.pgid, input.env);
85
+ return {
86
+ cpu: usage.cpu,
87
+ processes: usage.processes,
88
+ tree: treeFingerprint(input.root, input.env),
89
+ log: input.logFile === null ? null : (sizeOf(input.logFile) ?? 0),
90
+ opencode: input.opencodeDb === null ? null : opencodeMtime(input.opencodeDb),
91
+ };
92
+ }
93
+ /**
94
+ * Compares a sample with the stored baseline. Without a baseline (the turn's first sample) no signal
95
+ * fires except CPU, which counts from zero: the agent process group started without CPU time.
96
+ */
97
+ export function signals(current, baseline) {
98
+ const cpuBase = baseline?.cpu ?? 0;
99
+ const changed = (now, before) => baseline !== undefined && now !== null && before !== null && before !== undefined && now !== before;
100
+ return {
101
+ cpu: current.cpu - cpuBase >= CPU_SIGNAL_SECONDS,
102
+ tree: changed(current.tree, baseline?.tree),
103
+ log: baseline !== undefined && current.log !== null && current.log > (baseline.log ?? 0),
104
+ opencode: baseline !== undefined && current.opencode !== null && (baseline.opencode === null || current.opencode > baseline.opencode),
105
+ };
106
+ }
107
+ /**
108
+ * The baseline for the next poll. The CPU value only moves with recorded activity, or down when a
109
+ * process of the group exits and takes its CPU time with it.
110
+ */
111
+ export function nextBaseline(current, previous, active) {
112
+ const cpuBase = previous?.cpu ?? 0;
113
+ return {
114
+ cpu: active || current.cpu < cpuBase ? current.cpu : cpuBase,
115
+ tree: current.tree,
116
+ log: current.log,
117
+ opencode: current.opencode,
118
+ };
119
+ }
120
+ /** The `supervisor.log` fragment naming every signal and whether it fired, for example `cpu=yes tree=no`. */
121
+ export function formatSignals(fired) {
122
+ const yn = (value) => (value ? "yes" : "no");
123
+ return `cpu=${yn(fired.cpu)} tree=${yn(fired.tree)} log=${yn(fired.log)} opencode=${yn(fired.opencode)}`;
124
+ }
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Renders Claude Code's stream-json output (`claude -p --output-format stream-json --verbose`) as
3
+ * readable lines while a turn runs (issue #62). A line it cannot read is passed through, never thrown.
4
+ */
5
+ const DIM = "\x1b[2m";
6
+ const RESET = "\x1b[0m";
7
+ /** The longest tool call summary, in characters. */
8
+ const SUMMARY_MAX = 120;
9
+ function isObject(value) {
10
+ return typeof value === "object" && value !== null && !Array.isArray(value);
11
+ }
12
+ /** A passthrough line for a valid object: `[<type>]`, dimmed on a TTY. */
13
+ function passthrough(type, tty) {
14
+ const line = `[${typeof type === "string" ? type : String(JSON.stringify(type))}]`;
15
+ return tty ? `${DIM}${line}${RESET}` : line;
16
+ }
17
+ /** `input.command`, else `input.file_path`, else `input.pattern`, else the compact JSON of `input`. */
18
+ function summary(input) {
19
+ let text;
20
+ if (isObject(input) && typeof input.command === "string")
21
+ text = input.command;
22
+ else if (isObject(input) && typeof input.file_path === "string")
23
+ text = input.file_path;
24
+ else if (isObject(input) && typeof input.pattern === "string")
25
+ text = input.pattern;
26
+ else
27
+ text = JSON.stringify(input) ?? "";
28
+ return (text.split("\n")[0] ?? "").slice(0, SUMMARY_MAX);
29
+ }
30
+ function contentItems(event) {
31
+ const message = event.message;
32
+ return isObject(message) && Array.isArray(message.content) ? message.content : [];
33
+ }
34
+ function renderItem(item, eventType, tty) {
35
+ if (!isObject(item))
36
+ return [passthrough(typeof item, tty)];
37
+ if (eventType === "assistant" && item.type === "text" && typeof item.text === "string")
38
+ return item.text.split("\n");
39
+ if (eventType === "assistant" && item.type === "tool_use") {
40
+ const name = typeof item.name === "string" ? item.name : "";
41
+ return [`→ ${name} ${summary(item.input)}`];
42
+ }
43
+ if (eventType === "user" && item.type === "tool_result")
44
+ return [item.is_error === true ? " ✗ error" : " ✓ ok"];
45
+ return [passthrough(item.type, tty)];
46
+ }
47
+ /** The rendered lines for one line of stream-json output; an empty line gives none. */
48
+ export function renderClaudeLine(line, tty) {
49
+ if (line.trim() === "")
50
+ return [];
51
+ let event;
52
+ try {
53
+ event = JSON.parse(line);
54
+ }
55
+ catch {
56
+ return [line];
57
+ }
58
+ if (!isObject(event))
59
+ return [line];
60
+ switch (event.type) {
61
+ case "assistant":
62
+ case "user": {
63
+ const type = event.type;
64
+ return contentItems(event).flatMap((item) => renderItem(item, type, tty));
65
+ }
66
+ case "result": {
67
+ const subtype = typeof event.subtype === "string" ? event.subtype : "";
68
+ const text = typeof event.result === "string" ? event.result.split("\n") : [];
69
+ return [`result: ${subtype}`, ...text];
70
+ }
71
+ default:
72
+ return [passthrough(event.type, tty)];
73
+ }
74
+ }
@@ -6,9 +6,21 @@ export const claude = {
6
6
  modelFormat: "<model>",
7
7
  modelExample: "claude-sonnet-5",
8
8
  buildInvocation: (_role, model, promptFile) => ({
9
- argv: ["claude", "-p", "--model", model, "--permission-mode", "bypassPermissions", "--no-session-persistence"],
9
+ argv: [
10
+ "claude",
11
+ "-p",
12
+ "--model",
13
+ model,
14
+ "--permission-mode",
15
+ "bypassPermissions",
16
+ "--no-session-persistence",
17
+ "--output-format",
18
+ "stream-json",
19
+ "--verbose",
20
+ ],
10
21
  env: {},
11
22
  stdin: promptFile,
23
+ output: "claude-stream-json",
12
24
  }),
13
25
  vendorOf: () => "anthropic",
14
26
  skillDir: () => "~/.claude/skills/gdt",
package/dist/cli.js CHANGED
@@ -11,7 +11,7 @@ import { loadLocale, shippedLanguages } from "./locale.js";
11
11
  import { allowRound, answer, installSkill, pause, resume, setAgent, steer } from "./steering.js";
12
12
  import { supervise } from "./supervisor.js";
13
13
  import { work } from "./worker.js";
14
- import { retry, start, status, stop, wait } from "./workflow.js";
14
+ import { extend, retry, start, status, stop, wait } from "./workflow.js";
15
15
  /** Exit codes: 0 success, 1 a check failed, 2 usage error. */
16
16
  export const EXIT_OK = 0;
17
17
  export const EXIT_FAILED = 1;
@@ -30,6 +30,7 @@ Commands:
30
30
  wait Wait until the workflow needs attention
31
31
  stop Stop the workflow for an issue; start resumes it
32
32
  retry Prepare a controlled retry of the failed turn
33
+ extend Give a turn that is still active at its hard limit more time
33
34
  answer Answer an open question
34
35
  steer Send a directive to one role
35
36
  pause Stop dispatching new turns
@@ -108,6 +109,12 @@ Stops the supervisor, the role workers and any running agent turn.
108
109
 
109
110
  Stops the failed or interrupted workflow and clears that turn so
110
111
  "gdt start <issue>" runs it again.
112
+ `,
113
+ extend: `Usage: gdt extend <issue>
114
+
115
+ Gives a turn that is still active at its hard limit (workflow.turn_max_minutes)
116
+ that many more minutes, counted from now. Only valid while the workflow is
117
+ blocked at a turn's hard limit.
111
118
  `,
112
119
  pause: `Usage: gdt pause <issue>
113
120
 
@@ -445,19 +452,17 @@ function workflowCommand(command, args, io) {
445
452
  if (typeof parsed === "number")
446
453
  return parsed;
447
454
  const { issue, json } = parsed;
448
- const result = command === "start"
449
- ? start(issue, io.cwd, io.env)
450
- : command === "stop"
451
- ? stop(issue, io.cwd, io.env)
452
- : command === "retry"
453
- ? retry(issue, io.cwd, io.env)
454
- : command === "pause"
455
- ? pause(issue, io.cwd, io.env)
456
- : command === "resume"
457
- ? resume(issue, io.cwd, io.env)
458
- : command === "allow-round"
459
- ? allowRound(issue, io.cwd, io.env)
460
- : status(issue, io.cwd, io.env, json);
455
+ const commands = {
456
+ start: () => start(issue, io.cwd, io.env),
457
+ status: () => status(issue, io.cwd, io.env, json),
458
+ stop: () => stop(issue, io.cwd, io.env),
459
+ retry: () => retry(issue, io.cwd, io.env),
460
+ extend: () => extend(issue, io.cwd, io.env),
461
+ pause: () => pause(issue, io.cwd, io.env),
462
+ resume: () => resume(issue, io.cwd, io.env),
463
+ "allow-round": () => allowRound(issue, io.cwd, io.env),
464
+ };
465
+ const result = commands[command]();
461
466
  return emit(result, io);
462
467
  }
463
468
  /** `gdt wait <issue> [--timeout <seconds>] [--json]`: blocks until the workflow needs attention. */
@@ -597,6 +602,7 @@ export function run(argv, io) {
597
602
  case "status":
598
603
  case "stop":
599
604
  case "retry":
605
+ case "extend":
600
606
  case "pause":
601
607
  case "resume":
602
608
  case "allow-round":
package/dist/config.js CHANGED
@@ -15,6 +15,12 @@ export const LOCAL_CONFIG_PATH = ".gdt/config.local.toml";
15
15
  export const USER_CONFIG_DIR = "gdt";
16
16
  export const DEFAULT_LANGUAGE = "en";
17
17
  export const DEFAULT_MAX_ACCEPTANCE_CRITERIA = 8;
18
+ /** AC-4: the turn time limit in minutes when `workflow.turn_timeout_minutes` is absent. */
19
+ export const DEFAULT_TURN_TIMEOUT_MINUTES = 60;
20
+ /** The inactivity window in minutes when `workflow.turn_idle_minutes` is absent. */
21
+ export const DEFAULT_TURN_IDLE_MINUTES = 10;
22
+ /** The hard limit in minutes when `workflow.turn_max_minutes` is absent. */
23
+ export const DEFAULT_TURN_MAX_MINUTES = 120;
18
24
  /** The test agent: runs `script` instead of a coding agent. Only accepted when GDT_TEST_AGENTS=1. */
19
25
  export const TEST_AGENT = "fake";
20
26
  /** The OS home directory, or `env.HOME` when it names one (AC-1 of issue #51). */
@@ -58,7 +64,8 @@ function configSchemaFor(testAgents) {
58
64
  roles: z
59
65
  .strictObject({ developer: role.optional(), tester: role.optional(), reviewer: role.optional() })
60
66
  .optional(),
61
- workflow: z.strictObject({
67
+ workflow: z
68
+ .strictObject({
62
69
  max_correction_rounds: z.int().min(0).default(2),
63
70
  // Deliberately without a default: an empty gate must be an explicit choice.
64
71
  required_checks: z.array(z.string().min(1)),
@@ -69,6 +76,21 @@ function configSchemaFor(testAgents) {
69
76
  herdr_layout: z.enum(HERDR_LAYOUTS).default("tabs"),
70
77
  poll_seconds: z.number().positive().default(30),
71
78
  handoff_checks: z.int().min(1).default(5),
79
+ // AC-4: a positive number of minutes per turn, 60 by default; zero or negative is invalid.
80
+ turn_timeout_minutes: z.number().positive().default(DEFAULT_TURN_TIMEOUT_MINUTES),
81
+ // A turn past its deadline keeps running while it was active within this many minutes.
82
+ turn_idle_minutes: z.number().positive().default(DEFAULT_TURN_IDLE_MINUTES),
83
+ // A turn still active this long after its dispatch asks the user (`gdt extend` or `gdt retry`).
84
+ turn_max_minutes: z.number().positive().default(DEFAULT_TURN_MAX_MINUTES),
85
+ })
86
+ .superRefine((workflow, ctx) => {
87
+ if (workflow.turn_max_minutes < workflow.turn_timeout_minutes) {
88
+ ctx.addIssue({
89
+ code: "custom",
90
+ path: ["turn_max_minutes"],
91
+ message: `must be at least workflow.turn_timeout_minutes (${workflow.turn_timeout_minutes})`,
92
+ });
93
+ }
72
94
  }),
73
95
  contract: z
74
96
  .strictObject({