@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/dist/init.js ADDED
@@ -0,0 +1,307 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { adapterFor, supportedAgents } from "./agents/index.js";
4
+ import { CONFIG_PATH, DEFAULT_LANGUAGE, ROLES, userConfigPath } from "./config.js";
5
+ import { herdrPreflight, which } from "./doctor.js";
6
+ import { detectedChecks } from "./github.js";
7
+ import { ROLE_RULES_DIR, roleRulesPath } from "./prompts.js";
8
+ /** The proposal: one entry per supported agent, the usable terminal, detected checks and language. */
9
+ export function proposal(root, env) {
10
+ const agents = supportedAgents().flatMap((agent) => {
11
+ const adapter = adapterFor(agent);
12
+ if (adapter === undefined)
13
+ return [];
14
+ return [
15
+ {
16
+ agent,
17
+ found: which(adapter.binary, env) !== null,
18
+ model_format: adapter.modelFormat,
19
+ example: adapter.modelExample,
20
+ },
21
+ ];
22
+ });
23
+ return {
24
+ agents,
25
+ terminal: herdrPreflight(env) === null ? "herdr" : "headless",
26
+ required_checks: detectedChecks(root, env),
27
+ language: DEFAULT_LANGUAGE,
28
+ };
29
+ }
30
+ /** Parses `<agent>/<model>` at the first `/`, the same way `gdt set-agent` does. */
31
+ export function parseRoleSpec(spec) {
32
+ const slash = spec.indexOf("/");
33
+ const agent = slash === -1 ? spec : spec.slice(0, slash);
34
+ const model = slash === -1 ? "" : spec.slice(slash + 1);
35
+ const supported = supportedAgents();
36
+ if (!supported.includes(agent)) {
37
+ return { error: `Unsupported agent "${agent}"; supported agents: ${supported.join(", ")}` };
38
+ }
39
+ if (model.trim() === "")
40
+ return { error: `Missing model in "${spec}"; use <agent>/<model>` };
41
+ return { agent: agent, model };
42
+ }
43
+ /** The `.gdt/config.toml` text without roles; a key that keeps its schema default is not written. */
44
+ export function serializeConfig(config) {
45
+ const lines = [];
46
+ if (config.language !== DEFAULT_LANGUAGE)
47
+ lines.push(`language = ${JSON.stringify(config.language)}`, "");
48
+ lines.push("[workflow]", `required_checks = [${config.requiredChecks.map((name) => JSON.stringify(name)).join(", ")}]`);
49
+ if (config.allowNoRequiredChecks)
50
+ lines.push("allow_no_required_checks = true");
51
+ if (config.terminal !== "herdr")
52
+ lines.push(`terminal = ${JSON.stringify(config.terminal)}`);
53
+ lines.push("");
54
+ return lines.join("\n");
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
+ }
275
+ /** Writes the config, creating `.gdt/` when needed; returns an error message, or null on success. */
276
+ export function writeConfig(root, text) {
277
+ try {
278
+ const path = join(root, CONFIG_PATH);
279
+ mkdirSync(dirname(path), { recursive: true });
280
+ writeFileSync(path, text);
281
+ return null;
282
+ }
283
+ catch (err) {
284
+ return `Cannot write ${CONFIG_PATH}: ${err instanceof Error ? err.message : String(err)}`;
285
+ }
286
+ }
287
+ /**
288
+ * AC-4: creates each missing `.gdt/roles/<role>.md` as an empty file and returns its
289
+ * repository-relative path. An existing file is never touched, not even with `--force`.
290
+ */
291
+ export function createRoleRulesFiles(root) {
292
+ const created = [];
293
+ for (const role of ROLES) {
294
+ const path = roleRulesPath(root, role);
295
+ mkdirSync(dirname(path), { recursive: true });
296
+ try {
297
+ writeFileSync(path, "", { flag: "wx" });
298
+ created.push(`${ROLE_RULES_DIR}/${role}.md`);
299
+ }
300
+ catch (err) {
301
+ // Exists already: leave it byte-for-byte unchanged.
302
+ if (err.code !== "EEXIST")
303
+ throw err;
304
+ }
305
+ }
306
+ return created;
307
+ }
package/dist/prompts.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { resolve } from "node:path";
2
+ import { join, resolve } from "node:path";
3
3
  import { z } from "zod";
4
4
  import { loadLocale, SECTION_KEYS } from "./locale.js";
5
5
  import { marker, schemas } from "./protocol.js";
@@ -7,9 +7,26 @@ export const ROLE_FILES = ["developer", "tester", "reviewer", "issue-writer"];
7
7
  /** The record kind each workflow role writes. */
8
8
  export const RECORD_OF = { developer: "handoff", tester: "test", reviewer: "review" };
9
9
  const ROLES_DIR = new URL("../roles/", import.meta.url);
10
+ /** The directory, relative to a target repository's root, holding the per-role supplementary rules. */
11
+ export const ROLE_RULES_DIR = ".gdt/roles";
10
12
  export function roleFile(name) {
11
13
  return readFileSync(new URL(`${name}.md`, ROLES_DIR), "utf8");
12
14
  }
