@wardby/cli 0.4.0 → 0.5.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 (223) hide show
  1. package/.env.example +8 -1
  2. package/README.md +3 -1
  3. package/deploy/local/docker-compose.quickstart-coding.yml +27 -0
  4. package/dist/claude-coding-worker/driver.d.ts +2 -0
  5. package/dist/claude-coding-worker/driver.js +19 -3
  6. package/dist/claude-coding-worker/sdk.js +1 -1
  7. package/dist/cli.js +41 -22
  8. package/dist/coding/continuation-wording.d.ts +10 -0
  9. package/dist/coding/continuation-wording.js +13 -0
  10. package/dist/coding/docker-preflight.d.ts +12 -0
  11. package/dist/coding/docker-preflight.js +40 -0
  12. package/dist/coding/local-git.d.ts +22 -0
  13. package/dist/coding/local-git.js +102 -0
  14. package/dist/coding/local-repo-status.d.ts +7 -0
  15. package/dist/coding/local-repo-status.js +56 -0
  16. package/dist/coding/local-repo.d.ts +17 -0
  17. package/dist/coding/local-repo.js +65 -0
  18. package/dist/coding/observability.d.ts +1 -1
  19. package/dist/coding/observability.js +1 -0
  20. package/dist/coding/profile.d.ts +6 -0
  21. package/dist/coding/profile.js +13 -4
  22. package/dist/coding/protocol.d.ts +32 -7
  23. package/dist/coding/protocol.js +66 -4
  24. package/dist/coding-worker/artifact.d.ts +1 -0
  25. package/dist/coding-worker/errors.js +2 -0
  26. package/dist/config/providers.d.ts +13 -0
  27. package/dist/config/providers.js +21 -9
  28. package/dist/core/attribution.d.ts +13 -0
  29. package/dist/core/attribution.js +30 -0
  30. package/dist/core/budget-groups.d.ts +3 -1
  31. package/dist/core/budget-groups.js +53 -5
  32. package/dist/core/ci-context.d.ts +8 -0
  33. package/dist/core/ci-context.js +59 -0
  34. package/dist/core/delegation-siblings.d.ts +22 -0
  35. package/dist/core/delegation-siblings.js +40 -0
  36. package/dist/core/dispatch.d.ts +28 -4
  37. package/dist/core/dispatch.js +57 -15
  38. package/dist/core/engine-native.d.ts +16 -0
  39. package/dist/core/engine-native.js +49 -2
  40. package/dist/core/host-events.d.ts +25 -2
  41. package/dist/core/host-events.js +361 -19
  42. package/dist/core/host-status.d.ts +8 -3
  43. package/dist/core/host-status.js +38 -7
  44. package/dist/core/issue-events.d.ts +2 -6
  45. package/dist/core/issue-events.js +13 -19
  46. package/dist/core/pull-request-state-sync.d.ts +17 -0
  47. package/dist/core/pull-request-state-sync.js +53 -0
  48. package/dist/core/reconciler.d.ts +10 -1
  49. package/dist/core/reconciler.js +15 -2
  50. package/dist/core/related-pull-requests.d.ts +95 -0
  51. package/dist/core/related-pull-requests.js +338 -0
  52. package/dist/core/repo-access.d.ts +5 -1
  53. package/dist/core/repo-access.js +17 -1
  54. package/dist/core/review-fix-ledger.d.ts +20 -0
  55. package/dist/core/review-fix-ledger.js +41 -0
  56. package/dist/core/review-fix.d.ts +60 -0
  57. package/dist/core/review-fix.js +155 -0
  58. package/dist/core/review-host-tools.d.ts +13 -2
  59. package/dist/core/review-host-tools.js +72 -11
  60. package/dist/core/runner.d.ts +2 -2
  61. package/dist/core/runner.js +379 -184
  62. package/dist/core/serial-gate.d.ts +11 -0
  63. package/dist/core/serial-gate.js +19 -0
  64. package/dist/core/timing.d.ts +7 -0
  65. package/dist/core/timing.js +7 -0
  66. package/dist/env.js +6 -1
  67. package/dist/generated/prisma/browser.d.ts +23 -0
  68. package/dist/generated/prisma/client.d.ts +23 -0
  69. package/dist/generated/prisma/commonInputTypes.d.ts +22 -0
  70. package/dist/generated/prisma/internal/class.d.ts +33 -0
  71. package/dist/generated/prisma/internal/class.js +4 -4
  72. package/dist/generated/prisma/internal/prismaNamespace.d.ts +274 -1
  73. package/dist/generated/prisma/internal/prismaNamespace.js +47 -2
  74. package/dist/generated/prisma/internal/prismaNamespaceBrowser.d.ts +48 -0
  75. package/dist/generated/prisma/internal/prismaNamespaceBrowser.js +47 -2
  76. package/dist/generated/prisma/models/Agent.d.ts +422 -1
  77. package/dist/generated/prisma/models/AgentRepository.d.ts +120 -2
  78. package/dist/generated/prisma/models/CodingAgentProfile.d.ts +42 -1
  79. package/dist/generated/prisma/models/CodingRun.d.ts +205 -1
  80. package/dist/generated/prisma/models/DeferredReview.d.ts +1336 -0
  81. package/dist/generated/prisma/models/DeferredReview.js +1 -0
  82. package/dist/generated/prisma/models/LocalPullRequest.d.ts +1384 -0
  83. package/dist/generated/prisma/models/LocalPullRequest.js +1 -0
  84. package/dist/generated/prisma/models/LocalReview.d.ts +1315 -0
  85. package/dist/generated/prisma/models/LocalReview.js +1 -0
  86. package/dist/generated/prisma/models/Run.d.ts +223 -0
  87. package/dist/generated/prisma/models/RunHostCheck.d.ts +148 -1
  88. package/dist/generated/prisma/models.d.ts +3 -0
  89. package/dist/help-index.json +476 -37
  90. package/dist/import/neutral-schema.d.ts +2 -2
  91. package/dist/mcp/auth/access.d.ts +3 -1
  92. package/dist/mcp/auth/host-account-cli.js +2 -2
  93. package/dist/mcp/auth/repo-authorization.d.ts +6 -7
  94. package/dist/mcp/auth/repo-authorization.js +21 -0
  95. package/dist/mcp/index.js +4 -1
  96. package/dist/mcp/tools/agents.js +46 -4
  97. package/dist/mcp/tools/host-accounts.js +2 -2
  98. package/dist/mcp/tools/repositories.js +71 -12
  99. package/dist/mcp/tools/runs.js +23 -0
  100. package/dist/mcp/tools/trigger.js +153 -24
  101. package/dist/providers/engine/types.d.ts +11 -0
  102. package/dist/providers/executor/composition.js +12 -7
  103. package/dist/providers/executor/container.d.ts +40 -3
  104. package/dist/providers/executor/container.js +140 -9
  105. package/dist/providers/executor/types.d.ts +1 -1
  106. package/dist/providers/jobs/fake-kubernetes-api.d.ts +3 -2
  107. package/dist/providers/jobs/fake-kubernetes-api.js +3 -0
  108. package/dist/providers/jobs/kubernetes-api.d.ts +3 -1
  109. package/dist/providers/jobs/kubernetes-client.d.ts +2 -1
  110. package/dist/providers/jobs/kubernetes-client.js +3 -0
  111. package/dist/providers/jobs/kubernetes-isolation.d.ts +7 -0
  112. package/dist/providers/jobs/kubernetes-isolation.js +8 -3
  113. package/dist/providers/jobs/kubernetes-preflight.d.ts +8 -0
  114. package/dist/providers/jobs/kubernetes-preflight.js +7 -0
  115. package/dist/providers/jobs/kubernetes-quota.d.ts +31 -0
  116. package/dist/providers/jobs/kubernetes-quota.js +110 -0
  117. package/dist/providers/jobs/kubernetes.d.ts +9 -0
  118. package/dist/providers/jobs/kubernetes.js +50 -0
  119. package/dist/providers/jobs/types.d.ts +7 -0
  120. package/dist/providers/review-host/ci.d.ts +7 -0
  121. package/dist/providers/review-host/ci.js +20 -0
  122. package/dist/providers/review-host/github-events.d.ts +7 -0
  123. package/dist/providers/review-host/github-events.js +31 -7
  124. package/dist/providers/review-host/github.d.ts +19 -2
  125. package/dist/providers/review-host/github.js +145 -2
  126. package/dist/providers/review-host/index.d.ts +8 -2
  127. package/dist/providers/review-host/index.js +14 -3
  128. package/dist/providers/review-host/local.d.ts +72 -0
  129. package/dist/providers/review-host/local.js +434 -0
  130. package/dist/providers/review-host/types.d.ts +65 -3
  131. package/dist/providers/review-host/types.js +5 -3
  132. package/dist/providers/vcs/git.d.ts +10 -20
  133. package/dist/providers/vcs/git.js +71 -127
  134. package/dist/providers/vcs/github-remote.d.ts +67 -0
  135. package/dist/providers/vcs/github-remote.js +201 -0
  136. package/dist/providers/vcs/github.d.ts +81 -0
  137. package/dist/providers/vcs/github.js +203 -6
  138. package/dist/providers/vcs/index.d.ts +16 -1
  139. package/dist/providers/vcs/index.js +43 -15
  140. package/dist/providers/vcs/local-remote.d.ts +44 -0
  141. package/dist/providers/vcs/local-remote.js +108 -0
  142. package/dist/providers/vcs/remote.d.ts +58 -0
  143. package/dist/providers/vcs/remote.js +1 -0
  144. package/dist/providers/vcs/routing.d.ts +33 -0
  145. package/dist/providers/vcs/routing.js +52 -0
  146. package/dist/providers/vcs/types.d.ts +12 -1
  147. package/dist/quickstart/coding-db.d.ts +11 -0
  148. package/dist/quickstart/coding-db.js +75 -0
  149. package/dist/quickstart/coding-doctor.d.ts +13 -0
  150. package/dist/quickstart/coding-doctor.js +88 -0
  151. package/dist/quickstart/coding-images.d.ts +15 -0
  152. package/dist/quickstart/coding-images.js +79 -0
  153. package/dist/quickstart/coding-seed.d.ts +63 -0
  154. package/dist/quickstart/coding-seed.js +85 -0
  155. package/dist/quickstart/coding.d.ts +66 -0
  156. package/dist/quickstart/coding.js +430 -0
  157. package/dist/quickstart/images.d.ts +19 -0
  158. package/dist/quickstart/images.js +63 -0
  159. package/dist/quickstart/index.d.ts +6 -0
  160. package/dist/quickstart/index.js +72 -40
  161. package/dist/quickstart/starter-services.d.ts +45 -0
  162. package/dist/quickstart/starter-services.js +186 -0
  163. package/dist/quickstart-images.json +1 -0
  164. package/dist/serve.js +14 -1
  165. package/dist/viewer/api-schema.d.ts +176 -0
  166. package/dist/viewer/api-schema.js +27 -2
  167. package/dist/viewer/graph.d.ts +7 -2
  168. package/dist/viewer/graph.js +25 -11
  169. package/dist/viewer/http.d.ts +7 -1
  170. package/dist/viewer/http.js +8 -3
  171. package/dist/viewer/infra.d.ts +2 -0
  172. package/dist/viewer/infra.js +23 -0
  173. package/dist/viewer/run-detail.d.ts +2 -1
  174. package/dist/viewer/run-detail.js +2 -2
  175. package/docs/agent-recipes.md +123 -2
  176. package/docs/code-review-agents.md +293 -38
  177. package/docs/coding-agent-setup.md +141 -2
  178. package/docs/coding-packages.md +21 -0
  179. package/docs/coding-services.md +3 -0
  180. package/docs/coding-worker-isolation.md +87 -24
  181. package/docs/getting-started-gke.md +24 -0
  182. package/docs/getting-started.md +82 -8
  183. package/docs/jira-agents.md +26 -5
  184. package/docs/security-deployment.md +9 -0
  185. package/docs/viewer-api.md +21 -2
  186. package/help/admin-viewer.md +11 -1
  187. package/help/agent-recipes.md +25 -1
  188. package/help/builder-agent.md +4 -0
  189. package/help/code-review-agents.md +82 -2
  190. package/help/coding-packages.md +12 -1
  191. package/help/coding-services.md +4 -0
  192. package/help/cost-attribution.md +3 -1
  193. package/help/creating-agents.md +3 -0
  194. package/help/deploy-gke.md +2 -1
  195. package/help/errors/coding-provider-not-configured.md +60 -0
  196. package/help/errors/coding-turn-limit.md +25 -0
  197. package/help/errors/continuation-closed.md +75 -0
  198. package/help/errors/local-branch-conflict.md +37 -0
  199. package/help/errors/local-path-invalid.md +30 -0
  200. package/help/errors/local-ref-invalid.md +36 -0
  201. package/help/errors/local-ref-not-found.md +41 -0
  202. package/help/errors/local-repo-not-allowed.md +46 -0
  203. package/help/errors/local-repo-not-found.md +39 -0
  204. package/help/errors/model-unavailable.md +7 -6
  205. package/help/errors/vcs-github-not-configured.md +34 -0
  206. package/help/getting-started.md +4 -0
  207. package/help/github.md +22 -5
  208. package/help/jira.md +9 -2
  209. package/help/local-repositories.md +182 -0
  210. package/help/related-pull-requests.md +89 -0
  211. package/help/review-fix-rounds.md +69 -0
  212. package/help/troubleshooting/budgets.md +11 -0
  213. package/help/troubleshooting/coding-workers.md +22 -0
  214. package/help/troubleshooting/repository-access.md +5 -0
  215. package/package.json +5 -3
  216. package/prisma/migrations/20261004100000_delegations_and_coding_turns/migration.sql +12 -0
  217. package/prisma/migrations/20261005000000_review_fix_rounds/migration.sql +5 -0
  218. package/prisma/migrations/20261006000000_parallel_delegations/migration.sql +6 -0
  219. package/prisma/migrations/20261007000000_ci_rereview/migration.sql +4 -0
  220. package/prisma/migrations/20261008000000_review_after_ci/migration.sql +33 -0
  221. package/prisma/migrations/20261009000001_coding_run_local_branch/migration.sql +7 -0
  222. package/prisma/migrations/20261009000002_local_review/migration.sql +50 -0
  223. package/prisma/schema.prisma +98 -1
