@ashulab/carrier 0.0.0-stage → 0.1.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 (193) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +264 -2
  3. package/dist/chat/claude.js +367 -0
  4. package/dist/chat/codex.js +375 -0
  5. package/dist/chat/config.js +61 -0
  6. package/dist/chat/db.js +9 -0
  7. package/dist/chat/fake.js +203 -0
  8. package/dist/chat/folder.js +56 -0
  9. package/dist/chat/git.js +27 -0
  10. package/dist/chat/group.js +28 -0
  11. package/dist/chat/health.js +49 -0
  12. package/dist/chat/host.js +123 -0
  13. package/dist/chat/idle.js +20 -0
  14. package/dist/chat/migrations/0000_lame_quasar.sql +15 -0
  15. package/dist/chat/migrations/0001_quiet_penance.sql +23 -0
  16. package/dist/chat/migrations/0002_slim_ezekiel_stane.sql +1 -0
  17. package/dist/chat/migrations/0003_naive_iron_lad.sql +5 -0
  18. package/dist/chat/migrations/0004_skinny_dakota_north.sql +1 -0
  19. package/dist/chat/migrations/0005_tidy_strong_guy.sql +1 -0
  20. package/dist/chat/migrations/meta/0000_snapshot.json +121 -0
  21. package/dist/chat/migrations/meta/0001_snapshot.json +277 -0
  22. package/dist/chat/migrations/meta/0002_snapshot.json +284 -0
  23. package/dist/chat/migrations/meta/0003_snapshot.json +320 -0
  24. package/dist/chat/migrations/meta/0004_snapshot.json +327 -0
  25. package/dist/chat/migrations/meta/0005_snapshot.json +334 -0
  26. package/dist/chat/migrations/meta/_journal.json +48 -0
  27. package/dist/chat/names.js +97 -0
  28. package/dist/chat/plan.js +21 -0
  29. package/dist/chat/projects.js +242 -0
  30. package/dist/chat/runner.js +6 -0
  31. package/dist/chat/schema.js +48 -0
  32. package/dist/chat/server.js +202 -0
  33. package/dist/chat/sessions.js +766 -0
  34. package/dist/chat/sweep.js +64 -0
  35. package/dist/chat/transcript.js +31 -0
  36. package/dist/chat/words.js +20 -0
  37. package/dist/chat/workspace.js +134 -0
  38. package/dist/chat/worktree.js +142 -0
  39. package/dist/config/default-path.js +7 -0
  40. package/dist/config/engine-places.js +9 -0
  41. package/dist/config/json-schema.js +15 -0
  42. package/dist/config/load.js +48 -0
  43. package/dist/config/resolve.js +41 -0
  44. package/dist/config/schema.js +225 -0
  45. package/dist/control/epic.js +219 -0
  46. package/dist/control/events.js +48 -0
  47. package/dist/control/files.js +262 -0
  48. package/dist/control/replies.js +1 -0
  49. package/dist/control/server.js +237 -0
  50. package/dist/control/sessions.js +68 -0
  51. package/dist/doctor/doctor.js +255 -0
  52. package/dist/doctor/fixes.js +35 -0
  53. package/dist/execution/agent-runner.js +1 -0
  54. package/dist/execution/caller-result.js +61 -0
  55. package/dist/execution/events.js +19 -0
  56. package/dist/execution/failure.js +3 -0
  57. package/dist/execution/fake.js +82 -0
  58. package/dist/execution/git-workspaces.js +136 -0
  59. package/dist/execution/import-result.js +23 -0
  60. package/dist/execution/workspaces.js +1 -0
  61. package/dist/format/duration.js +12 -0
  62. package/dist/harnesses/agy.js +131 -0
  63. package/dist/harnesses/claude.js +246 -0
  64. package/dist/harnesses/codex.js +118 -0
  65. package/dist/harnesses/common.js +59 -0
  66. package/dist/harnesses/credentials.js +38 -0
  67. package/dist/harnesses/host-runner.js +225 -0
  68. package/dist/harnesses/local.js +25 -0
  69. package/dist/harnesses/names.js +9 -0
  70. package/dist/harnesses/replay-events.js +57 -0
  71. package/dist/integrations/available-repos.js +29 -0
  72. package/dist/integrations/forge-cwd.js +27 -0
  73. package/dist/integrations/github-graphql.js +216 -0
  74. package/dist/integrations/github.js +231 -0
  75. package/dist/logging/logger.js +53 -0
  76. package/dist/logging/redact.js +21 -0
  77. package/dist/orchestration/blockers.js +25 -0
  78. package/dist/orchestration/changes-slices.js +5 -0
  79. package/dist/orchestration/clock.js +7 -0
  80. package/dist/orchestration/engine.js +1068 -0
  81. package/dist/orchestration/fake.js +152 -0
  82. package/dist/orchestration/final-pr.js +15 -0
  83. package/dist/orchestration/github.js +31 -0
  84. package/dist/orchestration/reserve.js +32 -0
  85. package/dist/orchestration/resume-refused.js +7 -0
  86. package/dist/orchestration/retention.js +145 -0
  87. package/dist/orchestration/retry.js +16 -0
  88. package/dist/prompts/build-prompt.js +13 -0
  89. package/dist/prompts/fix.js +21 -0
  90. package/dist/prompts/intro.js +21 -0
  91. package/dist/prompts/invoke.js +2 -0
  92. package/dist/prompts/persona.js +12 -0
  93. package/dist/prompts/result.js +32 -0
  94. package/dist/prompts/review.js +15 -0
  95. package/dist/prompts/ship.js +8 -0
  96. package/dist/status/snapshot.js +92 -0
  97. package/dist/status/types.js +1 -0
  98. package/dist/storage/caller-results.js +7 -0
  99. package/dist/storage/db.js +8 -0
  100. package/dist/storage/epic-review-threads.js +19 -0
  101. package/dist/storage/epic-slices.js +17 -0
  102. package/dist/storage/events.js +52 -0
  103. package/dist/storage/executions.js +31 -0
  104. package/dist/storage/lock.js +21 -0
  105. package/dist/storage/migrations/0000_nosy_demogoblin.sql +5 -0
  106. package/dist/storage/migrations/0001_worried_grey_gargoyle.sql +3 -0
  107. package/dist/storage/migrations/0002_tough_squirrel_girl.sql +5 -0
  108. package/dist/storage/migrations/0003_loose_maria_hill.sql +4 -0
  109. package/dist/storage/migrations/0004_long_cerise.sql +18 -0
  110. package/dist/storage/migrations/0005_review_result.sql +2 -0
  111. package/dist/storage/migrations/0006_melted_menace.sql +10 -0
  112. package/dist/storage/migrations/0007_powerful_bloodscream.sql +2 -0
  113. package/dist/storage/migrations/0008_panel_state.sql +4 -0
  114. package/dist/storage/migrations/0009_workable_tiger_shark.sql +5 -0
  115. package/dist/storage/migrations/0010_nasty_blue_marvel.sql +4 -0
  116. package/dist/storage/migrations/0011_steep_lila_cheney.sql +13 -0
  117. package/dist/storage/migrations/0012_talented_big_bertha.sql +1 -0
  118. package/dist/storage/migrations/0013_third_mulholland_black.sql +17 -0
  119. package/dist/storage/migrations/0014_perfect_marten_broadcloak.sql +20 -0
  120. package/dist/storage/migrations/0015_long_bloodaxe.sql +1 -0
  121. package/dist/storage/migrations/0016_neat_true_believers.sql +5 -0
  122. package/dist/storage/migrations/0017_previous_swarm.sql +6 -0
  123. package/dist/storage/migrations/0018_opposite_plazm.sql +2 -0
  124. package/dist/storage/migrations/0019_hesitant_triton.sql +3 -0
  125. package/dist/storage/migrations/meta/0000_snapshot.json +49 -0
  126. package/dist/storage/migrations/meta/0001_snapshot.json +74 -0
  127. package/dist/storage/migrations/meta/0002_snapshot.json +105 -0
  128. package/dist/storage/migrations/meta/0003_snapshot.json +134 -0
  129. package/dist/storage/migrations/meta/0004_snapshot.json +257 -0
  130. package/dist/storage/migrations/meta/0005_snapshot.json +271 -0
  131. package/dist/storage/migrations/meta/0006_snapshot.json +337 -0
  132. package/dist/storage/migrations/meta/0007_snapshot.json +351 -0
  133. package/dist/storage/migrations/meta/0008_snapshot.json +379 -0
  134. package/dist/storage/migrations/meta/0009_snapshot.json +414 -0
  135. package/dist/storage/migrations/meta/0010_snapshot.json +442 -0
  136. package/dist/storage/migrations/meta/0011_snapshot.json +531 -0
  137. package/dist/storage/migrations/meta/0012_snapshot.json +538 -0
  138. package/dist/storage/migrations/meta/0013_snapshot.json +655 -0
  139. package/dist/storage/migrations/meta/0014_snapshot.json +772 -0
  140. package/dist/storage/migrations/meta/0015_snapshot.json +779 -0
  141. package/dist/storage/migrations/meta/0016_snapshot.json +793 -0
  142. package/dist/storage/migrations/meta/0017_snapshot.json +835 -0
  143. package/dist/storage/migrations/meta/0018_snapshot.json +849 -0
  144. package/dist/storage/migrations/meta/0019_snapshot.json +870 -0
  145. package/dist/storage/migrations/meta/_journal.json +146 -0
  146. package/dist/storage/schema.js +43 -0
  147. package/dist/terminal/cli/args.js +91 -0
  148. package/dist/terminal/cli/cli-error.js +11 -0
  149. package/dist/terminal/cli/commands/chat-host.js +18 -0
  150. package/dist/terminal/cli/commands/chat.js +321 -0
  151. package/dist/terminal/cli/commands/control.js +46 -0
  152. package/dist/terminal/cli/commands/doctor.js +15 -0
  153. package/dist/terminal/cli/commands/init.js +43 -0
  154. package/dist/terminal/cli/commands/restart.js +92 -0
  155. package/dist/terminal/cli/commands/run.js +20 -0
  156. package/dist/terminal/cli/commands/schema.js +4 -0
  157. package/dist/terminal/cli/commands/start.js +21 -0
  158. package/dist/terminal/cli/commands/status.js +61 -0
  159. package/dist/terminal/cli/commands/watch.js +112 -0
  160. package/dist/terminal/cli/compose.js +163 -0
  161. package/dist/terminal/cli/config-file.js +24 -0
  162. package/dist/terminal/cli/control-client.js +94 -0
  163. package/dist/terminal/cli/detached.js +41 -0
  164. package/dist/terminal/cli/interrupt.js +39 -0
  165. package/dist/terminal/cli/main.js +85 -0
  166. package/dist/terminal/cli/open-url.js +9 -0
  167. package/dist/terminal/dashboard/dashboard.js +204 -0
  168. package/dist/terminal/dashboard/history.js +38 -0
  169. package/dist/terminal/dashboard/interactive.js +60 -0
  170. package/dist/terminal/dashboard/keys.js +22 -0
  171. package/dist/terminal/dashboard/logs.js +85 -0
  172. package/dist/terminal/dashboard/panel.js +62 -0
  173. package/dist/terminal/dashboard/plain.js +32 -0
  174. package/dist/terminal/dashboard/printable.js +2 -0
  175. package/dist/terminal/dashboard/ui-state.js +110 -0
  176. package/dist/terminal/prompts/clack.js +121 -0
  177. package/dist/terminal/prompts/types.js +3 -0
  178. package/dist/terminal/wizard/build-config.js +66 -0
  179. package/dist/terminal/wizard/deps.js +94 -0
  180. package/dist/terminal/wizard/flags.js +1 -0
  181. package/dist/terminal/wizard/forge-registry.js +23 -0
  182. package/dist/terminal/wizard/prior.js +20 -0
  183. package/dist/terminal/wizard/questions/agent.js +61 -0
  184. package/dist/terminal/wizard/questions/identities.js +28 -0
  185. package/dist/terminal/wizard/questions/identity.js +30 -0
  186. package/dist/terminal/wizard/questions/options.js +93 -0
  187. package/dist/terminal/wizard/questions/out.js +16 -0
  188. package/dist/terminal/wizard/questions/persona.js +47 -0
  189. package/dist/terminal/wizard/questions/repos.js +61 -0
  190. package/dist/terminal/wizard/summary.js +31 -0
  191. package/dist/terminal/wizard/testing.js +106 -0
  192. package/dist/terminal/wizard/wizard.js +172 -0
  193. package/package.json +46 -3
