@gevezex/gdt 0.5.2 → 0.5.3
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 +3 -1
- package/dist/git.js +29 -0
- package/dist/github.js +20 -2
- package/dist/prompts.js +1 -0
- package/dist/supervisor.js +29 -2
- package/dist/worker.js +8 -6
- package/dist/workflow.js +3 -3
- package/package.json +1 -1
- package/roles/reviewer.md +2 -2
- package/roles/tester.md +2 -2
package/README.md
CHANGED
|
@@ -486,7 +486,9 @@ to be told: it blocks until the workflow needs attention.
|
|
|
486
486
|
|
|
487
487
|
The roles work in the checkout where you run `gdt start`: the developer checks
|
|
488
488
|
out the feature branch there, and the tester and reviewer read that same working
|
|
489
|
-
tree.
|
|
489
|
+
tree. When the pull request head is checked out in another worktree (for example
|
|
490
|
+
one the developer created) and not in that checkout, the tester and reviewer
|
|
491
|
+
turns run in that worktree instead. `gdt start` needs a **clean working tree** and refuses to run otherwise
|
|
490
492
|
("Commit or stash before starting."), so commit or stash first. Do not edit that
|
|
491
493
|
checkout while a workflow runs; if you want to keep working, use a separate
|
|
492
494
|
clone for gdt.
|
package/dist/git.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
2
3
|
function git(args, cwd, env) {
|
|
3
4
|
return spawnSync("git", args, { cwd, env, encoding: "utf8" }).stdout ?? "";
|
|
4
5
|
}
|
|
@@ -23,3 +24,31 @@ export function checkout(root, env) {
|
|
|
23
24
|
branch: git(["symbolic-ref", "--quiet", "--short", "HEAD"], root, env).trim(),
|
|
24
25
|
};
|
|
25
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* #76: the directory a tester or reviewer turn for `head` runs in. That is `root` when it has `head`
|
|
29
|
+
* checked out, otherwise the first other worktree at `head` whose directory exists, otherwise `root`.
|
|
30
|
+
*/
|
|
31
|
+
export function headWorktree(root, head, env) {
|
|
32
|
+
const real = (path) => {
|
|
33
|
+
try {
|
|
34
|
+
return realpathSync(path);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
};
|
|
40
|
+
const main = real(root) ?? root;
|
|
41
|
+
const atHead = [];
|
|
42
|
+
for (const entry of git(["worktree", "list", "--porcelain"], root, env).split("\n\n")) {
|
|
43
|
+
const fields = entry.split("\n");
|
|
44
|
+
const path = fields.find((f) => f.startsWith("worktree "))?.slice("worktree ".length);
|
|
45
|
+
if (path === undefined || !fields.includes(`HEAD ${head}`))
|
|
46
|
+
continue;
|
|
47
|
+
const dir = real(path);
|
|
48
|
+
if (dir !== null)
|
|
49
|
+
atHead.push(dir);
|
|
50
|
+
}
|
|
51
|
+
if (atHead.includes(main))
|
|
52
|
+
return root;
|
|
53
|
+
return atHead[0] ?? root;
|
|
54
|
+
}
|
package/dist/github.js
CHANGED
|
@@ -89,10 +89,28 @@ export function detectedChecks(cwd, env) {
|
|
|
89
89
|
return [];
|
|
90
90
|
}
|
|
91
91
|
}
|
|
92
|
-
/**
|
|
92
|
+
/** True when `body` contains a closing keyword ("Closes #n", "fixes #n", ...) for exactly issue `issue`. */
|
|
93
|
+
export function closesIssue(body, issue) {
|
|
94
|
+
return new RegExp(`(?<![\\w])(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\\s+#${issue}(?!\\d)`, "i").test(body);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* The issue body and the workflow's pull requests: the open pull requests GitHub links as closing the
|
|
98
|
+
* issue, or, when there are none, the open pull requests whose body closes the issue without GitHub
|
|
99
|
+
* having linked them (`unlinked` is then true). The fallback costs one `gh api --paginate` call that
|
|
100
|
+
* reads every open pull request, so an older pull request is never cut off by a list limit.
|
|
101
|
+
*/
|
|
93
102
|
export function issueSnapshot(issue, cwd, env) {
|
|
94
103
|
const data = ghJson(["issue", "view", String(issue), "--json", "body,closedByPullRequestsReferences"], cwd, env);
|
|
95
|
-
|
|
104
|
+
const linked = (data.closedByPullRequestsReferences ?? []).map((pr) => pr.number);
|
|
105
|
+
if (linked.length > 0)
|
|
106
|
+
return { body: data.body, pullRequests: linked, unlinked: false };
|
|
107
|
+
const pages = ghJson(["api", "--paginate", "--slurp", "repos/{owner}/{repo}/pulls?state=open&per_page=100"], cwd, env);
|
|
108
|
+
const unlinked = pages
|
|
109
|
+
.flat()
|
|
110
|
+
.filter((pr) => closesIssue(pr.body ?? "", issue))
|
|
111
|
+
.map((pr) => pr.number)
|
|
112
|
+
.sort((x, y) => x - y);
|
|
113
|
+
return { body: data.body, pullRequests: unlinked, unlinked: unlinked.length > 0 };
|
|
96
114
|
}
|
|
97
115
|
/** All comments on an issue or pull request conversation, oldest first. */
|
|
98
116
|
export function comments(repo, number, cwd, env) {
|
package/dist/prompts.js
CHANGED
|
@@ -72,6 +72,7 @@ export function buildPrompt(role, dispatch, project) {
|
|
|
72
72
|
`Round: ${dispatch.round}`,
|
|
73
73
|
`Pull request: ${dispatch.pr_number === null ? "none yet" : `#${dispatch.pr_number}`}`,
|
|
74
74
|
`Head: ${dispatch.head ?? "none yet"}`,
|
|
75
|
+
...(dispatch.workdir === undefined ? [] : [`Working directory: ${dispatch.workdir}`]),
|
|
75
76
|
`issue_body_sha256: ${dispatch.issue_body_sha256}`,
|
|
76
77
|
`Acceptance criteria: ${dispatch.acceptance_criteria.join(", ")}`,
|
|
77
78
|
`Language: ${locale.name}`,
|
package/dist/supervisor.js
CHANGED
|
@@ -8,6 +8,7 @@ import { backendFor } from "./backends/index.js";
|
|
|
8
8
|
import { loadConfig, ROLES } from "./config.js";
|
|
9
9
|
import { sectionText, validateContract } from "./contract.js";
|
|
10
10
|
import { decide, openFindings } from "./decision.js";
|
|
11
|
+
import { headWorktree } from "./git.js";
|
|
11
12
|
import { comments, issueSnapshot, pullRequest, repository, viewer } from "./github.js";
|
|
12
13
|
import { loadLocale } from "./locale.js";
|
|
13
14
|
import { notify } from "./notify.js";
|
|
@@ -101,6 +102,24 @@ function roleOf(record) {
|
|
|
101
102
|
return null;
|
|
102
103
|
}
|
|
103
104
|
}
|
|
105
|
+
/**
|
|
106
|
+
* #76 AC-5/AC-6: why a dispatch that already ran is blocked. A tester or reviewer record for another
|
|
107
|
+
* head, posted after the role's dispatch `key`, is named with both heads and the turn's working directory.
|
|
108
|
+
*/
|
|
109
|
+
export function alreadyRanReason(role, key, dispatch, trusted, root) {
|
|
110
|
+
if (dispatch?.key === key && dispatch.head !== null) {
|
|
111
|
+
const since = Date.parse(dispatch.dispatched_at);
|
|
112
|
+
const heads = trusted.flatMap((r) => (r.kind === "test" || r.kind === "review") && r.data.role === role && Date.parse(r.created_at) >= since ? [r.data.head] : []);
|
|
113
|
+
const other = heads.filter((head) => head !== dispatch.head).at(-1);
|
|
114
|
+
if (other !== undefined)
|
|
115
|
+
return `${role} record is for head ${other}, not the dispatch head ${dispatch.head}; the turn ran in ${dispatch.workdir ?? root}`;
|
|
116
|
+
}
|
|
117
|
+
return `${role} already ran for this dispatch without a usable record`;
|
|
118
|
+
}
|
|
119
|
+
/** Whether `reason` is an already-ran block (#76: also when it names a record for another head); `gdt retry` lifts it. */
|
|
120
|
+
export function isAlreadyRanReason(reason) {
|
|
121
|
+
return reason.includes(" already ran for this dispatch ") || reason.includes(", not the dispatch head ");
|
|
122
|
+
}
|
|
104
123
|
/** The record kind each workflow role writes; a diagnostic of another kind is never that role's. */
|
|
105
124
|
const ROLE_OF_KIND = { handoff: "developer", test: "tester", review: "reviewer" };
|
|
106
125
|
/** AC-1: the newest invalid role record the role posted after the turn's `after_comment_id`. */
|
|
@@ -129,6 +148,8 @@ class Supervisor {
|
|
|
129
148
|
backend;
|
|
130
149
|
trusted = [];
|
|
131
150
|
reportWarned = false;
|
|
151
|
+
/** Unlinked pull requests already logged, so the fallback line appears once per pull request. */
|
|
152
|
+
unlinkedLogged = new Set();
|
|
132
153
|
agents;
|
|
133
154
|
constructor(p, issue, config, locale, env, state, backend) {
|
|
134
155
|
this.p = p;
|
|
@@ -426,7 +447,7 @@ class Supervisor {
|
|
|
426
447
|
// leave a hung turn `running` forever.
|
|
427
448
|
if (this.settleTurn(now) !== null)
|
|
428
449
|
return "continue";
|
|
429
|
-
const { body, pullRequests } = issueSnapshot(this.issue, this.p.root, this.env);
|
|
450
|
+
const { body, pullRequests, unlinked } = issueSnapshot(this.issue, this.p.root, this.env);
|
|
430
451
|
const bodySha = sha256(body);
|
|
431
452
|
const changelog = sectionText(body, this.locale.sections.changelog) ?? "";
|
|
432
453
|
const known = this.state.contract;
|
|
@@ -494,6 +515,10 @@ class Supervisor {
|
|
|
494
515
|
return "continue";
|
|
495
516
|
}
|
|
496
517
|
const prNumber = pullRequests[0];
|
|
518
|
+
if (unlinked && prNumber !== undefined && !this.unlinkedLogged.has(prNumber)) {
|
|
519
|
+
this.unlinkedLogged.add(prNumber);
|
|
520
|
+
log(`pull request #${prNumber} closes #${this.issue} in its body but GitHub has not linked it; using it`);
|
|
521
|
+
}
|
|
497
522
|
const pr = prNumber === undefined ? null : pullRequest(prNumber, this.p.root, this.env);
|
|
498
523
|
if (pr !== null) {
|
|
499
524
|
if (this.state.head !== null && this.state.head !== pr.head) {
|
|
@@ -585,7 +610,7 @@ class Supervisor {
|
|
|
585
610
|
}
|
|
586
611
|
const key = dispatchKey(decision, pr?.head ?? null, bodySha);
|
|
587
612
|
if (this.state.dispatched.includes(key)) {
|
|
588
|
-
this.setStatus("blocked",
|
|
613
|
+
this.setStatus("blocked", alreadyRanReason(decision.role, key, readJson(this.p.dispatch(decision.role)), trusted, this.p.root), {
|
|
589
614
|
role: decision.role,
|
|
590
615
|
round: decision.round,
|
|
591
616
|
blocked_key: key,
|
|
@@ -608,6 +633,8 @@ class Supervisor {
|
|
|
608
633
|
language: this.config.language,
|
|
609
634
|
directives,
|
|
610
635
|
dispatched_at: now.toISOString(),
|
|
636
|
+
// #76 AC-1/AC-3: a verifier runs where the pull request head is checked out.
|
|
637
|
+
workdir: pr !== null && decision.role !== "developer" ? headWorktree(this.p.root, pr.head, this.env) : this.p.root,
|
|
611
638
|
};
|
|
612
639
|
// Dispatch file first: if gdt stop lands in between, the restarted supervisor dispatches the same key
|
|
613
640
|
// again, and the worker's exclusive "started" marker still runs it at most once.
|
package/dist/worker.js
CHANGED
|
@@ -21,7 +21,7 @@ export function waitingLine(role, last) {
|
|
|
21
21
|
return `${DIM}${role} waiting · ${detail}${RESET_COLOURS}`;
|
|
22
22
|
}
|
|
23
23
|
/** The command for one unattended turn: the role's agent adapter, or the test agent's script. */
|
|
24
|
-
export function invocation(roleName, role, root, promptFile, env) {
|
|
24
|
+
export function invocation(roleName, role, root, promptFile, env, cwd = root) {
|
|
25
25
|
if (role.agent === TEST_AGENT) {
|
|
26
26
|
if (role.script === undefined || !testAgentsEnabled(env))
|
|
27
27
|
throw new Error(`agent "${TEST_AGENT}" requires GDT_TEST_AGENTS=1 and a script`);
|
|
@@ -30,7 +30,7 @@ export function invocation(roleName, role, root, promptFile, env) {
|
|
|
30
30
|
const adapter = adapterFor(role.agent);
|
|
31
31
|
if (adapter === undefined)
|
|
32
32
|
throw new Error(`no adapter for agent "${role.agent}" yet`);
|
|
33
|
-
return adapter.buildInvocation(roleName, role.model, promptFile,
|
|
33
|
+
return adapter.buildInvocation(roleName, role.model, promptFile, cwd);
|
|
34
34
|
}
|
|
35
35
|
function log(line) {
|
|
36
36
|
process.stdout.write(`${logTimestamp()} ${line}\n`);
|
|
@@ -165,6 +165,8 @@ async function turn(root, role, dispatch, env, p) {
|
|
|
165
165
|
log("invalid config; run gdt doctor");
|
|
166
166
|
return finish(78);
|
|
167
167
|
}
|
|
168
|
+
// #76 AC-1/AC-2: the turn and its boundary check run where the supervisor found the head.
|
|
169
|
+
const workdir = dispatch.workdir ?? root;
|
|
168
170
|
const promptFile = p.prompt(dispatch.key);
|
|
169
171
|
// AC-2: a retried turn is told how its previous record was rejected. Reading the state (rather than
|
|
170
172
|
// the dispatch file) avoids a race with the worker that started before `gdt retry` finished.
|
|
@@ -182,7 +184,7 @@ async function turn(root, role, dispatch, env, p) {
|
|
|
182
184
|
const roleConfig = override === undefined ? report.roles[role] : { ...report.roles[role], agent: override.agent, model: override.model };
|
|
183
185
|
let inv;
|
|
184
186
|
try {
|
|
185
|
-
inv = invocation(role, roleConfig, root, promptFile, env);
|
|
187
|
+
inv = invocation(role, roleConfig, root, promptFile, env, workdir);
|
|
186
188
|
}
|
|
187
189
|
catch (err) {
|
|
188
190
|
log(err instanceof Error ? err.message : String(err));
|
|
@@ -200,9 +202,9 @@ async function turn(root, role, dispatch, env, p) {
|
|
|
200
202
|
GDT_ISSUE_BODY_SHA256: dispatch.issue_body_sha256,
|
|
201
203
|
GDT_DISPATCH_KEY: dispatch.key,
|
|
202
204
|
};
|
|
203
|
-
const before = checkout(
|
|
204
|
-
const exitCode = await runInvocation(inv,
|
|
205
|
-
const violation = boundaryViolation(role, before,
|
|
205
|
+
const before = checkout(workdir, env);
|
|
206
|
+
const exitCode = await runInvocation(inv, workdir, turnEnv, (pid) => writeFileSync(p.agent(dispatch.key), `${pid}\n`));
|
|
207
|
+
const violation = boundaryViolation(role, before, workdir, env);
|
|
206
208
|
if (violation !== undefined)
|
|
207
209
|
log(violation);
|
|
208
210
|
return finish(exitCode, violation);
|
package/dist/workflow.js
CHANGED
|
@@ -11,7 +11,7 @@ import { issueBody, repository } from "./github.js";
|
|
|
11
11
|
import { loadLocale } from "./locale.js";
|
|
12
12
|
import { isInvalidRecordReason } from "./protocol.js";
|
|
13
13
|
import { alive, lockHolder, paths, readJson, readOverrides, readState, writeJsonAtomic, writeState, } from "./state.js";
|
|
14
|
-
import { activeReason, cliPath, isHardLimitReason, isTimeoutReason, turnDeadline, turnHardLimit, turnLastActivity, } from "./supervisor.js";
|
|
14
|
+
import { activeReason, cliPath, isAlreadyRanReason, isHardLimitReason, isTimeoutReason, turnDeadline, turnHardLimit, turnLastActivity, } from "./supervisor.js";
|
|
15
15
|
export const ok = (stdout) => ({ code: 0, stdout, stderr: "" });
|
|
16
16
|
export const fail = (stderr) => ({ code: 1, stdout: "", stderr });
|
|
17
17
|
/** Statuses that need a person or end the workflow: `gdt wait` returns when it reaches one. */
|
|
@@ -206,7 +206,7 @@ function retryable(state) {
|
|
|
206
206
|
return true;
|
|
207
207
|
return (state.status === "blocked" &&
|
|
208
208
|
(state.reason.includes("without a visible handoff") ||
|
|
209
|
-
state.reason
|
|
209
|
+
isAlreadyRanReason(state.reason) ||
|
|
210
210
|
isTimeoutReason(state.reason) ||
|
|
211
211
|
isHardLimitReason(state.reason) ||
|
|
212
212
|
isInvalidRecordReason(state.reason)));
|
|
@@ -250,7 +250,7 @@ function blockedHint(issue, reason) {
|
|
|
250
250
|
if (isHardLimitReason(reason))
|
|
251
251
|
return `gdt extend ${issue} or gdt retry ${issue}`;
|
|
252
252
|
if (reason.includes("without a visible handoff") ||
|
|
253
|
-
reason
|
|
253
|
+
isAlreadyRanReason(reason) ||
|
|
254
254
|
isTimeoutReason(reason) ||
|
|
255
255
|
isInvalidRecordReason(reason)) {
|
|
256
256
|
return `gdt retry ${issue}`;
|
package/package.json
CHANGED
package/roles/reviewer.md
CHANGED
|
@@ -7,8 +7,8 @@ quality to be merged?
|
|
|
7
7
|
## Your job this turn
|
|
8
8
|
|
|
9
9
|
1. Read the issue body with `gh issue view <issue>`. The current body is the only specification.
|
|
10
|
-
2. Read the pull request and its diff with `gh pr view <pr>` and `gh pr diff <pr>`.
|
|
11
|
-
checkout
|
|
10
|
+
2. Read the pull request and its diff with `gh pr view <pr>` and `gh pr diff <pr>`. Your turn runs in a
|
|
11
|
+
checkout at the pull request head (the working directory in this prompt): confirm that `git rev-parse HEAD` equals the
|
|
12
12
|
head in this prompt (otherwise post `blocked`), read the code in context, and run the build and
|
|
13
13
|
tests if that helps. Never check out another commit.
|
|
14
14
|
3. Check, in this order:
|
package/roles/tester.md
CHANGED
|
@@ -7,8 +7,8 @@ check behaviour yourself.
|
|
|
7
7
|
## Your job this turn
|
|
8
8
|
|
|
9
9
|
1. Read the issue body with `gh issue view <issue>`. The current body is the only specification.
|
|
10
|
-
2.
|
|
11
|
-
`git rev-parse HEAD` equals the head in this prompt. If it does not, post `blocked` and say so;
|
|
10
|
+
2. Your turn runs in a checkout at the pull request head: the working directory in this prompt.
|
|
11
|
+
Confirm that `git rev-parse HEAD` equals the head in this prompt. If it does not, post `blocked` and say so;
|
|
12
12
|
never check out another commit.
|
|
13
13
|
3. For every AC: turn Given, When, Then and Example into a concrete check. Run the project's tests,
|
|
14
14
|
run the program, and try the example and its edge cases. Record what you did and what you saw.
|