15
+ /** The absolute path of a workflow role's supplementary rules file in `root`. */
16
+ export function roleRulesPath(root, role) {
17
+ return join(root, ROLE_RULES_DIR, `${role}.md`);
18
+ }
19
+ /**
20
+ * The role's supplementary rules, trimmed, or `null` when the file is missing or empty (whitespace
21
+ * only). Read on every call, so a change reaches the role's next turn without a restart (AC-3).
22
+ */
23
+ export function roleRules(root, role) {
24
+ const path = roleRulesPath(root, role);
25
+ if (!existsSync(path))
26
+ return null;
27
+ const text = readFileSync(path, "utf8").trimEnd();
28
+ return text.trim() === "" ? null : text;
29
+ }
13
30
  /**
14
31
  * Trusted directives for `role` posted after that role's previous dispatch, i.e. with a comment id
15
32
  * above `afterCommentId` (the highest comment id seen at that dispatch; 0 before the first one).
@@ -76,5 +93,9 @@ export function buildPrompt(role, dispatch, project) {
76
93
  if (existsSync(path))
77
94
  parts.push(["## Project rules", "", readFileSync(path, "utf8").trimEnd()].join("\n"));
78
95
  }
96
+ // AC-1/AC-2: a role's own supplementary rules, after the global project rules; empty adds nothing.
97
+ const rules = roleRules(project.root, role);
98
+ if (rules !== null)
99
+ parts.push(["## Role rules", "", rules].join("\n"));
79
100
  return `${parts.join("\n\n")}\n`;
80
101
  }
package/dist/steering.js CHANGED
@@ -3,7 +3,8 @@ import { homedir } from "node:os";
3
3
  import { dirname, join } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
5
  import { adapterFor, supportedAgents } from "./agents/index.js";
6
- import { ROLES } from "./config.js";
6
+ import { backendFor } from "./backends/index.js";
7
+ import { loadConfig, ROLES } from "./config.js";
7
8
  import { findRepository, which } from "./doctor.js";
8
9
  import { comments, issueSnapshot, postIssueComment, postPullRequestComment, repository } from "./github.js";
9
10
  import { formatRecord, parseRecords } from "./protocol.js";
@@ -129,6 +130,17 @@ export function setAgent(issue, role, spec, cwd, env) {
129
130
  if (readState(p) === null)
130
131
  return fail(`No workflow for #${issue}. Next: gdt start ${issue}\n`);
131
132
  writeJsonAtomic(p.overrides, { ...readOverrides(p), [checked]: { agent: agent, model } });
133
+ // AC-1: the agents overview must name the role's new agent. Best effort: the pane may not exist
134
+ // yet, the terminal may be headless, or herdr may be missing; the override still applies.
135
+ try {
136
+ const { report } = loadConfig(p.root, env);
137
+ if (report.valid && report.workflow.terminal === "herdr") {
138
+ backendFor(report, p.root, issue, env, p).setDisplayAgent(checked, `${checked} · ${agent}`);
139
+ }
140
+ }
141
+ catch {
142
+ // The override is written; the label is refreshed on the next `gdt start`.
143
+ }
132
144
  return ok(`${checked} for #${issue} now uses ${agent} with model ${model} from its next turn.\n`);
133
145
  }
134
146
  /** `gdt install-skill`: copies `skill/SKILL.md` into every detected harness; idempotent. */
@@ -107,6 +107,18 @@ class Supervisor {
107
107
  this.backend.setTitle(role, this.roleTitle(role, state));
108
108
  this.reportState(role, ROLE_PANE_STATE[state]);
109
109
  }
110
+ /** AC-1: sets the display-only agent label, for example `developer · opencode` or `gdt · supervisor`. */
111
+ setDisplay(name, label) {
112
+ try {
113
+ this.backend.setDisplayAgent(name, label);
114
+ }
115
+ catch (err) {
116
+ if (this.reportWarned)
117
+ return;
118
+ this.reportWarned = true;
119
+ log(`warning: herdr display label failed: ${err instanceof Error ? err.message : String(err)}`);
120
+ }
121
+ }
110
122
  /** Records a status; notifies once when entering a notifying status. */
111
123
  setStatus(status, reason, extra = {}) {
112
124
  if (this.state.status !== status || this.state.reason !== reason)
@@ -128,10 +140,13 @@ class Supervisor {
128
140
  }
129
141
  startWorkers() {
130
142
  this.backend.ensureWorkspace();
143
+ // AC-1: the agents overview shows the role next to the agent name, once per `gdt start`.
144
+ this.setDisplay("supervisor", "gdt · supervisor");
131
145
  this.backend.setTitle("supervisor", "supervisor · starting");
132
146
  this.reportState("supervisor", supervisorAgentState(this.state.status));
133
147
  for (const role of ROLES) {
134
148
  this.setRoleState(role, "WAITING");
149
+ this.setDisplay(role, `${role} · ${this.agents[role]}`);
135
150
  if (this.backend.alive(this.state.pids.workers[role] ?? -1))
136
151
  continue;
137
152
  this.state.pids.workers[role] = this.backend.spawnPane(role, [process.execPath, cliPath(), "_worker", String(this.issue), role]);
package/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.2.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
@@ -3,6 +3,20 @@
3
3
  You are the operator: the user talks to you in their own language, and you run
4
4
  gdt for them. They should never have to memorise a gdt command.
5
5
 
6
+ ## First-time setup
7
+
8
+ - With no `.gdt/config.toml`, run `gdt init --json`. It reports the supported
9
+ agents, which are on PATH, the terminal and the detected CI checks.
10
+ - Present that proposal to the user and ask which agent and model each role
11
+ (developer, tester, reviewer) uses. Do not pick models for them.
12
+ - Run `gdt init` with their choices, for example
13
+ `gdt init --developer opencode/deepseek/deepseek-v4-flash --tester claude/claude-sonnet-5 --reviewer codex/gpt-5.6-luna`.
14
+ It writes the 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`.
19
+
6
20
  ## Start
7
21
 
8
22
  - `gdt start <issue>` returns immediately; the supervisor keeps running on its own.