@@ -0,0 +1,231 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import { z } from 'zod';
4
+ import { epicSlicesQuery, epicSlicesResponse, pullRequestOf, pullRequestQuery, pullRequestResponse, sliceOf, threadsOf } from './github-graphql.js';
5
+ import { redact } from '../logging/redact.js';
6
+ import { AuthError, GitHubUnavailable, epicLabel, readyLabel } from '../orchestration/github.js';
7
+ const execFileAsync = promisify(execFile);
8
+ const PER_PAGE = 100;
9
+ const repoPattern = /^[\w.-]+\/[\w.-]+$/;
10
+ // Forge reports every failure with exit 1, so credentials problems are told apart by their stderr.
11
+ const authStderr = /Agent not found:|Private key for .* not found or unreadable|App JWT was rejected|has no installations|Bad credentials|HTTP 401|not logged in|gh auth login/i;
12
+ const networkStderr = /error connecting|dial tcp|no such host|ENOTFOUND|ECONNRESET|ECONNREFUSED|ETIMEDOUT|EAI_AGAIN|network is unreachable|i\/o timeout|connection reset|TLS handshake/i;
13
+ /** Any non-2xx answer that is neither "GitHub is down" nor "bad credentials", e.g. 404 or 422. */
14
+ class HttpError extends Error {
15
+ status;
16
+ constructor(status, body) {
17
+ super(`GitHub answered HTTP ${status}: ${body.slice(0, 300)}`);
18
+ this.status = status;
19
+ }
20
+ }
21
+ const issueRow = z.object({ number: z.int().positive(), title: z.string(), labels: z.array(z.object({ name: z.string() })), pull_request: z.unknown().optional() });
22
+ // GitHub does not require `sub_issues_summary` on an issue.
23
+ const epicRow = issueRow.extend({ sub_issues_summary: z.object({ total: z.int().nonnegative(), completed: z.int().nonnegative() }).optional() });
24
+ const issueDetail = z.object({ state: z.enum(['open', 'closed']), labels: z.array(z.object({ name: z.string() })) });
25
+ const pullRow = z.object({
26
+ number: z.int().positive(),
27
+ state: z.enum(['open', 'closed']),
28
+ merged_at: z.string().nullable(),
29
+ body: z.string().nullable(),
30
+ head: z.object({ sha: z.string().min(1), ref: z.string().min(1) }),
31
+ });
32
+ const labelRow = z.object({ name: z.string() });
33
+ function retryAfterMs(headers) {
34
+ const retryAfter = Number(headers['retry-after']);
35
+ if (headers['retry-after'] !== undefined && Number.isFinite(retryAfter))
36
+ return retryAfter * 1000;
37
+ const reset = Number(headers['x-ratelimit-reset']);
38
+ if (headers['x-ratelimit-remaining'] === '0' && Number.isFinite(reset))
39
+ return Math.max(0, reset * 1000 - Date.now());
40
+ return undefined;
41
+ }
42
+ function statusFailure(profile, status, headers, body) {
43
+ const wait = retryAfterMs(headers);
44
+ const rateLimited = status === 429 || (status === 403 && (wait !== undefined || /rate limit/i.test(body)));
45
+ if (rateLimited)
46
+ return new GitHubUnavailable('rate_limited', `HTTP ${status}`, wait);
47
+ if (status >= 500)
48
+ return new GitHubUnavailable('server_error', `HTTP ${status}`, wait);
49
+ if (status === 401)
50
+ return new AuthError(profile);
51
+ return new HttpError(status, body);
52
+ }
53
+ // `gh api -i` prints the status line, the headers, a blank line and the body, also on errors.
54
+ function parseResponse(stdout) {
55
+ const match = /^HTTP\/\S+ (\d{3})[^\n]*\n([\s\S]*?)\r?\n\r?\n([\s\S]*)$/.exec(stdout);
56
+ if (!match)
57
+ return null;
58
+ const headers = {};
59
+ for (const line of match[2].split(/\r?\n/)) {
60
+ const colon = line.indexOf(':');
61
+ if (colon > 0)
62
+ headers[line.slice(0, colon).toLowerCase()] = line.slice(colon + 1).trim();
63
+ }
64
+ return { status: Number(match[1]), headers, body: match[3] };
65
+ }
66
+ /** The REST bucket's numbers from the `x-ratelimit-*` headers; null when any is missing or malformed, and for the GraphQL bucket, which is separate. */
67
+ function rateLimitOf(headers) {
68
+ const resource = headers['x-ratelimit-resource'];
69
+ if (resource !== undefined && resource !== 'core')
70
+ return null;
71
+ const [limit, remaining, reset] = ['limit', 'remaining', 'reset'].map((name) => (/^\d+$/.test(headers[`x-ratelimit-${name}`] ?? '') ? Number(headers[`x-ratelimit-${name}`]) : NaN));
72
+ if (!Number.isSafeInteger(limit) || !Number.isSafeInteger(remaining) || !Number.isSafeInteger(reset))
73
+ return null;
74
+ return { remaining: remaining, limit: limit, resetAt: reset * 1000 };
75
+ }
76
+ export class GhGitHub {
77
+ options;
78
+ latestRateLimit = null;
79
+ constructor(options) {
80
+ this.options = options;
81
+ }
82
+ rateLimit() {
83
+ return this.latestRateLimit;
84
+ }
85
+ /** Keeps the numbers of the latest answer, a failure included: the call that hit the limit says it is 0. */
86
+ track(headers) {
87
+ this.latestRateLimit = rateLimitOf(headers) ?? this.latestRateLimit;
88
+ }
89
+ /** One `gh api` call: argv separated, no shell. Returns the body of a 2xx answer, throws for anything else. */
90
+ async api(repo, args) {
91
+ if (!repoPattern.test(repo))
92
+ throw new Error(`invalid repo "${repo}"`);
93
+ const { profile, executable } = this.options;
94
+ const profileName = profile.kind === 'forge' ? profile.agent : 'gh';
95
+ const [command, ...prefix] = profile.kind === 'forge' ? [executable ?? 'agent-forge', 'gh', profile.agent] : [executable ?? 'gh'];
96
+ try {
97
+ const { stdout } = await execFileAsync(command, [...prefix, 'api', '-i', ...args], {
98
+ cwd: this.options.cwd(repo),
99
+ timeout: this.options.timeoutMs,
100
+ maxBuffer: 64 * 1024 * 1024,
101
+ encoding: 'utf8',
102
+ });
103
+ const response = parseResponse(stdout);
104
+ if (!response)
105
+ throw new Error(`unexpected gh output: ${stdout.slice(0, 200)}`);
106
+ this.track(response.headers);
107
+ if (response.status >= 300)
108
+ throw statusFailure(profileName, response.status, response.headers, response.body);
109
+ return response.body;
110
+ }
111
+ catch (error) {
112
+ if (error instanceof GitHubUnavailable || error instanceof AuthError || error instanceof HttpError)
113
+ throw error;
114
+ const failed = error;
115
+ // Exceeding maxBuffer also kills the child, but that is not a timeout.
116
+ if (failed.killed && failed.code !== 'ERR_CHILD_PROCESS_STDIO_MAXBUFFER')
117
+ throw new GitHubUnavailable('timeout', `timed out after ${this.options.timeoutMs} ms`);
118
+ const response = parseResponse(failed.stdout ?? '');
119
+ if (response) {
120
+ this.track(response.headers);
121
+ throw statusFailure(profileName, response.status, response.headers, response.body);
122
+ }
123
+ const stderr = failed.stderr ?? '';
124
+ if (authStderr.test(stderr))
125
+ throw new AuthError(profileName);
126
+ if (networkStderr.test(stderr))
127
+ throw new GitHubUnavailable('network', redact(stderr).replace(/\s+/g, ' ').trim().slice(0, 300));
128
+ throw new Error(`gh failed: ${(stderr || failed.message).trim().slice(0, 300)}`);
129
+ }
130
+ }
131
+ async get(repo, path, schema) {
132
+ return schema.parse(JSON.parse(await this.api(repo, [`repos/${repo}/${path}`])));
133
+ }
134
+ /** Pages of a list endpoint, fetched lazily until one comes back short. */
135
+ async *pages(repo, path, params, row) {
136
+ for (let page = 1;; page++) {
137
+ const query = new URLSearchParams({ ...params, per_page: String(PER_PAGE), page: String(page) });
138
+ const rows = await this.get(repo, `${path}?${query}`, z.array(row));
139
+ yield rows;
140
+ if (rows.length < PER_PAGE)
141
+ return;
142
+ }
143
+ }
144
+ async listReadyEpics(repo) {
145
+ const epics = [];
146
+ for await (const rows of this.pages(repo, 'issues', { labels: readyLabel, state: 'open' }, issueRow)) {
147
+ for (const row of rows)
148
+ if (row.pull_request === undefined)
149
+ epics.push({ issue: row.number, title: row.title, labels: row.labels.map((label) => label.name) });
150
+ }
151
+ return epics;
152
+ }
153
+ async listOpenEpics(repo) {
154
+ const epics = [];
155
+ for await (const rows of this.pages(repo, 'issues', { labels: epicLabel, state: 'open' }, epicRow)) {
156
+ for (const row of rows) {
157
+ if (row.pull_request === undefined)
158
+ epics.push({ issue: row.number, title: row.title, labels: row.labels.map((label) => label.name), slices: row.sub_issues_summary ? { done: row.sub_issues_summary.completed, total: row.sub_issues_summary.total } : null });
159
+ }
160
+ }
161
+ return epics;
162
+ }
163
+ async readSlices(repo, issue) {
164
+ const [owner = '', name = ''] = repo.split('/');
165
+ const body = await this.api(repo, ['graphql', '-f', `query=${epicSlicesQuery}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${issue}`]);
166
+ const { subIssues } = epicSlicesResponse.parse(JSON.parse(body)).data.repository.issue;
167
+ return { slices: subIssues.nodes.map(sliceOf), truncated: subIssues.pageInfo.hasNextPage };
168
+ }
169
+ async readPullRequest(repo, number) {
170
+ const [owner = '', name = ''] = repo.split('/');
171
+ const body = await this.api(repo, ['graphql', '-f', `query=${pullRequestQuery}`, '-f', `owner=${owner}`, '-f', `name=${name}`, '-F', `number=${number}`]);
172
+ const response = pullRequestResponse.parse(JSON.parse(body));
173
+ return { pr: pullRequestOf(response), ...threadsOf(response) };
174
+ }
175
+ async getIssue(repo, issue) {
176
+ const { state, labels } = await this.get(repo, `issues/${issue}`, issueDetail);
177
+ return { state, labels: labels.map((label) => label.name) };
178
+ }
179
+ async getPullRequest(repo, number) {
180
+ try {
181
+ const pull = await this.get(repo, `pulls/${number}`, pullRow);
182
+ return { state: pull.merged_at === null ? pull.state : 'merged', body: pull.body ?? '', headSha: pull.head.sha, headRef: pull.head.ref };
183
+ }
184
+ catch (error) {
185
+ if (error instanceof HttpError && error.status === 404)
186
+ return null;
187
+ throw error;
188
+ }
189
+ }
190
+ async findPullRequest(repo, issue) {
191
+ // Closing keywords GitHub honors, optionally with an explicit reference to this same repo.
192
+ const closes = new RegExp(`\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\\s+(?:${repo.replaceAll('.', '\\.')})?#${issue}(?!\\d)`, 'i');
193
+ // Newest first: the epic's final PR is nearly always on the first page.
194
+ for await (const rows of this.pages(repo, 'pulls', { state: 'all', sort: 'created', direction: 'desc' }, pullRow)) {
195
+ const found = rows.find((pull) => closes.test(pull.body ?? ''));
196
+ if (found)
197
+ return { number: found.number, state: found.merged_at === null ? found.state : 'merged', headSha: found.head.sha };
198
+ }
199
+ return null;
200
+ }
201
+ async setLabels(repo, issue, { add, remove }) {
202
+ // Add first so the issue is never left without its state label.
203
+ if (add.length > 0)
204
+ await this.api(repo, ['-X', 'POST', `repos/${repo}/issues/${issue}/labels`, ...add.flatMap((label) => ['-f', `labels[]=${label}`])]);
205
+ for (const label of remove) {
206
+ try {
207
+ await this.api(repo, ['-X', 'DELETE', `repos/${repo}/issues/${issue}/labels/${encodeURIComponent(label)}`]);
208
+ }
209
+ catch (error) {
210
+ // Removing a label the issue lacks is a no-op.
211
+ if (!(error instanceof HttpError && error.status === 404 && /label does not exist/i.test(error.message)))
212
+ throw error;
213
+ }
214
+ }
215
+ }
216
+ async listLabels(repo) {
217
+ const names = [];
218
+ for await (const rows of this.pages(repo, 'labels', {}, labelRow))
219
+ names.push(...rows.map((row) => row.name));
220
+ return names;
221
+ }
222
+ async createLabel(repo, name, { color, description }) {
223
+ try {
224
+ await this.api(repo, ['-X', 'POST', `repos/${repo}/labels`, '-f', `name=${name}`, '-f', `color=${color}`, '-f', `description=${description}`]);
225
+ }
226
+ catch (error) {
227
+ if (!(error instanceof HttpError && error.status === 422 && /already_exists/.test(error.message)))
228
+ throw error;
229
+ }
230
+ }
231
+ }
@@ -0,0 +1,53 @@
1
+ import { appendFileSync, mkdirSync, openSync, writeSync } from 'node:fs';
2
+ import { dirname, join } from 'node:path';
3
+ import { format } from 'node:util';
4
+ import { configureUniversalLogger, LogLevel } from '@ashulab/universal-logger';
5
+ import { redact } from './redact.js';
6
+ /**
7
+ * Sends everything the process writes with console.* (the library and its dependencies included) to `file`,
8
+ * so the panel is the only thing that writes to the terminal. The level is not in the library's JSON: the
9
+ * console method used adds it. writeSync, not a stream: when it returns the line is in the kernel, so SIGINT loses nothing.
10
+ */
11
+ export function installFileLogging(file, minLevel) {
12
+ process.env.NODE_ENV ??= 'production'; // 'development' makes the library emit several lines per log
13
+ mkdirSync(dirname(file), { recursive: true });
14
+ const fd = openSync(file, 'a');
15
+ for (const method of ['log', 'info', 'warn', 'error', 'debug']) {
16
+ console[method] = (...args) => void writeSync(fd, redact(`[${method}] ${format(...args)}\n`));
17
+ }
18
+ configureUniversalLogger({ enabled: true, minLevel });
19
+ }
20
+ /** Context every log of an execution carries when it applies. */
21
+ export const taskContext = (c) => ({
22
+ repo: c.repo,
23
+ epic: c.epic,
24
+ executionId: c.executionId,
25
+ role: c.stage === 'review' ? 'reviewer' : 'author',
26
+ stage: c.stage,
27
+ ...(c.round && { round: c.round }),
28
+ });
29
+ const MAX_PENDING = 64 * 1024;
30
+ /**
31
+ * Where the whole output of one execution goes: `<logs>/executions/<executionId>/{stdout,stderr}.log`.
32
+ * Only whole lines are redacted and written, so a token split across two chunks is still caught; `flush` writes what is left when the run ends.
33
+ */
34
+ export function executionOutput(logs, executionId) {
35
+ const dir = join(logs, 'executions', executionId);
36
+ mkdirSync(dir, { recursive: true });
37
+ const pending = { stdout: '', stderr: '' };
38
+ const append = (stream, text) => text && appendFileSync(join(dir, `${stream}.log`), redact(text));
39
+ return {
40
+ write(stream, chunk) {
41
+ const text = pending[stream] + chunk;
42
+ // a line that never ends is written anyway past the cap: memory over a perfect redaction
43
+ const cut = text.length > MAX_PENDING ? text.length : text.lastIndexOf('\n') + 1;
44
+ append(stream, text.slice(0, cut));
45
+ pending[stream] = text.slice(cut);
46
+ },
47
+ flush() {
48
+ append('stdout', pending.stdout);
49
+ append('stderr', pending.stderr);
50
+ pending.stdout = pending.stderr = '';
51
+ },
52
+ };
53
+ }
@@ -0,0 +1,21 @@
1
+ // Replaces what looks like a secret. A pattern list can miss one (ADR 0006): this is a mitigation, not a guarantee.
2
+ // Order matters: a private key block goes first, because its body would match nothing else but is the biggest leak.
3
+ const patterns = [
4
+ // To the END line, or to the end of the text when the block was cut.
5
+ /-----BEGIN [A-Z ]*PRIVATE KEY(?: BLOCK)?-----[\s\S]*?(?:-----END [A-Z ]*PRIVATE KEY(?: BLOCK)?-----|$)/g,
6
+ // The header's value, with or without a scheme (Bearer, Basic, token).
7
+ /\bAuthorization\s*:\s*(?:(?:Bearer|Basic|Digest|Negotiate|Token)\s+)?[^\s"']+/gi,
8
+ // NAME=value where the name holds SECRET, TOKEN, PASSWORD or KEY; the value is quoted or runs to a space.
9
+ /\b\w*(?:SECRET|TOKEN|PASSWORD|KEY)\w*=(?!=)(?:"[^"]*"|'[^']*'|[^\s"']+)/gi,
10
+ /\b(?:gh[pousr]_|github_pat_)[A-Za-z0-9_]+/g, // GitHub: classic, OAuth, user, server, refresh, fine-grained
11
+ /\bsk-[A-Za-z0-9_-]{16,}/g, // OpenAI, Anthropic (sk-ant-), OpenRouter
12
+ /\b[sr]k_(?:live|test)_[A-Za-z0-9]{16,}/g, // Stripe
13
+ /\bAKIA[0-9A-Z]{16}\b/g, // AWS access key id
14
+ /\bAIza[0-9A-Za-z_-]{35}/g, // Google API key
15
+ /\bxox[abeprs]-[A-Za-z0-9-]{10,}/g, // Slack
16
+ /\bglpat-[A-Za-z0-9_-]{20,}/g, // GitLab
17
+ /\bnpm_[A-Za-z0-9]{36}\b/g, // npm
18
+ /\bhf_[A-Za-z0-9]{30,}/g, // Hugging Face
19
+ /\beyJ[\w-]{10,}\.[\w-]{10,}\.[\w-]{10,}/g, // JWT
20
+ ];
21
+ export const redact = (text) => patterns.reduce((out, pattern) => out.replace(pattern, '[redacted]'), text);
@@ -0,0 +1,25 @@
1
+ import { and, desc, eq, sql } from 'drizzle-orm';
2
+ import { callerResults } from '../storage/caller-results.js';
3
+ import { executions } from '../storage/executions.js';
4
+ /** The blockers of the epic's latest review that asked for changes: what the fix turn has to answer. */
5
+ export function latestBlockers(db, epicId) {
6
+ const last = db
7
+ .select({ result: callerResults.result })
8
+ .from(callerResults)
9
+ .innerJoin(executions, eq(executions.executionId, callerResults.executionId))
10
+ .where(and(eq(executions.epicId, epicId), eq(executions.stage, 'review'), eq(callerResults.outcome, 'changes_requested')))
11
+ .orderBy(desc(sql `executions.rowid`))
12
+ .get();
13
+ return last ? JSON.parse(last.result).blockers : [];
14
+ }
15
+ /** The observations of the epic's latest review when it completed: a fix right after it is the observations pass, null otherwise (that fix is for blockers). */
16
+ export function latestObservations(db, epicId) {
17
+ const last = db
18
+ .select({ outcome: callerResults.outcome, result: callerResults.result })
19
+ .from(callerResults)
20
+ .innerJoin(executions, eq(executions.executionId, callerResults.executionId))
21
+ .where(and(eq(executions.epicId, epicId), eq(executions.stage, 'review')))
22
+ .orderBy(desc(sql `executions.rowid`))
23
+ .get();
24
+ return last?.outcome === 'completed' ? (JSON.parse(last.result).observations ?? []) : null;
25
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Whether a shell command of the agent is one of the `gh` commands that change an epic's slices (take, open, merge, close).
3
+ * Only a signal to look: the data still comes from GitHub, so a missed or false match costs one query or one timer period.
4
+ */
5
+ export const changesSlices = (command) => /(^|[\s;&|(])gh\s+(?:issue\s+(?:edit|close)|pr\s+(?:create|merge|ready|close))(?=\s|$)/.test(command);
@@ -0,0 +1,7 @@
1
+ export const realClock = {
2
+ now: () => Date.now(),
3
+ after(ms, fn) {
4
+ const timer = setTimeout(fn, ms);
5
+ return () => clearTimeout(timer);
6
+ },
7
+ };