@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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +375 -0
  3. package/dist/agents/adapter.js +7 -0
  4. package/dist/agents/claude.js +13 -0
  5. package/dist/agents/codex.js +13 -0
  6. package/dist/agents/index.js +15 -0
  7. package/dist/agents/mcode.js +14 -0
  8. package/dist/agents/omp.js +14 -0
  9. package/dist/agents/opencode.js +15 -0
  10. package/dist/agents/pi.js +14 -0
  11. package/dist/backends/backend.js +1 -0
  12. package/dist/backends/headless.js +55 -0
  13. package/dist/backends/herdr.js +286 -0
  14. package/dist/backends/index.js +20 -0
  15. package/dist/cli.js +466 -0
  16. package/dist/config.js +210 -0
  17. package/dist/contract.js +133 -0
  18. package/dist/decision.js +175 -0
  19. package/dist/doctor.js +231 -0
  20. package/dist/finding.js +3 -0
  21. package/dist/git.js +25 -0
  22. package/dist/github.js +95 -0
  23. package/dist/locale.js +44 -0
  24. package/dist/notify.js +29 -0
  25. package/dist/prompts.js +80 -0
  26. package/dist/protocol.js +114 -0
  27. package/dist/state.js +113 -0
  28. package/dist/steering.js +165 -0
  29. package/dist/supervisor.js +369 -0
  30. package/dist/worker.js +232 -0
  31. package/dist/workflow.js +333 -0
  32. package/locales/en.toml +28 -0
  33. package/locales/nl.toml +28 -0
  34. package/package.json +61 -0
  35. package/roles/developer.md +66 -0
  36. package/roles/issue-writer.md +40 -0
  37. package/roles/reviewer.md +65 -0
  38. package/roles/tester.md +58 -0
  39. package/schemas/gdt-answer.v1.schema.json +32 -0
  40. package/schemas/gdt-directive.v1.schema.json +36 -0
  41. package/schemas/gdt-handoff.v1.schema.json +117 -0
  42. package/schemas/gdt-question.v1.schema.json +82 -0
  43. package/schemas/gdt-review.v1.schema.json +131 -0
  44. package/schemas/gdt-round.v1.schema.json +28 -0
  45. package/schemas/gdt-test.v1.schema.json +131 -0
  46. package/skill/SKILL.md +46 -0
