@gevezex/gdt 0.2.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 +89 -41
- package/dist/agents/claude.js +2 -0
- package/dist/agents/codex.js +2 -0
- package/dist/agents/mcode.js +2 -0
- package/dist/agents/omp.js +2 -0
- package/dist/agents/opencode.js +2 -0
- package/dist/agents/pi.js +2 -0
- package/dist/backends/headless.js +3 -0
- package/dist/backends/herdr.js +174 -19
- package/dist/backends/index.js +1 -0
- package/dist/cli.js +187 -8
- package/dist/config.js +188 -26
- package/dist/doctor.js +4 -4
- package/dist/github.js +24 -0
- package/dist/init.js +307 -0
- package/dist/prompts.js +22 -1
- package/dist/steering.js +13 -1
- package/dist/supervisor.js +15 -0
- package/dist/workflow.js +8 -5
- package/package.json +1 -1
- package/skill/SKILL.md +14 -0
package/dist/cli.js
CHANGED
|
@@ -2,11 +2,12 @@
|
|
|
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
|
-
import { findRepository, runDoctor } from "./doctor.js";
|
|
8
|
-
import { issueBody } from "./github.js";
|
|
9
|
-
import {
|
|
7
|
+
import { findRepository, herdrPreflight, runDoctor } from "./doctor.js";
|
|
8
|
+
import { detectedChecks, issueBody } from "./github.js";
|
|
9
|
+
import { createRoleRulesFiles, parseRoleSpec, proposal, serializeConfig, writeConfig, writeUserConfig } 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,7 +22,8 @@ Usage:
|
|
|
21
22
|
gdt <command> [options]
|
|
22
23
|
|
|
23
24
|
Commands:
|
|
24
|
-
|
|
25
|
+
init Write roles to the user config and .gdt/config.toml; install the skill
|
|
26
|
+
doctor Check tools, GitHub authentication and the configuration
|
|
25
27
|
check-issue Validate an issue body against the issue contract
|
|
26
28
|
start Start the workflow for an issue in the background
|
|
27
29
|
status Show the workflow status and the next step
|
|
@@ -43,9 +45,33 @@ Options:
|
|
|
43
45
|
`;
|
|
44
46
|
const DOCTOR_HELP = `Usage: gdt doctor [--json]
|
|
45
47
|
|
|
46
|
-
Checks that git and gh are installed, gh is authenticated, and that
|
|
47
|
-
.gdt/config.toml (
|
|
48
|
-
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
|
+
`;
|
|
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. 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.
|
|
64
|
+
|
|
65
|
+
Options:
|
|
66
|
+
--developer <agent>/<model> Agent and model for the developer role
|
|
67
|
+
--tester <agent>/<model> Agent and model for the tester role
|
|
68
|
+
--reviewer <agent>/<model> Agent and model for the reviewer role
|
|
69
|
+
--language <lang> Language for human-facing text (default en)
|
|
70
|
+
--terminal <herdr|headless> Terminal backend (default: herdr when usable)
|
|
71
|
+
--required-check <name> CI check that must pass; repeatable
|
|
72
|
+
--allow-no-required-checks Accept an empty required_checks list
|
|
73
|
+
--force Replace an existing .gdt/config.toml
|
|
74
|
+
--json Machine-readable proposal output
|
|
49
75
|
`;
|
|
50
76
|
const CHECK_ISSUE_HELP = `Usage: gdt check-issue <issue> [--json]
|
|
51
77
|
gdt check-issue --body-file <file> [--json]
|
|
@@ -150,6 +176,157 @@ function doctor(args, io) {
|
|
|
150
176
|
io.stdout(json ? `${JSON.stringify(report, null, 2)}\n` : formatFindings(report.findings));
|
|
151
177
|
return report.ok ? EXIT_OK : EXIT_FAILED;
|
|
152
178
|
}
|
|
179
|
+
function formatProposal(facts) {
|
|
180
|
+
const lines = ["agents:"];
|
|
181
|
+
for (const choice of facts.agents) {
|
|
182
|
+
lines.push(` ${choice.agent} ${choice.found ? "found" : "not found"} model: ${choice.model_format}, example: ${choice.example}`);
|
|
183
|
+
}
|
|
184
|
+
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>");
|
|
185
|
+
return `${lines.join("\n")}\n`;
|
|
186
|
+
}
|
|
187
|
+
/**
|
|
188
|
+
* `gdt init`: without role options, reports the proposal (AC-1); with all three, writes
|
|
189
|
+
* `.gdt/config.toml` (AC-2 to AC-5), then runs doctor and installs the skill (AC-6, AC-7).
|
|
190
|
+
*/
|
|
191
|
+
function initCommand(args, io) {
|
|
192
|
+
let developer;
|
|
193
|
+
let tester;
|
|
194
|
+
let reviewer;
|
|
195
|
+
let language;
|
|
196
|
+
let terminal;
|
|
197
|
+
const requiredChecks = [];
|
|
198
|
+
let allowNoRequiredChecks = false;
|
|
199
|
+
let force = false;
|
|
200
|
+
let json = false;
|
|
201
|
+
for (let i = 0; i < args.length; i++) {
|
|
202
|
+
const arg = args[i] ?? "";
|
|
203
|
+
if (arg === "--json")
|
|
204
|
+
json = true;
|
|
205
|
+
else if (arg === "--force")
|
|
206
|
+
force = true;
|
|
207
|
+
else if (arg === "--allow-no-required-checks")
|
|
208
|
+
allowNoRequiredChecks = true;
|
|
209
|
+
else if (arg === "--help" || arg === "-h") {
|
|
210
|
+
io.stdout(INIT_HELP);
|
|
211
|
+
return EXIT_OK;
|
|
212
|
+
}
|
|
213
|
+
else if (arg === "--developer" || arg === "--tester" || arg === "--reviewer" || arg === "--language" || arg === "--terminal" || arg === "--required-check") {
|
|
214
|
+
const given = args[++i];
|
|
215
|
+
if (given === undefined)
|
|
216
|
+
return usageError(io, `Missing value for "${arg}".`, "gdt init --help");
|
|
217
|
+
if (arg === "--developer")
|
|
218
|
+
developer = given;
|
|
219
|
+
else if (arg === "--tester")
|
|
220
|
+
tester = given;
|
|
221
|
+
else if (arg === "--reviewer")
|
|
222
|
+
reviewer = given;
|
|
223
|
+
else if (arg === "--language")
|
|
224
|
+
language = given;
|
|
225
|
+
else if (arg === "--terminal") {
|
|
226
|
+
if (given !== "herdr" && given !== "headless") {
|
|
227
|
+
return usageError(io, `Invalid terminal "${given}"; use herdr or headless.`, "gdt init --help");
|
|
228
|
+
}
|
|
229
|
+
terminal = given;
|
|
230
|
+
}
|
|
231
|
+
else
|
|
232
|
+
requiredChecks.push(given);
|
|
233
|
+
}
|
|
234
|
+
else if (arg.startsWith("-"))
|
|
235
|
+
return usageError(io, `Unknown option "${arg}" for "gdt init".`, "gdt init --help");
|
|
236
|
+
else
|
|
237
|
+
return usageError(io, `Unexpected argument "${arg}" for "gdt init".`, "gdt init --help");
|
|
238
|
+
}
|
|
239
|
+
const root = findRepository(io.cwd);
|
|
240
|
+
if (root === null) {
|
|
241
|
+
io.stderr(`${io.cwd} is not inside a Git repository. Run gdt from a checkout of the target repository.\n`);
|
|
242
|
+
return EXIT_FAILED;
|
|
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
|
+
}
|
|
249
|
+
const specs = { developer, tester, reviewer };
|
|
250
|
+
const given = ROLES.filter((role) => specs[role] !== undefined);
|
|
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)) {
|
|
253
|
+
const facts = proposal(root, io.env);
|
|
254
|
+
io.stdout(json ? `${JSON.stringify(facts, null, 2)}\n` : formatProposal(facts));
|
|
255
|
+
return EXIT_OK;
|
|
256
|
+
}
|
|
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`);
|
|
277
|
+
return EXIT_FAILED;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const languages = shippedLanguages();
|
|
281
|
+
if (language !== undefined && !languages.includes(language)) {
|
|
282
|
+
io.stderr(`No locale for language "${language}"; available languages: ${languages.join(", ")}\n`);
|
|
283
|
+
return EXIT_FAILED;
|
|
284
|
+
}
|
|
285
|
+
if (existsSync(join(root, CONFIG_PATH)) && !force) {
|
|
286
|
+
io.stderr(`${CONFIG_PATH} already exists; use --force to replace it\n`);
|
|
287
|
+
return EXIT_FAILED;
|
|
288
|
+
}
|
|
289
|
+
const resolvedTerminal = terminal ?? (herdrPreflight(io.env) === null ? "herdr" : "headless");
|
|
290
|
+
const checks = requiredChecks.length > 0 ? requiredChecks : detectedChecks(root, io.env);
|
|
291
|
+
if (checks.length === 0 && !allowNoRequiredChecks) {
|
|
292
|
+
io.stderr("No required checks detected; pass --required-check <name> for each check, or --allow-no-required-checks to accept none\n");
|
|
293
|
+
return EXIT_FAILED;
|
|
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
|
+
}
|
|
307
|
+
const text = serializeConfig({
|
|
308
|
+
language: language ?? DEFAULT_LANGUAGE,
|
|
309
|
+
terminal: resolvedTerminal,
|
|
310
|
+
requiredChecks: checks,
|
|
311
|
+
allowNoRequiredChecks,
|
|
312
|
+
});
|
|
313
|
+
const writeError = writeConfig(root, text);
|
|
314
|
+
if (writeError !== null) {
|
|
315
|
+
io.stderr(`${writeError}\n`);
|
|
316
|
+
return EXIT_FAILED;
|
|
317
|
+
}
|
|
318
|
+
// AC-4: the three supplementary role rules files; existing files are left unchanged.
|
|
319
|
+
for (const path of createRoleRulesFiles(root))
|
|
320
|
+
io.stdout(`created ${path}\n`);
|
|
321
|
+
// AC-7: report the new config with doctor, then install the skill; the config stays in place either way.
|
|
322
|
+
const report = runDoctor(root, io.env);
|
|
323
|
+
io.stdout(formatFindings(report.findings));
|
|
324
|
+
const installed = installSkill(io.env);
|
|
325
|
+
io.stdout(installed.stdout);
|
|
326
|
+
if (installed.stderr !== "")
|
|
327
|
+
io.stderr(installed.stderr);
|
|
328
|
+
return report.ok ? EXIT_OK : EXIT_FAILED;
|
|
329
|
+
}
|
|
153
330
|
function checkIssue(args, io) {
|
|
154
331
|
let json = false;
|
|
155
332
|
let issue;
|
|
@@ -412,6 +589,8 @@ export function run(argv, io) {
|
|
|
412
589
|
return EXIT_OK;
|
|
413
590
|
case "doctor":
|
|
414
591
|
return doctor(rest, io);
|
|
592
|
+
case "init":
|
|
593
|
+
return initCommand(rest, io);
|
|
415
594
|
case "check-issue":
|
|
416
595
|
return checkIssue(rest, io);
|
|
417
596
|
case "start":
|
package/dist/config.js
CHANGED
|
@@ -1,16 +1,37 @@
|
|
|
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"];
|
|
10
|
+
/** AC-2: how the herdr backend places the managed panes. */
|
|
11
|
+
export const HERDR_LAYOUTS = ["split", "tabs"];
|
|
8
12
|
export const CONFIG_PATH = ".gdt/config.toml";
|
|
9
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";
|
|
10
16
|
export const DEFAULT_LANGUAGE = "en";
|
|
11
17
|
export const DEFAULT_MAX_ACCEPTANCE_CRITERIA = 8;
|
|
12
18
|
/** The test agent: runs `script` instead of a coding agent. Only accepted when GDT_TEST_AGENTS=1. */
|
|
13
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
|
+
}
|
|
14
35
|
const roleSchema = z.strictObject({
|
|
15
36
|
agent: z.enum(AGENTS),
|
|
16
37
|
model: z.string().min(1),
|
|
@@ -33,7 +54,10 @@ function configSchemaFor(testAgents) {
|
|
|
33
54
|
const role = testAgents ? testRoleSchema : roleSchema;
|
|
34
55
|
return z.strictObject({
|
|
35
56
|
language: z.string().min(1).default(DEFAULT_LANGUAGE),
|
|
36
|
-
|
|
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(),
|
|
37
61
|
workflow: z.strictObject({
|
|
38
62
|
max_correction_rounds: z.int().min(0).default(2),
|
|
39
63
|
// Deliberately without a default: an empty gate must be an explicit choice.
|
|
@@ -41,6 +65,8 @@ function configSchemaFor(testAgents) {
|
|
|
41
65
|
allow_no_required_checks: z.boolean().default(false),
|
|
42
66
|
terminal: z.enum(TERMINALS).default("herdr"),
|
|
43
67
|
supervisor_pane: z.boolean().default(false),
|
|
68
|
+
// Default `tabs`: one tab per managed pane. `split` keeps the panes in one tab.
|
|
69
|
+
herdr_layout: z.enum(HERDR_LAYOUTS).default("tabs"),
|
|
44
70
|
poll_seconds: z.number().positive().default(30),
|
|
45
71
|
handoff_checks: z.int().min(1).default(5),
|
|
46
72
|
}),
|
|
@@ -79,7 +105,7 @@ function valueAt(data, path) {
|
|
|
79
105
|
function formatPath(path) {
|
|
80
106
|
return path.map(String).join(".");
|
|
81
107
|
}
|
|
82
|
-
/** The layer that supplied the value at `path`, preferring the
|
|
108
|
+
/** The layer that supplied the value at `path`, preferring the later layer. */
|
|
83
109
|
function sourceOf(layers, path) {
|
|
84
110
|
for (let i = layers.length - 1; i >= 0; i--) {
|
|
85
111
|
const layer = layers[i];
|
|
@@ -88,6 +114,18 @@ function sourceOf(layers, path) {
|
|
|
88
114
|
}
|
|
89
115
|
return layers[0]?.path ?? CONFIG_PATH;
|
|
90
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
|
+
}
|
|
91
129
|
function describe(value) {
|
|
92
130
|
return value === undefined ? "missing" : JSON.stringify(value);
|
|
93
131
|
}
|
|
@@ -115,10 +153,11 @@ function issueFindings(issue, merged, layers) {
|
|
|
115
153
|
return [error(`${key}: ${issue.message}`, `Correct ${key} in ${file}`)];
|
|
116
154
|
}
|
|
117
155
|
}
|
|
118
|
-
|
|
119
|
-
|
|
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");
|
|
120
159
|
try {
|
|
121
|
-
return { path, data: parseToml(text) };
|
|
160
|
+
return { path: display, kind, data: parseToml(text) };
|
|
122
161
|
}
|
|
123
162
|
catch (err) {
|
|
124
163
|
const where = err instanceof TomlError ? ` at line ${err.line}, column ${err.column}` : "";
|
|
@@ -126,15 +165,72 @@ function readLayer(root, path) {
|
|
|
126
165
|
return {
|
|
127
166
|
check: "config",
|
|
128
167
|
level: "error",
|
|
129
|
-
message: `${
|
|
130
|
-
fix: `Fix the TOML syntax in ${
|
|
168
|
+
message: `${display}: invalid TOML${where}: ${reason}`,
|
|
169
|
+
fix: `Fix the TOML syntax in ${display}`,
|
|
131
170
|
};
|
|
132
171
|
}
|
|
133
172
|
}
|
|
134
|
-
/**
|
|
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
|
+
*/
|
|
135
219
|
export function loadConfig(root, env = {}) {
|
|
136
|
-
const
|
|
137
|
-
|
|
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) {
|
|
138
234
|
return {
|
|
139
235
|
report: { valid: false, files },
|
|
140
236
|
findings: [
|
|
@@ -142,15 +238,20 @@ export function loadConfig(root, env = {}) {
|
|
|
142
238
|
check: "config",
|
|
143
239
|
level: "error",
|
|
144
240
|
message: `${CONFIG_PATH}: not found`,
|
|
145
|
-
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`,
|
|
146
242
|
},
|
|
147
243
|
],
|
|
148
244
|
};
|
|
149
245
|
}
|
|
150
246
|
const layers = [];
|
|
151
247
|
const findings = [];
|
|
152
|
-
|
|
153
|
-
|
|
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);
|
|
154
255
|
if ("data" in layer)
|
|
155
256
|
layers.push(layer);
|
|
156
257
|
else
|
|
@@ -158,28 +259,89 @@ export function loadConfig(root, env = {}) {
|
|
|
158
259
|
}
|
|
159
260
|
if (findings.length > 0)
|
|
160
261
|
return { report: { valid: false, files }, findings };
|
|
161
|
-
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;
|
|
162
308
|
const parsed = configSchemaFor(testAgentsEnabled(env)).safeParse(merged);
|
|
163
309
|
if (!parsed.success) {
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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));
|
|
168
316
|
}
|
|
317
|
+
if (hasErrors(findings) || !parsed.success)
|
|
318
|
+
return { report: { valid: false, files }, findings };
|
|
169
319
|
const config = parsed.data;
|
|
170
|
-
const
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
}
|
|
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
|
+
};
|
|
175
325
|
findings.push({
|
|
176
326
|
check: "config",
|
|
177
327
|
level: "ok",
|
|
178
|
-
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`,
|
|
179
333
|
fix: "",
|
|
180
334
|
});
|
|
181
335
|
findings.push(...semanticFindings(root, config));
|
|
182
|
-
|
|
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 };
|
|
183
345
|
}
|
|
184
346
|
/** Checks that pass the schema but would make gdt unsafe or surprising. */
|
|
185
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/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);
|