@@ -0,0 +1,434 @@
1
+ import { LocalRepoError, loadLocalRepoRoots, normalizeLocalRepository, resolveLocalRepository, } from "../../coding/local-repo.js";
2
+ import { SHA, isSafeRefName, isSafeRepoPath, localGit as git } from "../../coding/local-git.js";
3
+ import { skippedInListing } from "./github.js";
4
+ import { verdictConclusion } from "./review-format.js";
5
+ import { ReviewHostError, } from "./types.js";
6
+ /** GitHub lists at most 3 pages of 100 files; match it. */
7
+ const MAX_PR_FILES = 300;
8
+ const MAX_LISTED_FILES = 1000;
9
+ const MAX_BODY_CHARS = 8000;
10
+ const MAX_LOGGED_COMMITS = 100;
11
+ const MAX_PG_INT = 2_147_483_647;
12
+ const STATUS = {
13
+ A: "added",
14
+ M: "modified",
15
+ D: "removed",
16
+ R: "renamed",
17
+ C: "copied",
18
+ T: "changed",
19
+ };
20
+ function fail(message) {
21
+ throw new ReviewHostError("host_api_error", message);
22
+ }
23
+ function assertRepoPath(path) {
24
+ if (!isSafeRepoPath(path))
25
+ fail("path_invalid");
26
+ }
27
+ function prUrl(row) {
28
+ return row.runId ? `wardby://runs/${row.runId}/review` : `wardby://local-pulls/${row.number}`;
29
+ }
30
+ /** Hunks only, as GitHub's `patch` field: the `diff --git`/index/---/+++ header is dropped, no trailing newline. */
31
+ function hunks(diff) {
32
+ const start = diff.startsWith("@@") ? 0 : diff.indexOf("\n@@") + 1;
33
+ // indexOf -1 + 1 = 0 without a leading "@@": no hunks (binary, mode or pure rename).
34
+ if (start === 0 && !diff.startsWith("@@"))
35
+ return "";
36
+ return diff.slice(start).replace(/\n$/, "");
37
+ }
38
+ function tooLarge(err) {
39
+ return err?.code === "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
40
+ }
41
+ export class LocalReviewHost {
42
+ provider = "local";
43
+ db;
44
+ roots;
45
+ constructor(options) {
46
+ this.db = options.db;
47
+ this.roots = options.roots ?? (() => loadLocalRepoRoots(process.env).roots);
48
+ }
49
+ /** The canonical repository and its realpath, checked against the current roots. */
50
+ async repo(repository) {
51
+ try {
52
+ return await resolveLocalRepository(repository, this.roots());
53
+ }
54
+ catch (err) {
55
+ fail(err instanceof LocalRepoError ? err.code : "local_repo_invalid");
56
+ }
57
+ }
58
+ async pullRequest(canonical, requested, number) {
59
+ const row = Number.isSafeInteger(number) && number > 0 && number <= MAX_PG_INT
60
+ ? await this.db.localPullRequest.findUnique({ where: { number } })
61
+ : null;
62
+ if (!row || (row.repository !== canonical && row.repository !== normalizeLocalRepository(requested))) {
63
+ fail("no such local pull request");
64
+ }
65
+ return row;
66
+ }
67
+ /** Rejects anything but a plain branch/tag name before it can reach git as a revision or an option. */
68
+ async assertRefName(dir, name) {
69
+ if (!(await isSafeRefName(dir, name)))
70
+ fail("ref_invalid");
71
+ }
72
+ /** The commit a sha or branch name points at. */
73
+ async commitOf(dir, ref) {
74
+ if (!SHA.test(ref))
75
+ await this.assertRefName(dir, ref);
76
+ let out;
77
+ try {
78
+ out = await git(dir, ["rev-parse", "--verify", "--quiet", "--end-of-options", `${ref}^{commit}`], 64 * 1024);
79
+ }
80
+ catch {
81
+ fail("local_ref_not_found");
82
+ }
83
+ const sha = out.trim();
84
+ if (!SHA.test(sha))
85
+ fail("local_ref_not_found");
86
+ return sha;
87
+ }
88
+ /** The repository's checked-out HEAD commit (its "default branch"), and a label for it. */
89
+ async defaultRef(dir) {
90
+ let label = "HEAD";
91
+ try {
92
+ label = (await git(dir, ["symbolic-ref", "--short", "-q", "HEAD"], 64 * 1024)).trim() || "HEAD";
93
+ }
94
+ catch {
95
+ // detached HEAD
96
+ }
97
+ let sha;
98
+ try {
99
+ sha = (await git(dir, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], 64 * 1024)).trim();
100
+ }
101
+ catch {
102
+ fail("local_ref_not_found");
103
+ }
104
+ if (!SHA.test(sha))
105
+ fail("local_ref_not_found");
106
+ return { sha, label };
107
+ }
108
+ async resolveRef(dir, ref) {
109
+ return ref === undefined ? this.defaultRef(dir) : { sha: await this.commitOf(dir, ref), label: ref };
110
+ }
111
+ async isAncestor(dir, ancestor, descendant) {
112
+ try {
113
+ await git(dir, ["merge-base", "--is-ancestor", "--end-of-options", ancestor, descendant], 64 * 1024);
114
+ return true;
115
+ }
116
+ catch {
117
+ return false;
118
+ }
119
+ }
120
+ /** Files changed between two commits (both 40-hex), with rename detection, in git's order. */
121
+ async changedFiles(dir, from, to) {
122
+ const diff = ["diff", "--no-ext-diff", "--no-textconv", "--no-color", "-M", "-z"];
123
+ let nameStatus;
124
+ let numstat;
125
+ try {
126
+ nameStatus = await git(dir, [...diff, "--name-status", "--end-of-options", from, to]);
127
+ numstat = await git(dir, [...diff, "--numstat", "--end-of-options", from, to]);
128
+ }
129
+ catch {
130
+ fail("local_git_failed");
131
+ }
132
+ const counts = new Map();
133
+ const n = numstat.split("\0");
134
+ for (let i = 0; i < n.length && n[i] !== "";) {
135
+ const [add, del, ...rest] = n[i].split("\t");
136
+ let path = rest.join("\t");
137
+ if (path === "") {
138
+ path = n[i + 2] ?? "";
139
+ i += 3;
140
+ }
141
+ else {
142
+ i += 1;
143
+ }
144
+ // Binary files show "-": GitHub reports 0 for them.
145
+ counts.set(path, { additions: Number(add) || 0, deletions: Number(del) || 0 });
146
+ }
147
+ const files = [];
148
+ const s = nameStatus.split("\0");
149
+ for (let i = 0; i < s.length && s[i] !== "";) {
150
+ const code = s[i].charAt(0);
151
+ let filename;
152
+ let previousFilename;
153
+ if (code === "R" || code === "C") {
154
+ previousFilename = s[i + 1];
155
+ filename = s[i + 2] ?? "";
156
+ i += 3;
157
+ }
158
+ else {
159
+ filename = s[i + 1] ?? "";
160
+ i += 2;
161
+ }
162
+ files.push({
163
+ filename,
164
+ ...(previousFilename !== undefined ? { previousFilename } : {}),
165
+ status: STATUS[code] ?? "changed",
166
+ ...(counts.get(filename) ?? { additions: 0, deletions: 0 }),
167
+ });
168
+ }
169
+ return files.slice(0, MAX_PR_FILES);
170
+ }
171
+ /** Attaches patches, sharing one character budget across files and flagging truncation like GitHub. */
172
+ async withPatches(dir, from, to, files, maxPatchChars) {
173
+ let budget = maxPatchChars;
174
+ const out = [];
175
+ for (const file of files) {
176
+ const changes = file.additions + file.deletions;
177
+ if (budget <= 0) {
178
+ out.push({ ...file, patch: "", patchTruncated: changes > 0 });
179
+ continue;
180
+ }
181
+ let patch;
182
+ let complete = true;
183
+ try {
184
+ const paths = file.previousFilename ? [file.previousFilename, file.filename] : [file.filename];
185
+ patch = hunks(await git(dir, [
186
+ "diff",
187
+ "--no-ext-diff",
188
+ "--no-textconv",
189
+ "--no-color",
190
+ "-M",
191
+ "--end-of-options",
192
+ from,
193
+ to,
194
+ "--",
195
+ ...paths,
196
+ ]));
197
+ }
198
+ catch {
199
+ // Too large for the buffer (or unreadable): no patch, flagged.
200
+ patch = "";
201
+ complete = changes === 0;
202
+ }
203
+ const kept = patch.length <= budget ? patch : patch.slice(0, Math.max(0, budget));
204
+ budget -= kept.length;
205
+ out.push({ ...file, patch: kept, patchTruncated: kept.length < patch.length || !complete });
206
+ }
207
+ return out;
208
+ }
209
+ async repositoryPermission(_repository, _user) {
210
+ return { level: "admin", login: "local" };
211
+ }
212
+ async readPullRequest(repository, prNumber, opts) {
213
+ const { repository: canonical, path: dir } = await this.repo(repository);
214
+ const row = await this.pullRequest(canonical, repository, prNumber);
215
+ const headSha = await this.commitOf(dir, row.branch);
216
+ const baseTip = await this.commitOf(dir, row.base);
217
+ let baseSha;
218
+ try {
219
+ baseSha = (await git(dir, ["merge-base", "--end-of-options", baseTip, headSha], 64 * 1024)).trim();
220
+ }
221
+ catch {
222
+ fail("no common ancestor between the branch and its base");
223
+ }
224
+ if (!SHA.test(baseSha))
225
+ fail("no common ancestor between the branch and its base");
226
+ let from = baseSha;
227
+ let files = null;
228
+ let comparedFrom = null;
229
+ let baseMergedSince = false;
230
+ const since = opts.sinceSha;
231
+ if (since && SHA.test(since) && since !== headSha && (await this.isAncestor(dir, since, headSha))) {
232
+ comparedFrom = since;
233
+ files = await this.changedFiles(dir, since, headSha);
234
+ from = since;
235
+ let merges = "0";
236
+ try {
237
+ merges = await git(dir, ["rev-list", "--count", "--min-parents=2", "--end-of-options", `${since}..${headSha}`]);
238
+ }
239
+ catch {
240
+ fail("local_git_failed");
241
+ }
242
+ baseMergedSince = Number(merges.trim()) > 0;
243
+ if (baseMergedSince) {
244
+ // As on GitHub: the PR's own diff against its base, limited to files that changed since.
245
+ const changedSince = new Set(files.map((f) => f.filename));
246
+ files = (await this.changedFiles(dir, baseSha, headSha)).filter((f) => changedSince.has(f.filename));
247
+ from = baseSha;
248
+ }
249
+ }
250
+ files ??= await this.changedFiles(dir, baseSha, headSha);
251
+ let log = "";
252
+ try {
253
+ log = await git(dir, [
254
+ "log",
255
+ "--no-color",
256
+ `--max-count=${MAX_LOGGED_COMMITS}`,
257
+ "--format=- %h %s",
258
+ "--end-of-options",
259
+ `${baseSha}..${headSha}`,
260
+ ]);
261
+ }
262
+ catch {
263
+ // The commit list is a courtesy; the diff is the review.
264
+ }
265
+ const last = await this.db.localReview.findFirst({
266
+ where: { pullRequestId: row.id, agentId: opts.agentMarker },
267
+ orderBy: { createdAt: "desc" },
268
+ select: { headSha: true },
269
+ });
270
+ return {
271
+ number: row.number,
272
+ title: `${row.branch} into ${row.base}`,
273
+ body: (log.trim() ? `Commits on ${row.branch} since ${row.base}:\n${log.trimEnd()}` : "").slice(0, MAX_BODY_CHARS),
274
+ author: null,
275
+ state: "open",
276
+ merged: false,
277
+ draft: false,
278
+ baseRef: row.base,
279
+ headRef: row.branch,
280
+ headSha,
281
+ isFork: false,
282
+ htmlUrl: prUrl(row),
283
+ lastReviewedSha: last?.headSha ?? null,
284
+ comparedFrom,
285
+ baseMergedSince,
286
+ files: await this.withPatches(dir, from, headSha, files, opts.maxPatchChars),
287
+ openThreads: [],
288
+ ci: { headSha, state: "none", checks: [], truncated: false, statusesUnavailable: false },
289
+ };
290
+ }
291
+ async pullRequestHead(repository, prNumber) {
292
+ const { repository: canonical, path: dir } = await this.repo(repository);
293
+ const row = await this.pullRequest(canonical, repository, prNumber);
294
+ return { headSha: await this.commitOf(dir, row.branch), isFork: false, state: "open" };
295
+ }
296
+ async readFile(repository, path, ref, window) {
297
+ assertRepoPath(path);
298
+ const { path: dir } = await this.repo(repository);
299
+ const { sha, label } = await this.resolveRef(dir, ref);
300
+ const object = `${sha}:${path}`;
301
+ let type;
302
+ try {
303
+ type = (await git(dir, ["cat-file", "-t", "--end-of-options", object], 64 * 1024)).trim();
304
+ }
305
+ catch {
306
+ return { kind: "not_found", path };
307
+ }
308
+ if (type === "tree") {
309
+ let listing;
310
+ try {
311
+ listing = await git(dir, ["ls-tree", "-z", "--end-of-options", object]);
312
+ }
313
+ catch {
314
+ fail("local_git_failed");
315
+ }
316
+ const entries = listing
317
+ .split("\0")
318
+ .filter((entry) => entry !== "")
319
+ .map((entry) => {
320
+ const tab = entry.indexOf("\t");
321
+ const kind = entry.slice(0, tab).split(" ")[1];
322
+ return `${kind === "tree" ? "dir " : "file"} ${path}/${entry.slice(tab + 1)}`;
323
+ });
324
+ return { kind: "directory", path, entries };
325
+ }
326
+ if (type !== "blob")
327
+ return { kind: "not_found", path };
328
+ let text;
329
+ try {
330
+ text = await git(dir, ["cat-file", "blob", "--end-of-options", object]);
331
+ }
332
+ catch (err) {
333
+ fail(tooLarge(err) ? "file_too_large" : "local_git_failed");
334
+ }
335
+ const lines = text.split("\n");
336
+ const slice = lines.slice(window.startLine - 1, window.startLine - 1 + window.maxLines);
337
+ return {
338
+ kind: "file",
339
+ path,
340
+ ref: label,
341
+ totalLines: lines.length,
342
+ startLine: window.startLine,
343
+ endLine: window.startLine + slice.length - 1,
344
+ truncated: window.startLine - 1 + slice.length < lines.length,
345
+ content: slice.map((line, i) => `${window.startLine + i}: ${line}`).join("\n"),
346
+ text: slice.join("\n"),
347
+ };
348
+ }
349
+ async listFiles(repository, ref, pathPrefix = "") {
350
+ const { path: dir } = await this.repo(repository);
351
+ const { sha, label } = await this.resolveRef(dir, ref);
352
+ let listing;
353
+ try {
354
+ listing = await git(dir, ["ls-tree", "-r", "-l", "-z", "--full-tree", "--end-of-options", sha]);
355
+ }
356
+ catch {
357
+ fail("local_git_failed");
358
+ }
359
+ const entries = listing
360
+ .split("\0")
361
+ .filter((entry) => entry !== "")
362
+ .map((entry) => {
363
+ const tab = entry.indexOf("\t");
364
+ const [, kind, , size] = entry.slice(0, tab).split(/ +/);
365
+ return { kind, size: Number(size) || 0, path: entry.slice(tab + 1) };
366
+ })
367
+ .filter((e) => e.kind === "blob" && e.path.startsWith(pathPrefix) && !skippedInListing(e.path));
368
+ const shown = entries.slice(0, MAX_LISTED_FILES);
369
+ return {
370
+ ref: label,
371
+ count: entries.length,
372
+ truncated: entries.length > MAX_LISTED_FILES,
373
+ files: shown.map((e) => `${e.path} (${e.size} bytes)`),
374
+ paths: shown.map((e) => e.path),
375
+ };
376
+ }
377
+ async publishReview(repository, input) {
378
+ const { repository: canonical, path: dir } = await this.repo(repository);
379
+ const row = await this.pullRequest(canonical, repository, input.prNumber);
380
+ const currentHeadSha = await this.commitOf(dir, row.branch);
381
+ if (input.headSha !== currentHeadSha)
382
+ return { published: false, reason: "stale_head", currentHeadSha };
383
+ const review = await this.db.localReview.create({
384
+ data: {
385
+ pullRequestId: row.id,
386
+ agentId: input.agentMarker,
387
+ headSha: currentHeadSha,
388
+ verdict: input.verdict,
389
+ summary: input.summary,
390
+ body: input.body,
391
+ comments: input.comments.map(({ path, line, side, severity, body }) => ({ path, line, side, severity, body })),
392
+ },
393
+ select: { id: true },
394
+ });
395
+ const url = `${prUrl(row)}#${review.id}`;
396
+ return {
397
+ published: true,
398
+ reviewUrl: url,
399
+ summaryCommentUrl: url,
400
+ checkId: input.checkId ?? null,
401
+ checkConclusion: verdictConclusion(input.verdict),
402
+ inlineCount: input.comments.length,
403
+ outsideDiffCount: 0,
404
+ resolvedThreadIds: [],
405
+ // A local pull request has no threads to resolve.
406
+ skippedThreadIds: [...new Set(input.resolveThreadIds ?? [])],
407
+ };
408
+ }
409
+ /** Stored as a COMMENT review on the branch's current head. The host is not told who comments: agentId is "". */
410
+ async comment(repository, input) {
411
+ const { repository: canonical, path: dir } = await this.repo(repository);
412
+ const row = await this.pullRequest(canonical, repository, input.number);
413
+ const headSha = await this.commitOf(dir, row.branch);
414
+ const review = await this.db.localReview.create({
415
+ data: {
416
+ pullRequestId: row.id,
417
+ agentId: "",
418
+ headSha,
419
+ verdict: "COMMENT",
420
+ summary: "",
421
+ body: input.body,
422
+ comments: [],
423
+ },
424
+ select: { id: true },
425
+ });
426
+ return { url: `${prUrl(row)}#${review.id}`, id: review.id };
427
+ }
428
+ async editComment(_repository, _input) { }
429
+ async acknowledge(_repository, _target) { }
430
+ async startCheck(_repository, _input) {
431
+ return { checkId: "local" };
432
+ }
433
+ async completeCheck(_repository, _input) { }
434
+ }
@@ -1,12 +1,14 @@
1
1
  /**
2
- * Host-neutral code-review surface for native agents. GitHub is the first
3
- * implementation (./github.ts); Bitbucket Cloud is anticipated. Every method
2
+ * Host-neutral code-review surface for native agents. GitHub (./github.ts) and
3
+ * local git repositories (./local.ts) implement it; Bitbucket Cloud is anticipated. Every method
4
4
  * takes the repository and mints its own least-permission credential — no
5
5
  * method accepts a token or a permission. See
6
6
  * docs/private/2026-09-25-code-review-host-design.md §5.
7
7
  */