@@ -0,0 +1,286 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { existsSync, mkdirSync, openSync, readFileSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { ROLES } from "../config.js";
5
+ import { which } from "../doctor.js";
6
+ import { alive, writeJsonAtomic } from "../state.js";
7
+ /** The pane names this backend manages, in creation order (AC-2: without a supervisor pane, only roles). */
8
+ function paneNames(opts) {
9
+ return opts.supervisorPane ? ["supervisor", ...ROLES] : [...ROLES];
10
+ }
11
+ function isRecord(value) {
12
+ return typeof value === "object" && value !== null;
13
+ }
14
+ function sleepSync(ms) {
15
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
16
+ }
17
+ function shellQuote(text) {
18
+ return `'${text.replace(/'/g, `'\\''`)}'`;
19
+ }
20
+ function bin(env) {
21
+ const path = which("herdr", env);
22
+ if (path === null)
23
+ throw new Error("herdr: not found on PATH");
24
+ return path;
25
+ }
26
+ /** Runs one herdr command and returns its raw stdout. Throws on a non-zero exit. */
27
+ function call(args, opts) {
28
+ const result = spawnSync(bin(opts.env), args, { cwd: opts.root, env: opts.env, encoding: "utf8" });
29
+ if (result.status !== 0) {
30
+ const reason = (result.stderr ?? "").trim() || (result.stdout ?? "").trim() || `herdr exited with ${result.status ?? result.signal}`;
31
+ throw new Error(`herdr ${args.slice(0, 2).join(" ")}: ${reason}`);
32
+ }
33
+ return result.stdout ?? "";
34
+ }
35
+ /** Runs `pane run`, which prints nothing on success. */
36
+ function callVoid(args, opts) {
37
+ call(args, opts);
38
+ }
39
+ /** Runs `pane report-agent`, which prints nothing on success. Throws on a non-zero exit. */
40
+ function reportAgent(opts, paneId, label, state) {
41
+ const args = ["pane", "report-agent", "--source", "gdt", "--agent", label, "--state", state, paneId];
42
+ const result = spawnSync(bin(opts.env), args, { cwd: opts.root, env: opts.env, encoding: "utf8" });
43
+ if (result.status !== 0) {
44
+ const reason = (result.stderr ?? "").trim() || (result.stdout ?? "").trim() || `herdr exited with ${result.status ?? result.signal}`;
45
+ throw new Error(`herdr pane report-agent: ${reason}`);
46
+ }
47
+ }
48
+ /** Parses the `{ "result": ... }` envelope; a herdr `error` becomes a thrown Error. */
49
+ function resultOf(out, command) {
50
+ let data;
51
+ try {
52
+ data = JSON.parse(out);
53
+ }
54
+ catch {
55
+ throw new Error(`herdr ${command}: unexpected output`);
56
+ }
57
+ if (!isRecord(data))
58
+ throw new Error(`herdr ${command}: unexpected output`);
59
+ if (isRecord(data.error))
60
+ throw new Error(`herdr: ${String(data.error.message ?? data.error.code ?? "unknown error")}`);
61
+ if (!isRecord(data.result))
62
+ throw new Error(`herdr ${command}: no result`);
63
+ return data.result;
64
+ }
65
+ function workspaceList(opts) {
66
+ const result = resultOf(call(["workspace", "list"], opts), "workspace list");
67
+ const list = result.workspaces;
68
+ if (!Array.isArray(list))
69
+ return [];
70
+ return list.filter((w) => isRecord(w) && typeof w.workspace_id === "string");
71
+ }
72
+ function paneIds(opts, workspaceId) {
73
+ const result = resultOf(call(["pane", "list", "--workspace", workspaceId], opts), "pane list");
74
+ const list = result.panes;
75
+ if (!Array.isArray(list))
76
+ return [];
77
+ return list.flatMap((p) => (isRecord(p) && typeof p.pane_id === "string" ? [p.pane_id] : []));
78
+ }
79
+ function createWorkspace(opts) {
80
+ const label = `gdt-${opts.issue}`;
81
+ const result = resultOf(call(["workspace", "create", "--cwd", opts.root, "--label", label, "--no-focus"], opts), "workspace create");
82
+ const workspace = result.workspace;
83
+ const rootPane = result.root_pane;
84
+ if (!isRecord(workspace) || typeof workspace.workspace_id !== "string" || !isRecord(rootPane) || typeof rootPane.pane_id !== "string") {
85
+ throw new Error("herdr workspace create did not return a workspace and root pane");
86
+ }
87
+ return { workspace_id: workspace.workspace_id, root_pane: rootPane.pane_id };
88
+ }
89
+ /** Splits `anchor` to the right; `ratio` is the share of the width that `anchor` keeps. */
90
+ function splitPane(opts, anchor, ratio) {
91
+ const result = resultOf(call(["pane", "split", anchor, "--direction", "right", "--ratio", ratio.toFixed(4), "--no-focus"], opts), "pane split");
92
+ const pane = result.pane;
93
+ if (!isRecord(pane) || typeof pane.pane_id !== "string")
94
+ throw new Error("herdr pane split did not return a pane id");
95
+ return pane.pane_id;
96
+ }
97
+ function readPanes(file) {
98
+ if (!existsSync(file))
99
+ return null;
100
+ try {
101
+ return JSON.parse(readFileSync(file, "utf8"));
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ function writePanes(file, state) {
108
+ writeJsonAtomic(file, state);
109
+ }
110
+ function roleTitle(role, agent, state) {
111
+ return role === "supervisor" ? `supervisor · ${state}` : `${role} · ${agent} · ${state}`;
112
+ }
113
+ /**
114
+ * One herdr workspace per issue, with four panes for supervisor, developer, tester and reviewer.
115
+ * Panes are reused across `start` calls; their ids live in `panes.json` so a restarted process can
116
+ * find them. Verified against herdr 0.9.1 in the default session.
117
+ */
118
+ export function herdr(opts) {
119
+ let warnedMissing = false;
120
+ const findPane = (name) => {
121
+ const state = readPanes(opts.panesFile);
122
+ const id = state?.panes[name]?.pane_id;
123
+ return id === undefined || id === "" ? null : id;
124
+ };
125
+ const rename = (name, title) => {
126
+ if (warnedMissing)
127
+ return;
128
+ const paneId = findPane(name);
129
+ if (paneId === null) {
130
+ warnedMissing = true;
131
+ return;
132
+ }
133
+ call(["pane", "rename", paneId, title], opts);
134
+ };
135
+ return {
136
+ ensureWorkspace() {
137
+ mkdirSync(opts.pidDir, { recursive: true });
138
+ const known = readPanes(opts.panesFile);
139
+ const list = workspaceList(opts);
140
+ let workspaceId = known !== null && list.some((w) => w.workspace_id === known.workspace_id) ? known.workspace_id : undefined;
141
+ if (workspaceId === undefined) {
142
+ const existing = list.find((w) => w.label === `gdt-${opts.issue}`);
143
+ if (existing !== undefined)
144
+ workspaceId = existing.workspace_id;
145
+ else
146
+ workspaceId = createWorkspace(opts).workspace_id;
147
+ }
148
+ const live = new Set(paneIds(opts, workspaceId));
149
+ // AC-4: a supervisor pane from an earlier run (while `supervisor_pane` was true) is closed; its
150
+ // entry never enters the new panes.json, so no role can adopt the pane.
151
+ if (!opts.supervisorPane && known?.workspace_id === workspaceId) {
152
+ const leftover = known.panes.supervisor;
153
+ if (leftover !== undefined && live.has(leftover.pane_id)) {
154
+ call(["pane", "close", leftover.pane_id], opts);
155
+ live.delete(leftover.pane_id);
156
+ }
157
+ }
158
+ const names = paneNames(opts);
159
+ const mapping = {};
160
+ for (const name of names) {
161
+ const record = known?.workspace_id === workspaceId ? known.panes[name] : undefined;
162
+ if (record !== undefined && live.has(record.pane_id))
163
+ mapping[name] = record;
164
+ }
165
+ // Adopt panes that a previous run left behind (for example after its panes.json was removed).
166
+ const claimed = new Set(Object.values(mapping).flatMap((p) => (p === undefined ? [] : [p.pane_id])));
167
+ const free = [...live].filter((id) => !claimed.has(id));
168
+ for (const name of names) {
169
+ if (mapping[name] === undefined) {
170
+ const reuse = free.shift();
171
+ if (reuse !== undefined)
172
+ mapping[name] = { pane_id: reuse };
173
+ }
174
+ }
175
+ // Each new pane is split off the right of the previous one, so panes run left to right in
176
+ // `names` order. The anchor keeps 1/(panes still to fill), which makes a new workspace's panes
177
+ // equally wide.
178
+ const first = Object.values(mapping).flatMap((p) => (p === undefined ? [] : [p.pane_id])).find((id) => id !== "");
179
+ let previous;
180
+ names.forEach((name, index) => {
181
+ const record = mapping[name];
182
+ if (record !== undefined) {
183
+ previous = record.pane_id;
184
+ return;
185
+ }
186
+ const anchor = previous ?? first;
187
+ if (anchor === undefined)
188
+ throw new Error(`herdr workspace ${workspaceId} has no pane to split`);
189
+ previous = splitPane(opts, anchor, 1 / (names.length - index + 1));
190
+ mapping[name] = { pane_id: previous };
191
+ });
192
+ writePanes(opts.panesFile, { workspace_id: workspaceId, panes: mapping });
193
+ if (opts.supervisorPane)
194
+ rename("supervisor", roleTitle("supervisor", "", "starting"));
195
+ for (const role of ROLES)
196
+ rename(role, roleTitle(role, opts.agents[role], "WAITING"));
197
+ },
198
+ spawnPane(name, argv) {
199
+ // AC-3: without a supervisor pane the supervisor is a detached process whose output is its log.
200
+ if (name === "supervisor" && !opts.supervisorPane)
201
+ return spawnDetached(opts, "supervisor", argv);
202
+ const state = readPanes(opts.panesFile);
203
+ const paneId = state?.panes[name]?.pane_id;
204
+ if (state === null || paneId === undefined || paneId === "")
205
+ throw new Error(`herdr: no pane named ${name}`);
206
+ if (argv.length === 0)
207
+ throw new Error("spawnPane: empty argv");
208
+ mkdirSync(opts.pidDir, { recursive: true });
209
+ const pidFile = join(opts.pidDir, name);
210
+ rmSync(pidFile, { force: true });
211
+ // The wrapper shell writes its pid and then becomes the gdt process, so the pid stays valid
212
+ // while the pane's own shell remains for the next command.
213
+ const inner = `echo $$ > ${shellQuote(pidFile)}; exec ${argv.map(shellQuote).join(" ")}`;
214
+ callVoid(["pane", "run", paneId, `sh -c ${shellQuote(inner)}`], opts);
215
+ const pid = waitForPid(pidFile);
216
+ state.panes[name] = { pane_id: paneId, pid };
217
+ writePanes(opts.panesFile, state);
218
+ return pid;
219
+ },
220
+ setTitle(name, title) {
221
+ // AC-6: without a supervisor pane there is no title to set for the supervisor.
222
+ if (name === "supervisor" && !opts.supervisorPane)
223
+ return;
224
+ rename(name, title);
225
+ },
226
+ reportState(name, state) {
227
+ // AC-6: without a supervisor pane no supervisor state is reported to herdr.
228
+ if (name === "supervisor" && !opts.supervisorPane)
229
+ return;
230
+ const paneId = findPane(name);
231
+ if (paneId === null)
232
+ throw new Error(`herdr: no pane named ${name}`);
233
+ // AC-4/AC-5: the role's agent is the label, the supervisor reports as `gdt`.
234
+ const label = name === "supervisor" ? "gdt" : opts.agents[name];
235
+ reportAgent(opts, paneId, label, state);
236
+ },
237
+ alive: (handle) => alive(handle),
238
+ attach: () => {
239
+ const state = readPanes(opts.panesFile);
240
+ // `workspace focus` only switches the server's focused workspace; plain `herdr` attaches the terminal.
241
+ return state === null ? "herdr" : `herdr workspace focus ${state.workspace_id} && herdr (workspace gdt-${opts.issue})`;
242
+ },
243
+ close(handle) {
244
+ try {
245
+ process.kill(handle, "SIGTERM");
246
+ }
247
+ catch {
248
+ // Already gone.
249
+ }
250
+ for (let waited = 0; waited < 5000 && alive(handle); waited += 50)
251
+ sleepSync(50);
252
+ if (alive(handle)) {
253
+ try {
254
+ process.kill(handle, "SIGKILL");
255
+ }
256
+ catch {
257
+ // Already gone.
258
+ }
259
+ }
260
+ },
261
+ };
262
+ }
263
+ /** Starts `argv` detached with stdout and stderr in `logs/<name>.log`, like the headless backend. */
264
+ function spawnDetached(opts, name, argv) {
265
+ const [command, ...args] = argv;
266
+ if (command === undefined)
267
+ throw new Error("spawnPane: empty argv");
268
+ mkdirSync(opts.logs, { recursive: true });
269
+ const out = openSync(join(opts.logs, `${name}.log`), "a");
270
+ const child = spawn(command, args, { cwd: opts.root, env: opts.env, detached: true, stdio: ["ignore", out, out] });
271
+ child.unref();
272
+ if (child.pid === undefined)
273
+ throw new Error(`could not start ${name}`);
274
+ return child.pid;
275
+ }
276
+ function waitForPid(file) {
277
+ for (let waited = 0; waited < 5000; waited += 50) {
278
+ if (existsSync(file)) {
279
+ const pid = Number(readFileSync(file, "utf8").trim());
280
+ if (Number.isInteger(pid) && pid > 0)
281
+ return pid;
282
+ }
283
+ sleepSync(50);
284
+ }
285
+ throw new Error(`herdr pane did not report its process id (${file})`);
286
+ }
@@ -0,0 +1,20 @@
1
+ import { ROLES } from "../config.js";
2
+ import { headless } from "./headless.js";
3
+ import { herdr } from "./herdr.js";
4
+ /** The backend selected by `workflow.terminal` for issue `issue` in `p.root`. */
5
+ export function backendFor(config, root, issue, env, p) {
6
+ if (config.workflow.terminal === "herdr") {
7
+ const agents = Object.fromEntries(ROLES.map((role) => [role, config.roles[role].agent]));
8
+ return herdr({
9
+ root,
10
+ issue,
11
+ env,
12
+ panesFile: p.panes,
13
+ pidDir: p.pids,
14
+ logs: p.logs,
15
+ agents,
16
+ supervisorPane: config.workflow.supervisor_pane,
17
+ });
18
+ }
19
+ return headless(p.logs, root, env);
20
+ }