@gevezex/gdt 0.3.0 → 0.4.1
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 +160 -41
- package/dist/cli.js +52 -24
- package/dist/config.js +184 -26
- package/dist/doctor.js +4 -4
- package/dist/init.js +222 -7
- package/dist/workflow.js +8 -5
- package/package.json +1 -1
- package/skill/SKILL.md +7 -3
package/README.md
CHANGED
|
@@ -122,13 +122,14 @@ evidence too.
|
|
|
122
122
|
| `git` | the shared checkout the roles work in |
|
|
123
123
|
| `gh`, logged in (`gh auth login`) | issues, pull requests, comments, checks |
|
|
124
124
|
| at least one agent CLI | `claude`, `codex`, `opencode`, `mcode`, `pi` or `omp` (see below) |
|
|
125
|
+
| 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
126
|
| [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
127
|
|
|
127
128
|
### Agent prerequisites
|
|
128
129
|
|
|
129
|
-
Before the first `gdt start`, every agent CLI you name in
|
|
130
|
-
already work on your machine with the exact
|
|
131
|
-
role:
|
|
130
|
+
Before the first `gdt start`, every agent CLI you name in the user config
|
|
131
|
+
(`~/.config/gdt/config.toml`) must already work on your machine with the exact
|
|
132
|
+
model id you set there. For each role:
|
|
132
133
|
|
|
133
134
|
1. install the agent CLI,
|
|
134
135
|
2. log in or configure its API key or subscription,
|
|
@@ -159,6 +160,12 @@ npm run build
|
|
|
159
160
|
npm link # puts `gdt` on your PATH
|
|
160
161
|
```
|
|
161
162
|
|
|
163
|
+
A linked install runs `dist/` of that checkout, so gdt runs the code you built
|
|
164
|
+
there. A workflow that runs gdt on that same checkout (for example on gdt's own
|
|
165
|
+
repository) can rebuild `dist/` and change the running gdt mid-workflow. Use a
|
|
166
|
+
linked install only to develop gdt; to run workflows, install the published
|
|
167
|
+
package with `npm i -g @gevezex/gdt`.
|
|
168
|
+
|
|
162
169
|
Then install the operator skill, so your coding agent knows how to drive gdt:
|
|
163
170
|
|
|
164
171
|
```bash
|
|
@@ -168,13 +175,34 @@ gdt install-skill
|
|
|
168
175
|
It copies `skill/SKILL.md` into the skill directory of every agent CLI it finds
|
|
169
176
|
(for example `~/.claude/skills/gdt`) and is safe to run again.
|
|
170
177
|
|
|
178
|
+
## Security
|
|
179
|
+
|
|
180
|
+
Before the first `gdt start`, know what a role turn can do:
|
|
181
|
+
|
|
182
|
+
- Every role turn runs its agent CLI **without permission prompts** and with
|
|
183
|
+
shell access to the machine. The adapters pass, for example,
|
|
184
|
+
`--permission-mode bypassPermissions` for Claude Code and
|
|
185
|
+
`--dangerously-bypass-approvals-and-sandbox` for Codex, because nobody is
|
|
186
|
+
there to answer a prompt. Run gdt only where you accept that.
|
|
187
|
+
- Issue and comment text is **task data** for the roles, never instructions to
|
|
188
|
+
the supervisor. Treat an issue body or a comment as untrusted input.
|
|
189
|
+
- The tester and reviewer are **checked mechanically**: after their turn the
|
|
190
|
+
supervisor verifies that HEAD, branch and the tracked files are unchanged, and
|
|
191
|
+
blocks the workflow otherwise.
|
|
192
|
+
- Roles act with **your own `gh` login**. An agent-written comment is
|
|
193
|
+
indistinguishable from one you wrote; gdt never merges, deploys or closes
|
|
194
|
+
issues.
|
|
195
|
+
|
|
196
|
+
The full invocation for each agent is in [docs/agents.md](docs/agents.md); the
|
|
197
|
+
trust boundaries are in
|
|
198
|
+
[section 10 of docs/design.md](docs/design.md#10-security-and-trust-boundaries).
|
|
199
|
+
|
|
171
200
|
## Quick start
|
|
172
201
|
|
|
173
|
-
### 1.
|
|
202
|
+
### 1. Set up the user config once per machine
|
|
174
203
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
yourself:
|
|
204
|
+
Roles belong to you, not to a repository, so they live in your user config. Ask
|
|
205
|
+
your coding agent, or run `gdt init` yourself:
|
|
178
206
|
|
|
179
207
|
```bash
|
|
180
208
|
gdt init --developer opencode/deepseek/deepseek-v4-flash \
|
|
@@ -182,24 +210,35 @@ gdt init --developer opencode/deepseek/deepseek-v4-flash \
|
|
|
182
210
|
--reviewer codex/gpt-5.6-luna
|
|
183
211
|
```
|
|
184
212
|
|
|
185
|
-
`gdt init` writes the
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
213
|
+
`gdt init` writes the roles to the user config (`~/.config/gdt/config.toml`, or
|
|
214
|
+
`$XDG_CONFIG_HOME/gdt/config.toml`) and installs the operator skill. Run it from
|
|
215
|
+
one of your checkouts: it also creates that repository's `.gdt/config.toml`, so
|
|
216
|
+
that first repository is configured too. Without the three role options, it
|
|
217
|
+
reuses the roles from your user config when they are all there; otherwise it
|
|
218
|
+
only reports what it found (agents on `PATH`, the terminal, the detected CI
|
|
219
|
+
checks) so your agent can discuss the roles with you first. It never overwrites
|
|
220
|
+
an existing config without `--force`.
|
|
221
|
+
|
|
222
|
+
### 2. Configure each target repository
|
|
190
223
|
|
|
191
|
-
|
|
224
|
+
For every other repository you want gdt to work on, create `.gdt/config.toml`
|
|
225
|
+
with `gdt init` (no role options: it reuses the roles from your user config), or
|
|
226
|
+
commit a file based on [`examples/config.toml`](examples/config.toml). `gdt init`
|
|
227
|
+
requires at least one required check unless you pass
|
|
228
|
+
`--allow-no-required-checks`.
|
|
229
|
+
|
|
230
|
+
Then check your setup in that repository:
|
|
192
231
|
|
|
193
232
|
```bash
|
|
194
233
|
gdt doctor
|
|
195
234
|
```
|
|
196
235
|
|
|
197
|
-
`doctor` checks `git`, `gh` and its login, the agent CLIs, herdr (when used)
|
|
198
|
-
and
|
|
199
|
-
developer and tester use the same model vendor,
|
|
200
|
-
independent then.
|
|
236
|
+
`doctor` checks `git`, `gh` and its login, the agent CLIs, herdr (when used) and
|
|
237
|
+
the user, repository and local config, and prints a `fix:` line for every
|
|
238
|
+
problem. It also warns when developer and tester use the same model vendor,
|
|
239
|
+
because the tester is less independent then.
|
|
201
240
|
|
|
202
|
-
###
|
|
241
|
+
### 3. Write the issue as a contract
|
|
203
242
|
|
|
204
243
|
The issue body is the only specification. It needs fixed sections and numbered
|
|
205
244
|
acceptance criteria, each with Given, When, Then and a concrete Example:
|
|
@@ -248,7 +287,7 @@ gdt check-issue --body-file body.md # a draft, without calling GitHub
|
|
|
248
287
|
Your agent can help write the body with the issue-writer instructions in
|
|
249
288
|
[`roles/issue-writer.md`](roles/issue-writer.md).
|
|
250
289
|
|
|
251
|
-
###
|
|
290
|
+
### 4. Start it from your agent
|
|
252
291
|
|
|
253
292
|
Just ask your coding agent, in your own language:
|
|
254
293
|
|
|
@@ -266,7 +305,7 @@ gdt wait 251 # blocks until the workflow needs attention
|
|
|
266
305
|
gdt status 251 # one line plus the next step
|
|
267
306
|
```
|
|
268
307
|
|
|
269
|
-
###
|
|
308
|
+
### 5. Answer, steer, merge
|
|
270
309
|
|
|
271
310
|
| Situation | What you (or your agent) run |
|
|
272
311
|
|---|---|
|
|
@@ -292,7 +331,7 @@ changes product behaviour makes the role ask for the issue body to be updated.
|
|
|
292
331
|
| `awaiting_human` | a role asked a question | `gdt answer <n> <question-id> "<text>"` |
|
|
293
332
|
| `blocked` | a gate failed or a role reported blocked; the reason says why | follow the hint, e.g. `gdt allow-round <n>` |
|
|
294
333
|
| `contract_changed` | the issue body changed; evidence is reset | wait |
|
|
295
|
-
| `failed` | an agent turn exited non-zero | `gdt retry <n>` |
|
|
334
|
+
| `failed` | an agent turn exited non-zero | `gdt retry <n>`, then `gdt start <n>` |
|
|
296
335
|
| `paused` / `stopped` | you paused or stopped it | `gdt resume <n>` / `gdt start <n>` |
|
|
297
336
|
| `ready_to_merge` | all gates passed | review and merge the PR |
|
|
298
337
|
|
|
@@ -302,8 +341,8 @@ Every command supports `--help`; `status` and `wait` also support `--json`.
|
|
|
302
341
|
|
|
303
342
|
| Command | Effect |
|
|
304
343
|
|---|---|
|
|
305
|
-
| `gdt init` |
|
|
306
|
-
| `gdt doctor` | Check tools, GitHub login, agents, herdr and
|
|
344
|
+
| `gdt init` | Write the roles to the user config and `.gdt/config.toml`, install the operator skill |
|
|
345
|
+
| `gdt doctor` | Check tools, GitHub login, agents, herdr and the user, repository and local config |
|
|
307
346
|
| `gdt check-issue <n>` | Validate an issue body against the contract |
|
|
308
347
|
| `gdt start <n>` | Preflight, start the supervisor and workers, return |
|
|
309
348
|
| `gdt status <n>` | Status, role, round, open findings and next step |
|
|
@@ -319,23 +358,38 @@ Every command supports `--help`; `status` and `wait` also support `--json`.
|
|
|
319
358
|
|
|
320
359
|
## Configuration
|
|
321
360
|
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
361
|
+
Roles belong to a person, not to a repository, so gdt keeps them in a per-user
|
|
362
|
+
config. gdt loads three files and merges them in this order, with the later file
|
|
363
|
+
winning per key:
|
|
364
|
+
|
|
365
|
+
1. the **user config** — `$XDG_CONFIG_HOME/gdt/config.toml`, or
|
|
366
|
+
`~/.config/gdt/config.toml` when `XDG_CONFIG_HOME` is not set. It holds only
|
|
367
|
+
`[roles.*]`. A relative `XDG_CONFIG_HOME` is ignored.
|
|
368
|
+
2. the **repository config** — `.gdt/config.toml`, committed. It holds the project
|
|
369
|
+
settings and must not contain `[roles.*]`.
|
|
370
|
+
3. the **local config** — `.gdt/config.local.toml`, never committed. It overrides
|
|
371
|
+
any key, for example one role's model on this machine.
|
|
372
|
+
|
|
373
|
+
`gdt init` writes the roles to the user config and the project settings to
|
|
374
|
+
`.gdt/config.toml`. Ready-made files are in [`examples/`](examples/):
|
|
375
|
+
[`examples/user-config.toml`](examples/user-config.toml),
|
|
376
|
+
[`examples/config.toml`](examples/config.toml) and
|
|
377
|
+
[`examples/config.local.toml`](examples/config.local.toml).
|
|
378
|
+
|
|
379
|
+
| Key | File | Default | Meaning |
|
|
380
|
+
|---|---|---|---|
|
|
381
|
+
| `roles.<role>.agent` | user | required | `claude`, `codex`, `opencode`, `mcode`, `pi` or `omp` |
|
|
382
|
+
| `roles.<role>.model` | user | required | Model id for that agent |
|
|
383
|
+
| `language` | repository | `"en"` | Language of issue and PR text (`en`, `nl`) |
|
|
384
|
+
| `workflow.required_checks` | repository | required | CI checks that must be green before `ready_to_merge` |
|
|
385
|
+
| `workflow.allow_no_required_checks` | repository | `false` | Allow an empty `required_checks` list |
|
|
386
|
+
| `workflow.max_correction_rounds` | repository | `2` | Correction rounds after round 0 |
|
|
387
|
+
| `workflow.terminal` | repository | `"herdr"` | `"herdr"` or `"headless"` |
|
|
388
|
+
| `workflow.supervisor_pane` | repository | `false` | herdr: also show the supervisor in a pane |
|
|
389
|
+
| `workflow.herdr_layout` | repository | `tabs` | herdr: `"tabs"` (one tab per pane) or `"split"` (panes side by side in one tab) |
|
|
390
|
+
| `workflow.poll_seconds` | repository | `30` | How often the supervisor reads GitHub |
|
|
391
|
+
| `contract.max_acceptance_criteria` | repository | `8` | Maximum number of ACs per issue |
|
|
392
|
+
| `contract.extra_rules` | repository | none | File with project rules added to every role prompt |
|
|
339
393
|
|
|
340
394
|
### Per-role rules
|
|
341
395
|
|
|
@@ -356,6 +410,26 @@ the three files empty so you can see where your rules go; commit them like
|
|
|
356
410
|
|
|
357
411
|
How each agent CLI is invoked is documented in [docs/agents.md](docs/agents.md).
|
|
358
412
|
|
|
413
|
+
## Upgrading
|
|
414
|
+
|
|
415
|
+
### From 0.3 to 0.4
|
|
416
|
+
|
|
417
|
+
Since 0.4.0, `[roles.*]` in `.gdt/config.toml` is an error and `gdt start`
|
|
418
|
+
refuses to run. Roles now live in the user config. To upgrade:
|
|
419
|
+
|
|
420
|
+
1. Move the three `[roles.*]` tables from `.gdt/config.toml` to the user config
|
|
421
|
+
at `~/.config/gdt/config.toml`, or `$XDG_CONFIG_HOME/gdt/config.toml` when
|
|
422
|
+
`XDG_CONFIG_HOME` is set.
|
|
423
|
+
2. Remove the `[roles.*]` tables from `.gdt/config.toml`, so only the repository
|
|
424
|
+
settings (`[workflow]`, `[contract]`) remain.
|
|
425
|
+
3. Run `gdt doctor` to check that the configuration is valid again.
|
|
426
|
+
|
|
427
|
+
Instead of steps 1 and 2 you can run `gdt init --force` with the three role
|
|
428
|
+
options (see [Quick start](#quick-start)). It writes the roles to the user
|
|
429
|
+
config, but it also replaces `.gdt/config.toml` in the current checkout with
|
|
430
|
+
freshly detected defaults, so any custom repository settings there are lost.
|
|
431
|
+
Use the manual move when you have changed `[workflow]` or `[contract]`.
|
|
432
|
+
|
|
359
433
|
## Watching it: herdr or headless
|
|
360
434
|
|
|
361
435
|
```text
|
|
@@ -373,6 +447,51 @@ Everything gdt keeps for an issue lives under `.git/gdt/issue-<n>/`: `state.json
|
|
|
373
447
|
(the workflow state), `logs/` (one log per process) and `runs/` (the prompt and
|
|
374
448
|
result of every turn). It is never committed.
|
|
375
449
|
|
|
450
|
+
### Notifications
|
|
451
|
+
|
|
452
|
+
When a workflow needs you, gdt notifies you through the first of
|
|
453
|
+
`terminal-notifier`, `osascript` (macOS) or `notify-send` (Linux) that is on
|
|
454
|
+
`PATH` and works. If none of them is available, the notification is only
|
|
455
|
+
written to `.git/gdt/issue-<n>/logs/supervisor.log`, and `gdt wait` is the way
|
|
456
|
+
to be told: it blocks until the workflow needs attention.
|
|
457
|
+
|
|
458
|
+
## The checkout during and after a workflow
|
|
459
|
+
|
|
460
|
+
The roles work in the checkout where you run `gdt start`: the developer checks
|
|
461
|
+
out the feature branch there, and the tester and reviewer read that same working
|
|
462
|
+
tree. `gdt start` needs a **clean working tree** and refuses to run otherwise
|
|
463
|
+
("Commit or stash before starting."), so commit or stash first. Do not edit that
|
|
464
|
+
checkout while a workflow runs; if you want to keep working, use a separate
|
|
465
|
+
clone for gdt.
|
|
466
|
+
|
|
467
|
+
After `ready_to_merge`, or after `gdt stop`, two things stay behind:
|
|
468
|
+
|
|
469
|
+
- the herdr workspace `gdt-<n>`, which you can close yourself in herdr;
|
|
470
|
+
- the state directory `.git/gdt/issue-<n>/`, which you may delete once the pull
|
|
471
|
+
request is merged and no gdt process for that issue is running.
|
|
472
|
+
|
|
473
|
+
## Troubleshooting
|
|
474
|
+
|
|
475
|
+
When a turn fails or a workflow stops unexpectedly, start with:
|
|
476
|
+
|
|
477
|
+
```bash
|
|
478
|
+
gdt status <n>
|
|
479
|
+
gdt doctor
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
`gdt status <n>` shows the status, role, round and next step; `gdt doctor`
|
|
483
|
+
reports a `fix:` line for every configuration or tool problem.
|
|
484
|
+
|
|
485
|
+
Everything gdt keeps for an issue is under the state directory: one log per
|
|
486
|
+
process in `.git/gdt/issue-<n>/logs/`, and the prompt and result of every turn
|
|
487
|
+
in `.git/gdt/issue-<n>/runs/`.
|
|
488
|
+
|
|
489
|
+
- **exit code 78** means the configuration was invalid or the turn's prompt
|
|
490
|
+
could not be built. Run `gdt doctor`, fix what it reports, then
|
|
491
|
+
`gdt retry <n>` and `gdt start <n>`.
|
|
492
|
+
- **any other non-zero exit code** means the agent CLI itself failed; its log
|
|
493
|
+
under `.git/gdt/issue-<n>/logs/` shows why.
|
|
494
|
+
|
|
376
495
|
## Principles
|
|
377
496
|
|
|
378
497
|
- **The issue body is the contract.** Work starts only when there are no open questions.
|
|
@@ -395,7 +514,7 @@ npm ci
|
|
|
395
514
|
npm run build
|
|
396
515
|
npm run lint
|
|
397
516
|
npm test
|
|
398
|
-
node dist/cli.js doctor # run
|
|
517
|
+
node dist/cli.js doctor # run in a repository configured for gdt (user + repository + local config)
|
|
399
518
|
```
|
|
400
519
|
|
|
401
520
|
Rules for agents working on this repository: [AGENTS.md](AGENTS.md).
|
package/dist/cli.js
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
-
import { CONFIG_PATH, DEFAULT_LANGUAGE, DEFAULT_MAX_ACCEPTANCE_CRITERIA, loadConfig, ROLES } from "./config.js";
|
|
5
|
+
import { CONFIG_PATH, DEFAULT_LANGUAGE, DEFAULT_MAX_ACCEPTANCE_CRITERIA, loadConfig, readUserConfig, ROLES } from "./config.js";
|
|
6
6
|
import { validateContract } from "./contract.js";
|
|
7
7
|
import { findRepository, herdrPreflight, runDoctor } from "./doctor.js";
|
|
8
8
|
import { detectedChecks, issueBody } from "./github.js";
|
|
9
|
-
import { createRoleRulesFiles, parseRoleSpec, proposal, serializeConfig, writeConfig } from "./init.js";
|
|
9
|
+
import { createRoleRulesFiles, parseRoleSpec, proposal, serializeConfig, writeConfig, writeUserConfig } from "./init.js";
|
|
10
10
|
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";
|
|
@@ -22,8 +22,8 @@ Usage:
|
|
|
22
22
|
gdt <command> [options]
|
|
23
23
|
|
|
24
24
|
Commands:
|
|
25
|
-
init
|
|
26
|
-
doctor Check tools, GitHub authentication and
|
|
25
|
+
init Write roles to the user config and .gdt/config.toml; install the skill
|
|
26
|
+
doctor Check tools, GitHub authentication and the configuration
|
|
27
27
|
check-issue Validate an issue body against the issue contract
|
|
28
28
|
start Start the workflow for an issue in the background
|
|
29
29
|
status Show the workflow status and the next step
|
|
@@ -45,9 +45,9 @@ Options:
|
|
|
45
45
|
`;
|
|
46
46
|
const DOCTOR_HELP = `Usage: gdt doctor [--json]
|
|
47
47
|
|
|
48
|
-
Checks that git and gh are installed, gh is authenticated, and that
|
|
49
|
-
.gdt/config.toml (
|
|
50
|
-
Exits with 1 when any finding has level "error".
|
|
48
|
+
Checks that git and gh are installed, gh is authenticated, and that the user
|
|
49
|
+
config (roles), .gdt/config.toml (project settings) and .gdt/config.local.toml
|
|
50
|
+
are valid. Exits with 1 when any finding has level "error".
|
|
51
51
|
`;
|
|
52
52
|
const INIT_HELP = `Usage: gdt init [--json]
|
|
53
53
|
gdt init --developer <agent>/<model> --tester <agent>/<model> --reviewer <agent>/<model>
|
|
@@ -55,9 +55,12 @@ const INIT_HELP = `Usage: gdt init [--json]
|
|
|
55
55
|
[--required-check <name>]... [--allow-no-required-checks] [--force]
|
|
56
56
|
|
|
57
57
|
Without the three role options, reports the supported agents, whether each is on
|
|
58
|
-
PATH, the usable terminal, the detected CI checks and the language
|
|
59
|
-
|
|
60
|
-
|
|
58
|
+
PATH, the usable terminal, the detected CI checks and the language. When the
|
|
59
|
+
user config (see below) defines all three roles it writes .gdt/config.toml; with
|
|
60
|
+
the three role options it writes the roles to the user config and the project
|
|
61
|
+
settings to .gdt/config.toml, then runs "gdt doctor" and installs the operator
|
|
62
|
+
skill. The user config is $XDG_CONFIG_HOME/gdt/config.toml, or
|
|
63
|
+
$HOME/.config/gdt/config.toml when XDG_CONFIG_HOME is not set.
|
|
61
64
|
|
|
62
65
|
Options:
|
|
63
66
|
--developer <agent>/<model> Agent and model for the developer role
|
|
@@ -238,27 +241,41 @@ function initCommand(args, io) {
|
|
|
238
241
|
io.stderr(`${io.cwd} is not inside a Git repository. Run gdt from a checkout of the target repository.\n`);
|
|
239
242
|
return EXIT_FAILED;
|
|
240
243
|
}
|
|
244
|
+
const user = readUserConfig(io.env);
|
|
245
|
+
if (user.findings.length > 0) {
|
|
246
|
+
io.stderr(`${user.findings.map((finding) => finding.message).join("\n")}\n`);
|
|
247
|
+
return EXIT_FAILED;
|
|
248
|
+
}
|
|
241
249
|
const specs = { developer, tester, reviewer };
|
|
242
250
|
const given = ROLES.filter((role) => specs[role] !== undefined);
|
|
243
|
-
|
|
251
|
+
// AC-6: without a user config that defines all three roles, `gdt init` only reports the proposal.
|
|
252
|
+
if (given.length === 0 && !ROLES.every((role) => user.roles[role] !== undefined)) {
|
|
244
253
|
const facts = proposal(root, io.env);
|
|
245
254
|
io.stdout(json ? `${JSON.stringify(facts, null, 2)}\n` : formatProposal(facts));
|
|
246
255
|
return EXIT_OK;
|
|
247
256
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
.
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
257
|
+
let roles;
|
|
258
|
+
if (given.length > 0) {
|
|
259
|
+
if (given.length < ROLES.length) {
|
|
260
|
+
const missing = ROLES.filter((role) => specs[role] === undefined)
|
|
261
|
+
.map((role) => `"--${role}"`)
|
|
262
|
+
.join(", ");
|
|
263
|
+
return usageError(io, `Missing ${missing} for "gdt init".`, "gdt init --help");
|
|
264
|
+
}
|
|
265
|
+
roles = {};
|
|
266
|
+
for (const role of ROLES) {
|
|
267
|
+
const parsed = parseRoleSpec(specs[role] ?? "");
|
|
268
|
+
if ("error" in parsed) {
|
|
269
|
+
io.stderr(`${parsed.error}\n`);
|
|
270
|
+
return EXIT_FAILED;
|
|
271
|
+
}
|
|
272
|
+
roles[role] = parsed;
|
|
273
|
+
}
|
|
274
|
+
// AC-5: refuse before writing anything when the user config already defines a role.
|
|
275
|
+
if (!force && user.defined.length > 0) {
|
|
276
|
+
io.stderr(`The user config ${user.path} already defines roles (${user.defined.join(", ")}); use --force to replace them\n`);
|
|
259
277
|
return EXIT_FAILED;
|
|
260
278
|
}
|
|
261
|
-
roles[role] = parsed;
|
|
262
279
|
}
|
|
263
280
|
const languages = shippedLanguages();
|
|
264
281
|
if (language !== undefined && !languages.includes(language)) {
|
|
@@ -275,8 +292,19 @@ function initCommand(args, io) {
|
|
|
275
292
|
io.stderr("No required checks detected; pass --required-check <name> for each check, or --allow-no-required-checks to accept none\n");
|
|
276
293
|
return EXIT_FAILED;
|
|
277
294
|
}
|
|
295
|
+
// AC-5: write the roles to the user config; AC-6 leaves an existing user config untouched.
|
|
296
|
+
if (roles !== undefined) {
|
|
297
|
+
const userError = writeUserConfig(io.env, roles);
|
|
298
|
+
if (userError !== null) {
|
|
299
|
+
io.stderr(`${userError}\n`);
|
|
300
|
+
return EXIT_FAILED;
|
|
301
|
+
}
|
|
302
|
+
io.stdout(`roles: written to ${user.path}\n`);
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
io.stdout(`roles: from ${user.path}\n`);
|
|
306
|
+
}
|
|
278
307
|
const text = serializeConfig({
|
|
279
|
-
roles,
|
|
280
308
|
language: language ?? DEFAULT_LANGUAGE,
|
|
281
309
|
terminal: resolvedTerminal,
|
|
282
310
|
requiredChecks: checks,
|
package/dist/config.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
-
import {
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
3
4
|
import { parse as parseToml, TomlError } from "smol-toml";
|
|
4
5
|
import { z } from "zod";
|
|
6
|
+
import { hasErrors } from "./finding.js";
|
|
5
7
|
export const AGENTS = ["claude", "codex", "opencode", "mcode", "pi", "omp"];
|
|
6
8
|
export const ROLES = ["developer", "tester", "reviewer"];
|
|
7
9
|
export const TERMINALS = ["herdr", "headless"];
|
|
@@ -9,10 +11,27 @@ export const TERMINALS = ["herdr", "headless"];
|
|
|
9
11
|
export const HERDR_LAYOUTS = ["split", "tabs"];
|
|
10
12
|
export const CONFIG_PATH = ".gdt/config.toml";
|
|
11
13
|
export const LOCAL_CONFIG_PATH = ".gdt/config.local.toml";
|
|
14
|
+
/** The `gdt` directory under the XDG config home that holds the per-user config. */
|
|
15
|
+
export const USER_CONFIG_DIR = "gdt";
|
|
12
16
|
export const DEFAULT_LANGUAGE = "en";
|
|
13
17
|
export const DEFAULT_MAX_ACCEPTANCE_CRITERIA = 8;
|
|
14
18
|
/** The test agent: runs `script` instead of a coding agent. Only accepted when GDT_TEST_AGENTS=1. */
|
|
15
19
|
export const TEST_AGENT = "fake";
|
|
20
|
+
/** The OS home directory, or `env.HOME` when it names one (AC-1 of issue #51). */
|
|
21
|
+
function homeDir(env) {
|
|
22
|
+
const home = env.HOME;
|
|
23
|
+
return home === undefined || home === "" ? homedir() : home;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The per-user config path. `$XDG_CONFIG_HOME/gdt/config.toml` when `XDG_CONFIG_HOME` is an
|
|
27
|
+
* absolute path, otherwise `$HOME/.config/gdt/config.toml`. A relative `XDG_CONFIG_HOME` is
|
|
28
|
+
* ignored, as the XDG base directory specification requires.
|
|
29
|
+
*/
|
|
30
|
+
export function userConfigPath(env) {
|
|
31
|
+
const xdg = env.XDG_CONFIG_HOME;
|
|
32
|
+
const base = xdg !== undefined && isAbsolute(xdg) ? xdg : join(homeDir(env), ".config");
|
|
33
|
+
return join(base, USER_CONFIG_DIR, "config.toml");
|
|
34
|
+
}
|
|
16
35
|
const roleSchema = z.strictObject({
|
|
17
36
|
agent: z.enum(AGENTS),
|
|
18
37
|
model: z.string().min(1),
|
|
@@ -35,7 +54,10 @@ function configSchemaFor(testAgents) {
|
|
|
35
54
|
const role = testAgents ? testRoleSchema : roleSchema;
|
|
36
55
|
return z.strictObject({
|
|
37
56
|
language: z.string().min(1).default(DEFAULT_LANGUAGE),
|
|
38
|
-
|
|
57
|
+
// Roles live in the user config now; a role that is absent is reported separately (AC-4).
|
|
58
|
+
roles: z
|
|
59
|
+
.strictObject({ developer: role.optional(), tester: role.optional(), reviewer: role.optional() })
|
|
60
|
+
.optional(),
|
|
39
61
|
workflow: z.strictObject({
|
|
40
62
|
max_correction_rounds: z.int().min(0).default(2),
|
|
41
63
|
// Deliberately without a default: an empty gate must be an explicit choice.
|
|
@@ -83,7 +105,7 @@ function valueAt(data, path) {
|
|
|
83
105
|
function formatPath(path) {
|
|
84
106
|
return path.map(String).join(".");
|
|
85
107
|
}
|
|
86
|
-
/** The layer that supplied the value at `path`, preferring the
|
|
108
|
+
/** The layer that supplied the value at `path`, preferring the later layer. */
|
|
87
109
|
function sourceOf(layers, path) {
|
|
88
110
|
for (let i = layers.length - 1; i >= 0; i--) {
|
|
89
111
|
const layer = layers[i];
|
|
@@ -92,6 +114,18 @@ function sourceOf(layers, path) {
|
|
|
92
114
|
}
|
|
93
115
|
return layers[0]?.path ?? CONFIG_PATH;
|
|
94
116
|
}
|
|
117
|
+
/** The config file that last set a key of `role`; a role is only layered in the user and local config. */
|
|
118
|
+
function roleSource(layers, role, fallback) {
|
|
119
|
+
for (let i = layers.length - 1; i >= 0; i--) {
|
|
120
|
+
const layer = layers[i];
|
|
121
|
+
if (layer === undefined)
|
|
122
|
+
continue;
|
|
123
|
+
const table = valueAt(layer.data, ["roles", role]);
|
|
124
|
+
if (isTable(table) && Object.keys(table).length > 0)
|
|
125
|
+
return layer.path;
|
|
126
|
+
}
|
|
127
|
+
return fallback;
|
|
128
|
+
}
|
|
95
129
|
function describe(value) {
|
|
96
130
|
return value === undefined ? "missing" : JSON.stringify(value);
|
|
97
131
|
}
|
|
@@ -119,10 +153,11 @@ function issueFindings(issue, merged, layers) {
|
|
|
119
153
|
return [error(`${key}: ${issue.message}`, `Correct ${key} in ${file}`)];
|
|
120
154
|
}
|
|
121
155
|
}
|
|
122
|
-
|
|
123
|
-
|
|
156
|
+
/** Reads and parses one TOML file; `display` is the path used in findings. */
|
|
157
|
+
function readLayer(fullPath, display, kind) {
|
|
158
|
+
const text = readFileSync(fullPath, "utf8");
|
|
124
159
|
try {
|
|
125
|
-
return { path, data: parseToml(text) };
|
|
160
|
+
return { path: display, kind, data: parseToml(text) };
|
|
126
161
|
}
|
|
127
162
|
catch (err) {
|
|
128
163
|
const where = err instanceof TomlError ? ` at line ${err.line}, column ${err.column}` : "";
|
|
@@ -130,15 +165,72 @@ function readLayer(root, path) {
|
|
|
130
165
|
return {
|
|
131
166
|
check: "config",
|
|
132
167
|
level: "error",
|
|
133
|
-
message: `${
|
|
134
|
-
fix: `Fix the TOML syntax in ${
|
|
168
|
+
message: `${display}: invalid TOML${where}: ${reason}`,
|
|
169
|
+
fix: `Fix the TOML syntax in ${display}`,
|
|
135
170
|
};
|
|
136
171
|
}
|
|
137
172
|
}
|
|
138
|
-
/**
|
|
173
|
+
/**
|
|
174
|
+
* Reads the user config for `gdt init`. It parses the file and reports which role tables it
|
|
175
|
+
* contains; it does not validate the repository config, because `gdt init` may run before that
|
|
176
|
+
* file exists (AC-6).
|
|
177
|
+
*/
|
|
178
|
+
export function readUserConfig(env) {
|
|
179
|
+
const path = userConfigPath(env);
|
|
180
|
+
if (!existsSync(path))
|
|
181
|
+
return { path, exists: false, roles: {}, defined: [], findings: [] };
|
|
182
|
+
const layer = readLayer(path, path, "user");
|
|
183
|
+
if (!("data" in layer))
|
|
184
|
+
return { path, exists: true, roles: {}, defined: [], findings: [layer] };
|
|
185
|
+
const roles = {};
|
|
186
|
+
const defined = [];
|
|
187
|
+
const raw = layer.data.roles;
|
|
188
|
+
if (isTable(raw)) {
|
|
189
|
+
for (const role of ROLES) {
|
|
190
|
+
const table = raw[role];
|
|
191
|
+
if (!isTable(table))
|
|
192
|
+
continue;
|
|
193
|
+
defined.push(role);
|
|
194
|
+
if (typeof table.agent === "string" && typeof table.model === "string") {
|
|
195
|
+
roles[role] = {
|
|
196
|
+
agent: table.agent,
|
|
197
|
+
model: table.model,
|
|
198
|
+
...(typeof table.script === "string" ? { script: table.script } : {}),
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { path, exists: true, roles, defined, findings: [] };
|
|
204
|
+
}
|
|
205
|
+
function roleMissingFinding(role, userPath) {
|
|
206
|
+
const init = "gdt init --developer <agent>/<model> --tester <agent>/<model> --reviewer <agent>/<model>";
|
|
207
|
+
return {
|
|
208
|
+
check: "config",
|
|
209
|
+
level: "error",
|
|
210
|
+
message: `roles.${role}: missing`,
|
|
211
|
+
fix: `Add roles.${role} to ${userPath} with "${init}"`,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Loads the user config, the repository config and the local config, merges them in that order
|
|
216
|
+
* (later layers win per key) and validates the result. Roles are read from the user config unless
|
|
217
|
+
* the local config overrides them; a `[roles.*]` table in the repository config is an error (AC-2).
|
|
218
|
+
*/
|
|
139
219
|
export function loadConfig(root, env = {}) {
|
|
140
|
-
const
|
|
141
|
-
|
|
220
|
+
const userPath = userConfigPath(env);
|
|
221
|
+
const repoPath = join(root, CONFIG_PATH);
|
|
222
|
+
const localPath = join(root, LOCAL_CONFIG_PATH);
|
|
223
|
+
const userExists = existsSync(userPath);
|
|
224
|
+
const repoExists = existsSync(repoPath);
|
|
225
|
+
const localExists = existsSync(localPath);
|
|
226
|
+
const files = [];
|
|
227
|
+
if (userExists)
|
|
228
|
+
files.push(userPath);
|
|
229
|
+
if (repoExists)
|
|
230
|
+
files.push(CONFIG_PATH);
|
|
231
|
+
if (localExists)
|
|
232
|
+
files.push(LOCAL_CONFIG_PATH);
|
|
233
|
+
if (!repoExists) {
|
|
142
234
|
return {
|
|
143
235
|
report: { valid: false, files },
|
|
144
236
|
findings: [
|
|
@@ -146,15 +238,20 @@ export function loadConfig(root, env = {}) {
|
|
|
146
238
|
check: "config",
|
|
147
239
|
level: "error",
|
|
148
240
|
message: `${CONFIG_PATH}: not found`,
|
|
149
|
-
fix: `Create ${CONFIG_PATH}; example: https://github.com/gevezex/gdt/blob/main/
|
|
241
|
+
fix: `Create ${CONFIG_PATH}; example: https://github.com/gevezex/gdt/blob/main/examples/config.toml`,
|
|
150
242
|
},
|
|
151
243
|
],
|
|
152
244
|
};
|
|
153
245
|
}
|
|
154
246
|
const layers = [];
|
|
155
247
|
const findings = [];
|
|
156
|
-
|
|
157
|
-
|
|
248
|
+
const inputs = [[repoPath, CONFIG_PATH, "repo"]];
|
|
249
|
+
if (userExists)
|
|
250
|
+
inputs.unshift([userPath, userPath, "user"]);
|
|
251
|
+
if (localExists)
|
|
252
|
+
inputs.push([localPath, LOCAL_CONFIG_PATH, "local"]);
|
|
253
|
+
for (const [full, display, kind] of inputs) {
|
|
254
|
+
const layer = readLayer(full, display, kind);
|
|
158
255
|
if ("data" in layer)
|
|
159
256
|
layers.push(layer);
|
|
160
257
|
else
|
|
@@ -162,28 +259,89 @@ export function loadConfig(root, env = {}) {
|
|
|
162
259
|
}
|
|
163
260
|
if (findings.length > 0)
|
|
164
261
|
return { report: { valid: false, files }, findings };
|
|
165
|
-
const
|
|
262
|
+
const userLayer = layers.find((layer) => layer.kind === "user");
|
|
263
|
+
const repoLayer = layers.find((layer) => layer.kind === "repo");
|
|
264
|
+
// AC-3: the user config holds only roles.
|
|
265
|
+
if (userLayer !== undefined) {
|
|
266
|
+
for (const key of Object.keys(userLayer.data)) {
|
|
267
|
+
if (key === "roles")
|
|
268
|
+
continue;
|
|
269
|
+
findings.push({
|
|
270
|
+
check: "config",
|
|
271
|
+
level: "error",
|
|
272
|
+
message: `${key}: not allowed in ${userPath}`,
|
|
273
|
+
fix: `Move ${key} to ${CONFIG_PATH}`,
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// AC-2: the repository config must not commit roles.
|
|
278
|
+
const repoRoles = repoLayer?.data.roles;
|
|
279
|
+
if (isTable(repoRoles)) {
|
|
280
|
+
for (const role of Object.keys(repoRoles)) {
|
|
281
|
+
findings.push({
|
|
282
|
+
check: "config",
|
|
283
|
+
level: "error",
|
|
284
|
+
message: `roles.${role}: not allowed in ${CONFIG_PATH}`,
|
|
285
|
+
fix: `Move roles.${role} to ${userPath} or ${LOCAL_CONFIG_PATH}`,
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
else if (repoRoles !== undefined) {
|
|
290
|
+
findings.push({
|
|
291
|
+
check: "config",
|
|
292
|
+
level: "error",
|
|
293
|
+
message: `roles: not allowed in ${CONFIG_PATH}`,
|
|
294
|
+
fix: `Move roles to ${userPath} or ${LOCAL_CONFIG_PATH}`,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
// Roles come from the user config and the local config only; the repository config is excluded.
|
|
298
|
+
const roleData = layers
|
|
299
|
+
.filter((layer) => layer.kind !== "repo")
|
|
300
|
+
.reduce((acc, layer) => (isTable(layer.data.roles) ? merge(acc, layer.data.roles) : acc), {});
|
|
301
|
+
const merged = layers.reduce((acc, layer) => {
|
|
302
|
+
const copy = { ...layer.data };
|
|
303
|
+
delete copy.roles;
|
|
304
|
+
return merge(acc, copy);
|
|
305
|
+
}, {});
|
|
306
|
+
if (Object.keys(roleData).length > 0)
|
|
307
|
+
merged.roles = roleData;
|
|
166
308
|
const parsed = configSchemaFor(testAgentsEnabled(env)).safeParse(merged);
|
|
167
309
|
if (!parsed.success) {
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
310
|
+
findings.push(...parsed.error.issues.flatMap((issue) => issueFindings(issue, merged, layers)));
|
|
311
|
+
}
|
|
312
|
+
// AC-4: every role the user and local configs do not define is an error pointing at the user config.
|
|
313
|
+
for (const role of ROLES) {
|
|
314
|
+
if (!isTable(valueAt(merged, ["roles", role])))
|
|
315
|
+
findings.push(roleMissingFinding(role, userPath));
|
|
172
316
|
}
|
|
317
|
+
if (hasErrors(findings) || !parsed.success)
|
|
318
|
+
return { report: { valid: false, files }, findings };
|
|
173
319
|
const config = parsed.data;
|
|
174
|
-
const
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}
|
|
320
|
+
const resolved = {
|
|
321
|
+
developer: { ...config.roles?.developer, source: roleSource(layers, "developer", userPath) },
|
|
322
|
+
tester: { ...config.roles?.tester, source: roleSource(layers, "tester", userPath) },
|
|
323
|
+
reviewer: { ...config.roles?.reviewer, source: roleSource(layers, "reviewer", userPath) },
|
|
324
|
+
};
|
|
179
325
|
findings.push({
|
|
180
326
|
check: "config",
|
|
181
327
|
level: "ok",
|
|
182
|
-
message:
|
|
328
|
+
message: localExists && userExists
|
|
329
|
+
? `${CONFIG_PATH} is valid, with roles from ${userPath} and overrides from ${LOCAL_CONFIG_PATH}`
|
|
330
|
+
: localExists
|
|
331
|
+
? `${CONFIG_PATH} is valid, with overrides from ${LOCAL_CONFIG_PATH}`
|
|
332
|
+
: `${CONFIG_PATH} is valid`,
|
|
183
333
|
fix: "",
|
|
184
334
|
});
|
|
185
335
|
findings.push(...semanticFindings(root, config));
|
|
186
|
-
|
|
336
|
+
const report = {
|
|
337
|
+
valid: true,
|
|
338
|
+
files,
|
|
339
|
+
language: config.language,
|
|
340
|
+
workflow: config.workflow,
|
|
341
|
+
contract: config.contract,
|
|
342
|
+
roles: resolved,
|
|
343
|
+
};
|
|
344
|
+
return { report, findings };
|
|
187
345
|
}
|
|
188
346
|
/** Checks that pass the schema but would make gdt unsafe or surprising. */
|
|
189
347
|
function semanticFindings(root, config) {
|
package/dist/doctor.js
CHANGED
|
@@ -2,7 +2,7 @@ import { spawnSync } from "node:child_process";
|
|
|
2
2
|
import { accessSync, appendFileSync, constants, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { delimiter, dirname, join, resolve } from "node:path";
|
|
4
4
|
import { adapterFor } from "./agents/index.js";
|
|
5
|
-
import { CONFIG_PATH, LOCAL_CONFIG_PATH, loadConfig, ROLES, TEST_AGENT } from "./config.js";
|
|
5
|
+
import { CONFIG_PATH, LOCAL_CONFIG_PATH, loadConfig, ROLES, TEST_AGENT, userConfigPath } from "./config.js";
|
|
6
6
|
import { hasErrors } from "./finding.js";
|
|
7
7
|
const TOOLS = [
|
|
8
8
|
{ name: "git", fix: "Install Git: https://git-scm.com/downloads" },
|
|
@@ -159,7 +159,7 @@ function excludeFinding(root, git, env) {
|
|
|
159
159
|
* (docs/agents.md). Generic: any agent absent from the adapter registry is rejected here. The test
|
|
160
160
|
* agent is exempt: it runs a script instead of a CLI.
|
|
161
161
|
*/
|
|
162
|
-
export function unsupportedAgentFindings(roles) {
|
|
162
|
+
export function unsupportedAgentFindings(roles, userConfig) {
|
|
163
163
|
return ROLES.flatMap((role) => {
|
|
164
164
|
const { agent } = roles[role];
|
|
165
165
|
if (agent === TEST_AGENT || adapterFor(agent) !== undefined)
|
|
@@ -169,14 +169,14 @@ export function unsupportedAgentFindings(roles) {
|
|
|
169
169
|
check: `roles.${role}`,
|
|
170
170
|
level: "error",
|
|
171
171
|
message: `roles.${role}.agent: ${agent} has no unattended mode; see docs/agents.md`,
|
|
172
|
-
fix: `Set roles.${role}.agent in ${
|
|
172
|
+
fix: `Set roles.${role}.agent in ${userConfig} to an agent with an unattended mode`,
|
|
173
173
|
},
|
|
174
174
|
];
|
|
175
175
|
});
|
|
176
176
|
}
|
|
177
177
|
/** Checks the agent binary of every configured role, and that the tester is independent of the developer. */
|
|
178
178
|
function agentFindings(config, env) {
|
|
179
|
-
const findings = unsupportedAgentFindings(config.roles);
|
|
179
|
+
const findings = unsupportedAgentFindings(config.roles, userConfigPath(env));
|
|
180
180
|
for (const role of ROLES) {
|
|
181
181
|
const { agent } = config.roles[role];
|
|
182
182
|
const adapter = adapterFor(agent);
|
package/dist/init.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { adapterFor, supportedAgents } from "./agents/index.js";
|
|
4
|
-
import { CONFIG_PATH, DEFAULT_LANGUAGE, ROLES } from "./config.js";
|
|
4
|
+
import { CONFIG_PATH, DEFAULT_LANGUAGE, ROLES, userConfigPath } from "./config.js";
|
|
5
5
|
import { herdrPreflight, which } from "./doctor.js";
|
|
6
6
|
import { detectedChecks } from "./github.js";
|
|
7
7
|
import { ROLE_RULES_DIR, roleRulesPath } from "./prompts.js";
|
|
@@ -40,15 +40,11 @@ export function parseRoleSpec(spec) {
|
|
|
40
40
|
return { error: `Missing model in "${spec}"; use <agent>/<model>` };
|
|
41
41
|
return { agent: agent, model };
|
|
42
42
|
}
|
|
43
|
-
/** The `.gdt/config.toml` text; a key that keeps its schema default is not written
|
|
43
|
+
/** The `.gdt/config.toml` text without roles; a key that keeps its schema default is not written. */
|
|
44
44
|
export function serializeConfig(config) {
|
|
45
45
|
const lines = [];
|
|
46
46
|
if (config.language !== DEFAULT_LANGUAGE)
|
|
47
47
|
lines.push(`language = ${JSON.stringify(config.language)}`, "");
|
|
48
|
-
for (const role of ROLES) {
|
|
49
|
-
const { agent, model } = config.roles[role];
|
|
50
|
-
lines.push(`[roles.${role}]`, `agent = ${JSON.stringify(agent)}`, `model = ${JSON.stringify(model)}`, "");
|
|
51
|
-
}
|
|
52
48
|
lines.push("[workflow]", `required_checks = [${config.requiredChecks.map((name) => JSON.stringify(name)).join(", ")}]`);
|
|
53
49
|
if (config.allowNoRequiredChecks)
|
|
54
50
|
lines.push("allow_no_required_checks = true");
|
|
@@ -57,6 +53,225 @@ export function serializeConfig(config) {
|
|
|
57
53
|
lines.push("");
|
|
58
54
|
return lines.join("\n");
|
|
59
55
|
}
|
|
56
|
+
function roleTable(role, spec) {
|
|
57
|
+
return `[roles.${role}]\nagent = ${JSON.stringify(spec.agent)}\nmodel = ${JSON.stringify(spec.model)}\n`;
|
|
58
|
+
}
|
|
59
|
+
/** The user config text for `roles`, used when the file does not exist yet. */
|
|
60
|
+
export function serializeUserConfig(roles) {
|
|
61
|
+
return ROLES.map((role) => roleTable(role, roles[role])).join("\n");
|
|
62
|
+
}
|
|
63
|
+
/** Strips the surrounding quotes of one TOML key segment. */
|
|
64
|
+
function unquoteKey(segment) {
|
|
65
|
+
if (segment.length >= 2 && segment.startsWith('"') && segment.endsWith('"')) {
|
|
66
|
+
try {
|
|
67
|
+
const value = JSON.parse(segment);
|
|
68
|
+
if (typeof value === "string")
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Not JSON-compatible; fall back to a plain strip.
|
|
73
|
+
}
|
|
74
|
+
return segment.slice(1, -1);
|
|
75
|
+
}
|
|
76
|
+
if (segment.length >= 2 && segment.startsWith("'") && segment.endsWith("'"))
|
|
77
|
+
return segment.slice(1, -1);
|
|
78
|
+
return segment;
|
|
79
|
+
}
|
|
80
|
+
/** Splits a TOML dotted key into its segments, honoring quoted segments (e.g. `roles."a.b"`). */
|
|
81
|
+
function keySegments(key) {
|
|
82
|
+
const segments = [];
|
|
83
|
+
let current = "";
|
|
84
|
+
let quote = null;
|
|
85
|
+
for (let i = 0; i < key.length; i += 1) {
|
|
86
|
+
const ch = key[i] ?? "";
|
|
87
|
+
if (quote === '"') {
|
|
88
|
+
current += ch;
|
|
89
|
+
if (ch === "\\") {
|
|
90
|
+
current += key[i + 1] ?? "";
|
|
91
|
+
i += 1;
|
|
92
|
+
}
|
|
93
|
+
else if (ch === '"')
|
|
94
|
+
quote = null;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (quote === "'") {
|
|
98
|
+
current += ch;
|
|
99
|
+
if (ch === "'")
|
|
100
|
+
quote = null;
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
if (ch === '"' || ch === "'") {
|
|
104
|
+
quote = ch;
|
|
105
|
+
current += ch;
|
|
106
|
+
}
|
|
107
|
+
else if (ch === ".") {
|
|
108
|
+
segments.push(unquoteKey(current.trim()));
|
|
109
|
+
current = "";
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
current += ch;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
segments.push(unquoteKey(current.trim()));
|
|
116
|
+
return segments;
|
|
117
|
+
}
|
|
118
|
+
const HEADER_RE = /^\s*\[\[?\s*(.*?)\s*\]\]?\s*(?:#.*)?$/;
|
|
119
|
+
/** The dotted path of a `[table]` / `[[array]]` header, or null when the line is not a header. */
|
|
120
|
+
function headerSegments(line) {
|
|
121
|
+
const match = HEADER_RE.exec(line);
|
|
122
|
+
return match === null ? null : keySegments(match[1] ?? "");
|
|
123
|
+
}
|
|
124
|
+
/** Splits `key = value` at the first `=` outside quoted key segments. */
|
|
125
|
+
function splitAssignment(line) {
|
|
126
|
+
let quote = null;
|
|
127
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
128
|
+
const ch = line[i] ?? "";
|
|
129
|
+
if (quote === '"') {
|
|
130
|
+
if (ch === "\\")
|
|
131
|
+
i += 1;
|
|
132
|
+
else if (ch === '"')
|
|
133
|
+
quote = null;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (quote === "'") {
|
|
137
|
+
if (ch === "'")
|
|
138
|
+
quote = null;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (ch === '"' || ch === "'")
|
|
142
|
+
quote = ch;
|
|
143
|
+
else if (ch === "=")
|
|
144
|
+
return { key: line.slice(0, i), value: line.slice(i + 1) };
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
/** Tracks one TOML value across lines so multi-line values stay attached to their assignment. */
|
|
149
|
+
class ValueScan {
|
|
150
|
+
depth = 0;
|
|
151
|
+
mode = "normal";
|
|
152
|
+
/** Feeds one line; returns true once the value is complete at the end of that line. */
|
|
153
|
+
feed(line) {
|
|
154
|
+
for (let i = 0; i < line.length; i += 1) {
|
|
155
|
+
const ch = line[i] ?? "";
|
|
156
|
+
switch (this.mode) {
|
|
157
|
+
case "normal":
|
|
158
|
+
if (ch === "#")
|
|
159
|
+
i = line.length;
|
|
160
|
+
else if (ch === '"') {
|
|
161
|
+
if (line.startsWith('"""', i)) {
|
|
162
|
+
this.mode = "basic-multi";
|
|
163
|
+
i += 2;
|
|
164
|
+
}
|
|
165
|
+
else
|
|
166
|
+
this.mode = "basic";
|
|
167
|
+
}
|
|
168
|
+
else if (ch === "'") {
|
|
169
|
+
if (line.startsWith("'''", i)) {
|
|
170
|
+
this.mode = "literal-multi";
|
|
171
|
+
i += 2;
|
|
172
|
+
}
|
|
173
|
+
else
|
|
174
|
+
this.mode = "literal";
|
|
175
|
+
}
|
|
176
|
+
else if (ch === "[" || ch === "{")
|
|
177
|
+
this.depth += 1;
|
|
178
|
+
else if (ch === "]" || ch === "}")
|
|
179
|
+
this.depth -= 1;
|
|
180
|
+
break;
|
|
181
|
+
case "basic":
|
|
182
|
+
if (ch === "\\")
|
|
183
|
+
i += 1;
|
|
184
|
+
else if (ch === '"')
|
|
185
|
+
this.mode = "normal";
|
|
186
|
+
break;
|
|
187
|
+
case "literal":
|
|
188
|
+
if (ch === "'")
|
|
189
|
+
this.mode = "normal";
|
|
190
|
+
break;
|
|
191
|
+
case "basic-multi":
|
|
192
|
+
if (line.startsWith('"""', i)) {
|
|
193
|
+
this.mode = "normal";
|
|
194
|
+
i += 2;
|
|
195
|
+
}
|
|
196
|
+
else if (ch === "\\")
|
|
197
|
+
i += 1;
|
|
198
|
+
break;
|
|
199
|
+
case "literal-multi":
|
|
200
|
+
if (line.startsWith("'''", i)) {
|
|
201
|
+
this.mode = "normal";
|
|
202
|
+
i += 2;
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return this.mode === "normal" && this.depth === 0;
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* AC-5: replaces the `roles` key of `existing`, keeping every other line (comments, other keys and
|
|
212
|
+
* tables) byte-for-byte. Roles are removed whatever valid TOML form they were written in — role
|
|
213
|
+
* tables, a `[roles]` table with inline entries, a top-level inline `roles = { ... }`, or dotted
|
|
214
|
+
* keys — so a rewrite never leaves a duplicate definition behind. The new tables are appended.
|
|
215
|
+
*/
|
|
216
|
+
export function upsertRoles(existing, roles) {
|
|
217
|
+
const kept = [];
|
|
218
|
+
let table = null;
|
|
219
|
+
let pending = null;
|
|
220
|
+
for (const line of existing.split("\n")) {
|
|
221
|
+
if (pending !== null) {
|
|
222
|
+
const complete = pending.scan.feed(line);
|
|
223
|
+
if (!pending.drop)
|
|
224
|
+
kept.push(line);
|
|
225
|
+
if (complete)
|
|
226
|
+
pending = null;
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
const trimmed = line.trim();
|
|
230
|
+
const inRoleRegion = table !== null && table[0] === "roles";
|
|
231
|
+
if (trimmed === "" || trimmed.startsWith("#")) {
|
|
232
|
+
if (!inRoleRegion)
|
|
233
|
+
kept.push(line);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
const header = headerSegments(line);
|
|
237
|
+
if (header !== null) {
|
|
238
|
+
table = header;
|
|
239
|
+
if (header[0] !== "roles")
|
|
240
|
+
kept.push(line);
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
const assignment = splitAssignment(line);
|
|
244
|
+
if (assignment === null) {
|
|
245
|
+
if (!inRoleRegion)
|
|
246
|
+
kept.push(line);
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
const drop = inRoleRegion || (table === null && keySegments(assignment.key)[0] === "roles");
|
|
250
|
+
if (!drop)
|
|
251
|
+
kept.push(line);
|
|
252
|
+
const scan = new ValueScan();
|
|
253
|
+
if (!scan.feed(assignment.value))
|
|
254
|
+
pending = { scan, drop };
|
|
255
|
+
}
|
|
256
|
+
while (kept.length > 0 && (kept[kept.length - 1] ?? "").trim() === "")
|
|
257
|
+
kept.pop();
|
|
258
|
+
const prefix = kept.length > 0 ? `${kept.join("\n")}\n\n` : "";
|
|
259
|
+
const blocks = ROLES.map((role) => roleTable(role, roles[role]).trimEnd()).join("\n\n");
|
|
260
|
+
return `${prefix}${blocks}\n`;
|
|
261
|
+
}
|
|
262
|
+
/** Writes the roles to the user config, creating its directory; returns an error message or null. */
|
|
263
|
+
export function writeUserConfig(env, roles) {
|
|
264
|
+
const path = userConfigPath(env);
|
|
265
|
+
try {
|
|
266
|
+
const existing = existsSync(path) ? readFileSync(path, "utf8") : null;
|
|
267
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
268
|
+
writeFileSync(path, existing === null ? serializeUserConfig(roles) : upsertRoles(existing, roles));
|
|
269
|
+
return null;
|
|
270
|
+
}
|
|
271
|
+
catch (err) {
|
|
272
|
+
return `Cannot write ${path}: ${err instanceof Error ? err.message : String(err)}`;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
60
275
|
/** Writes the config, creating `.gdt/` when needed; returns an error message, or null on success. */
|
|
61
276
|
export function writeConfig(root, text) {
|
|
62
277
|
try {
|
package/dist/workflow.js
CHANGED
|
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
|
|
|
3
3
|
import { relative } from "node:path";
|
|
4
4
|
import { headless } from "./backends/headless.js";
|
|
5
5
|
import { backendFor } from "./backends/index.js";
|
|
6
|
-
import { loadConfig, ROLES } from "./config.js";
|
|
6
|
+
import { loadConfig, ROLES, userConfigPath } from "./config.js";
|
|
7
7
|
import { validateContract } from "./contract.js";
|
|
8
8
|
import { findRepository, herdrPreflight, unsupportedAgentFindings } from "./doctor.js";
|
|
9
9
|
import { changedFiles } from "./git.js";
|
|
@@ -55,10 +55,13 @@ export function start(issue, cwd, env) {
|
|
|
55
55
|
const root = findRepository(cwd);
|
|
56
56
|
if (root === null)
|
|
57
57
|
return fail(`${cwd} is not inside a Git repository. Run gdt from a checkout of the target repository.\n`);
|
|
58
|
-
const { report } = loadConfig(root, env);
|
|
59
|
-
if (!report.valid)
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
const { report, findings } = loadConfig(root, env);
|
|
59
|
+
if (!report.valid) {
|
|
60
|
+
// AC-2: `gdt start` refuses with the same config errors as `gdt doctor`.
|
|
61
|
+
const errors = findings.filter((finding) => finding.level === "error").map((finding) => finding.message);
|
|
62
|
+
return fail(`${errors.join("\n")}\nRun "gdt doctor" for details.\n`);
|
|
63
|
+
}
|
|
64
|
+
const unsupported = unsupportedAgentFindings(report.roles, userConfigPath(env));
|
|
62
65
|
if (unsupported.length > 0)
|
|
63
66
|
return fail(`${unsupported.map((f) => f.message).join("\n")}\n`);
|
|
64
67
|
if (report.workflow.terminal === "herdr") {
|
package/package.json
CHANGED
package/skill/SKILL.md
CHANGED
|
@@ -5,13 +5,17 @@ gdt for them. They should never have to memorise a gdt command.
|
|
|
5
5
|
|
|
6
6
|
## First-time setup
|
|
7
7
|
|
|
8
|
-
- With no `.gdt/config.toml`, run `gdt init --json`. It
|
|
9
|
-
|
|
8
|
+
- With no `.gdt/config.toml`, run `gdt init --json`. It reports the supported
|
|
9
|
+
agents, which are on PATH, the terminal and the detected CI checks.
|
|
10
10
|
- Present that proposal to the user and ask which agent and model each role
|
|
11
11
|
(developer, tester, reviewer) uses. Do not pick models for them.
|
|
12
12
|
- Run `gdt init` with their choices, for example
|
|
13
13
|
`gdt init --developer opencode/deepseek/deepseek-v4-flash --tester claude/claude-sonnet-5 --reviewer codex/gpt-5.6-luna`.
|
|
14
|
-
It writes the
|
|
14
|
+
It writes the roles to the user config (`~/.config/gdt/config.toml`, or
|
|
15
|
+
`$XDG_CONFIG_HOME/gdt/config.toml`) and the project settings to
|
|
16
|
+
`.gdt/config.toml`, then runs `gdt doctor` and installs this skill.
|
|
17
|
+
- When the user config already defines all three roles, `gdt init` without role
|
|
18
|
+
options reuses them and only writes `.gdt/config.toml`.
|
|
15
19
|
|
|
16
20
|
## Start
|
|
17
21
|
|