8
- export type ReviewHostProvider = "github";
8
+ export type ReviewHostProvider = "github" | "local";
9
9
  export declare const REVIEW_HOST_PROVIDERS: readonly ReviewHostProvider[];
10
+ /** Providers with host user accounts to link (link_host_account); a local repository has none. */
11
+ export declare const HOST_ACCOUNT_PROVIDERS: readonly ReviewHostProvider[];
10
12
  export type ReviewVerdict = "APPROVE" | "CHANGES_REQUESTED" | "COMMENT";
11
13
  export type CheckConclusion = "success" | "failure" | "neutral";
12
14
  export type CommentSide = "LEFT" | "RIGHT";
@@ -30,6 +32,30 @@ export interface ReviewThreadView {
30
32
  /** The finding as posted, without its marker, capped at 1,000 characters. */
31
33
  body: string;
32
34
  }
35
+ export type CiState = "passing" | "failing" | "pending" | "inconclusive" | "none" | "unavailable";
36
+ /** One CI result on a commit: a check run or a commit status. */
37
+ export interface CiCheckView {
38
+ /** Check run name or status context (repository-controlled text), capped at 100 characters. */
39
+ name: string;
40
+ kind: "check_run" | "status";
41
+ status: "queued" | "in_progress" | "completed";
42
+ /** Set when completed: success, failure, neutral, cancelled, skipped, timed_out, action_required, stale, startup_failure, error. */
43
+ conclusion: string | null;
44
+ /** The app that reported a check run (e.g. "github-actions"); null for statuses. */
45
+ app: string | null;
46
+ }
47
+ /** CI on the PR's head commit, excluding everything wardby's own App reported. */
48
+ export interface CiView {
49
+ headSha: string;
50
+ state: CiState;
51
+ checks: CiCheckView[];
52
+ /** More results exist than are listed. */
53
+ truncated: boolean;
54
+ /** Commit statuses could not be read (permission not granted); only check runs are listed. */
55
+ statusesUnavailable: boolean;
56
+ /** Set when state is "unavailable": a host error code. */
57
+ unavailableReason?: string;
58
+ }
33
59
  export interface PullRequestView {
34
60
  number: number;
35
61
  title: string;
@@ -56,12 +82,24 @@ export interface PullRequestView {
56
82
  files: PullRequestFileView[];
57
83
  /** This agent's own unresolved review threads on the PR (the first 100 threads are searched). */
58
84
  openThreads: ReviewThreadView[];
85
+ /** CI on headSha (check runs and commit statuses, minus wardby's own); absent on hosts that can't read CI. */
86
+ ci?: CiView;
59
87
  }
60
88
  export interface PullRequestHead {
61
89
  headSha: string;
62
90
  isFork: boolean;
63
91
  state: string;
64
92
  }
93
+ export interface PullRequestOrigin extends PullRequestHead {
94
+ /** The run id in the description's marker; only on a PR the App itself authored. */
95
+ markerRunId?: string;
96
+ /** The PR's label names; empty on a host without labels. */
97
+ labels: string[];
98
+ /** True once merged; absent on hosts that can't say. */
99
+ merged?: boolean;
100
+ /** True while a draft; absent on hosts that can't say. */
101
+ draft?: boolean;
102
+ }
65
103
  export type FileReadResult = {
66
104
  kind: "file";
67
105
  path: string;
@@ -200,6 +238,23 @@ export interface CodeReviewHost {
200
238
  agentMarker: string;
201
239
  }): Promise<PullRequestView>;
202
240
  pullRequestHead(repository: string, prNumber: number): Promise<PullRequestHead>;
241
+ /** CI on one commit, excluding wardby's own checks. Never throws. Hosts that can't say leave it out. */
242
+ readCi?(repository: string, headSha: string): Promise<CiView>;
243
+ /** Head, labels, and (on an App-authored PR) the run marker. Hosts that can't say leave it out. */
244
+ pullRequestOrigin?(repository: string, prNumber: number): Promise<PullRequestOrigin>;
245
+ /** Adds a label to a pull request (created if missing). Absent on hosts without labels. */
246
+ addLabel?(repository: string, prNumber: number, label: string): Promise<void>;
247
+ /**
248
+ * Replaces (or inserts) wardby's marked Related pull requests section in an
249
+ * open pull request's description — only on a PR the App itself authored
250
+ * whose run marker is `expectedMarkerRunId`. "skipped" covers every refusal
251
+ * (not the App's, another run's, closed, malformed markers, too long).
252
+ * Absent on hosts that can't edit descriptions.
253
+ */
254
+ replaceRelatedSection?(repository: string, prNumber: number, input: {
255
+ expectedMarkerRunId: string;
256
+ block: string;
257
+ }): Promise<"updated" | "unchanged" | "skipped">;
203
258
  readFile(repository: string, path: string, ref: string | undefined, window: {
204
259
  startLine: number;
205
260
  maxLines: number;
@@ -278,6 +333,13 @@ export type HostEvent = {
278
333
  repository: string;
279
334
  prNumber: number;
280
335
  merged: boolean;
336
+ } | {
337
+ /** A CI check suite (not wardby's own) finished on a commit some open pull requests point at. */
338
+ kind: "ci_completed";
339
+ provider: ReviewHostProvider;
340
+ repository: string;
341
+ headSha: string;
342
+ prNumbers: number[];
281
343
  } | {
282
344
  kind: "check_rerun";
283
345
  provider: ReviewHostProvider;
@@ -1,11 +1,13 @@
1
1
  /**
2
- * Host-neutral code-review surface for native agents. GitHub is the first
3
- * implementation (./github.ts); Bitbucket Cloud is anticipated. Every method
2
+ * Host-neutral code-review surface for native agents. GitHub (./github.ts) and
3
+ * local git repositories (./local.ts) implement it; Bitbucket Cloud is anticipated. Every method
4
4
  * takes the repository and mints its own least-permission credential — no
5
5
  * method accepts a token or a permission. See
6
6
  * docs/private/2026-09-25-code-review-host-design.md §5.
7
7
  */
8
- export const REVIEW_HOST_PROVIDERS = ["github"];
8
+ export const REVIEW_HOST_PROVIDERS = ["github", "local"];
9
+ /** Providers with host user accounts to link (link_host_account); a local repository has none. */
10
+ export const HOST_ACCOUNT_PROVIDERS = ["github"];
9
11
  export const HOST_PERMISSION_RANK = {
10
12
  none: 0,
11
13
  read: 1,
@@ -1,4 +1,5 @@
1
- import { type GitHubRepositoryAccess, type RepositoryFileInput } from "./github.js";
1
+ import { type RepositoryFileInput } from "./github.js";
2
+ import type { GitRemote } from "./remote.js";
2
3
  import type { ContinuationFinishedDetails, ContinuationOutcome, FinalizeChangesDetails, FinalizeChangesResult, PreparedWorkspace, VcsPrepareInput, VcsProvider } from "./types.js";
3
4
  export declare const DEFAULT_MAX_CHANGED_FILES = 1000;
4
5
  export declare const DEFAULT_MAX_DIFF_BYTES: number;
@@ -36,12 +37,11 @@ export declare class NodeGitCommandRunner implements GitCommandRunner {
36
37
  }
37
38
  export interface GitVcsProviderOptions {
38
39
  rootDir: string;
39
- github: GitHubRepositoryAccess;
40
+ /** Where repositories are cloned from and pushed to (GitHubRemote in production). */
41
+ remote: GitRemote;
40
42
  git?: GitCommandRunner;
41
43
  maxChangedFiles?: number;
42
44
  maxDiffBytes?: number;
43
- /** Test-only transport override; production composition always uses github.com. */
44
- cloneUrlForRepository?: (repository: string) => string;
45
45
  }
46
46
  /**
47
47
  * Whether a changed path is protected, in this order: the WARDBY_PROTECTED_PATHS
@@ -55,37 +55,27 @@ export interface GitVcsProviderOptions {
55
55
  * in-flight run gets it.
56
56
  */
57
57
  export declare function protectedPathMatcher(patterns: readonly string[]): (path: string) => boolean;
58
+ export declare function validateChangedPath(path: string): string;
58
59
  export declare class GitVcsProvider implements VcsProvider {
59
- private readonly options;
60
60
  private readonly rootDir;
61
61
  private readonly git;
62
62
  private readonly maxChangedFiles;
63
63
  private readonly maxDiffBytes;
64
- private readonly cloneUrlForRepository;
64
+ private readonly remote;
65
65
  constructor(options: GitVcsProviderOptions);
66
66
  prepareWorkspace(input: VcsPrepareInput): Promise<PreparedWorkspace>;
67
67
  recoverWorkspace(input: VcsPrepareInput): Promise<PreparedWorkspace | null>;
68
68
  finalizeChanges(workspace: PreparedWorkspace, details?: FinalizeChangesDetails): Promise<FinalizeChangesResult>;
69
69
  cleanup(workspace: PreparedWorkspace): Promise<void>;
70
70
  /**
71
- * Best-effort "wardby is working on this PR" signal (see VcsProvider) --
72
- * whole body wrapped so this can NEVER throw or otherwise affect the
73
- * real coding run, mirroring docker.ts's readWorkerFailureDiagnostic
74
- * ("diagnostics are optional and must never affect terminal cleanup").
75
- * No-op for a fresh (non-continuation) workspace: there's no PR to
76
- * attach anything to until its one commit lands.
71
+ * Best-effort "wardby is working on this PR" signal (see VcsProvider),
72
+ * delegated to the remote; a remote without one is a no-op. Wrapped so
73
+ * this can NEVER throw or otherwise affect the real coding run.
77
74
  */
78
75
  notifyContinuationStarted(workspace: PreparedWorkspace, details?: {
79
76
  agentName?: string;
80
77
  }): Promise<void>;
81
- /**
82
- * Companion to notifyContinuationStarted -- finds and updates whatever
83
- * that call created, never creates fresh state itself (see
84
- * upsertContinuationStatusComment vs updateContinuationStatusComment,
85
- * and createContinuationCheckRun vs completeContinuationCheckRun in
86
- * github.ts). Safe to call more than once for the same run. Same
87
- * never-throw contract as notifyContinuationStarted.
88
- */
78
+ /** Companion to notifyContinuationStarted, delegated to the remote under the same never-throw contract. */
89
79
  notifyContinuationFinished(workspace: PreparedWorkspace, outcome: ContinuationOutcome, details?: ContinuationFinishedDetails): Promise<void>;
90
80
  readRepositoryFile(input: RepositoryFileInput): Promise<string | null>;
91
81
  private validateInput;