@gevezex/gdt 0.2.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/LICENSE +21 -0
- package/README.md +375 -0
- package/dist/agents/adapter.js +7 -0
- package/dist/agents/claude.js +13 -0
- package/dist/agents/codex.js +13 -0
- package/dist/agents/index.js +15 -0
- package/dist/agents/mcode.js +14 -0
- package/dist/agents/omp.js +14 -0
- package/dist/agents/opencode.js +15 -0
- package/dist/agents/pi.js +14 -0
- package/dist/backends/backend.js +1 -0
- package/dist/backends/headless.js +55 -0
- package/dist/backends/herdr.js +286 -0
- package/dist/backends/index.js +20 -0
- package/dist/cli.js +466 -0
- package/dist/config.js +210 -0
- package/dist/contract.js +133 -0
- package/dist/decision.js +175 -0
- package/dist/doctor.js +231 -0
- package/dist/finding.js +3 -0
- package/dist/git.js +25 -0
- package/dist/github.js +95 -0
- package/dist/locale.js +44 -0
- package/dist/notify.js +29 -0
- package/dist/prompts.js +80 -0
- package/dist/protocol.js +114 -0
- package/dist/state.js +113 -0
- package/dist/steering.js +165 -0
- package/dist/supervisor.js +369 -0
- package/dist/worker.js +232 -0
- package/dist/workflow.js +333 -0
- package/locales/en.toml +28 -0
- package/locales/nl.toml +28 -0
- package/package.json +61 -0
- package/roles/developer.md +66 -0
- package/roles/issue-writer.md +40 -0
- package/roles/reviewer.md +65 -0
- package/roles/tester.md +58 -0
- package/schemas/gdt-answer.v1.schema.json +32 -0
- package/schemas/gdt-directive.v1.schema.json +36 -0
- package/schemas/gdt-handoff.v1.schema.json +117 -0
- package/schemas/gdt-question.v1.schema.json +82 -0
- package/schemas/gdt-review.v1.schema.json +131 -0
- package/schemas/gdt-round.v1.schema.json +28 -0
- package/schemas/gdt-test.v1.schema.json +131 -0
- package/skill/SKILL.md +46 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { CONFIG_PATH, DEFAULT_LANGUAGE, DEFAULT_MAX_ACCEPTANCE_CRITERIA, loadConfig, ROLES } from "./config.js";
|
|
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";
|
|
10
|
+
import { allowRound, answer, installSkill, pause, resume, setAgent, steer } from "./steering.js";
|
|
11
|
+
import { supervise } from "./supervisor.js";
|
|
12
|
+
import { work } from "./worker.js";
|
|
13
|
+
import { retry, start, status, stop, wait } from "./workflow.js";
|
|
14
|
+
/** Exit codes: 0 success, 1 a check failed, 2 usage error. */
|
|
15
|
+
export const EXIT_OK = 0;
|
|
16
|
+
export const EXIT_FAILED = 1;
|
|
17
|
+
export const EXIT_USAGE = 2;
|
|
18
|
+
const HELP = `gdt - GitHub issues to merge-ready pull requests
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
gdt <command> [options]
|
|
22
|
+
|
|
23
|
+
Commands:
|
|
24
|
+
doctor Check tools, GitHub authentication and .gdt/config.toml
|
|
25
|
+
check-issue Validate an issue body against the issue contract
|
|
26
|
+
start Start the workflow for an issue in the background
|
|
27
|
+
status Show the workflow status and the next step
|
|
28
|
+
wait Wait until the workflow needs attention
|
|
29
|
+
stop Stop the workflow for an issue; start resumes it
|
|
30
|
+
retry Prepare a controlled retry of the failed turn
|
|
31
|
+
answer Answer an open question
|
|
32
|
+
steer Send a directive to one role
|
|
33
|
+
pause Stop dispatching new turns
|
|
34
|
+
resume Continue dispatching after pause
|
|
35
|
+
allow-round Grant one extra correction round
|
|
36
|
+
set-agent Override the agent and model of one role
|
|
37
|
+
install-skill Install the operator skill into detected agent harnesses
|
|
38
|
+
|
|
39
|
+
Options:
|
|
40
|
+
--json Machine-readable output
|
|
41
|
+
--help, -h Show help
|
|
42
|
+
--version Print the gdt version
|
|
43
|
+
`;
|
|
44
|
+
const DOCTOR_HELP = `Usage: gdt doctor [--json]
|
|
45
|
+
|
|
46
|
+
Checks that git and gh are installed, gh is authenticated, and that
|
|
47
|
+
.gdt/config.toml (merged with .gdt/config.local.toml) is valid.
|
|
48
|
+
Exits with 1 when any finding has level "error".
|
|
49
|
+
`;
|
|
50
|
+
const CHECK_ISSUE_HELP = `Usage: gdt check-issue <issue> [--json]
|
|
51
|
+
gdt check-issue --body-file <file> [--json]
|
|
52
|
+
|
|
53
|
+
Validates an issue body against the issue contract for the configured language
|
|
54
|
+
(language in .gdt/config.toml, default en). With <issue>, the body is fetched
|
|
55
|
+
with "gh issue view"; with --body-file, a local file is read and GitHub is not
|
|
56
|
+
called. Exits with 1 when the contract is invalid.
|
|
57
|
+
`;
|
|
58
|
+
const WORKFLOW_HELP = {
|
|
59
|
+
start: `Usage: gdt start <issue>
|
|
60
|
+
|
|
61
|
+
Checks the working tree and the issue contract, starts the supervisor and the
|
|
62
|
+
role workers as detached processes and returns. Logs: .git/gdt/issue-<n>/logs.
|
|
63
|
+
`,
|
|
64
|
+
status: `Usage: gdt status <issue> [--json]
|
|
65
|
+
|
|
66
|
+
Shows the workflow status and the next step.
|
|
67
|
+
`,
|
|
68
|
+
wait: `Usage: gdt wait <issue> [--timeout <seconds>] [--json]
|
|
69
|
+
|
|
70
|
+
Blocks on the local workflow state and returns with the "gdt status" output as
|
|
71
|
+
soon as the workflow needs attention (an action status) or the supervisor is
|
|
72
|
+
gone. It returns at once when the workflow is already in an action status.
|
|
73
|
+
--timeout gives up after the given number of seconds and exits 1. Reads only
|
|
74
|
+
local state under .git/gdt/.
|
|
75
|
+
`,
|
|
76
|
+
stop: `Usage: gdt stop <issue>
|
|
77
|
+
|
|
78
|
+
Stops the supervisor, the role workers and any running agent turn.
|
|
79
|
+
"gdt start <issue>" resumes the same workflow.
|
|
80
|
+
`,
|
|
81
|
+
retry: `Usage: gdt retry <issue>
|
|
82
|
+
|
|
83
|
+
Stops the failed or interrupted workflow and clears that turn so
|
|
84
|
+
"gdt start <issue>" runs it again.
|
|
85
|
+
`,
|
|
86
|
+
pause: `Usage: gdt pause <issue>
|
|
87
|
+
|
|
88
|
+
Stops dispatching new turns; "gdt resume <issue>" continues.
|
|
89
|
+
`,
|
|
90
|
+
resume: `Usage: gdt resume <issue>
|
|
91
|
+
|
|
92
|
+
Continues dispatching after "gdt pause <issue>".
|
|
93
|
+
`,
|
|
94
|
+
"allow-round": `Usage: gdt allow-round <issue>
|
|
95
|
+
|
|
96
|
+
Grants one extra correction round when the round budget is exhausted.
|
|
97
|
+
`,
|
|
98
|
+
};
|
|
99
|
+
const STEERING_HELP = {
|
|
100
|
+
answer: `Usage: gdt answer <issue> <question-id> <text>
|
|
101
|
+
|
|
102
|
+
Posts a [gdt-answer:v1] comment for an open question. Exits 1 when the
|
|
103
|
+
question is unknown or already answered.
|
|
104
|
+
`,
|
|
105
|
+
steer: `Usage: gdt steer <issue> --role <role> <text>
|
|
106
|
+
|
|
107
|
+
Posts a [gdt-directive:v1] comment for one role on the workflow pull request,
|
|
108
|
+
or on the issue before a pull request exists. An unknown role exits 1.
|
|
109
|
+
`,
|
|
110
|
+
"set-agent": `Usage: gdt set-agent <issue> <role> <agent>/<model>
|
|
111
|
+
|
|
112
|
+
Overrides the configured agent and model for one role from its next turn;
|
|
113
|
+
"gdt status --json" shows the override. An unsupported agent exits 1.
|
|
114
|
+
`,
|
|
115
|
+
"install-skill": `Usage: gdt install-skill
|
|
116
|
+
|
|
117
|
+
Copies skill/SKILL.md into the skill directory of every detected agent
|
|
118
|
+
harness and lists the paths. Running it again reports "up to date".
|
|
119
|
+
`,
|
|
120
|
+
};
|
|
121
|
+
export function version() {
|
|
122
|
+
const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
|
|
123
|
+
return pkg.version;
|
|
124
|
+
}
|
|
125
|
+
function usageError(io, message, help) {
|
|
126
|
+
io.stderr(`${message} Run "${help}".\n`);
|
|
127
|
+
return EXIT_USAGE;
|
|
128
|
+
}
|
|
129
|
+
const MARKS = { ok: "ok ", warning: "warning", error: "error " };
|
|
130
|
+
function formatFindings(findings) {
|
|
131
|
+
const lines = findings.map((f) => `${MARKS[f.level]} ${f.message}${f.fix === "" ? "" : `\n fix: ${f.fix}`}`);
|
|
132
|
+
const errors = findings.filter((f) => f.level === "error").length;
|
|
133
|
+
const warnings = findings.filter((f) => f.level === "warning").length;
|
|
134
|
+
lines.push("", `${errors} error(s), ${warnings} warning(s)`);
|
|
135
|
+
return `${lines.join("\n")}\n`;
|
|
136
|
+
}
|
|
137
|
+
function doctor(args, io) {
|
|
138
|
+
let json = false;
|
|
139
|
+
for (const arg of args) {
|
|
140
|
+
if (arg === "--json")
|
|
141
|
+
json = true;
|
|
142
|
+
else if (arg === "--help" || arg === "-h") {
|
|
143
|
+
io.stdout(DOCTOR_HELP);
|
|
144
|
+
return EXIT_OK;
|
|
145
|
+
}
|
|
146
|
+
else
|
|
147
|
+
return usageError(io, `Unknown option "${arg}" for "gdt doctor".`, "gdt doctor --help");
|
|
148
|
+
}
|
|
149
|
+
const report = runDoctor(io.cwd, io.env);
|
|
150
|
+
io.stdout(json ? `${JSON.stringify(report, null, 2)}\n` : formatFindings(report.findings));
|
|
151
|
+
return report.ok ? EXIT_OK : EXIT_FAILED;
|
|
152
|
+
}
|
|
153
|
+
function checkIssue(args, io) {
|
|
154
|
+
let json = false;
|
|
155
|
+
let issue;
|
|
156
|
+
let bodyFile;
|
|
157
|
+
for (let i = 0; i < args.length; i++) {
|
|
158
|
+
const arg = args[i] ?? "";
|
|
159
|
+
if (arg === "--json")
|
|
160
|
+
json = true;
|
|
161
|
+
else if (arg === "--help" || arg === "-h") {
|
|
162
|
+
io.stdout(CHECK_ISSUE_HELP);
|
|
163
|
+
return EXIT_OK;
|
|
164
|
+
}
|
|
165
|
+
else if (arg === "--body-file") {
|
|
166
|
+
bodyFile = args[++i];
|
|
167
|
+
if (bodyFile === undefined)
|
|
168
|
+
return usageError(io, 'Missing file for "--body-file".', "gdt check-issue --help");
|
|
169
|
+
}
|
|
170
|
+
else if (arg.startsWith("-")) {
|
|
171
|
+
return usageError(io, `Unknown option "${arg}" for "gdt check-issue".`, "gdt check-issue --help");
|
|
172
|
+
}
|
|
173
|
+
else if (issue === undefined && /^[1-9]\d*$/.test(arg))
|
|
174
|
+
issue = Number(arg);
|
|
175
|
+
else
|
|
176
|
+
return usageError(io, `Unexpected argument "${arg}" for "gdt check-issue".`, "gdt check-issue --help");
|
|
177
|
+
}
|
|
178
|
+
if (issue !== undefined && bodyFile !== undefined) {
|
|
179
|
+
return usageError(io, 'Give either an issue number or --body-file to "gdt check-issue", not both.', "gdt check-issue --help");
|
|
180
|
+
}
|
|
181
|
+
if (issue === undefined && bodyFile === undefined) {
|
|
182
|
+
return usageError(io, 'Missing issue number for "gdt check-issue".', "gdt check-issue --help");
|
|
183
|
+
}
|
|
184
|
+
const root = findRepository(io.cwd) ?? io.cwd;
|
|
185
|
+
let language = DEFAULT_LANGUAGE;
|
|
186
|
+
let maxAcceptanceCriteria = DEFAULT_MAX_ACCEPTANCE_CRITERIA;
|
|
187
|
+
if (existsSync(join(root, CONFIG_PATH))) {
|
|
188
|
+
const { report } = loadConfig(root, io.env);
|
|
189
|
+
if (!report.valid) {
|
|
190
|
+
io.stderr(`${CONFIG_PATH} is invalid. Run "gdt doctor" for details.\n`);
|
|
191
|
+
return EXIT_FAILED;
|
|
192
|
+
}
|
|
193
|
+
language = report.language;
|
|
194
|
+
maxAcceptanceCriteria = report.contract.max_acceptance_criteria;
|
|
195
|
+
}
|
|
196
|
+
let locale;
|
|
197
|
+
try {
|
|
198
|
+
locale = loadLocale(language);
|
|
199
|
+
}
|
|
200
|
+
catch (err) {
|
|
201
|
+
io.stderr(`${err instanceof Error ? err.message : String(err)}\n`);
|
|
202
|
+
return EXIT_FAILED;
|
|
203
|
+
}
|
|
204
|
+
let body;
|
|
205
|
+
if (bodyFile !== undefined) {
|
|
206
|
+
try {
|
|
207
|
+
body = readFileSync(resolve(io.cwd, bodyFile), "utf8");
|
|
208
|
+
}
|
|
209
|
+
catch (err) {
|
|
210
|
+
io.stderr(`Cannot read ${bodyFile}: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
211
|
+
return EXIT_FAILED;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
const fetched = issueBody(issue ?? 0, root, io.env);
|
|
216
|
+
if ("error" in fetched) {
|
|
217
|
+
io.stderr(`${fetched.error}\n`);
|
|
218
|
+
return EXIT_FAILED;
|
|
219
|
+
}
|
|
220
|
+
body = fetched.body;
|
|
221
|
+
}
|
|
222
|
+
const label = bodyFile ?? `Issue #${issue}`;
|
|
223
|
+
const result = validateContract(body, locale, { maxAcceptanceCriteria });
|
|
224
|
+
if (json)
|
|
225
|
+
io.stdout(`${JSON.stringify(result, null, 2)}\n`);
|
|
226
|
+
else if (result.valid) {
|
|
227
|
+
io.stdout(`${label}: contract valid (${result.acceptance_criteria.length} acceptance criteria)\n`);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
const lines = result.errors.map((error) => ` - ${error}`);
|
|
231
|
+
io.stdout(`${label}: contract invalid (${result.errors.length} error(s))\n${lines.join("\n")}\n`);
|
|
232
|
+
}
|
|
233
|
+
return result.valid ? EXIT_OK : EXIT_FAILED;
|
|
234
|
+
}
|
|
235
|
+
/** Parses `<issue> [--json]` for the workflow commands; returns an exit code on usage errors or help. */
|
|
236
|
+
function issueArgs(command, args, io, allowJson) {
|
|
237
|
+
let json = false;
|
|
238
|
+
let issue;
|
|
239
|
+
for (const arg of args) {
|
|
240
|
+
if (arg === "--json" && allowJson)
|
|
241
|
+
json = true;
|
|
242
|
+
else if (arg === "--help" || arg === "-h") {
|
|
243
|
+
io.stdout(WORKFLOW_HELP[command] ?? HELP);
|
|
244
|
+
return EXIT_OK;
|
|
245
|
+
}
|
|
246
|
+
else if (arg.startsWith("-")) {
|
|
247
|
+
return usageError(io, `Unknown option "${arg}" for "gdt ${command}".`, `gdt ${command} --help`);
|
|
248
|
+
}
|
|
249
|
+
else if (issue === undefined && /^[1-9]\d*$/.test(arg))
|
|
250
|
+
issue = Number(arg);
|
|
251
|
+
else
|
|
252
|
+
return usageError(io, `Unexpected argument "${arg}" for "gdt ${command}".`, `gdt ${command} --help`);
|
|
253
|
+
}
|
|
254
|
+
if (issue === undefined)
|
|
255
|
+
return usageError(io, `Missing issue number for "gdt ${command}".`, `gdt ${command} --help`);
|
|
256
|
+
return { issue, json };
|
|
257
|
+
}
|
|
258
|
+
/** Writes a command result to the matching streams and returns its exit code. */
|
|
259
|
+
function emit(result, io) {
|
|
260
|
+
if (result.stdout !== "")
|
|
261
|
+
io.stdout(result.stdout);
|
|
262
|
+
if (result.stderr !== "")
|
|
263
|
+
io.stderr(result.stderr);
|
|
264
|
+
return result.code;
|
|
265
|
+
}
|
|
266
|
+
function workflowCommand(command, args, io) {
|
|
267
|
+
const parsed = issueArgs(command, args, io, command === "status");
|
|
268
|
+
if (typeof parsed === "number")
|
|
269
|
+
return parsed;
|
|
270
|
+
const { issue, json } = parsed;
|
|
271
|
+
const result = command === "start"
|
|
272
|
+
? start(issue, io.cwd, io.env)
|
|
273
|
+
: command === "stop"
|
|
274
|
+
? stop(issue, io.cwd, io.env)
|
|
275
|
+
: command === "retry"
|
|
276
|
+
? retry(issue, io.cwd, io.env)
|
|
277
|
+
: command === "pause"
|
|
278
|
+
? pause(issue, io.cwd, io.env)
|
|
279
|
+
: command === "resume"
|
|
280
|
+
? resume(issue, io.cwd, io.env)
|
|
281
|
+
: command === "allow-round"
|
|
282
|
+
? allowRound(issue, io.cwd, io.env)
|
|
283
|
+
: status(issue, io.cwd, io.env, json);
|
|
284
|
+
return emit(result, io);
|
|
285
|
+
}
|
|
286
|
+
/** `gdt wait <issue> [--timeout <seconds>] [--json]`: blocks until the workflow needs attention. */
|
|
287
|
+
function waitCommand(args, io) {
|
|
288
|
+
let json = false;
|
|
289
|
+
let timeoutSeconds = null;
|
|
290
|
+
let issue;
|
|
291
|
+
for (let i = 0; i < args.length; i++) {
|
|
292
|
+
const arg = args[i] ?? "";
|
|
293
|
+
if (arg === "--json")
|
|
294
|
+
json = true;
|
|
295
|
+
else if (arg === "--help" || arg === "-h") {
|
|
296
|
+
io.stdout(WORKFLOW_HELP.wait ?? HELP);
|
|
297
|
+
return EXIT_OK;
|
|
298
|
+
}
|
|
299
|
+
else if (arg === "--timeout") {
|
|
300
|
+
const value = args[++i];
|
|
301
|
+
const seconds = value === undefined ? Number.NaN : Number(value);
|
|
302
|
+
if (!Number.isFinite(seconds) || seconds <= 0) {
|
|
303
|
+
return usageError(io, `Expected a positive number of seconds after "--timeout", got "${value ?? ""}".`, "gdt wait --help");
|
|
304
|
+
}
|
|
305
|
+
timeoutSeconds = seconds;
|
|
306
|
+
}
|
|
307
|
+
else if (arg.startsWith("-")) {
|
|
308
|
+
return usageError(io, `Unknown option "${arg}" for "gdt wait".`, "gdt wait --help");
|
|
309
|
+
}
|
|
310
|
+
else if (issue === undefined && /^[1-9]\d*$/.test(arg))
|
|
311
|
+
issue = Number(arg);
|
|
312
|
+
else
|
|
313
|
+
return usageError(io, `Unexpected argument "${arg}" for "gdt wait".`, "gdt wait --help");
|
|
314
|
+
}
|
|
315
|
+
if (issue === undefined)
|
|
316
|
+
return usageError(io, 'Missing issue number for "gdt wait".', "gdt wait --help");
|
|
317
|
+
return emit(wait(issue, io.cwd, io.env, { json, timeoutSeconds }), io);
|
|
318
|
+
}
|
|
319
|
+
/** `gdt answer <issue> <question-id> <text>`; everything after the id is the answer text. */
|
|
320
|
+
function answerCommand(args, io) {
|
|
321
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
322
|
+
io.stdout(STEERING_HELP.answer ?? HELP);
|
|
323
|
+
return EXIT_OK;
|
|
324
|
+
}
|
|
325
|
+
const [issueArg, questionId, ...rest] = args;
|
|
326
|
+
if (issueArg === undefined || questionId === undefined || rest.length === 0) {
|
|
327
|
+
return usageError(io, 'Usage: gdt answer <issue> <question-id> <text>.', "gdt answer --help");
|
|
328
|
+
}
|
|
329
|
+
if (!/^[1-9]\d*$/.test(issueArg))
|
|
330
|
+
return usageError(io, `Expected an issue number, got "${issueArg}".`, "gdt answer --help");
|
|
331
|
+
return emit(answer(Number(issueArg), questionId, rest.join(" "), io.cwd, io.env), io);
|
|
332
|
+
}
|
|
333
|
+
/** `gdt steer <issue> --role <role> <text>`. */
|
|
334
|
+
function steerCommand(args, io) {
|
|
335
|
+
let issue;
|
|
336
|
+
let role;
|
|
337
|
+
const text = [];
|
|
338
|
+
for (let i = 0; i < args.length; i++) {
|
|
339
|
+
const arg = args[i] ?? "";
|
|
340
|
+
if (arg === "--help" || arg === "-h") {
|
|
341
|
+
io.stdout(STEERING_HELP.steer ?? HELP);
|
|
342
|
+
return EXIT_OK;
|
|
343
|
+
}
|
|
344
|
+
else if (arg === "--role") {
|
|
345
|
+
role = args[++i];
|
|
346
|
+
if (role === undefined)
|
|
347
|
+
return usageError(io, 'Missing value for "--role".', "gdt steer --help");
|
|
348
|
+
}
|
|
349
|
+
else if (arg.startsWith("-") && !/^-\d/.test(arg)) {
|
|
350
|
+
return usageError(io, `Unknown option "${arg}" for "gdt steer".`, "gdt steer --help");
|
|
351
|
+
}
|
|
352
|
+
else if (issue === undefined && /^[1-9]\d*$/.test(arg)) {
|
|
353
|
+
issue = Number(arg);
|
|
354
|
+
}
|
|
355
|
+
else {
|
|
356
|
+
text.push(arg);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (issue === undefined)
|
|
360
|
+
return usageError(io, 'Missing issue number for "gdt steer".', "gdt steer --help");
|
|
361
|
+
if (role === undefined)
|
|
362
|
+
return usageError(io, 'Missing "--role <role>" for "gdt steer".', "gdt steer --help");
|
|
363
|
+
return emit(steer(issue, role, text.join(" "), io.cwd, io.env), io);
|
|
364
|
+
}
|
|
365
|
+
/** `gdt set-agent <issue> <role> <agent>/<model>`. */
|
|
366
|
+
function setAgentCommand(args, io) {
|
|
367
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
368
|
+
io.stdout(STEERING_HELP["set-agent"] ?? HELP);
|
|
369
|
+
return EXIT_OK;
|
|
370
|
+
}
|
|
371
|
+
const [issueArg, role, spec, ...extra] = args;
|
|
372
|
+
if (issueArg === undefined || role === undefined || spec === undefined || extra.length > 0) {
|
|
373
|
+
return usageError(io, "Usage: gdt set-agent <issue> <role> <agent>/<model>.", "gdt set-agent --help");
|
|
374
|
+
}
|
|
375
|
+
if (!/^[1-9]\d*$/.test(issueArg))
|
|
376
|
+
return usageError(io, `Expected an issue number, got "${issueArg}".`, "gdt set-agent --help");
|
|
377
|
+
return emit(setAgent(Number(issueArg), role, spec, io.cwd, io.env), io);
|
|
378
|
+
}
|
|
379
|
+
/** `gdt install-skill`. */
|
|
380
|
+
function installSkillCommand(args, io) {
|
|
381
|
+
for (const arg of args) {
|
|
382
|
+
if (arg === "--help" || arg === "-h") {
|
|
383
|
+
io.stdout(STEERING_HELP["install-skill"] ?? HELP);
|
|
384
|
+
return EXIT_OK;
|
|
385
|
+
}
|
|
386
|
+
return usageError(io, `Unexpected argument "${arg}" for "gdt install-skill".`, "gdt install-skill --help");
|
|
387
|
+
}
|
|
388
|
+
return emit(installSkill(io.env), io);
|
|
389
|
+
}
|
|
390
|
+
/** Internal entry points started by the terminal backend; not part of the public CLI. */
|
|
391
|
+
async function internal(command, args, io) {
|
|
392
|
+
const issue = Number(args[0]);
|
|
393
|
+
const root = findRepository(io.cwd) ?? io.cwd;
|
|
394
|
+
if (command === "_supervise")
|
|
395
|
+
return supervise(root, issue, io.env);
|
|
396
|
+
const role = args[1];
|
|
397
|
+
if (!ROLES.includes(role))
|
|
398
|
+
return EXIT_USAGE;
|
|
399
|
+
return work(root, issue, role, io.env);
|
|
400
|
+
}
|
|
401
|
+
export function run(argv, io) {
|
|
402
|
+
const [command, ...rest] = argv;
|
|
403
|
+
switch (command) {
|
|
404
|
+
case undefined:
|
|
405
|
+
case "--help":
|
|
406
|
+
case "-h":
|
|
407
|
+
case "help":
|
|
408
|
+
io.stdout(HELP);
|
|
409
|
+
return EXIT_OK;
|
|
410
|
+
case "--version":
|
|
411
|
+
io.stdout(`${version()}\n`);
|
|
412
|
+
return EXIT_OK;
|
|
413
|
+
case "doctor":
|
|
414
|
+
return doctor(rest, io);
|
|
415
|
+
case "check-issue":
|
|
416
|
+
return checkIssue(rest, io);
|
|
417
|
+
case "start":
|
|
418
|
+
case "status":
|
|
419
|
+
case "stop":
|
|
420
|
+
case "retry":
|
|
421
|
+
case "pause":
|
|
422
|
+
case "resume":
|
|
423
|
+
case "allow-round":
|
|
424
|
+
return workflowCommand(command, rest, io);
|
|
425
|
+
case "wait":
|
|
426
|
+
return waitCommand(rest, io);
|
|
427
|
+
case "answer":
|
|
428
|
+
return answerCommand(rest, io);
|
|
429
|
+
case "steer":
|
|
430
|
+
return steerCommand(rest, io);
|
|
431
|
+
case "set-agent":
|
|
432
|
+
return setAgentCommand(rest, io);
|
|
433
|
+
case "install-skill":
|
|
434
|
+
return installSkillCommand(rest, io);
|
|
435
|
+
default:
|
|
436
|
+
if (command.startsWith("-"))
|
|
437
|
+
return usageError(io, `Unknown option "${command}".`, "gdt --help");
|
|
438
|
+
return usageError(io, `Unknown command "${command}".`, "gdt --help");
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
function isEntryPoint() {
|
|
442
|
+
const script = process.argv[1];
|
|
443
|
+
if (script === undefined)
|
|
444
|
+
return false;
|
|
445
|
+
try {
|
|
446
|
+
return realpathSync(script) === realpathSync(fileURLToPath(import.meta.url));
|
|
447
|
+
}
|
|
448
|
+
catch {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (isEntryPoint()) {
|
|
453
|
+
const io = {
|
|
454
|
+
cwd: process.cwd(),
|
|
455
|
+
env: process.env,
|
|
456
|
+
stdout: (text) => process.stdout.write(text),
|
|
457
|
+
stderr: (text) => process.stderr.write(text),
|
|
458
|
+
};
|
|
459
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
460
|
+
if (command === "_supervise" || command === "_worker") {
|
|
461
|
+
process.exitCode = await internal(command, rest, io);
|
|
462
|
+
}
|
|
463
|
+
else {
|
|
464
|
+
process.exitCode = run(process.argv.slice(2), io);
|
|
465
|
+
}
|
|
466
|
+
}
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { parse as parseToml, TomlError } from "smol-toml";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
export const AGENTS = ["claude", "codex", "opencode", "mcode", "pi", "omp"];
|
|
6
|
+
export const ROLES = ["developer", "tester", "reviewer"];
|
|
7
|
+
export const TERMINALS = ["herdr", "headless"];
|
|
8
|
+
export const CONFIG_PATH = ".gdt/config.toml";
|
|
9
|
+
export const LOCAL_CONFIG_PATH = ".gdt/config.local.toml";
|
|
10
|
+
export const DEFAULT_LANGUAGE = "en";
|
|
11
|
+
export const DEFAULT_MAX_ACCEPTANCE_CRITERIA = 8;
|
|
12
|
+
/** The test agent: runs `script` instead of a coding agent. Only accepted when GDT_TEST_AGENTS=1. */
|
|
13
|
+
export const TEST_AGENT = "fake";
|
|
14
|
+
const roleSchema = z.strictObject({
|
|
15
|
+
agent: z.enum(AGENTS),
|
|
16
|
+
model: z.string().min(1),
|
|
17
|
+
});
|
|
18
|
+
const testRoleSchema = z
|
|
19
|
+
.strictObject({
|
|
20
|
+
agent: z.enum([...AGENTS, TEST_AGENT]),
|
|
21
|
+
model: z.string().min(1),
|
|
22
|
+
script: z.string().min(1).optional(),
|
|
23
|
+
})
|
|
24
|
+
.superRefine((role, ctx) => {
|
|
25
|
+
if (role.agent === TEST_AGENT && role.script === undefined) {
|
|
26
|
+
ctx.addIssue({ code: "custom", path: ["script"], message: `required for agent "${TEST_AGENT}"` });
|
|
27
|
+
}
|
|
28
|
+
if (role.agent !== TEST_AGENT && role.script !== undefined) {
|
|
29
|
+
ctx.addIssue({ code: "custom", path: ["script"], message: `only allowed for agent "${TEST_AGENT}"` });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
function configSchemaFor(testAgents) {
|
|
33
|
+
const role = testAgents ? testRoleSchema : roleSchema;
|
|
34
|
+
return z.strictObject({
|
|
35
|
+
language: z.string().min(1).default(DEFAULT_LANGUAGE),
|
|
36
|
+
roles: z.strictObject({ developer: role, tester: role, reviewer: role }),
|
|
37
|
+
workflow: z.strictObject({
|
|
38
|
+
max_correction_rounds: z.int().min(0).default(2),
|
|
39
|
+
// Deliberately without a default: an empty gate must be an explicit choice.
|
|
40
|
+
required_checks: z.array(z.string().min(1)),
|
|
41
|
+
allow_no_required_checks: z.boolean().default(false),
|
|
42
|
+
terminal: z.enum(TERMINALS).default("herdr"),
|
|
43
|
+
supervisor_pane: z.boolean().default(false),
|
|
44
|
+
poll_seconds: z.number().positive().default(30),
|
|
45
|
+
handoff_checks: z.int().min(1).default(5),
|
|
46
|
+
}),
|
|
47
|
+
contract: z
|
|
48
|
+
.strictObject({
|
|
49
|
+
max_acceptance_criteria: z.int().min(1).default(DEFAULT_MAX_ACCEPTANCE_CRITERIA),
|
|
50
|
+
extra_rules: z.string().min(1).optional(),
|
|
51
|
+
})
|
|
52
|
+
.prefault({}),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
export const configSchema = configSchemaFor(false);
|
|
56
|
+
export function testAgentsEnabled(env) {
|
|
57
|
+
return env.GDT_TEST_AGENTS === "1";
|
|
58
|
+
}
|
|
59
|
+
function isTable(value) {
|
|
60
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
61
|
+
}
|
|
62
|
+
function merge(base, over) {
|
|
63
|
+
const out = { ...base };
|
|
64
|
+
for (const [key, value] of Object.entries(over)) {
|
|
65
|
+
const current = out[key];
|
|
66
|
+
out[key] = isTable(current) && isTable(value) ? merge(current, value) : value;
|
|
67
|
+
}
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
function valueAt(data, path) {
|
|
71
|
+
let current = data;
|
|
72
|
+
for (const key of path) {
|
|
73
|
+
if (!isTable(current) && !Array.isArray(current))
|
|
74
|
+
return undefined;
|
|
75
|
+
current = current[key];
|
|
76
|
+
}
|
|
77
|
+
return current;
|
|
78
|
+
}
|
|
79
|
+
function formatPath(path) {
|
|
80
|
+
return path.map(String).join(".");
|
|
81
|
+
}
|
|
82
|
+
/** The layer that supplied the value at `path`, preferring the local override. */
|
|
83
|
+
function sourceOf(layers, path) {
|
|
84
|
+
for (let i = layers.length - 1; i >= 0; i--) {
|
|
85
|
+
const layer = layers[i];
|
|
86
|
+
if (layer && valueAt(layer.data, path) !== undefined)
|
|
87
|
+
return layer.path;
|
|
88
|
+
}
|
|
89
|
+
return layers[0]?.path ?? CONFIG_PATH;
|
|
90
|
+
}
|
|
91
|
+
function describe(value) {
|
|
92
|
+
return value === undefined ? "missing" : JSON.stringify(value);
|
|
93
|
+
}
|
|
94
|
+
function issueFindings(issue, merged, layers) {
|
|
95
|
+
const key = formatPath(issue.path);
|
|
96
|
+
const input = valueAt(merged, issue.path);
|
|
97
|
+
const file = sourceOf(layers, issue.path);
|
|
98
|
+
const error = (message, fix) => ({ check: "config", level: "error", message, fix });
|
|
99
|
+
switch (issue.code) {
|
|
100
|
+
case "invalid_value": {
|
|
101
|
+
const allowed = issue.values.map(String).join(", ");
|
|
102
|
+
return [error(`${key}: ${describe(input)} is not one of ${allowed}`, `Set ${key} in ${file} to one of ${allowed}`)];
|
|
103
|
+
}
|
|
104
|
+
case "invalid_type":
|
|
105
|
+
if (input === undefined) {
|
|
106
|
+
return [error(`${key}: missing; expected ${issue.expected}`, `Add ${key} to ${file}`)];
|
|
107
|
+
}
|
|
108
|
+
return [error(`${key}: expected ${issue.expected}, got ${describe(input)}`, `Correct ${key} in ${file}`)];
|
|
109
|
+
case "unrecognized_keys":
|
|
110
|
+
return issue.keys.map((unknownKey) => {
|
|
111
|
+
const fullKey = formatPath([...issue.path, unknownKey]);
|
|
112
|
+
return error(`${fullKey}: unknown key`, `Remove or rename ${fullKey} in ${sourceOf(layers, [...issue.path, unknownKey])}`);
|
|
113
|
+
});
|
|
114
|
+
default:
|
|
115
|
+
return [error(`${key}: ${issue.message}`, `Correct ${key} in ${file}`)];
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function readLayer(root, path) {
|
|
119
|
+
const text = readFileSync(join(root, path), "utf8");
|
|
120
|
+
try {
|
|
121
|
+
return { path, data: parseToml(text) };
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
const where = err instanceof TomlError ? ` at line ${err.line}, column ${err.column}` : "";
|
|
125
|
+
const reason = err instanceof Error ? err.message.split("\n")[0] : String(err);
|
|
126
|
+
return {
|
|
127
|
+
check: "config",
|
|
128
|
+
level: "error",
|
|
129
|
+
message: `${path}: invalid TOML${where}: ${reason}`,
|
|
130
|
+
fix: `Fix the TOML syntax in ${path}`,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Loads, merges and validates `.gdt/config.toml` and `.gdt/config.local.toml` in `root`. */
|
|
135
|
+
export function loadConfig(root, env = {}) {
|
|
136
|
+
const files = [CONFIG_PATH, LOCAL_CONFIG_PATH].filter((path) => existsSync(join(root, path)));
|
|
137
|
+
if (!files.includes(CONFIG_PATH)) {
|
|
138
|
+
return {
|
|
139
|
+
report: { valid: false, files },
|
|
140
|
+
findings: [
|
|
141
|
+
{
|
|
142
|
+
check: "config",
|
|
143
|
+
level: "error",
|
|
144
|
+
message: `${CONFIG_PATH}: not found`,
|
|
145
|
+
fix: `Create ${CONFIG_PATH}; example: https://github.com/gevezex/gdt/blob/main/docs/design.md#4-configuration-per-target-repository`,
|
|
146
|
+
},
|
|
147
|
+
],
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const layers = [];
|
|
151
|
+
const findings = [];
|
|
152
|
+
for (const path of files) {
|
|
153
|
+
const layer = readLayer(root, path);
|
|
154
|
+
if ("data" in layer)
|
|
155
|
+
layers.push(layer);
|
|
156
|
+
else
|
|
157
|
+
findings.push(layer);
|
|
158
|
+
}
|
|
159
|
+
if (findings.length > 0)
|
|
160
|
+
return { report: { valid: false, files }, findings };
|
|
161
|
+
const merged = layers.reduce((acc, layer) => merge(acc, layer.data), {});
|
|
162
|
+
const parsed = configSchemaFor(testAgentsEnabled(env)).safeParse(merged);
|
|
163
|
+
if (!parsed.success) {
|
|
164
|
+
return {
|
|
165
|
+
report: { valid: false, files },
|
|
166
|
+
findings: parsed.error.issues.flatMap((issue) => issueFindings(issue, merged, layers)),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
const config = parsed.data;
|
|
170
|
+
const roles = Object.fromEntries(ROLES.map((role) => {
|
|
171
|
+
const localRole = layers.length > 1 ? valueAt(layers[layers.length - 1]?.data, ["roles", role]) : undefined;
|
|
172
|
+
const source = isTable(localRole) && Object.keys(localRole).length > 0 ? LOCAL_CONFIG_PATH : CONFIG_PATH;
|
|
173
|
+
return [role, { ...config.roles[role], source }];
|
|
174
|
+
}));
|
|
175
|
+
findings.push({
|
|
176
|
+
check: "config",
|
|
177
|
+
level: "ok",
|
|
178
|
+
message: files.length > 1 ? `${CONFIG_PATH} is valid, with overrides from ${LOCAL_CONFIG_PATH}` : `${CONFIG_PATH} is valid`,
|
|
179
|
+
fix: "",
|
|
180
|
+
});
|
|
181
|
+
findings.push(...semanticFindings(root, config));
|
|
182
|
+
return { report: { valid: true, files, ...config, roles }, findings };
|
|
183
|
+
}
|
|
184
|
+
/** Checks that pass the schema but would make gdt unsafe or surprising. */
|
|
185
|
+
function semanticFindings(root, config) {
|
|
186
|
+
const findings = [];
|
|
187
|
+
if (config.workflow.required_checks.length === 0) {
|
|
188
|
+
findings.push({
|
|
189
|
+
check: "workflow.required_checks",
|
|
190
|
+
level: config.workflow.allow_no_required_checks ? "warning" : "error",
|
|
191
|
+
message: "workflow.required_checks is empty; set workflow.allow_no_required_checks = true to accept this",
|
|
192
|
+
fix: config.workflow.allow_no_required_checks
|
|
193
|
+
? "Add the names of the CI checks that must pass to workflow.required_checks"
|
|
194
|
+
: "Add the names of the CI checks that must pass to workflow.required_checks, or set workflow.allow_no_required_checks = true",
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const rules = config.contract.extra_rules;
|
|
198
|
+
if (rules !== undefined) {
|
|
199
|
+
// A configured file that does not exist is almost always a typo.
|
|
200
|
+
findings.push(existsSync(join(root, rules))
|
|
201
|
+
? { check: "contract.extra_rules", level: "ok", message: `contract.extra_rules: ${rules} found`, fix: "" }
|
|
202
|
+
: {
|
|
203
|
+
check: "contract.extra_rules",
|
|
204
|
+
level: "error",
|
|
205
|
+
message: `contract.extra_rules: ${rules} not found`,
|
|
206
|
+
fix: `Create ${rules} or correct or remove contract.extra_rules`,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
return findings;
|
|
210
|
+
}
|