@gevezex/gdt 0.3.0 → 0.4.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
@@ -126,9 +126,9 @@ evidence too.
126
126
 
127
127
  ### Agent prerequisites
128
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:
129
+ Before the first `gdt start`, every agent CLI you name in the user config
130
+ (`~/.config/gdt/config.toml`) must already work on your machine with the exact
131
+ model id you set there. For each role:
132
132
 
133
133
  1. install the agent CLI,
134
134
  2. log in or configure its API key or subscription,
@@ -182,11 +182,14 @@ gdt init --developer opencode/deepseek/deepseek-v4-flash \
182
182
  --reviewer codex/gpt-5.6-luna
183
183
  ```
184
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`.
185
+ `gdt init` writes the roles to your user config (`~/.config/gdt/config.toml`,
186
+ or `$XDG_CONFIG_HOME/gdt/config.toml`), writes the project settings to
187
+ `.gdt/config.toml`, runs `gdt doctor` and installs the operator skill. Without the
188
+ three role options, it reuses the roles from your user config when they are all
189
+ there; otherwise it only reports what it found (agents on `PATH`, the terminal,
190
+ the detected CI checks) so your agent can discuss the roles with you first. It
191
+ never overwrites an existing config without `--force`, and it requires at least
192
+ one required check unless you pass `--allow-no-required-checks`.
190
193
 
191
194
  Then check your setup:
192
195
 
@@ -302,7 +305,7 @@ Every command supports `--help`; `status` and `wait` also support `--json`.
302
305
 
303
306
  | Command | Effect |
304
307
  |---|---|
305
- | `gdt init` | Create `.gdt/config.toml` and install the operator skill |
308
+ | `gdt init` | Write the roles to the user config and `.gdt/config.toml`, install the operator skill |
306
309
  | `gdt doctor` | Check tools, GitHub login, agents, herdr and `.gdt/config.toml` |
307
310
  | `gdt check-issue <n>` | Validate an issue body against the contract |
308
311
  | `gdt start <n>` | Preflight, start the supervisor and workers, return |
@@ -319,23 +322,38 @@ Every command supports `--help`; `status` and `wait` also support `--json`.
319
322
 
320
323
  ## Configuration
321
324
 
322
- `.gdt/config.toml` is committed; `.gdt/config.local.toml` holds machine-local
323
- overrides (for example another model) and is not committed.
324
-
325
- | Key | Default | Meaning |
326
- |---|---|---|
327
- | `language` | `"en"` | Language of issue and PR text (`en`, `nl`) |
328
- | `roles.<role>.agent` | required | `claude`, `codex`, `opencode`, `mcode`, `pi` or `omp` |
329
- | `roles.<role>.model` | required | Model id for that agent |
330
- | `workflow.required_checks` | required | CI checks that must be green before `ready_to_merge` |
331
- | `workflow.allow_no_required_checks` | `false` | Allow an empty `required_checks` list |
332
- | `workflow.max_correction_rounds` | `2` | Correction rounds after round 0 |
333
- | `workflow.terminal` | `"herdr"` | `"herdr"` or `"headless"` |
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) |
336
- | `workflow.poll_seconds` | `30` | How often the supervisor reads GitHub |
337
- | `contract.max_acceptance_criteria` | `8` | Maximum number of ACs per issue |
338
- | `contract.extra_rules` | none | File with project rules added to every role prompt |
325
+ Roles belong to a person, not to a repository, so gdt keeps them in a per-user
326
+ config. gdt loads three files and merges them in this order, with the later file
327
+ winning per key:
328
+
329
+ 1. the **user config** — `$XDG_CONFIG_HOME/gdt/config.toml`, or
330
+ `~/.config/gdt/config.toml` when `XDG_CONFIG_HOME` is not set. It holds only
331
+ `[roles.*]`. A relative `XDG_CONFIG_HOME` is ignored.
332
+ 2. the **repository config** — `.gdt/config.toml`, committed. It holds the project
333
+ settings and must not contain `[roles.*]`.
334
+ 3. the **local config** — `.gdt/config.local.toml`, never committed. It overrides
335
+ any key, for example one role's model on this machine.
336
+
337
+ `gdt init` writes the roles to the user config and the project settings to
338
+ `.gdt/config.toml`. Ready-made files are in [`examples/`](examples/):
339
+ [`examples/user-config.toml`](examples/user-config.toml),
340
+ [`examples/config.toml`](examples/config.toml) and
341
+ [`examples/config.local.toml`](examples/config.local.toml).
342
+
343
+ | Key | File | Default | Meaning |
344
+ |---|---|---|---|
345
+ | `roles.<role>.agent` | user | required | `claude`, `codex`, `opencode`, `mcode`, `pi` or `omp` |
346
+ | `roles.<role>.model` | user | required | Model id for that agent |
347
+ | `language` | repository | `"en"` | Language of issue and PR text (`en`, `nl`) |
348
+ | `workflow.required_checks` | repository | required | CI checks that must be green before `ready_to_merge` |
349
+ | `workflow.allow_no_required_checks` | repository | `false` | Allow an empty `required_checks` list |
350
+ | `workflow.max_correction_rounds` | repository | `2` | Correction rounds after round 0 |
351
+ | `workflow.terminal` | repository | `"herdr"` | `"herdr"` or `"headless"` |
352
+ | `workflow.supervisor_pane` | repository | `false` | herdr: also show the supervisor in a pane |
353
+ | `workflow.herdr_layout` | repository | `tabs` | herdr: `"tabs"` (one tab per pane) or `"split"` (panes side by side in one tab) |
354
+ | `workflow.poll_seconds` | repository | `30` | How often the supervisor reads GitHub |
355
+ | `contract.max_acceptance_criteria` | repository | `8` | Maximum number of ACs per issue |
356
+ | `contract.extra_rules` | repository | none | File with project rules added to every role prompt |
339
357
 
