@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 +166 -38
- package/dist/activity.js +124 -0
- package/dist/agents/claude-stream.js +74 -0
- package/dist/agents/claude.js +13 -1
- package/dist/cli.js +20 -14
- package/dist/config.js +23 -1
- package/dist/prompts.js +23 -1
- package/dist/protocol.js +10 -2
- package/dist/state.js +2 -0
- package/dist/supervisor.js +263 -9
- package/dist/worker.js +34 -6
- package/dist/workflow.js +93 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,25 +1,37 @@
|
|
|
1
1
|
# gdt
|
|
2
2
|
|
|
3
|
-
**GitHub
|
|
3
|
+
**A deterministic supervisor that takes a GitHub issue through develop → test → review, with a different model in each role.**
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
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
|
+

|
|
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
|
-
|
|
18
|
-
|
|
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.
|
|
214
|
+
### 1. Set up the user config once per machine
|
|
174
215
|
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
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
|
|
202
|
-
developer and tester use the same model vendor,
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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
|
|
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
|
|
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).
|
package/dist/activity.js
ADDED
|
@@ -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
|
+
}
|
package/dist/agents/claude.js
CHANGED
|
@@ -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: [
|
|
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
|
|
449
|
-
|
|
450
|
-
:
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
|
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({
|