@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 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. `gdt start` needs a **clean working tree** and refuses to run otherwise
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
- /** The issue body and the open pull requests that close the issue ("Closes #n"). */
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
- return { body: data.body, pullRequests: (data.closedByPullRequestsReferences ?? []).map((pr) => pr.number) };
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}`,
@@ -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", `${decision.role} already ran for this dispatch without a usable record`, {
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, root);
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(root, env);
204
- const exitCode = await runInvocation(inv, root, turnEnv, (pid) => writeFileSync(p.agent(dispatch.key), `${pid}\n`));
205
- const violation = boundaryViolation(role, before, root, env);
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.includes("already ran") ||
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.includes("already ran") ||
253
+ isAlreadyRanReason(reason) ||
254
254
  isTimeoutReason(reason) ||
255
255
  isInvalidRecordReason(reason)) {
256
256
  return `gdt retry ${issue}`;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gevezex/gdt",
3
- "version": "0.5.2",
3
+ "version": "0.5.3",
4
4
  "description": "GitHub issues to merge-ready pull requests, with a developer, tester and reviewer agent.",
5
5
  "keywords": [
6
6
  "github",
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>`. All roles share one
11
- checkout, and it is already at the pull request head: confirm that `git rev-parse HEAD` equals the
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. All roles share one checkout, and it is already at the pull request head. Confirm that
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.