340
358
  ### Per-role rules
341
359
 
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 Create .gdt/config.toml and install the operator skill
26
- doctor Check tools, GitHub authentication and .gdt/config.toml
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 (merged with .gdt/config.local.toml) is valid.
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, and writes
59
- nothing. With them, writes .gdt/config.toml, runs "gdt doctor" and installs the
60
- operator skill.
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
- if (given.length === 0) {
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
- 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`);
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 { join } from "node:path";
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
- roles: z.strictObject({ developer: role, tester: role, reviewer: role }),
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 local override. */
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
- function readLayer(root, path) {
123
- const text = readFileSync(join(root, path), "utf8");
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: `${path}: invalid TOML${where}: ${reason}`,
134
- fix: `Fix the TOML syntax in ${path}`,
168
+ message: `${display}: invalid TOML${where}: ${reason}`,
169
+ fix: `Fix the TOML syntax in ${display}`,
135
170
  };
136
171
  }
137
172
  }
138
- /** Loads, merges and validates `.gdt/config.toml` and `.gdt/config.local.toml` in `root`. */
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 files = [CONFIG_PATH, LOCAL_CONFIG_PATH].filter((path) => existsSync(join(root, path)));
141
- if (!files.includes(CONFIG_PATH)) {
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/docs/design.md#4-configuration-per-target-repository`,
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
- for (const path of files) {
157
- const layer = readLayer(root, path);
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 merged = layers.reduce((acc, layer) => merge(acc, layer.data), {});
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
- return {
169
- report: { valid: false, files },
170
- findings: parsed.error.issues.flatMap((issue) => issueFindings(issue, merged, layers)),
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 roles = Object.fromEntries(ROLES.map((role) => {
175
- const localRole = layers.length > 1 ? valueAt(layers[layers.length - 1]?.data, ["roles", role]) : undefined;
176
- const source = isTable(localRole) && Object.keys(localRole).length > 0 ? LOCAL_CONFIG_PATH : CONFIG_PATH;
177
- return [role, { ...config.roles[role], source }];
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: files.length > 1 ? `${CONFIG_PATH} is valid, with overrides from ${LOCAL_CONFIG_PATH}` : `${CONFIG_PATH} is valid`,
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
- return { report: { valid: true, files, ...config, roles }, findings };
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 ${CONFIG_PATH} to an agent with an unattended mode`,
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 (AC-2). */
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
- return fail('.gdt/config.toml is invalid. Run "gdt doctor" for details.\n');
61
- const unsupported = unsupportedAgentFindings(report.roles);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gevezex/gdt",
3
- "version": "0.3.0",
3
+ "version": "0.4.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
@@ -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 writes nothing and reports
9
- the supported agents, which are on PATH, the terminal and the detected CI checks.
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 config, runs `gdt doctor` and installs this skill.
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