@gevezex/gdt 0.2.0 → 0.3.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
@@ -121,8 +121,24 @@ evidence too.
121
121
  | Node 24 LTS | runs gdt |
122
122
  | `git` | the shared checkout the roles work in |
123
123
  | `gh`, logged in (`gh auth login`) | issues, pull requests, comments, checks |
124
- | at least one agent CLI | `claude`, `codex`, `opencode`, `mcode`, `pi` or `omp` |
125
- | [herdr](https://herdr.dev) 0.9.1+ (optional) | watch the roles live; otherwise use `terminal = "headless"` |
124
+ | at least one agent CLI | `claude`, `codex`, `opencode`, `mcode`, `pi` or `omp` (see below) |
125
+ | [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
+ ### Agent prerequisites
128
+
129
+ Before the first `gdt start`, every agent CLI you name in `.gdt/config.toml` must
130
+ already work on your machine with the exact model id you set there. For each
131
+ role:
132
+
133
+ 1. install the agent CLI,
134
+ 2. log in or configure its API key or subscription,
135
+ 3. run it once successfully with the model id from `roles.<role>.model` (for
136
+ example `opencode/deepseek/deepseek-v4-flash`).
137
+
138
+ gdt runs the agent CLI as is, so the agent uses its own login, tokens and
139
+ credits. gdt stores no credentials and does not log in, choose a plan, or track
140
+ token use or costs. `gdt doctor` only checks that the CLI is on `PATH`, not that
141
+ its login, API key or model works.
126
142
 
127
143
  ## Install
128
144
 
@@ -156,28 +172,22 @@ It copies `skill/SKILL.md` into the skill directory of every agent CLI it finds
156
172
 
157
173
  ### 1. Configure the target repository
158
174
 
159
- In the repository you want gdt to work on, commit `.gdt/config.toml`:
160
-
161
- ```toml
162
- language = "en" # language for issue and PR text: "en" or "nl"
163
-
164
- [roles.developer]
165
- agent = "opencode"
166
- model = "deepseek/deepseek-v4-flash"
167
-
168
- [roles.tester]
169
- agent = "claude"
170
- model = "claude-sonnet-5"
171
-
172
- [roles.reviewer]
173
- agent = "codex"
174
- model = "gpt-5.6-luna"
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:
175
178
 
176
- [workflow]
177
- required_checks = ["test"] # CI check names that must be green
178
- terminal = "herdr" # or "headless"
179
+ ```bash
180
+ gdt init --developer opencode/deepseek/deepseek-v4-flash \
181
+ --tester claude/claude-sonnet-5 \
182
+ --reviewer codex/gpt-5.6-luna
179
183
  ```
180
184
 
185
+ `gdt init` writes the config, runs `gdt doctor` and installs the operator skill.
186
+ Without the three role options it only reports what it found (agents on `PATH`,
187
+ the terminal, the detected CI checks) so your agent can discuss the roles with
188
+ you first. It never overwrites an existing config without `--force`, and it
189
+ requires at least one required check unless you pass `--allow-no-required-checks`.
190
+
181
191
  Then check your setup:
182
192
 
183
193
  ```bash
@@ -292,6 +302,7 @@ Every command supports `--help`; `status` and `wait` also support `--json`.
292
302
 
293
303
  | Command | Effect |
294
304
  |---|---|
305
+ | `gdt init` | Create `.gdt/config.toml` and install the operator skill |
295
306
  | `gdt doctor` | Check tools, GitHub login, agents, herdr and `.gdt/config.toml` |
296
307
  | `gdt check-issue <n>` | Validate an issue body against the contract |
297
308
  | `gdt start <n>` | Preflight, start the supervisor and workers, return |
@@ -321,21 +332,40 @@ overrides (for example another model) and is not committed.
321
332
  | `workflow.max_correction_rounds` | `2` | Correction rounds after round 0 |
322
333
  | `workflow.terminal` | `"herdr"` | `"herdr"` or `"headless"` |
323
334
  | `workflow.supervisor_pane` | `false` | herdr: also show the supervisor in a pane |
335
+ | `workflow.herdr_layout` | `tabs` | herdr: `"tabs"` (one tab per pane) or `"split"` (panes side by side in one tab) |
324
336
  | `workflow.poll_seconds` | `30` | How often the supervisor reads GitHub |
325
337
  | `contract.max_acceptance_criteria` | `8` | Maximum number of ACs per issue |
326
338
  | `contract.extra_rules` | none | File with project rules added to every role prompt |
327
339
 
340
+ ### Per-role rules
341
+
342
+ Each workflow role can have supplementary rules of its own, in the target repository:
343
+
344
+ | File | Role |
345
+ |---|---|
346
+ | `.gdt/roles/developer.md` | developer |
347
+ | `.gdt/roles/tester.md` | tester |
348
+ | `.gdt/roles/reviewer.md` | reviewer |
349
+
350
+ When a file has content, gdt appends it to that role's prompt on every turn under a
351
+ `## Role rules` heading, after `## Project rules` when `contract.extra_rules` sets one.
352
+ An empty or missing file adds nothing and is not an error. This only adds: gdt's own role
353
+ files in the package are never replaced, so the protocol stays intact. `gdt init` creates
354
+ the three files empty so you can see where your rules go; commit them like
355
+ `.gdt/config.toml`.
356
+
328
357
  How each agent CLI is invoked is documented in [docs/agents.md](docs/agents.md).
329
358
 
330
359
  ## Watching it: herdr or headless
331
360
 
332
361
  ```text
333
362
  herdr workspace "gdt-251" headless
334
- ┌──────────────┬──────────────┬──────────────┐
335
- │ developer · │ tester · │ reviewer · │ detached processes,
336
- │ opencode · │ claude · │ codex · │ one log file each in
337
- │ RUNNING │ WAITING │ WAITING │ .git/gdt/issue-251/logs/
338
- └──────────────┴──────────────┴──────────────┘
363
+ ┌────────────┐ ┌────────────┐ ┌────────────┐
364
+ │ developer │ │ tester │ │ reviewer │ detached processes,
365
+ │ opencode · │ │ claude · │ │ codex · │ one log file each in
366
+ │ RUNNING │ │ WAITING │ │ WAITING │ .git/gdt/issue-251/logs/
367
+ └────────────┘ └────────────┘ └────────────┘
368
+ one tab per role, labelled developer, tester and reviewer
339
369
  supervisor runs detached → logs/supervisor.log
340
370
  ```
341
371
 
@@ -3,6 +3,8 @@ export const claude = {
3
3
  binary: "claude",
4
4
  title: "Claude Code",
5
5
  install: "Install Claude Code: https://docs.anthropic.com/en/docs/claude-code",
6
+ modelFormat: "<model>",
7
+ modelExample: "claude-sonnet-5",
6
8
  buildInvocation: (_role, model, promptFile) => ({
7
9
  argv: ["claude", "-p", "--model", model, "--permission-mode", "bypassPermissions", "--no-session-persistence"],
8
10
  env: {},
@@ -3,6 +3,8 @@ export const codex = {
3
3
  binary: "codex",
4
4
  title: "Codex",
5
5
  install: "Install Codex: npm i -g @openai/codex",
6
+ modelFormat: "<model>",
7
+ modelExample: "gpt-5.6-luna",
6
8
  buildInvocation: (_role, model, promptFile, cwd) => ({
7
9
  argv: ["codex", "exec", "--model", model, "--cd", cwd, "--dangerously-bypass-approvals-and-sandbox", "--ephemeral", "-"],
8
10
  env: {},
@@ -4,6 +4,8 @@ export const mcode = {
4
4
  binary: "mcode",
5
5
  title: "MCode",
6
6
  install: "Install MiniMax Code: npm i -g @minimax-ai/code",
7
+ modelFormat: "provider/model",
8
+ modelExample: "minimax/MiniMax-M3",
7
9
  buildInvocation: (_role, model, promptFile, cwd) => ({
8
10
  argv: ["mcode", "exec", "--model", model, "--cwd", cwd, "--permission", "full", "--input", "-"],
9
11
  env: {},
@@ -4,6 +4,8 @@ export const omp = {
4
4
  binary: "omp",
5
5
  title: "omp",
6
6
  install: "Install omp: https://omp.sh",
7
+ modelFormat: "provider/id",
8
+ modelExample: "openai/gpt-5.2",
7
9
  buildInvocation: (_role, model, promptFile) => ({
8
10
  argv: ["omp", "--print", "--model", model, "--no-session", "--auto-approve"],
9
11
  env: {},
@@ -5,6 +5,8 @@ export const opencode = {
5
5
  binary: "opencode",
6
6
  title: "OpenCode",
7
7
  install: "Install OpenCode: https://opencode.ai",
8
+ modelFormat: "provider/model",
9
+ modelExample: "deepseek/deepseek-v4-flash",
8
10
  buildInvocation: (_role, model, promptFile) => ({
9
11
  argv: ["opencode", "run", "--model", model, "--auto", "--file", promptFile, OPENCODE_MESSAGE],
10
12
  env: {},
package/dist/agents/pi.js CHANGED
@@ -4,6 +4,8 @@ export const pi = {
4
4
  binary: "pi",
5
5
  title: "pi",
6
6
  install: "Install pi: npm i -g --ignore-scripts @earendil-works/pi-coding-agent",
7
+ modelFormat: "provider/id",
8
+ modelExample: "anthropic/claude-sonnet-4",
7
9
  buildInvocation: (_role, model, promptFile) => ({
8
10
  argv: ["pi", "--print", "--model", model, "--no-session", "--no-approve"],
9
11
  env: {},
@@ -37,6 +37,9 @@ export function headless(logs, cwd, env) {
37
37
  setTitle: () => {
38
38
  // Headless panes have no title.
39
39
  },
40
+ setDisplayAgent: () => {
41
+ // Headless panes have no agents overview.
42
+ },
40
43
  reportState: () => {
41
44
  // AC-7: headless mode reports no agent state.
42
45
  },
@@ -45,6 +45,19 @@ function reportAgent(opts, paneId, label, state) {
45
45
  throw new Error(`herdr pane report-agent: ${reason}`);
46
46
  }
47
47
  }
48
+ /**
49
+ * AC-1: sets the display-only agent label of a pane. herdr 0.9.1 requires the pane id before the
50
+ * options here (`herdr pane report-metadata <pane> --source gdt --display-agent <label>`); with the
51
+ * options first it exits 2 with `unknown option: gdt`.
52
+ */
53
+ function reportDisplayAgent(opts, paneId, label) {
54
+ const args = ["pane", "report-metadata", paneId, "--source", "gdt", "--display-agent", label];
55
+ const result = spawnSync(bin(opts.env), args, { cwd: opts.root, env: opts.env, encoding: "utf8" });
56
+ if (result.status !== 0) {
57
+ const reason = (result.stderr ?? "").trim() || (result.stdout ?? "").trim() || `herdr exited with ${result.status ?? result.signal}`;
58
+ throw new Error(`herdr pane report-metadata: ${reason}`);
59
+ }
60
+ }
48
61
  /** Parses the `{ "result": ... }` envelope; a herdr `error` becomes a thrown Error. */
49
62
  function resultOf(out, command) {
50
63
  let data;
@@ -69,12 +82,59 @@ function workspaceList(opts) {
69
82
  return [];
70
83
  return list.filter((w) => isRecord(w) && typeof w.workspace_id === "string");
71
84
  }
72
- function paneIds(opts, workspaceId) {
85
+ /** `pane list --workspace`, with the tab each pane currently lives in. */
86
+ function paneLocations(opts, workspaceId) {
73
87
  const result = resultOf(call(["pane", "list", "--workspace", workspaceId], opts), "pane list");
74
88
  const list = result.panes;
75
89
  if (!Array.isArray(list))
76
90
  return [];
77
- return list.flatMap((p) => (isRecord(p) && typeof p.pane_id === "string" ? [p.pane_id] : []));
91
+ return list.flatMap((p) => isRecord(p) && typeof p.pane_id === "string"
92
+ ? [{ pane_id: p.pane_id, ...(typeof p.tab_id === "string" ? { tab_id: p.tab_id } : {}) }]
93
+ : []);
94
+ }
95
+ /** `tab list --workspace`, in herdr's order. */
96
+ function tabLocations(opts, workspaceId) {
97
+ const result = resultOf(call(["tab", "list", "--workspace", workspaceId], opts), "tab list");
98
+ const list = result.tabs;
99
+ if (!Array.isArray(list))
100
+ return [];
101
+ return list.flatMap((tab) => isRecord(tab) && typeof tab.tab_id === "string"
102
+ ? [
103
+ {
104
+ tab_id: tab.tab_id,
105
+ label: typeof tab.label === "string" ? tab.label : "",
106
+ pane_count: typeof tab.pane_count === "number" ? tab.pane_count : 0,
107
+ },
108
+ ]
109
+ : []);
110
+ }
111
+ /** AC-3: creates a tab labelled with a pane name; its root pane becomes the managed pane. */
112
+ function createTab(opts, workspaceId, label) {
113
+ const args = ["tab", "create", "--workspace", workspaceId, "--cwd", opts.root, "--label", label, "--no-focus"];
114
+ const result = resultOf(call(args, opts), "tab create");
115
+ const pane = result.root_pane;
116
+ if (!isRecord(pane) || typeof pane.pane_id !== "string")
117
+ throw new Error("herdr tab create did not return a root pane");
118
+ return pane.pane_id;
119
+ }
120
+ /** AC-5: moves an existing pane into a tab of its own, labelled with the pane name. */
121
+ function movePaneToNewTab(opts, pane, label) {
122
+ call(["pane", "move", pane, "--new-tab", "--label", label, "--no-focus"], opts);
123
+ }
124
+ /** AC-5: moves an existing pane next to `anchor` in `tab`, keeping `ratio` of the anchor's width. */
125
+ function movePaneIntoTab(opts, pane, tab, anchor, ratio) {
126
+ const args = ["pane", "move", pane, "--tab", tab, "--split", "right", "--target-pane", anchor, "--ratio", ratio.toFixed(4), "--no-focus"];
127
+ call(args, opts);
128
+ }
129
+ function renameTab(opts, tab, label) {
130
+ call(["tab", "rename", tab, label], opts);
131
+ }
132
+ /** AC-5: a tab that lost its last pane during a move must not remain. */
133
+ function closeEmptyTabs(opts, workspaceId, keep) {
134
+ for (const tab of tabLocations(opts, workspaceId)) {
135
+ if (tab.pane_count === 0 && !keep.has(tab.tab_id))
136
+ call(["tab", "close", tab.tab_id], opts);
137
+ }
78
138
  }
79
139
  function createWorkspace(opts) {
80
140
  const label = `gdt-${opts.issue}`;
@@ -110,6 +170,68 @@ function writePanes(file, state) {
110
170
  function roleTitle(role, agent, state) {
111
171
  return role === "supervisor" ? `supervisor · ${state}` : `${role} · ${agent} · ${state}`;
112
172
  }
173
+ /**
174
+ * AC-4/AC-5: all managed panes in one tab, left to right in `names` order. This is a no-op when the
175
+ * panes already share a tab, so a workspace created in split layout is left exactly as before.
176
+ */
177
+ function arrangeSplit(opts, workspaceId, names, mapping, paneTab) {
178
+ const paneOf = (name) => mapping[name]?.pane_id;
179
+ const first = names[0];
180
+ if (first === undefined)
181
+ return;
182
+ const targetPane = paneOf(first);
183
+ if (targetPane === undefined)
184
+ return;
185
+ const tabs = new Set(names.flatMap((name) => {
186
+ const pane = paneOf(name);
187
+ if (pane === undefined)
188
+ return [];
189
+ const tab = paneTab.get(pane);
190
+ return tab === undefined ? [] : [tab];
191
+ }));
192
+ if (tabs.size <= 1)
193
+ return;
194
+ const target = paneTab.get(targetPane);
195
+ if (target === undefined)
196
+ return;
197
+ let anchor = targetPane;
198
+ names.slice(1).forEach((name, index) => {
199
+ const pane = paneOf(name);
200
+ if (pane === undefined)
201
+ return;
202
+ if (paneTab.get(pane) !== target)
203
+ movePaneIntoTab(opts, pane, target, anchor, 1 / (names.length - (index + 1) + 1));
204
+ anchor = pane;
205
+ });
206
+ }
207
+ /** AC-3/AC-5: one tab per managed pane, labelled with the pane name, in `names` order. */
208
+ function arrangeTabs(opts, workspaceId, names, mapping) {
209
+ const tabByPane = new Map();
210
+ const countByTab = new Map();
211
+ for (const pane of paneLocations(opts, workspaceId)) {
212
+ if (pane.tab_id === undefined)
213
+ continue;
214
+ tabByPane.set(pane.pane_id, pane.tab_id);
215
+ countByTab.set(pane.tab_id, (countByTab.get(pane.tab_id) ?? 0) + 1);
216
+ }
217
+ const labels = new Map(tabLocations(opts, workspaceId).map((tab) => [tab.tab_id, tab.label]));
218
+ const kept = new Set();
219
+ for (const name of names) {
220
+ const pane = mapping[name]?.pane_id;
221
+ if (pane === undefined)
222
+ continue;
223
+ const tab = tabByPane.get(pane);
224
+ // Keep a pane that is already alone in a tab of its own; only relabel that tab when needed.
225
+ if (tab !== undefined && !kept.has(tab) && countByTab.get(tab) === 1) {
226
+ kept.add(tab);
227
+ if (labels.get(tab) !== name)
228
+ renameTab(opts, tab, name);
229
+ continue;
230
+ }
231
+ movePaneToNewTab(opts, pane, name);
232
+ }
233
+ closeEmptyTabs(opts, workspaceId, kept);
234
+ }
113
235
  /**
114
236
  * One herdr workspace per issue, with four panes for supervisor, developer, tester and reviewer.
115
237
  * Panes are reused across `start` calls; their ids live in `panes.json` so a restarted process can
@@ -145,7 +267,12 @@ export function herdr(opts) {
145
267
  else
146
268
  workspaceId = createWorkspace(opts).workspace_id;
147
269
  }
148
- const live = new Set(paneIds(opts, workspaceId));
270
+ const locations = paneLocations(opts, workspaceId);
271
+ const live = new Set(locations.map((pane) => pane.pane_id));
272
+ const paneTab = new Map();
273
+ for (const pane of locations)
274
+ if (pane.tab_id !== undefined)
275
+ paneTab.set(pane.pane_id, pane.tab_id);
149
276
  // AC-4: a supervisor pane from an earlier run (while `supervisor_pane` was true) is closed; its
150
277
  // entry never enters the new panes.json, so no role can adopt the pane.
151
278
  if (!opts.supervisorPane && known?.workspace_id === workspaceId) {
@@ -153,6 +280,7 @@ export function herdr(opts) {
153
280
  if (leftover !== undefined && live.has(leftover.pane_id)) {
154
281
  call(["pane", "close", leftover.pane_id], opts);
155
282
  live.delete(leftover.pane_id);
283
+ paneTab.delete(leftover.pane_id);
156
284
  }
157
285
  }
158
286
  const names = paneNames(opts);
@@ -172,23 +300,41 @@ export function herdr(opts) {
172
300
  mapping[name] = { pane_id: reuse };
173
301
  }
174
302
  }
175
- // Each new pane is split off the right of the previous one, so panes run left to right in
176
- // `names` order. The anchor keeps 1/(panes still to fill), which makes a new workspace's panes
177
- // equally wide.
178
- const first = Object.values(mapping).flatMap((p) => (p === undefined ? [] : [p.pane_id])).find((id) => id !== "");
179
- let previous;
180
- names.forEach((name, index) => {
181
- const record = mapping[name];
182
- if (record !== undefined) {
183
- previous = record.pane_id;
184
- return;
303
+ // Provide a pane for every name that still lacks one, in `names` order.
304
+ if (opts.layout === "tabs") {
305
+ // AC-3: each missing pane gets its own tab, labelled with the pane name.
306
+ for (const name of names) {
307
+ if (mapping[name] === undefined)
308
+ mapping[name] = { pane_id: createTab(opts, workspaceId, name) };
185
309
  }
186
- const anchor = previous ?? first;
187
- if (anchor === undefined)
188
- throw new Error(`herdr workspace ${workspaceId} has no pane to split`);
189
- previous = splitPane(opts, anchor, 1 / (names.length - index + 1));
190
- mapping[name] = { pane_id: previous };
191
- });
310
+ }
311
+ else {
312
+ // AC-4: each new pane is split off the right of the previous one, so panes run left to right
313
+ // in `names` order. The anchor keeps 1/(panes still to fill), which makes a new workspace's
314
+ // panes equally wide.
315
+ const first = Object.values(mapping).flatMap((p) => (p === undefined ? [] : [p.pane_id])).find((id) => id !== "");
316
+ let previous;
317
+ names.forEach((name, index) => {
318
+ const record = mapping[name];
319
+ if (record !== undefined) {
320
+ previous = record.pane_id;
321
+ return;
322
+ }
323
+ const anchor = previous ?? first;
324
+ if (anchor === undefined)
325
+ throw new Error(`herdr workspace ${workspaceId} has no pane to split`);
326
+ previous = splitPane(opts, anchor, 1 / (names.length - index + 1));
327
+ mapping[name] = { pane_id: previous };
328
+ const anchorTab = paneTab.get(anchor);
329
+ if (anchorTab !== undefined)
330
+ paneTab.set(previous, anchorTab);
331
+ });
332
+ }
333
+ // AC-3/AC-5: place the panes according to the configured layout.
334
+ if (opts.layout === "tabs")
335
+ arrangeTabs(opts, workspaceId, names, mapping);
336
+ else
337
+ arrangeSplit(opts, workspaceId, names, mapping, paneTab);
192
338
  writePanes(opts.panesFile, { workspace_id: workspaceId, panes: mapping });
193
339
  if (opts.supervisorPane)
194
340
  rename("supervisor", roleTitle("supervisor", "", "starting"));
@@ -223,6 +369,15 @@ export function herdr(opts) {
223
369
  return;
224
370
  rename(name, title);
225
371
  },
372
+ setDisplayAgent(name, label) {
373
+ // Without a supervisor pane there is no supervisor label to set.
374
+ if (name === "supervisor" && !opts.supervisorPane)
375
+ return;
376
+ const paneId = findPane(name);
377
+ if (paneId === null)
378
+ return;
379
+ reportDisplayAgent(opts, paneId, label);
380
+ },
226
381
  reportState(name, state) {
227
382
  // AC-6: without a supervisor pane no supervisor state is reported to herdr.
228
383
  if (name === "supervisor" && !opts.supervisorPane)
@@ -14,6 +14,7 @@ export function backendFor(config, root, issue, env, p) {
14
14
  logs: p.logs,
15
15
  agents,
16
16
  supervisorPane: config.workflow.supervisor_pane,
17
+ layout: config.workflow.herdr_layout,
17
18
  });
18
19
  }
19
20
  return headless(p.logs, root, env);
package/dist/cli.js CHANGED
@@ -4,9 +4,10 @@ import { join, resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { CONFIG_PATH, DEFAULT_LANGUAGE, DEFAULT_MAX_ACCEPTANCE_CRITERIA, loadConfig, ROLES } from "./config.js";
6
6
  import { validateContract } from "./contract.js";
7
- import { findRepository, runDoctor } from "./doctor.js";
8
- import { issueBody } from "./github.js";
9
- import { loadLocale } from "./locale.js";
7
+ import { findRepository, herdrPreflight, runDoctor } from "./doctor.js";
8
+ import { detectedChecks, issueBody } from "./github.js";
9
+ import { createRoleRulesFiles, parseRoleSpec, proposal, serializeConfig, writeConfig } from "./init.js";
10
+ import { loadLocale, shippedLanguages } from "./locale.js";
10
11
  import { allowRound, answer, installSkill, pause, resume, setAgent, steer } from "./steering.js";
11
12
  import { supervise } from "./supervisor.js";
12
13
  import { work } from "./worker.js";
@@ -21,6 +22,7 @@ Usage:
21
22
  gdt <command> [options]
22
23
 
23
24
  Commands:
25
+ init Create .gdt/config.toml and install the operator skill
24
26
  doctor Check tools, GitHub authentication and .gdt/config.toml
25
27
  check-issue Validate an issue body against the issue contract
26
28
  start Start the workflow for an issue in the background
@@ -47,6 +49,27 @@ Checks that git and gh are installed, gh is authenticated, and that
47
49
  .gdt/config.toml (merged with .gdt/config.local.toml) is valid.
48
50
  Exits with 1 when any finding has level "error".
49
51
  `;
52
+ const INIT_HELP = `Usage: gdt init [--json]
53
+ gdt init --developer <agent>/<model> --tester <agent>/<model> --reviewer <agent>/<model>
54
+ [--language <lang>] [--terminal <herdr|headless>]
55
+ [--required-check <name>]... [--allow-no-required-checks] [--force]
56
+
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, and writes
59
+ nothing. With them, writes .gdt/config.toml, runs "gdt doctor" and installs the
60
+ operator skill.
61
+
62
+ Options:
63
+ --developer <agent>/<model> Agent and model for the developer role
64
+ --tester <agent>/<model> Agent and model for the tester role
65
+ --reviewer <agent>/<model> Agent and model for the reviewer role
66
+ --language <lang> Language for human-facing text (default en)
67
+ --terminal <herdr|headless> Terminal backend (default: herdr when usable)
68
+ --required-check <name> CI check that must pass; repeatable
69
+ --allow-no-required-checks Accept an empty required_checks list
70
+ --force Replace an existing .gdt/config.toml
71
+ --json Machine-readable proposal output
72
+ `;
50
73
  const CHECK_ISSUE_HELP = `Usage: gdt check-issue <issue> [--json]
51
74
  gdt check-issue --body-file <file> [--json]
52
75
 
@@ -150,6 +173,132 @@ function doctor(args, io) {
150
173
  io.stdout(json ? `${JSON.stringify(report, null, 2)}\n` : formatFindings(report.findings));
151
174
  return report.ok ? EXIT_OK : EXIT_FAILED;
152
175
  }
176
+ function formatProposal(facts) {
177
+ const lines = ["agents:"];
178
+ for (const choice of facts.agents) {
179
+ lines.push(` ${choice.agent} ${choice.found ? "found" : "not found"} model: ${choice.model_format}, example: ${choice.example}`);
180
+ }
181
+ lines.push(`terminal: ${facts.terminal}`, `required_checks: ${facts.required_checks.length === 0 ? "none" : facts.required_checks.join(", ")}`, `language: ${facts.language}`, "", "Next: gdt init --developer <agent>/<model> --tester <agent>/<model> --reviewer <agent>/<model>");
182
+ return `${lines.join("\n")}\n`;
183
+ }
184
+ /**
185
+ * `gdt init`: without role options, reports the proposal (AC-1); with all three, writes
186
+ * `.gdt/config.toml` (AC-2 to AC-5), then runs doctor and installs the skill (AC-6, AC-7).
187
+ */
188
+ function initCommand(args, io) {
189
+ let developer;
190
+ let tester;
191
+ let reviewer;
192
+ let language;
193
+ let terminal;
194
+ const requiredChecks = [];
195
+ let allowNoRequiredChecks = false;
196
+ let force = false;
197
+ let json = false;
198
+ for (let i = 0; i < args.length; i++) {
199
+ const arg = args[i] ?? "";
200
+ if (arg === "--json")
201
+ json = true;
202
+ else if (arg === "--force")
203
+ force = true;
204
+ else if (arg === "--allow-no-required-checks")
205
+ allowNoRequiredChecks = true;
206
+ else if (arg === "--help" || arg === "-h") {
207
+ io.stdout(INIT_HELP);
208
+ return EXIT_OK;
209
+ }
210
+ else if (arg === "--developer" || arg === "--tester" || arg === "--reviewer" || arg === "--language" || arg === "--terminal" || arg === "--required-check") {
211
+ const given = args[++i];
212
+ if (given === undefined)
213
+ return usageError(io, `Missing value for "${arg}".`, "gdt init --help");
214
+ if (arg === "--developer")
215
+ developer = given;
216
+ else if (arg === "--tester")
217
+ tester = given;
218
+ else if (arg === "--reviewer")
219
+ reviewer = given;
220
+ else if (arg === "--language")
221
+ language = given;
222
+ else if (arg === "--terminal") {
223
+ if (given !== "herdr" && given !== "headless") {
224
+ return usageError(io, `Invalid terminal "${given}"; use herdr or headless.`, "gdt init --help");
225
+ }
226
+ terminal = given;
227
+ }
228
+ else
229
+ requiredChecks.push(given);
230
+ }
231
+ else if (arg.startsWith("-"))
232
+ return usageError(io, `Unknown option "${arg}" for "gdt init".`, "gdt init --help");
233
+ else
234
+ return usageError(io, `Unexpected argument "${arg}" for "gdt init".`, "gdt init --help");
235
+ }
236
+ const root = findRepository(io.cwd);
237
+ if (root === null) {
238
+ io.stderr(`${io.cwd} is not inside a Git repository. Run gdt from a checkout of the target repository.\n`);
239
+ return EXIT_FAILED;
240
+ }
241
+ const specs = { developer, tester, reviewer };
242
+ const given = ROLES.filter((role) => specs[role] !== undefined);
243
+ if (given.length === 0) {
244
+ const facts = proposal(root, io.env);
245
+ io.stdout(json ? `${JSON.stringify(facts, null, 2)}\n` : formatProposal(facts));
246
+ return EXIT_OK;
247
+ }
248
+ if (given.length < ROLES.length) {
249
+ const missing = ROLES.filter((role) => specs[role] === undefined)
250
+ .map((role) => `"--${role}"`)
251
+ .join(", ");
252
+ return usageError(io, `Missing ${missing} for "gdt init".`, "gdt init --help");
253
+ }
254
+ const roles = {};
255
+ for (const role of ROLES) {
256
+ const parsed = parseRoleSpec(specs[role] ?? "");
257
+ if ("error" in parsed) {
258
+ io.stderr(`${parsed.error}\n`);
259
+ return EXIT_FAILED;
260
+ }
261
+ roles[role] = parsed;
262
+ }
263
+ const languages = shippedLanguages();
264
+ if (language !== undefined && !languages.includes(language)) {
265
+ io.stderr(`No locale for language "${language}"; available languages: ${languages.join(", ")}\n`);
266
+ return EXIT_FAILED;
267
+ }
268
+ if (existsSync(join(root, CONFIG_PATH)) && !force) {
269
+ io.stderr(`${CONFIG_PATH} already exists; use --force to replace it\n`);
270
+ return EXIT_FAILED;
271
+ }
272
+ const resolvedTerminal = terminal ?? (herdrPreflight(io.env) === null ? "herdr" : "headless");
273
+ const checks = requiredChecks.length > 0 ? requiredChecks : detectedChecks(root, io.env);
274
+ if (checks.length === 0 && !allowNoRequiredChecks) {
275
+ io.stderr("No required checks detected; pass --required-check <name> for each check, or --allow-no-required-checks to accept none\n");
276
+ return EXIT_FAILED;
277
+ }
278
+ const text = serializeConfig({
279
+ roles,
280
+ language: language ?? DEFAULT_LANGUAGE,
281
+ terminal: resolvedTerminal,
282
+ requiredChecks: checks,
283
+ allowNoRequiredChecks,
284
+ });
285
+ const writeError = writeConfig(root, text);
286
+ if (writeError !== null) {
287
+ io.stderr(`${writeError}\n`);
288
+ return EXIT_FAILED;
289
+ }
290
+ // AC-4: the three supplementary role rules files; existing files are left unchanged.
291
+ for (const path of createRoleRulesFiles(root))
292
+ io.stdout(`created ${path}\n`);
293
+ // AC-7: report the new config with doctor, then install the skill; the config stays in place either way.
294
+ const report = runDoctor(root, io.env);
295
+ io.stdout(formatFindings(report.findings));
296
+ const installed = installSkill(io.env);
297
+ io.stdout(installed.stdout);
298
+ if (installed.stderr !== "")
299
+ io.stderr(installed.stderr);
300
+ return report.ok ? EXIT_OK : EXIT_FAILED;
301
+ }
153
302
  function checkIssue(args, io) {
154
303
  let json = false;
155
304
  let issue;
@@ -412,6 +561,8 @@ export function run(argv, io) {
412
561
  return EXIT_OK;
413
562
  case "doctor":
414
563
  return doctor(rest, io);
564
+ case "init":
565
+ return initCommand(rest, io);
415
566
  case "check-issue":
416
567
  return checkIssue(rest, io);
417
568
  case "start":
package/dist/config.js CHANGED
@@ -5,6 +5,8 @@ import { z } from "zod";
5
5
  export const AGENTS = ["claude", "codex", "opencode", "mcode", "pi", "omp"];
6
6
  export const ROLES = ["developer", "tester", "reviewer"];
7
7
  export const TERMINALS = ["herdr", "headless"];
8
+ /** AC-2: how the herdr backend places the managed panes. */
9
+ export const HERDR_LAYOUTS = ["split", "tabs"];
8
10
  export const CONFIG_PATH = ".gdt/config.toml";
9
11
  export const LOCAL_CONFIG_PATH = ".gdt/config.local.toml";
10
12
  export const DEFAULT_LANGUAGE = "en";
@@ -41,6 +43,8 @@ function configSchemaFor(testAgents) {
41
43
  allow_no_required_checks: z.boolean().default(false),
42
44
  terminal: z.enum(TERMINALS).default("herdr"),
43
45
  supervisor_pane: z.boolean().default(false),
46
+ // Default `tabs`: one tab per managed pane. `split` keeps the panes in one tab.
47
+ herdr_layout: z.enum(HERDR_LAYOUTS).default("tabs"),
44
48
  poll_seconds: z.number().positive().default(30),
45
49
  handoff_checks: z.int().min(1).default(5),
46
50
  }),
package/dist/github.js CHANGED
@@ -65,6 +65,30 @@ export function repository(cwd, env) {
65
65
  export function viewer(cwd, env) {
66
66
  return ghJson(["api", "user"], cwd, env).login;
67
67
  }
68
+ /**
69
+ * The distinct names of the check runs and commit statuses on the latest commit of the repository's
70
+ * default branch, sorted alphabetically. Returns `[]` when `gh` cannot read them (no remote, no
71
+ * authentication, no GitHub): `gdt init` then treats the repository as having no detected checks.
72
+ */
73
+ export function detectedChecks(cwd, env) {
74
+ try {
75
+ const repo = ghJson(["repo", "view", "--json", "nameWithOwner,defaultBranchRef"], cwd, env);
76
+ const name = typeof repo.nameWithOwner === "string" ? repo.nameWithOwner : null;
77
+ const branch = typeof repo.defaultBranchRef?.name === "string" ? repo.defaultBranchRef.name : null;
78
+ if (name === null || branch === null)
79
+ return [];
80
+ const runs = ghJson(["api", `repos/${name}/commits/${branch}/check-runs`], cwd, env);
81
+ const statuses = ghJson(["api", `repos/${name}/commits/${branch}/status`], cwd, env);
82
+ const names = [
83
+ ...(runs.check_runs ?? []).map((run) => run.name),
84
+ ...(statuses.statuses ?? []).map((status) => status.context),
85
+ ].filter((value) => typeof value === "string" && value !== "");
86
+ return [...new Set(names)].sort();
87
+ }
88
+ catch {
89
+ return [];
90
+ }
91
+ }
68
92
  /** The issue body and the open pull requests that close the issue ("Closes #n"). */
69
93
  export function issueSnapshot(issue, cwd, env) {
70
94
  const data = ghJson(["issue", "view", String(issue), "--json", "body,closedByPullRequestsReferences"], cwd, env);
package/dist/init.js ADDED
@@ -0,0 +1,92 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { adapterFor, supportedAgents } from "./agents/index.js";
4
+ import { CONFIG_PATH, DEFAULT_LANGUAGE, ROLES } from "./config.js";
5
+ import { herdrPreflight, which } from "./doctor.js";
6
+ import { detectedChecks } from "./github.js";
7
+ import { ROLE_RULES_DIR, roleRulesPath } from "./prompts.js";
8
+ /** The proposal: one entry per supported agent, the usable terminal, detected checks and language. */
9
+ export function proposal(root, env) {
10
+ const agents = supportedAgents().flatMap((agent) => {
11
+ const adapter = adapterFor(agent);
12
+ if (adapter === undefined)
13
+ return [];
14
+ return [
15
+ {
16
+ agent,
17
+ found: which(adapter.binary, env) !== null,
18
+ model_format: adapter.modelFormat,
19
+ example: adapter.modelExample,
20
+ },
21
+ ];
22
+ });
23
+ return {
24
+ agents,
25
+ terminal: herdrPreflight(env) === null ? "herdr" : "headless",
26
+ required_checks: detectedChecks(root, env),
27
+ language: DEFAULT_LANGUAGE,
28
+ };
29
+ }
30
+ /** Parses `<agent>/<model>` at the first `/`, the same way `gdt set-agent` does. */
31
+ export function parseRoleSpec(spec) {
32
+ const slash = spec.indexOf("/");
33
+ const agent = slash === -1 ? spec : spec.slice(0, slash);
34
+ const model = slash === -1 ? "" : spec.slice(slash + 1);
35
+ const supported = supportedAgents();
36
+ if (!supported.includes(agent)) {
37
+ return { error: `Unsupported agent "${agent}"; supported agents: ${supported.join(", ")}` };
38
+ }
39
+ if (model.trim() === "")
40
+ return { error: `Missing model in "${spec}"; use <agent>/<model>` };
41
+ return { agent: agent, model };
42
+ }
43
+ /** The `.gdt/config.toml` text; a key that keeps its schema default is not written (AC-2). */
44
+ export function serializeConfig(config) {
45
+ const lines = [];
46
+ if (config.language !== DEFAULT_LANGUAGE)
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
+ lines.push("[workflow]", `required_checks = [${config.requiredChecks.map((name) => JSON.stringify(name)).join(", ")}]`);
53
+ if (config.allowNoRequiredChecks)
54
+ lines.push("allow_no_required_checks = true");
55
+ if (config.terminal !== "herdr")
56
+ lines.push(`terminal = ${JSON.stringify(config.terminal)}`);
57
+ lines.push("");
58
+ return lines.join("\n");
59
+ }
60
+ /** Writes the config, creating `.gdt/` when needed; returns an error message, or null on success. */
61
+ export function writeConfig(root, text) {
62
+ try {
63
+ const path = join(root, CONFIG_PATH);
64
+ mkdirSync(dirname(path), { recursive: true });
65
+ writeFileSync(path, text);
66
+ return null;
67
+ }
68
+ catch (err) {
69
+ return `Cannot write ${CONFIG_PATH}: ${err instanceof Error ? err.message : String(err)}`;
70
+ }
71
+ }
72
+ /**
73
+ * AC-4: creates each missing `.gdt/roles/<role>.md` as an empty file and returns its
74
+ * repository-relative path. An existing file is never touched, not even with `--force`.
75
+ */
76
+ export function createRoleRulesFiles(root) {
77
+ const created = [];
78
+ for (const role of ROLES) {
79
+ const path = roleRulesPath(root, role);
80
+ mkdirSync(dirname(path), { recursive: true });
81
+ try {
82
+ writeFileSync(path, "", { flag: "wx" });
83
+ created.push(`${ROLE_RULES_DIR}/${role}.md`);
84
+ }
85
+ catch (err) {
86
+ // Exists already: leave it byte-for-byte unchanged.
87
+ if (err.code !== "EEXIST")
88
+ throw err;
89
+ }
90
+ }
91
+ return created;
92
+ }
package/dist/prompts.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { resolve } from "node:path";
2
+ import { join, resolve } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { loadLocale, SECTION_KEYS } from "./locale.js";
5
5
  import { marker, schemas } from "./protocol.js";
@@ -7,9 +7,26 @@ export const ROLE_FILES = ["developer", "tester", "reviewer", "issue-writer"];
7
7
  /** The record kind each workflow role writes. */
8
8
  export const RECORD_OF = { developer: "handoff", tester: "test", reviewer: "review" };
9
9
  const ROLES_DIR = new URL("../roles/", import.meta.url);
10
+ /** The directory, relative to a target repository's root, holding the per-role supplementary rules. */
11
+ export const ROLE_RULES_DIR = ".gdt/roles";
10
12
  export function roleFile(name) {
11
13
  return readFileSync(new URL(`${name}.md`, ROLES_DIR), "utf8");
12
14
  }
15
+ /** The absolute path of a workflow role's supplementary rules file in `root`. */
16
+ export function roleRulesPath(root, role) {
17
+ return join(root, ROLE_RULES_DIR, `${role}.md`);
18
+ }
19
+ /**
20
+ * The role's supplementary rules, trimmed, or `null` when the file is missing or empty (whitespace
21
+ * only). Read on every call, so a change reaches the role's next turn without a restart (AC-3).
22
+ */
23
+ export function roleRules(root, role) {
24
+ const path = roleRulesPath(root, role);
25
+ if (!existsSync(path))
26
+ return null;
27
+ const text = readFileSync(path, "utf8").trimEnd();
28
+ return text.trim() === "" ? null : text;
29
+ }
13
30
  /**
14
31
  * Trusted directives for `role` posted after that role's previous dispatch, i.e. with a comment id
15
32
  * above `afterCommentId` (the highest comment id seen at that dispatch; 0 before the first one).
@@ -76,5 +93,9 @@ export function buildPrompt(role, dispatch, project) {
76
93
  if (existsSync(path))
77
94
  parts.push(["## Project rules", "", readFileSync(path, "utf8").trimEnd()].join("\n"));
78
95
  }
96
+ // AC-1/AC-2: a role's own supplementary rules, after the global project rules; empty adds nothing.
97
+ const rules = roleRules(project.root, role);
98
+ if (rules !== null)
99
+ parts.push(["## Role rules", "", rules].join("\n"));
79
100
  return `${parts.join("\n\n")}\n`;
80
101
  }
package/dist/steering.js CHANGED
@@ -3,7 +3,8 @@ import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { adapterFor, supportedAgents } from "./agents/index.js";
6
- import { ROLES } from "./config.js";
6
+ import { backendFor } from "./backends/index.js";
7
+ import { loadConfig, ROLES } from "./config.js";
7
8
  import { findRepository, which } from "./doctor.js";
8
9
  import { comments, issueSnapshot, postIssueComment, postPullRequestComment, repository } from "./github.js";
9
10
  import { formatRecord, parseRecords } from "./protocol.js";
@@ -129,6 +130,17 @@ export function setAgent(issue, role, spec, cwd, env) {
129
130
  if (readState(p) === null)
130
131
  return fail(`No workflow for #${issue}. Next: gdt start ${issue}\n`);
131
132
  writeJsonAtomic(p.overrides, { ...readOverrides(p), [checked]: { agent: agent, model } });
133
+ // AC-1: the agents overview must name the role's new agent. Best effort: the pane may not exist
134
+ // yet, the terminal may be headless, or herdr may be missing; the override still applies.
135
+ try {
136
+ const { report } = loadConfig(p.root, env);
137
+ if (report.valid && report.workflow.terminal === "herdr") {
138
+ backendFor(report, p.root, issue, env, p).setDisplayAgent(checked, `${checked} · ${agent}`);
139
+ }
140
+ }
141
+ catch {
142
+ // The override is written; the label is refreshed on the next `gdt start`.
143
+ }
132
144
  return ok(`${checked} for #${issue} now uses ${agent} with model ${model} from its next turn.\n`);
133
145
  }
134
146
  /** `gdt install-skill`: copies `skill/SKILL.md` into every detected harness; idempotent. */
@@ -107,6 +107,18 @@ class Supervisor {
107
107
  this.backend.setTitle(role, this.roleTitle(role, state));
108
108
  this.reportState(role, ROLE_PANE_STATE[state]);
109
109
  }
110
+ /** AC-1: sets the display-only agent label, for example `developer · opencode` or `gdt · supervisor`. */
111
+ setDisplay(name, label) {
112
+ try {
113
+ this.backend.setDisplayAgent(name, label);
114
+ }
115
+ catch (err) {
116
+ if (this.reportWarned)
117
+ return;
118
+ this.reportWarned = true;
119
+ log(`warning: herdr display label failed: ${err instanceof Error ? err.message : String(err)}`);
120
+ }
121
+ }
110
122
  /** Records a status; notifies once when entering a notifying status. */
111
123
  setStatus(status, reason, extra = {}) {
112
124
  if (this.state.status !== status || this.state.reason !== reason)
@@ -128,10 +140,13 @@ class Supervisor {
128
140
  }
129
141
  startWorkers() {
130
142
  this.backend.ensureWorkspace();
143
+ // AC-1: the agents overview shows the role next to the agent name, once per `gdt start`.
144
+ this.setDisplay("supervisor", "gdt · supervisor");
131
145
  this.backend.setTitle("supervisor", "supervisor · starting");
132
146
  this.reportState("supervisor", supervisorAgentState(this.state.status));
133
147
  for (const role of ROLES) {
134
148
  this.setRoleState(role, "WAITING");
149
+ this.setDisplay(role, `${role} · ${this.agents[role]}`);
135
150
  if (this.backend.alive(this.state.pids.workers[role] ?? -1))
136
151
  continue;
137
152
  this.state.pids.workers[role] = this.backend.spawnPane(role, [process.execPath, cliPath(), "_worker", String(this.issue), role]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gevezex/gdt",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "GitHub issues to merge-ready pull requests, with a developer, tester and reviewer agent.",
5
5
  "keywords": [
6
6
  "github",
package/skill/SKILL.md CHANGED
@@ -3,6 +3,16 @@
3
3
  You are the operator: the user talks to you in their own language, and you run
4
4
  gdt for them. They should never have to memorise a gdt command.
5
5
 
6
+ ## First-time setup
7
+
8
+ - With no `.gdt/config.toml`, run `gdt init --json`. It writes nothing and reports
9
+ the supported agents, which are on PATH, the terminal and the detected CI checks.
10
+ - Present that proposal to the user and ask which agent and model each role
11
+ (developer, tester, reviewer) uses. Do not pick models for them.
12
+ - Run `gdt init` with their choices, for example
13
+ `gdt init --developer opencode/deepseek/deepseek-v4-flash --tester claude/claude-sonnet-5 --reviewer codex/gpt-5.6-luna`.
14
+ It writes the config, runs `gdt doctor` and installs this skill.
15
+
6
16
  ## Start
7
17
 
8
18
  - `gdt start <issue>` returns immediately; the supervisor keeps running on its own.