@dotdotdash/afterhours 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.
package/dist/index.js ADDED
@@ -0,0 +1,2920 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ writeReports
4
+ } from "./chunk-UC2WX3XO.js";
5
+
6
+ // src/cli/main.ts
7
+ import { Command } from "commander";
8
+
9
+ // src/config/load.ts
10
+ import { existsSync, readFileSync } from "fs";
11
+ import { dirname, join } from "path";
12
+ import { parse } from "yaml";
13
+
14
+ // src/config/schema.ts
15
+ import { z } from "zod";
16
+ var projectSchema = z.object({
17
+ type: z.enum(["auto", "website", "server", "library"]).default("auto"),
18
+ packageManager: z.enum(["auto", "npm", "pnpm", "yarn", "bun"]).default("auto"),
19
+ build: z.union([z.enum(["auto", "none"]), z.string()]).default("auto"),
20
+ test: z.union([z.enum(["auto", "none"]), z.string()]).default("auto"),
21
+ run: z.union([z.enum(["auto"]), z.string()]).default("auto"),
22
+ runHealthcheck: z.object({
23
+ type: z.enum(["http", "log", "exit"]).default("http"),
24
+ url: z.string().optional(),
25
+ timeoutSec: z.number().int().positive().optional()
26
+ }).strict().optional()
27
+ }).strict();
28
+ var sandboxSchema = z.object({
29
+ mode: z.enum(["docker", "host"]).default("docker"),
30
+ image: z.string().default("node:20-bookworm"),
31
+ network: z.enum(["bridge", "none"]).default("bridge")
32
+ }).strict();
33
+ var scheduleSchema = z.object({ cron: z.string().default("0 3 * * 1") }).strict();
34
+ var platformSchema = z.object({
35
+ provider: z.enum(["github", "gitlab", "azure", "codecommit", "gcsr", "generic"]).default("github"),
36
+ remote: z.string().default("origin"),
37
+ baseBranch: z.string().default("main"),
38
+ branchPrefix: z.string().default("afterhours/"),
39
+ autoMerge: z.boolean().default(false),
40
+ mergeStrategy: z.enum(["squash", "merge", "rebase"]).default("squash")
41
+ }).strict();
42
+ var llmSchema = z.object({
43
+ provider: z.enum(["anthropic", "openai", "ollama", "github-models", "gemini", "copilot"]).default("anthropic"),
44
+ model: z.string().default("claude-sonnet-4-5"),
45
+ apiKeyEnv: z.string().default("ANTHROPIC_API_KEY"),
46
+ baseUrlEnv: z.string().optional(),
47
+ maxTokens: z.number().int().positive().default(8e3)
48
+ }).strict();
49
+ var agentExternalSchema = z.object({
50
+ cli: z.enum(["aider", "claude", "codex"]).default("aider"),
51
+ extraArgs: z.array(z.string()).default([])
52
+ }).strict();
53
+ var agentSchema = z.object({
54
+ mode: z.enum(["builtin", "external"]).default("builtin"),
55
+ external: agentExternalSchema.optional()
56
+ }).strict();
57
+ var dependencyAuditSchema = z.object({
58
+ enabled: z.boolean().default(true),
59
+ securityOnly: z.boolean().default(false),
60
+ allowMajor: z.boolean().default(false),
61
+ notifyOnFailure: z.boolean().default(true)
62
+ }).strict();
63
+ var codeOptimizationSchema = z.object({
64
+ enabled: z.boolean().default(true),
65
+ priorityThreshold: z.enum(["low", "medium", "high", "severe"]).default("high"),
66
+ notifyOnComplete: z.boolean().default(false)
67
+ }).strict();
68
+ var deploymentSchema = z.object({
69
+ enabled: z.boolean().default(false),
70
+ targets: z.array(z.unknown()).default([])
71
+ }).strict();
72
+ var issueTriageSchema = z.object({
73
+ enabled: z.boolean().default(false),
74
+ allowedAuthors: z.array(z.string()).default(["*"]),
75
+ notifyOnFailure: z.boolean().default(true)
76
+ }).strict();
77
+ var tasksSchema = z.object({
78
+ dependencyAudit: dependencyAuditSchema.default({}),
79
+ codeOptimization: codeOptimizationSchema.default({}),
80
+ deployment: deploymentSchema.default({}),
81
+ issueTriage: issueTriageSchema.default({})
82
+ }).strict();
83
+ var notificationSchema = z.object({
84
+ type: z.enum(["github", "email", "webhook"]).default("github"),
85
+ toEnv: z.string().optional(),
86
+ fromEnv: z.string().optional(),
87
+ smtp: z.object({
88
+ hostEnv: z.string().optional(),
89
+ portEnv: z.string().optional(),
90
+ userEnv: z.string().optional(),
91
+ passEnv: z.string().optional()
92
+ }).strict().optional(),
93
+ urlEnv: z.string().optional()
94
+ }).strict();
95
+ var contactSchema = z.object({
96
+ name: z.string(),
97
+ emailEnv: z.string().optional()
98
+ }).strict();
99
+ var ConfigSchema = z.object({
100
+ version: z.number().int().positive(),
101
+ project: projectSchema,
102
+ sandbox: sandboxSchema,
103
+ schedule: scheduleSchema,
104
+ platform: platformSchema,
105
+ llm: llmSchema,
106
+ agent: agentSchema,
107
+ tasks: tasksSchema,
108
+ notifications: z.array(notificationSchema).default([]),
109
+ contacts: z.array(contactSchema).default([])
110
+ }).strict();
111
+
112
+ // src/secrets/index.ts
113
+ import { config as loadDotEnv } from "dotenv";
114
+
115
+ // src/util/errors.ts
116
+ var AfterhoursError = class extends Error {
117
+ constructor(message, options) {
118
+ super(message, options);
119
+ this.name = "AfterhoursError";
120
+ }
121
+ };
122
+ var ConfigError = class extends AfterhoursError {
123
+ constructor(message, options) {
124
+ super(message, options);
125
+ this.name = "ConfigError";
126
+ }
127
+ };
128
+ var SecretError = class extends AfterhoursError {
129
+ constructor(message, options) {
130
+ super(message, options);
131
+ this.name = "SecretError";
132
+ }
133
+ };
134
+ var UnsupportedCapabilityError = class extends AfterhoursError {
135
+ constructor(message, options) {
136
+ super(message, options);
137
+ this.name = "UnsupportedCapabilityError";
138
+ }
139
+ };
140
+
141
+ // src/secrets/index.ts
142
+ loadDotEnv();
143
+ function readEnv(name) {
144
+ return process.env[name];
145
+ }
146
+ function resolveEnvRefs(value) {
147
+ if (typeof value === "string") {
148
+ const ref = value.match(/^\$\{env:([A-Z0-9_]+)\}$/i);
149
+ if (ref) {
150
+ const envName = ref[1];
151
+ const resolved = readEnv(envName);
152
+ if (resolved === void 0) {
153
+ throw new SecretError(`Missing required environment variable ${envName}`);
154
+ }
155
+ return resolved;
156
+ }
157
+ return value;
158
+ }
159
+ if (Array.isArray(value)) {
160
+ return value.map((item) => resolveEnvRefs(item));
161
+ }
162
+ if (value && typeof value === "object") {
163
+ return Object.fromEntries(
164
+ Object.entries(value).map(([key, entryValue]) => [key, resolveEnvRefs(entryValue)])
165
+ );
166
+ }
167
+ return value;
168
+ }
169
+ function redact(text) {
170
+ const secretValues = Object.values(process.env).filter((value) => Boolean(value));
171
+ return secretValues.reduce((acc, secret) => acc.replaceAll(secret, "[REDACTED]"), text);
172
+ }
173
+
174
+ // src/config/load.ts
175
+ function loadConfig(startDir = process.cwd()) {
176
+ let currentDir = startDir;
177
+ while (true) {
178
+ const configPath = join(currentDir, ".afterhours", "config.yml");
179
+ if (existsSync(configPath)) {
180
+ try {
181
+ const content = readFileSync(configPath, "utf8");
182
+ const parsed = parse(content);
183
+ const resolved = resolveEnvRefs(parsed);
184
+ return ConfigSchema.parse(resolved);
185
+ } catch (error) {
186
+ if (error instanceof ConfigError) {
187
+ throw error;
188
+ }
189
+ throw new ConfigError(`Invalid config at ${configPath}: ${error}`);
190
+ }
191
+ }
192
+ const parent = dirname(currentDir);
193
+ if (parent === currentDir) {
194
+ throw new ConfigError("No .afterhours/config.yml found");
195
+ }
196
+ currentDir = parent;
197
+ }
198
+ }
199
+
200
+ // src/detect/index.ts
201
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "fs";
202
+ import { join as join2 } from "path";
203
+ function detectPackageManager(dir) {
204
+ const files = [
205
+ join2(dir, "pnpm-lock.yaml"),
206
+ join2(dir, "yarn.lock"),
207
+ join2(dir, "bun.lockb")
208
+ ];
209
+ if (existsSync2(files[0])) return "pnpm";
210
+ if (existsSync2(files[1])) return "yarn";
211
+ if (existsSync2(files[2])) return "bun";
212
+ return "npm";
213
+ }
214
+ function detectProjectType(dir) {
215
+ const pkgPath = join2(dir, "package.json");
216
+ if (!existsSync2(pkgPath)) return "library";
217
+ const data = JSON.parse(readFileSync2(pkgPath, "utf8"));
218
+ const deps = { ...data.dependencies, ...data.devDependencies };
219
+ const names = Object.keys(deps ?? {});
220
+ const hasServerFramework = names.some((dep) => ["express", "fastify", "koa", "nest", "http"].includes(dep));
221
+ const hasWebsiteFramework = names.some((dep) => ["next", "vite", "react", "svelte", "astro"].includes(dep));
222
+ if (data.bin || data.main || data.exports) {
223
+ if (!hasServerFramework && !hasWebsiteFramework) {
224
+ return "library";
225
+ }
226
+ }
227
+ if (hasServerFramework || data.scripts?.start?.includes("3000") || data.scripts?.dev?.includes("3000")) {
228
+ return "server";
229
+ }
230
+ if (hasWebsiteFramework || data.scripts?.build?.includes("vite") || data.scripts?.build?.includes("next")) {
231
+ return "website";
232
+ }
233
+ return "library";
234
+ }
235
+ function detectCommands(dir, pm) {
236
+ const pkgPath = join2(dir, "package.json");
237
+ if (!existsSync2(pkgPath)) {
238
+ return { build: "npm run build", test: "npm test", run: "npm run start" };
239
+ }
240
+ const data = JSON.parse(readFileSync2(pkgPath, "utf8"));
241
+ const scripts = data.scripts ?? {};
242
+ const runner = pm === "pnpm" ? "pnpm" : pm === "yarn" ? "yarn" : pm === "bun" ? "bun" : "npm";
243
+ return {
244
+ build: scripts.build ? `${runner} run build` : `${runner} run build`,
245
+ test: scripts.test ? `${runner} test` : `${runner} test`,
246
+ run: scripts.start ? `${runner} run start` : scripts.dev ? `${runner} run dev` : `${runner} run start`
247
+ };
248
+ }
249
+
250
+ // src/cli/doctor.ts
251
+ function registerDoctor(program) {
252
+ program.command("doctor").description("Check configuration, secrets, and project detection").option("--platform", "Include platform provider details").action(async (opts) => {
253
+ let ok = true;
254
+ let config;
255
+ try {
256
+ config = loadConfig();
257
+ console.log("\u2705 Config: valid");
258
+ } catch (err) {
259
+ if (err instanceof ConfigError) {
260
+ console.error(`\u274C Config: ${err.message}`);
261
+ } else {
262
+ console.error(`\u274C Config: ${err}`);
263
+ }
264
+ ok = false;
265
+ }
266
+ if (config) {
267
+ const cwd = process.cwd();
268
+ const pm = detectPackageManager(cwd);
269
+ const type = detectProjectType(cwd);
270
+ const cmds = detectCommands(cwd, pm);
271
+ console.log(`
272
+ Project detection:`);
273
+ console.log(` type: ${type}`);
274
+ console.log(` packageManager: ${pm}`);
275
+ console.log(` build: ${cmds.build}`);
276
+ console.log(` test: ${cmds.test}`);
277
+ console.log(` run: ${cmds.run}`);
278
+ const secretEnvNames = [];
279
+ if (config.llm.apiKeyEnv) secretEnvNames.push(config.llm.apiKeyEnv);
280
+ for (const n of config.notifications) {
281
+ if (n.toEnv) secretEnvNames.push(n.toEnv);
282
+ if (n.fromEnv) secretEnvNames.push(n.fromEnv);
283
+ if (n.urlEnv) secretEnvNames.push(n.urlEnv);
284
+ if (n.smtp) {
285
+ if (n.smtp.hostEnv) secretEnvNames.push(n.smtp.hostEnv);
286
+ if (n.smtp.portEnv) secretEnvNames.push(n.smtp.portEnv);
287
+ if (n.smtp.userEnv) secretEnvNames.push(n.smtp.userEnv);
288
+ if (n.smtp.passEnv) secretEnvNames.push(n.smtp.passEnv);
289
+ }
290
+ }
291
+ for (const c of config.contacts) {
292
+ if (c.emailEnv) secretEnvNames.push(c.emailEnv);
293
+ }
294
+ if (secretEnvNames.length > 0) {
295
+ console.log(`
296
+ Secrets presence:`);
297
+ for (const name of [...new Set(secretEnvNames)]) {
298
+ const val = readEnv(name);
299
+ if (val !== void 0) {
300
+ console.log(` \u2705 ${name} = (set)`);
301
+ } else {
302
+ console.log(` \u26A0\uFE0F ${name} = (not set)`);
303
+ }
304
+ }
305
+ }
306
+ if (opts.platform) {
307
+ console.log(`
308
+ Platform provider: ${config.platform.provider}`);
309
+ const requiredPlatformEnvs = [];
310
+ switch (config.platform.provider) {
311
+ case "github":
312
+ requiredPlatformEnvs.push("GITHUB_TOKEN");
313
+ break;
314
+ case "gitlab":
315
+ requiredPlatformEnvs.push("GITLAB_TOKEN");
316
+ break;
317
+ case "azure":
318
+ requiredPlatformEnvs.push("AZURE_DEVOPS_TOKEN");
319
+ break;
320
+ case "codecommit":
321
+ requiredPlatformEnvs.push("AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY");
322
+ break;
323
+ default:
324
+ break;
325
+ }
326
+ for (const name of requiredPlatformEnvs) {
327
+ const val = readEnv(name);
328
+ if (val !== void 0) {
329
+ console.log(` \u2705 ${name} = (set)`);
330
+ } else {
331
+ console.log(` \u274C ${name} = (not set)`);
332
+ ok = false;
333
+ }
334
+ }
335
+ }
336
+ }
337
+ process.exit(ok ? 0 : 1);
338
+ });
339
+ }
340
+
341
+ // src/platform/generic.ts
342
+ var GenericGitProvider = class {
343
+ name = "generic";
344
+ capabilities = {
345
+ pullRequests: false,
346
+ merge: false,
347
+ releases: false,
348
+ comments: false,
349
+ issues: false
350
+ };
351
+ openPullRequest() {
352
+ return Promise.reject(new UnsupportedCapabilityError("generic provider does not support pull requests; push the branch and open a PR manually"));
353
+ }
354
+ mergePullRequest() {
355
+ return Promise.reject(new UnsupportedCapabilityError("generic provider does not support merging pull requests"));
356
+ }
357
+ comment() {
358
+ return Promise.reject(new UnsupportedCapabilityError("generic provider does not support PR comments"));
359
+ }
360
+ createRelease() {
361
+ return Promise.reject(new UnsupportedCapabilityError("generic provider does not support releases"));
362
+ }
363
+ listIssues() {
364
+ return Promise.reject(new UnsupportedCapabilityError("generic provider does not support issues"));
365
+ }
366
+ commentOnIssue() {
367
+ return Promise.reject(new UnsupportedCapabilityError("generic provider does not support issues"));
368
+ }
369
+ closeIssue() {
370
+ return Promise.reject(new UnsupportedCapabilityError("generic provider does not support issues"));
371
+ }
372
+ };
373
+
374
+ // src/platform/github.ts
375
+ function parseGitHubRemote(remoteUrl) {
376
+ const https = remoteUrl.match(/https?:\/\/github\.com\/([^/]+)\/([^/.]+)(?:\.git)?/);
377
+ if (https) return { owner: https[1], repo: https[2] };
378
+ const ssh = remoteUrl.match(/git@github\.com:([^/]+)\/([^/.]+)(?:\.git)?/);
379
+ if (ssh) return { owner: ssh[1], repo: ssh[2] };
380
+ throw new Error(`Cannot parse GitHub remote URL: ${remoteUrl}`);
381
+ }
382
+ var GitHubProvider = class {
383
+ constructor(token, owner, repo) {
384
+ this.token = token;
385
+ this.owner = owner;
386
+ this.repo = repo;
387
+ }
388
+ token;
389
+ owner;
390
+ repo;
391
+ name = "github";
392
+ capabilities = {
393
+ pullRequests: true,
394
+ merge: true,
395
+ releases: true,
396
+ comments: true,
397
+ issues: true
398
+ };
399
+ get headers() {
400
+ return {
401
+ Authorization: `Bearer ${this.token}`,
402
+ Accept: "application/vnd.github+json",
403
+ "Content-Type": "application/json",
404
+ "X-GitHub-Api-Version": "2022-11-28"
405
+ };
406
+ }
407
+ async openPullRequest(opts) {
408
+ const res = await fetch(`https://api.github.com/repos/${this.owner}/${this.repo}/pulls`, {
409
+ method: "POST",
410
+ headers: this.headers,
411
+ body: JSON.stringify(opts)
412
+ });
413
+ if (!res.ok) {
414
+ const err = await res.text();
415
+ throw new Error(`GitHub openPullRequest failed: ${res.status} ${err}`);
416
+ }
417
+ const data = await res.json();
418
+ return { id: String(data.id), url: data.html_url, number: data.number };
419
+ }
420
+ async mergePullRequest(pr, strategy) {
421
+ const mergeMethod = strategy === "squash" ? "squash" : strategy === "rebase" ? "rebase" : "merge";
422
+ const res = await fetch(
423
+ `https://api.github.com/repos/${this.owner}/${this.repo}/pulls/${pr.number}/merge`,
424
+ { method: "PUT", headers: this.headers, body: JSON.stringify({ merge_method: mergeMethod }) }
425
+ );
426
+ if (!res.ok) {
427
+ const err = await res.text();
428
+ throw new Error(`GitHub mergePullRequest failed: ${res.status} ${err}`);
429
+ }
430
+ }
431
+ async comment(pr, body) {
432
+ const res = await fetch(
433
+ `https://api.github.com/repos/${this.owner}/${this.repo}/issues/${pr.number}/comments`,
434
+ { method: "POST", headers: this.headers, body: JSON.stringify({ body }) }
435
+ );
436
+ if (!res.ok) {
437
+ const err = await res.text();
438
+ throw new Error(`GitHub comment failed: ${res.status} ${err}`);
439
+ }
440
+ }
441
+ async createRelease(opts) {
442
+ const res = await fetch(`https://api.github.com/repos/${this.owner}/${this.repo}/releases`, {
443
+ method: "POST",
444
+ headers: this.headers,
445
+ body: JSON.stringify({
446
+ tag_name: opts.tag,
447
+ name: opts.name,
448
+ body: opts.body,
449
+ draft: false,
450
+ prerelease: false
451
+ })
452
+ });
453
+ if (!res.ok) {
454
+ const err = await res.text();
455
+ throw new Error(`GitHub createRelease failed: ${res.status} ${err}`);
456
+ }
457
+ const data = await res.json();
458
+ return { id: String(data.id), url: data.html_url };
459
+ }
460
+ async listIssues() {
461
+ const res = await fetch(
462
+ `https://api.github.com/repos/${this.owner}/${this.repo}/issues?state=open&per_page=100`,
463
+ { headers: this.headers }
464
+ );
465
+ if (!res.ok) {
466
+ const err = await res.text();
467
+ throw new Error(`GitHub listIssues failed: ${res.status} ${err}`);
468
+ }
469
+ const data = await res.json();
470
+ return data.filter((item) => !item.pull_request).map((item) => ({
471
+ id: String(item.id),
472
+ number: item.number,
473
+ title: item.title,
474
+ body: item.body ?? "",
475
+ url: item.html_url,
476
+ author: item.user?.login ?? "unknown"
477
+ }));
478
+ }
479
+ async commentOnIssue(issue, body) {
480
+ const res = await fetch(
481
+ `https://api.github.com/repos/${this.owner}/${this.repo}/issues/${issue.number}/comments`,
482
+ { method: "POST", headers: this.headers, body: JSON.stringify({ body }) }
483
+ );
484
+ if (!res.ok) {
485
+ const err = await res.text();
486
+ throw new Error(`GitHub commentOnIssue failed: ${res.status} ${err}`);
487
+ }
488
+ }
489
+ async closeIssue(issue, body) {
490
+ await this.commentOnIssue(issue, body);
491
+ const res = await fetch(
492
+ `https://api.github.com/repos/${this.owner}/${this.repo}/issues/${issue.number}`,
493
+ { method: "PATCH", headers: this.headers, body: JSON.stringify({ state: "closed" }) }
494
+ );
495
+ if (!res.ok) {
496
+ const err = await res.text();
497
+ throw new Error(`GitHub closeIssue failed: ${res.status} ${err}`);
498
+ }
499
+ }
500
+ };
501
+
502
+ // src/platform/gitlab.ts
503
+ function parseGitLabRemote(remoteUrl) {
504
+ const https = remoteUrl.match(/https?:\/\/([^/]+)\/(.*?)(?:\.git)?$/);
505
+ if (https) return { baseUrl: `https://${https[1]}`, projectPath: https[2] };
506
+ const ssh = remoteUrl.match(/git@([^:]+):(.+?)(?:\.git)?$/);
507
+ if (ssh) return { baseUrl: `https://${ssh[1]}`, projectPath: ssh[2] };
508
+ throw new Error(`Cannot parse GitLab remote URL: ${remoteUrl}`);
509
+ }
510
+ var GitLabProvider = class {
511
+ constructor(token, baseUrl, projectPath) {
512
+ this.token = token;
513
+ this.baseUrl = baseUrl;
514
+ this.projectPath = projectPath;
515
+ this.encodedProject = encodeURIComponent(projectPath);
516
+ }
517
+ token;
518
+ baseUrl;
519
+ projectPath;
520
+ name = "gitlab";
521
+ capabilities = {
522
+ pullRequests: true,
523
+ merge: true,
524
+ releases: true,
525
+ comments: true,
526
+ issues: false
527
+ };
528
+ encodedProject;
529
+ get headers() {
530
+ return {
531
+ "PRIVATE-TOKEN": this.token,
532
+ "Content-Type": "application/json"
533
+ };
534
+ }
535
+ async openPullRequest(opts) {
536
+ const res = await fetch(`${this.baseUrl}/api/v4/projects/${this.encodedProject}/merge_requests`, {
537
+ method: "POST",
538
+ headers: this.headers,
539
+ body: JSON.stringify({
540
+ source_branch: opts.head,
541
+ target_branch: opts.base,
542
+ title: opts.title,
543
+ description: opts.body
544
+ })
545
+ });
546
+ if (!res.ok) {
547
+ const err = await res.text();
548
+ throw new Error(`GitLab openMR failed: ${res.status} ${err}`);
549
+ }
550
+ const data = await res.json();
551
+ return { id: String(data.id), url: data.web_url, number: data.iid };
552
+ }
553
+ async mergePullRequest(pr, strategy) {
554
+ const squash = strategy === "squash";
555
+ const res = await fetch(
556
+ `${this.baseUrl}/api/v4/projects/${this.encodedProject}/merge_requests/${pr.number}/merge`,
557
+ { method: "PUT", headers: this.headers, body: JSON.stringify({ squash }) }
558
+ );
559
+ if (!res.ok) {
560
+ const err = await res.text();
561
+ throw new Error(`GitLab merge failed: ${res.status} ${err}`);
562
+ }
563
+ }
564
+ async comment(pr, body) {
565
+ const res = await fetch(
566
+ `${this.baseUrl}/api/v4/projects/${this.encodedProject}/merge_requests/${pr.number}/notes`,
567
+ { method: "POST", headers: this.headers, body: JSON.stringify({ body }) }
568
+ );
569
+ if (!res.ok) {
570
+ const err = await res.text();
571
+ throw new Error(`GitLab comment failed: ${res.status} ${err}`);
572
+ }
573
+ }
574
+ async createRelease(opts) {
575
+ const res = await fetch(
576
+ `${this.baseUrl}/api/v4/projects/${this.encodedProject}/releases`,
577
+ {
578
+ method: "POST",
579
+ headers: this.headers,
580
+ body: JSON.stringify({ name: opts.name, tag_name: opts.tag, description: opts.body })
581
+ }
582
+ );
583
+ if (!res.ok) {
584
+ const err = await res.text();
585
+ throw new Error(`GitLab createRelease failed: ${res.status} ${err}`);
586
+ }
587
+ const data = await res.json();
588
+ return {
589
+ id: data.tag_name,
590
+ url: data._links?.self ?? `${this.baseUrl}/${this.projectPath}/-/releases/${data.tag_name}`
591
+ };
592
+ }
593
+ listIssues() {
594
+ return Promise.reject(new UnsupportedCapabilityError("GitLab issue triage is not yet implemented"));
595
+ }
596
+ commentOnIssue() {
597
+ return Promise.reject(new UnsupportedCapabilityError("GitLab issue triage is not yet implemented"));
598
+ }
599
+ closeIssue() {
600
+ return Promise.reject(new UnsupportedCapabilityError("GitLab issue triage is not yet implemented"));
601
+ }
602
+ };
603
+
604
+ // src/platform/azure.ts
605
+ function parseAzureRemote(remoteUrl) {
606
+ const devAzure = remoteUrl.match(/https?:\/\/dev\.azure\.com\/([^/]+)\/([^/]+)\/_git\/([^/.]+)/);
607
+ if (devAzure) return { org: devAzure[1], project: devAzure[2], repo: devAzure[3] };
608
+ const vsTs = remoteUrl.match(/https?:\/\/([^.]+)\.visualstudio\.com\/([^/]+)\/_git\/([^/.]+)/);
609
+ if (vsTs) return { org: vsTs[1], project: vsTs[2], repo: vsTs[3] };
610
+ throw new Error(`Cannot parse Azure Repos remote URL: ${remoteUrl}`);
611
+ }
612
+ var AzureReposProvider = class {
613
+ constructor(token, org, project, repo) {
614
+ this.token = token;
615
+ this.org = org;
616
+ this.project = project;
617
+ this.repo = repo;
618
+ }
619
+ token;
620
+ org;
621
+ project;
622
+ repo;
623
+ name = "azure";
624
+ capabilities = {
625
+ pullRequests: true,
626
+ merge: true,
627
+ releases: false,
628
+ comments: true,
629
+ issues: false
630
+ };
631
+ get headers() {
632
+ const encoded = Buffer.from(`:${this.token}`).toString("base64");
633
+ return {
634
+ Authorization: `Basic ${encoded}`,
635
+ "Content-Type": "application/json"
636
+ };
637
+ }
638
+ get base() {
639
+ return `https://dev.azure.com/${this.org}/${encodeURIComponent(this.project)}/_apis/git/repositories/${encodeURIComponent(this.repo)}`;
640
+ }
641
+ async openPullRequest(opts) {
642
+ const res = await fetch(`${this.base}/pullrequests?api-version=7.1`, {
643
+ method: "POST",
644
+ headers: this.headers,
645
+ body: JSON.stringify({
646
+ title: opts.title,
647
+ description: opts.body,
648
+ sourceRefName: `refs/heads/${opts.head}`,
649
+ targetRefName: `refs/heads/${opts.base}`
650
+ })
651
+ });
652
+ if (!res.ok) {
653
+ const err = await res.text();
654
+ throw new Error(`Azure openPR failed: ${res.status} ${err}`);
655
+ }
656
+ const data = await res.json();
657
+ return { id: String(data.pullRequestId), url: data.url, number: data.pullRequestId };
658
+ }
659
+ async mergePullRequest(pr, strategy) {
660
+ const mergeStrategy = strategy === "squash" ? 1 : strategy === "rebase" ? 3 : 2;
661
+ const res = await fetch(`${this.base}/pullrequests/${pr.number}?api-version=7.1`, {
662
+ method: "PATCH",
663
+ headers: this.headers,
664
+ body: JSON.stringify({ status: "completed", completionOptions: { mergeStrategy } })
665
+ });
666
+ if (!res.ok) {
667
+ const err = await res.text();
668
+ throw new Error(`Azure mergePR failed: ${res.status} ${err}`);
669
+ }
670
+ }
671
+ async comment(pr, body) {
672
+ const res = await fetch(`${this.base}/pullrequests/${pr.number}/threads?api-version=7.1`, {
673
+ method: "POST",
674
+ headers: this.headers,
675
+ body: JSON.stringify({ comments: [{ parentCommentId: 0, content: body, commentType: 1 }], status: 1 })
676
+ });
677
+ if (!res.ok) {
678
+ const err = await res.text();
679
+ throw new Error(`Azure comment failed: ${res.status} ${err}`);
680
+ }
681
+ }
682
+ createRelease(opts) {
683
+ throw new UnsupportedCapabilityError(
684
+ `Azure Repos does not support GitHub-style releases. Use the git tag "${opts.tag}" and create a release in your CI/CD pipeline.`
685
+ );
686
+ }
687
+ listIssues() {
688
+ return Promise.reject(new UnsupportedCapabilityError("Azure Repos does not support issue triage via this provider"));
689
+ }
690
+ commentOnIssue() {
691
+ return Promise.reject(new UnsupportedCapabilityError("Azure Repos does not support issue triage via this provider"));
692
+ }
693
+ closeIssue() {
694
+ return Promise.reject(new UnsupportedCapabilityError("Azure Repos does not support issue triage via this provider"));
695
+ }
696
+ };
697
+
698
+ // src/platform/aws-gcsr.ts
699
+ var AwsCodeCommitProvider = class {
700
+ constructor(region, repoName, accessKeyId, secretAccessKey) {
701
+ this.region = region;
702
+ this.repoName = repoName;
703
+ this.accessKeyId = accessKeyId;
704
+ this.secretAccessKey = secretAccessKey;
705
+ }
706
+ region;
707
+ repoName;
708
+ accessKeyId;
709
+ secretAccessKey;
710
+ name = "codecommit";
711
+ capabilities = {
712
+ pullRequests: true,
713
+ merge: false,
714
+ releases: false,
715
+ comments: false,
716
+ issues: false
717
+ };
718
+ async openPullRequest(opts) {
719
+ const endpoint = `https://codecommit.${this.region}.amazonaws.com`;
720
+ const body = JSON.stringify({
721
+ title: opts.title,
722
+ description: opts.body,
723
+ sourceReference: opts.head,
724
+ destinationReference: opts.base,
725
+ repositoryName: this.repoName
726
+ });
727
+ const headers = await this.signRequest("POST", "/pullRequests", body);
728
+ const res = await fetch(`${endpoint}/pullRequests`, {
729
+ method: "POST",
730
+ headers,
731
+ body
732
+ });
733
+ if (!res.ok) {
734
+ const err = await res.text();
735
+ throw new Error(`CodeCommit openPR failed: ${res.status} ${err}`);
736
+ }
737
+ const data = await res.json();
738
+ const prId = data.pullRequest.pullRequestId;
739
+ return {
740
+ id: prId,
741
+ url: `https://${this.region}.console.aws.amazon.com/codesuite/codecommit/repositories/${this.repoName}/pull-requests/${prId}`,
742
+ number: Number(prId)
743
+ };
744
+ }
745
+ mergePullRequest() {
746
+ throw new UnsupportedCapabilityError("CodeCommit provider merge not yet implemented; use the AWS console");
747
+ }
748
+ comment() {
749
+ throw new UnsupportedCapabilityError("CodeCommit provider comments not yet implemented");
750
+ }
751
+ createRelease(opts) {
752
+ throw new UnsupportedCapabilityError(
753
+ `CodeCommit does not support GitHub-style releases. Push tag "${opts.tag}" and create a release in your CI pipeline.`
754
+ );
755
+ }
756
+ listIssues() {
757
+ return Promise.reject(new UnsupportedCapabilityError("CodeCommit provider does not support issue triage"));
758
+ }
759
+ commentOnIssue() {
760
+ return Promise.reject(new UnsupportedCapabilityError("CodeCommit provider does not support issue triage"));
761
+ }
762
+ closeIssue() {
763
+ return Promise.reject(new UnsupportedCapabilityError("CodeCommit provider does not support issue triage"));
764
+ }
765
+ async signRequest(method, path, body) {
766
+ const now = /* @__PURE__ */ new Date();
767
+ const date = now.toISOString().replace(/[-:]/g, "").split(".")[0] + "Z";
768
+ const dateShort = date.slice(0, 8);
769
+ const service = "codecommit";
770
+ const region = this.region;
771
+ const encoder = new TextEncoder();
772
+ const hash = async (data) => {
773
+ const buffer = await crypto.subtle.digest("SHA-256", encoder.encode(data));
774
+ return Array.from(new Uint8Array(buffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
775
+ };
776
+ const hmac = async (key, data) => {
777
+ const keyData = typeof key === "string" ? encoder.encode(key) : key;
778
+ const cryptoKey = await crypto.subtle.importKey("raw", keyData, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
779
+ return crypto.subtle.sign("HMAC", cryptoKey, encoder.encode(data));
780
+ };
781
+ const payloadHash = await hash(body);
782
+ const canonicalHeaders = `content-type:application/json
783
+ host:codecommit.${region}.amazonaws.com
784
+ x-amz-date:${date}
785
+ `;
786
+ const signedHeaders = "content-type;host;x-amz-date";
787
+ const canonicalRequest = [method, path, "", canonicalHeaders, signedHeaders, payloadHash].join("\n");
788
+ const credentialScope = `${dateShort}/${region}/${service}/aws4_request`;
789
+ const stringToSign = ["AWS4-HMAC-SHA256", date, credentialScope, await hash(canonicalRequest)].join("\n");
790
+ const kDate = await hmac(`AWS4${this.secretAccessKey}`, dateShort);
791
+ const kRegion = await hmac(kDate, region);
792
+ const kService = await hmac(kRegion, service);
793
+ const kSigning = await hmac(kService, "aws4_request");
794
+ const sigBuffer = await hmac(kSigning, stringToSign);
795
+ const signature = Array.from(new Uint8Array(sigBuffer)).map((b) => b.toString(16).padStart(2, "0")).join("");
796
+ return {
797
+ "Content-Type": "application/json",
798
+ "X-Amz-Date": date,
799
+ Authorization: `AWS4-HMAC-SHA256 Credential=${this.accessKeyId}/${credentialScope},SignedHeaders=${signedHeaders},Signature=${signature}`
800
+ };
801
+ }
802
+ };
803
+ var GoogleCloudSourceProvider = class {
804
+ constructor(projectId) {
805
+ this.projectId = projectId;
806
+ }
807
+ projectId;
808
+ name = "gcsr";
809
+ capabilities = {
810
+ pullRequests: false,
811
+ merge: false,
812
+ releases: false,
813
+ comments: false,
814
+ issues: false
815
+ };
816
+ openPullRequest() {
817
+ throw new UnsupportedCapabilityError(
818
+ "Google Cloud Source Repositories has limited PR API support. Push the branch and create a review manually in the Cloud Console."
819
+ );
820
+ }
821
+ mergePullRequest() {
822
+ throw new UnsupportedCapabilityError("GCSR does not support PR merging via API");
823
+ }
824
+ comment() {
825
+ throw new UnsupportedCapabilityError("GCSR does not support PR comments via API");
826
+ }
827
+ createRelease() {
828
+ throw new UnsupportedCapabilityError("GCSR does not support releases; use Cloud Build or another CI step");
829
+ }
830
+ listIssues() {
831
+ return Promise.reject(new UnsupportedCapabilityError("GCSR does not support issue triage"));
832
+ }
833
+ commentOnIssue() {
834
+ return Promise.reject(new UnsupportedCapabilityError("GCSR does not support issue triage"));
835
+ }
836
+ closeIssue() {
837
+ return Promise.reject(new UnsupportedCapabilityError("GCSR does not support issue triage"));
838
+ }
839
+ };
840
+
841
+ // src/platform/index.ts
842
+ function createProvider(config) {
843
+ const { provider } = config.platform;
844
+ const remoteUrl = config._remoteUrl ?? "";
845
+ switch (provider) {
846
+ case "github": {
847
+ const token = process.env.GITHUB_TOKEN ?? "";
848
+ if (remoteUrl) {
849
+ const { owner, repo } = parseGitHubRemote(remoteUrl);
850
+ return new GitHubProvider(token, owner, repo);
851
+ }
852
+ return new GitHubProvider(token, "", "");
853
+ }
854
+ case "gitlab": {
855
+ const token = process.env.GITLAB_TOKEN ?? "";
856
+ if (remoteUrl) {
857
+ const { baseUrl, projectPath } = parseGitLabRemote(remoteUrl);
858
+ return new GitLabProvider(token, baseUrl, projectPath);
859
+ }
860
+ return new GitLabProvider(token, "https://gitlab.com", "");
861
+ }
862
+ case "azure": {
863
+ const token = process.env.AZURE_DEVOPS_TOKEN ?? "";
864
+ if (remoteUrl) {
865
+ const { org, project, repo } = parseAzureRemote(remoteUrl);
866
+ return new AzureReposProvider(token, org, project, repo);
867
+ }
868
+ return new AzureReposProvider(token, "", "", "");
869
+ }
870
+ case "codecommit": {
871
+ const region = process.env.AWS_REGION ?? "us-east-1";
872
+ const accessKeyId = process.env.AWS_ACCESS_KEY_ID ?? "";
873
+ const secretAccessKey = process.env.AWS_SECRET_ACCESS_KEY ?? "";
874
+ const repoName = remoteUrl.split("/").pop()?.replace(/\.git$/, "") ?? "";
875
+ return new AwsCodeCommitProvider(region, repoName, accessKeyId, secretAccessKey);
876
+ }
877
+ case "gcsr": {
878
+ const projectId = process.env.GCP_PROJECT_ID ?? "";
879
+ return new GoogleCloudSourceProvider(projectId);
880
+ }
881
+ case "generic":
882
+ return new GenericGitProvider();
883
+ default:
884
+ throw new ConfigError(`Unknown platform provider: "${provider}". Must be one of: github, gitlab, azure, codecommit, gcsr, generic`);
885
+ }
886
+ }
887
+
888
+ // src/sandbox/host.ts
889
+ import { spawn } from "child_process";
890
+ var HostSandbox = class {
891
+ mountDir;
892
+ constructor(mountDir) {
893
+ this.mountDir = mountDir;
894
+ }
895
+ run(cmd, opts = {}) {
896
+ return new Promise((resolve2, reject) => {
897
+ const timeoutMs = (opts.timeoutSec ?? 300) * 1e3;
898
+ const env = { ...process.env, ...opts.env ?? {} };
899
+ const child = spawn(cmd, {
900
+ shell: true,
901
+ cwd: opts.cwd ?? this.mountDir,
902
+ env,
903
+ stdio: ["ignore", "pipe", "pipe"]
904
+ });
905
+ let stdout = "";
906
+ let stderr = "";
907
+ child.stdout?.on("data", (chunk) => {
908
+ stdout += chunk.toString();
909
+ });
910
+ child.stderr?.on("data", (chunk) => {
911
+ stderr += chunk.toString();
912
+ });
913
+ const timer = setTimeout(() => {
914
+ child.kill("SIGKILL");
915
+ reject(new Error(`Command timed out after ${opts.timeoutSec ?? 300}s: ${cmd}`));
916
+ }, timeoutMs);
917
+ child.on("close", (code) => {
918
+ clearTimeout(timer);
919
+ resolve2({
920
+ code: code ?? 1,
921
+ stdout: redact(stdout),
922
+ stderr: redact(stderr)
923
+ });
924
+ });
925
+ child.on("error", (err) => {
926
+ clearTimeout(timer);
927
+ reject(err);
928
+ });
929
+ });
930
+ }
931
+ async start(cmd, opts = {}) {
932
+ const env = { ...process.env, ...opts.env ?? {} };
933
+ const child = spawn(cmd, {
934
+ shell: true,
935
+ cwd: opts.cwd ?? this.mountDir,
936
+ env,
937
+ stdio: "ignore"
938
+ });
939
+ const handle = {
940
+ stop: () => new Promise((resolve2) => {
941
+ if (child.killed) {
942
+ resolve2();
943
+ return;
944
+ }
945
+ child.on("close", () => resolve2());
946
+ child.kill("SIGTERM");
947
+ setTimeout(() => {
948
+ if (!child.killed) child.kill("SIGKILL");
949
+ }, 5e3);
950
+ })
951
+ };
952
+ return handle;
953
+ }
954
+ };
955
+
956
+ // src/sandbox/docker.ts
957
+ import { execFileSync, spawn as spawn2 } from "child_process";
958
+ function isDockerAvailable() {
959
+ try {
960
+ execFileSync("docker", ["info"], { stdio: "ignore" });
961
+ return true;
962
+ } catch {
963
+ return false;
964
+ }
965
+ }
966
+ var DockerSandbox = class {
967
+ constructor(mountDir, image, network = "bridge") {
968
+ this.image = image;
969
+ this.network = network;
970
+ this.mountDir = mountDir;
971
+ if (!isDockerAvailable()) {
972
+ throw new AfterhoursError(
973
+ "Docker is not available. Set sandbox.mode: host in your config to run without Docker."
974
+ );
975
+ }
976
+ }
977
+ image;
978
+ network;
979
+ mountDir;
980
+ run(cmd, opts = {}) {
981
+ const args = this.buildDockerRunArgs(cmd, opts, false);
982
+ return new Promise((resolve2, reject) => {
983
+ const timeoutMs = (opts.timeoutSec ?? 300) * 1e3;
984
+ const child = spawn2("docker", args, { stdio: ["ignore", "pipe", "pipe"] });
985
+ let stdout = "";
986
+ let stderr = "";
987
+ child.stdout?.on("data", (chunk) => {
988
+ stdout += chunk.toString();
989
+ });
990
+ child.stderr?.on("data", (chunk) => {
991
+ stderr += chunk.toString();
992
+ });
993
+ const timer = setTimeout(() => {
994
+ child.kill("SIGKILL");
995
+ reject(new Error(`Docker command timed out after ${opts.timeoutSec ?? 300}s`));
996
+ }, timeoutMs);
997
+ child.on("close", (code) => {
998
+ clearTimeout(timer);
999
+ resolve2({ code: code ?? 1, stdout: redact(stdout), stderr: redact(stderr) });
1000
+ });
1001
+ child.on("error", (err) => {
1002
+ clearTimeout(timer);
1003
+ reject(err);
1004
+ });
1005
+ });
1006
+ }
1007
+ async start(cmd, opts = {}) {
1008
+ const containerName = `afterhours-${Date.now()}`;
1009
+ const args = this.buildDockerRunArgs(cmd, opts, true, containerName);
1010
+ const child = spawn2("docker", args, { stdio: "ignore" });
1011
+ child.unref();
1012
+ const handle = {
1013
+ stop: () => new Promise((resolve2) => {
1014
+ const rm = spawn2("docker", ["rm", "-f", containerName], { stdio: "ignore" });
1015
+ rm.on("close", () => resolve2());
1016
+ rm.on("error", () => resolve2());
1017
+ })
1018
+ };
1019
+ return handle;
1020
+ }
1021
+ buildDockerRunArgs(cmd, opts, detach, name) {
1022
+ const args = ["run", "--rm"];
1023
+ if (detach) args.push("-d");
1024
+ if (name) args.push("--name", name);
1025
+ args.push("--network", this.network);
1026
+ args.push("-v", `${this.mountDir}:/workspace`);
1027
+ args.push("-w", "/workspace");
1028
+ const env = { ...process.env, ...opts.env ?? {} };
1029
+ for (const [key, value] of Object.entries(env)) {
1030
+ if (value !== void 0) args.push("-e", `${key}=${value}`);
1031
+ }
1032
+ args.push(this.image, "sh", "-c", cmd);
1033
+ return args;
1034
+ }
1035
+ };
1036
+
1037
+ // src/sandbox/index.ts
1038
+ function createSandbox(config) {
1039
+ if (config.sandbox.mode === "docker") {
1040
+ return new DockerSandbox(config.mountDir, config.sandbox.image, config.sandbox.network);
1041
+ }
1042
+ return new HostSandbox(config.mountDir);
1043
+ }
1044
+
1045
+ // src/llm/anthropic.ts
1046
+ import { z as z2 } from "zod";
1047
+ function toolToAnthropicDef(tool) {
1048
+ const shape = tool.parameters.shape ?? {};
1049
+ const properties = {};
1050
+ const required = [];
1051
+ for (const [key, val] of Object.entries(shape)) {
1052
+ const zodVal = val;
1053
+ properties[key] = { type: "string" };
1054
+ if (zodVal.description) properties[key].description = zodVal.description;
1055
+ if (!(zodVal instanceof z2.ZodOptional)) required.push(key);
1056
+ }
1057
+ return {
1058
+ name: tool.name,
1059
+ description: tool.description,
1060
+ input_schema: { type: "object", properties, required }
1061
+ };
1062
+ }
1063
+ var AnthropicProvider = class {
1064
+ constructor(apiKey, model, baseUrl = "https://api.anthropic.com") {
1065
+ this.apiKey = apiKey;
1066
+ this.model = model;
1067
+ this.baseUrl = baseUrl;
1068
+ }
1069
+ apiKey;
1070
+ model;
1071
+ baseUrl;
1072
+ async chat(opts) {
1073
+ const messages = opts.messages.filter((m) => m.role !== "system").map((m) => ({
1074
+ role: m.role,
1075
+ content: m.content
1076
+ }));
1077
+ const system = opts.messages.find((m) => m.role === "system")?.content;
1078
+ const body = {
1079
+ model: this.model,
1080
+ max_tokens: opts.maxTokens ?? 8e3,
1081
+ messages
1082
+ };
1083
+ if (system) body.system = system;
1084
+ if (opts.tools?.length) body.tools = opts.tools.map(toolToAnthropicDef);
1085
+ const res = await fetch(`${this.baseUrl}/v1/messages`, {
1086
+ method: "POST",
1087
+ headers: {
1088
+ "x-api-key": this.apiKey,
1089
+ "anthropic-version": "2023-06-01",
1090
+ "Content-Type": "application/json"
1091
+ },
1092
+ body: JSON.stringify(body)
1093
+ });
1094
+ if (!res.ok) {
1095
+ const err = await res.text();
1096
+ throw new AfterhoursError(`Anthropic API error ${res.status}: ${err}`);
1097
+ }
1098
+ const data = await res.json();
1099
+ let text = "";
1100
+ const toolCalls = [];
1101
+ for (const block of data.content) {
1102
+ if (block.type === "text") text += block.text ?? "";
1103
+ if (block.type === "tool_use") {
1104
+ toolCalls.push({ id: block.id ?? "", name: block.name ?? "", arguments: block.input ?? {} });
1105
+ }
1106
+ }
1107
+ const stopReason = data.stop_reason === "tool_use" ? "tool_use" : data.stop_reason === "max_tokens" ? "max_tokens" : data.stop_reason === "stop_sequence" ? "stop_sequence" : "end_turn";
1108
+ return { text, toolCalls, stopReason };
1109
+ }
1110
+ };
1111
+
1112
+ // src/llm/openai.ts
1113
+ import { z as z3 } from "zod";
1114
+ function toolToOpenAIDef(tool) {
1115
+ const shape = tool.parameters.shape ?? {};
1116
+ const properties = {};
1117
+ const required = [];
1118
+ for (const [key, val] of Object.entries(shape)) {
1119
+ const zodVal = val;
1120
+ properties[key] = { type: "string" };
1121
+ if (zodVal.description) properties[key].description = zodVal.description;
1122
+ if (!(zodVal instanceof z3.ZodOptional)) required.push(key);
1123
+ }
1124
+ return {
1125
+ type: "function",
1126
+ function: {
1127
+ name: tool.name,
1128
+ description: tool.description,
1129
+ parameters: { type: "object", properties, required }
1130
+ }
1131
+ };
1132
+ }
1133
+ var OpenAIProvider = class {
1134
+ constructor(apiKey, model, baseUrl = "https://api.openai.com/v1") {
1135
+ this.apiKey = apiKey;
1136
+ this.model = model;
1137
+ this.baseUrl = baseUrl;
1138
+ }
1139
+ apiKey;
1140
+ model;
1141
+ baseUrl;
1142
+ async chat(opts) {
1143
+ const body = {
1144
+ model: this.model,
1145
+ max_tokens: opts.maxTokens ?? 8e3,
1146
+ messages: opts.messages.map((m) => ({ role: m.role, content: m.content }))
1147
+ };
1148
+ if (opts.tools?.length) body.tools = opts.tools.map(toolToOpenAIDef);
1149
+ const res = await fetch(`${this.baseUrl}/chat/completions`, {
1150
+ method: "POST",
1151
+ headers: {
1152
+ Authorization: `Bearer ${this.apiKey}`,
1153
+ "Content-Type": "application/json"
1154
+ },
1155
+ body: JSON.stringify(body)
1156
+ });
1157
+ if (!res.ok) {
1158
+ const err = await res.text();
1159
+ throw new AfterhoursError(`OpenAI API error ${res.status}: ${err}`);
1160
+ }
1161
+ const data = await res.json();
1162
+ const choice = data.choices[0];
1163
+ const text = choice?.message.content ?? "";
1164
+ const toolCalls = (choice?.message.tool_calls ?? []).map((tc) => ({
1165
+ id: tc.id,
1166
+ name: tc.function.name,
1167
+ arguments: JSON.parse(tc.function.arguments)
1168
+ }));
1169
+ const finishReason = choice?.finish_reason;
1170
+ const stopReason = finishReason === "tool_calls" ? "tool_use" : finishReason === "length" ? "max_tokens" : "end_turn";
1171
+ return { text, toolCalls, stopReason };
1172
+ }
1173
+ };
1174
+
1175
+ // src/llm/ollama.ts
1176
+ var OllamaProvider = class {
1177
+ constructor(model, baseUrl = "http://localhost:11434") {
1178
+ this.model = model;
1179
+ this.baseUrl = baseUrl;
1180
+ this.openai = new OpenAIProvider("ollama", model, `${baseUrl}/v1`);
1181
+ }
1182
+ model;
1183
+ baseUrl;
1184
+ openai;
1185
+ async chat(opts) {
1186
+ try {
1187
+ return await this.openai.chat(opts);
1188
+ } catch {
1189
+ return this.chatJsonFallback(opts);
1190
+ }
1191
+ }
1192
+ async chatJsonFallback(opts) {
1193
+ const toolsDescription = (opts.tools ?? []).map((t) => `${t.name}: ${t.description}`).join("\n");
1194
+ const systemInstructions = opts.tools?.length ? `You are a helpful assistant. When you need to use a tool, respond ONLY with a JSON object in this exact format: {"tool":"<tool_name>","args":{...}}. Available tools:
1195
+ ${toolsDescription}` : void 0;
1196
+ const messages = [...opts.messages];
1197
+ if (systemInstructions) {
1198
+ messages.unshift({ role: "system", content: systemInstructions });
1199
+ }
1200
+ const body = {
1201
+ model: this.model,
1202
+ messages: messages.map((m) => ({ role: m.role, content: m.content })),
1203
+ stream: false,
1204
+ format: opts.tools?.length ? "json" : void 0
1205
+ };
1206
+ const res = await fetch(`${this.baseUrl}/api/chat`, {
1207
+ method: "POST",
1208
+ headers: { "Content-Type": "application/json" },
1209
+ body: JSON.stringify(body)
1210
+ });
1211
+ if (!res.ok) {
1212
+ const err = await res.text();
1213
+ throw new AfterhoursError(`Ollama API error ${res.status}: ${err}`);
1214
+ }
1215
+ const data = await res.json();
1216
+ const rawContent = data.message.content;
1217
+ if (opts.tools?.length) {
1218
+ try {
1219
+ const parsed = JSON.parse(rawContent);
1220
+ if (parsed.tool) {
1221
+ return {
1222
+ text: "",
1223
+ toolCalls: [{ id: `call_${Date.now()}`, name: parsed.tool, arguments: parsed.args ?? {} }],
1224
+ stopReason: "tool_use"
1225
+ };
1226
+ }
1227
+ } catch {
1228
+ }
1229
+ }
1230
+ return { text: rawContent, toolCalls: [], stopReason: "end_turn" };
1231
+ }
1232
+ };
1233
+
1234
+ // src/llm/github-models.ts
1235
+ var GitHubModelsProvider = class {
1236
+ constructor(apiKey, model) {
1237
+ this.model = model;
1238
+ this.openai = new OpenAIProvider(apiKey, model, "https://models.inference.ai.azure.com");
1239
+ }
1240
+ model;
1241
+ openai;
1242
+ chat(opts) {
1243
+ return this.openai.chat(opts);
1244
+ }
1245
+ };
1246
+
1247
+ // src/llm/gemini.ts
1248
+ import { z as z4 } from "zod";
1249
+ function toolToGeminiDecl(tool) {
1250
+ const shape = tool.parameters.shape ?? {};
1251
+ const properties = {};
1252
+ const required = [];
1253
+ for (const [key, val] of Object.entries(shape)) {
1254
+ const zodVal = val;
1255
+ properties[key] = { type: "STRING" };
1256
+ if (zodVal.description) properties[key].description = zodVal.description;
1257
+ if (!(zodVal instanceof z4.ZodOptional)) required.push(key);
1258
+ }
1259
+ return {
1260
+ name: tool.name,
1261
+ description: tool.description,
1262
+ parameters: { type: "OBJECT", properties, required }
1263
+ };
1264
+ }
1265
+ var GeminiProvider = class {
1266
+ constructor(apiKey, model, baseUrl = "https://generativelanguage.googleapis.com/v1beta") {
1267
+ this.apiKey = apiKey;
1268
+ this.model = model;
1269
+ this.baseUrl = baseUrl;
1270
+ }
1271
+ apiKey;
1272
+ model;
1273
+ baseUrl;
1274
+ async chat(opts) {
1275
+ const systemText = opts.messages.filter((m) => m.role === "system").map((m) => m.content).join("\n");
1276
+ const contents = opts.messages.filter((m) => m.role !== "system").map((m) => ({
1277
+ role: m.role === "assistant" ? "model" : "user",
1278
+ parts: [{ text: m.content }]
1279
+ }));
1280
+ const body = {
1281
+ contents,
1282
+ generationConfig: { maxOutputTokens: opts.maxTokens ?? 8e3 }
1283
+ };
1284
+ if (systemText) body.systemInstruction = { parts: [{ text: systemText }] };
1285
+ if (opts.tools?.length) body.tools = [{ functionDeclarations: opts.tools.map(toolToGeminiDecl) }];
1286
+ const res = await fetch(`${this.baseUrl}/models/${this.model}:generateContent`, {
1287
+ method: "POST",
1288
+ headers: {
1289
+ "x-goog-api-key": this.apiKey,
1290
+ "Content-Type": "application/json"
1291
+ },
1292
+ body: JSON.stringify(body)
1293
+ });
1294
+ if (!res.ok) {
1295
+ const err = await res.text();
1296
+ throw new AfterhoursError(`Gemini API error ${res.status}: ${err}`);
1297
+ }
1298
+ const data = await res.json();
1299
+ const candidate = data.candidates?.[0];
1300
+ const parts = candidate?.content?.parts ?? [];
1301
+ let text = "";
1302
+ const toolCalls = [];
1303
+ for (const part of parts) {
1304
+ if (part.text) text += part.text;
1305
+ if (part.functionCall) {
1306
+ toolCalls.push({
1307
+ id: `call_${toolCalls.length}_${part.functionCall.name}`,
1308
+ name: part.functionCall.name,
1309
+ arguments: part.functionCall.args ?? {}
1310
+ });
1311
+ }
1312
+ }
1313
+ const finishReason = candidate?.finishReason;
1314
+ const stopReason = toolCalls.length > 0 ? "tool_use" : finishReason === "MAX_TOKENS" ? "max_tokens" : "end_turn";
1315
+ return { text, toolCalls, stopReason };
1316
+ }
1317
+ };
1318
+
1319
+ // src/llm/copilot.ts
1320
+ var CopilotProvider = class {
1321
+ constructor(githubToken, model, apiBaseUrl = "https://api.githubcopilot.com", tokenUrl = "https://api.github.com/copilot_internal/v2/token") {
1322
+ this.githubToken = githubToken;
1323
+ this.model = model;
1324
+ this.apiBaseUrl = apiBaseUrl;
1325
+ this.tokenUrl = tokenUrl;
1326
+ }
1327
+ githubToken;
1328
+ model;
1329
+ apiBaseUrl;
1330
+ tokenUrl;
1331
+ cachedToken;
1332
+ cachedTokenExpiresAt = 0;
1333
+ async getCopilotToken() {
1334
+ const nowSec = Date.now() / 1e3;
1335
+ if (this.cachedToken && this.cachedTokenExpiresAt - 60 > nowSec) {
1336
+ return this.cachedToken;
1337
+ }
1338
+ const res = await fetch(this.tokenUrl, {
1339
+ headers: {
1340
+ Authorization: `token ${this.githubToken}`,
1341
+ "User-Agent": "afterhours"
1342
+ }
1343
+ });
1344
+ if (!res.ok) {
1345
+ const err = await res.text();
1346
+ throw new AfterhoursError(`GitHub Copilot token exchange failed: ${res.status} ${err}`);
1347
+ }
1348
+ const data = await res.json();
1349
+ this.cachedToken = data.token;
1350
+ this.cachedTokenExpiresAt = data.expires_at;
1351
+ return this.cachedToken;
1352
+ }
1353
+ async chat(opts) {
1354
+ const token = await this.getCopilotToken();
1355
+ const body = {
1356
+ model: this.model,
1357
+ max_tokens: opts.maxTokens ?? 8e3,
1358
+ messages: opts.messages.map((m) => ({ role: m.role, content: m.content }))
1359
+ };
1360
+ if (opts.tools?.length) body.tools = opts.tools.map(toolToOpenAIDef);
1361
+ const res = await fetch(`${this.apiBaseUrl}/chat/completions`, {
1362
+ method: "POST",
1363
+ headers: {
1364
+ Authorization: `Bearer ${token}`,
1365
+ "Content-Type": "application/json",
1366
+ "Copilot-Integration-Id": "vscode-chat",
1367
+ "Editor-Version": "afterhours/0.2.0"
1368
+ },
1369
+ body: JSON.stringify(body)
1370
+ });
1371
+ if (!res.ok) {
1372
+ const err = await res.text();
1373
+ throw new AfterhoursError(`GitHub Copilot API error ${res.status}: ${err}`);
1374
+ }
1375
+ const data = await res.json();
1376
+ const choice = data.choices[0];
1377
+ const text = choice?.message.content ?? "";
1378
+ const toolCalls = (choice?.message.tool_calls ?? []).map((tc) => ({
1379
+ id: tc.id,
1380
+ name: tc.function.name,
1381
+ arguments: JSON.parse(tc.function.arguments)
1382
+ }));
1383
+ const finishReason = choice?.finish_reason;
1384
+ const stopReason = finishReason === "tool_calls" ? "tool_use" : finishReason === "length" ? "max_tokens" : "end_turn";
1385
+ return { text, toolCalls, stopReason };
1386
+ }
1387
+ };
1388
+
1389
+ // src/llm/index.ts
1390
+ function createLlm(config) {
1391
+ const { provider, model, apiKeyEnv, baseUrlEnv } = config.llm;
1392
+ const baseUrl = baseUrlEnv ? readEnv(baseUrlEnv) : void 0;
1393
+ switch (provider) {
1394
+ case "anthropic": {
1395
+ const key = readEnv(apiKeyEnv);
1396
+ if (!key) throw new SecretError(`Missing required env var ${apiKeyEnv} for Anthropic LLM provider`);
1397
+ return new AnthropicProvider(key, model, baseUrl ?? "https://api.anthropic.com");
1398
+ }
1399
+ case "openai": {
1400
+ const key = readEnv(apiKeyEnv);
1401
+ if (!key) throw new SecretError(`Missing required env var ${apiKeyEnv} for OpenAI LLM provider`);
1402
+ return new OpenAIProvider(key, model, baseUrl ?? "https://api.openai.com/v1");
1403
+ }
1404
+ case "ollama":
1405
+ return new OllamaProvider(model, baseUrl ?? "http://localhost:11434");
1406
+ case "github-models": {
1407
+ const key = readEnv(apiKeyEnv);
1408
+ if (!key) throw new SecretError(`Missing required env var ${apiKeyEnv} for GitHub Models LLM provider`);
1409
+ return new GitHubModelsProvider(key, model);
1410
+ }
1411
+ case "gemini": {
1412
+ const key = readEnv(apiKeyEnv);
1413
+ if (!key) throw new SecretError(`Missing required env var ${apiKeyEnv} for Gemini LLM provider`);
1414
+ return new GeminiProvider(key, model, baseUrl ?? "https://generativelanguage.googleapis.com/v1beta");
1415
+ }
1416
+ case "copilot": {
1417
+ const key = readEnv(apiKeyEnv);
1418
+ if (!key) throw new SecretError(`Missing required env var ${apiKeyEnv} for GitHub Copilot LLM provider (a GitHub token for a Copilot-licensed account)`);
1419
+ return new CopilotProvider(key, model, baseUrl ?? "https://api.githubcopilot.com");
1420
+ }
1421
+ default:
1422
+ throw new ConfigError(`Unknown LLM provider: "${provider}". Must be one of: anthropic, openai, ollama, github-models, gemini, copilot`);
1423
+ }
1424
+ }
1425
+
1426
+ // src/agent/loop.ts
1427
+ async function runAgentLoop(opts) {
1428
+ const maxIterations = opts.maxIterations ?? 30;
1429
+ const transcript = [];
1430
+ const filesChanged = [];
1431
+ let summary = "";
1432
+ const messages = [
1433
+ { role: "user", content: opts.goal }
1434
+ ];
1435
+ transcript.push({ role: "system", content: opts.systemPrompt });
1436
+ transcript.push({ role: "user", content: opts.goal });
1437
+ for (let iter = 0; iter < maxIterations; iter++) {
1438
+ const response = await opts.llm.chat({
1439
+ messages: [
1440
+ { role: "system", content: opts.systemPrompt },
1441
+ ...messages
1442
+ ],
1443
+ tools: opts.tools,
1444
+ maxTokens: opts.maxTokens
1445
+ });
1446
+ if (response.text) {
1447
+ transcript.push({ role: "assistant", content: response.text });
1448
+ summary = response.text;
1449
+ }
1450
+ if (response.stopReason === "end_turn" || response.toolCalls.length === 0) {
1451
+ return { summary, filesChanged, transcript, stopReason: "done" };
1452
+ }
1453
+ if (response.stopReason === "max_tokens") {
1454
+ return { summary, filesChanged, transcript, stopReason: "max_tokens" };
1455
+ }
1456
+ const assistantMsg = {
1457
+ role: "assistant",
1458
+ content: response.text || `[tool calls: ${response.toolCalls.map((tc) => tc.name).join(", ")}]`
1459
+ };
1460
+ messages.push(assistantMsg);
1461
+ for (const toolCall of response.toolCalls) {
1462
+ const toolResult = await executeToolCall(toolCall, opts.registry, filesChanged);
1463
+ const toolResultMsg = {
1464
+ role: "user",
1465
+ content: `[Tool result for ${toolCall.name} (id=${toolCall.id})]: ${toolResult}`
1466
+ };
1467
+ messages.push(toolResultMsg);
1468
+ transcript.push({ role: "tool", content: `${toolCall.name}: ${toolResult}` });
1469
+ }
1470
+ }
1471
+ return { summary, filesChanged, transcript, stopReason: "max_iterations" };
1472
+ }
1473
+ async function executeToolCall(toolCall, registry, filesChanged) {
1474
+ const fn = registry[toolCall.name];
1475
+ if (!fn) {
1476
+ return `[Error: unknown tool "${toolCall.name}"]`;
1477
+ }
1478
+ try {
1479
+ const result = await fn(toolCall.arguments);
1480
+ if (toolCall.name === "write_file" && toolCall.arguments.path) {
1481
+ filesChanged.push(String(toolCall.arguments.path));
1482
+ }
1483
+ return result;
1484
+ } catch (err) {
1485
+ return `[Error: ${err}]`;
1486
+ }
1487
+ }
1488
+
1489
+ // src/agent/tools/fs.ts
1490
+ import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync, readdirSync, statSync } from "fs";
1491
+ import { join as join3, resolve, relative } from "path";
1492
+ import { z as z5 } from "zod";
1493
+ var MAX_FILE_SIZE = 1e5;
1494
+ var MAX_SEARCH_RESULTS = 50;
1495
+ function assertSafePath(repoRoot, filePath) {
1496
+ const abs = resolve(repoRoot, filePath);
1497
+ const rel = relative(repoRoot, abs);
1498
+ if (rel.startsWith("..") || abs !== join3(repoRoot, rel)) {
1499
+ throw new Error(`Path traversal rejected: ${filePath}`);
1500
+ }
1501
+ return abs;
1502
+ }
1503
+ var readFileTool = {
1504
+ name: "read_file",
1505
+ description: "Read the contents of a file in the repository.",
1506
+ parameters: z5.object({
1507
+ path: z5.string().describe("Repo-relative path to the file")
1508
+ })
1509
+ };
1510
+ function readFile(repoRoot, path) {
1511
+ const abs = assertSafePath(repoRoot, path);
1512
+ if (!existsSync3(abs)) throw new Error(`File not found: ${path}`);
1513
+ const stat = statSync(abs);
1514
+ if (stat.size > MAX_FILE_SIZE) return `[File too large to read: ${stat.size} bytes]`;
1515
+ return readFileSync3(abs, "utf8");
1516
+ }
1517
+ var writeFileTool = {
1518
+ name: "write_file",
1519
+ description: "Write content to a file in the repository, creating it if needed.",
1520
+ parameters: z5.object({
1521
+ path: z5.string().describe("Repo-relative path to write to"),
1522
+ content: z5.string().describe("Full content to write")
1523
+ })
1524
+ };
1525
+ function writeFile(repoRoot, path, content) {
1526
+ const abs = assertSafePath(repoRoot, path);
1527
+ writeFileSync(abs, content, "utf8");
1528
+ }
1529
+ var listDirTool = {
1530
+ name: "list_dir",
1531
+ description: "List files and directories under a path in the repository.",
1532
+ parameters: z5.object({
1533
+ path: z5.string().describe("Repo-relative path to list (defaults to root if empty)")
1534
+ })
1535
+ };
1536
+ function listDir(repoRoot, dirPath) {
1537
+ const abs = assertSafePath(repoRoot, dirPath || ".");
1538
+ if (!existsSync3(abs)) return `Directory not found: ${dirPath}`;
1539
+ const entries = readdirSync(abs, { withFileTypes: true });
1540
+ return entries.map((e) => e.isDirectory() ? `${e.name}/` : e.name).join("\n");
1541
+ }
1542
+ var searchTextTool = {
1543
+ name: "search_text",
1544
+ description: "Search for a text pattern (literal or regex) across repository files.",
1545
+ parameters: z5.object({
1546
+ pattern: z5.string().describe("Text or regex to search for"),
1547
+ fileGlob: z5.string().optional().describe("Optional glob to restrict search scope"),
1548
+ isRegex: z5.string().optional().describe('Set to "true" to treat pattern as regex')
1549
+ })
1550
+ };
1551
+ function searchText(repoRoot, pattern, fileGlob, isRegex) {
1552
+ const results = [];
1553
+ const regex = isRegex === "true" ? new RegExp(pattern) : null;
1554
+ function walk(dir) {
1555
+ if (results.length >= MAX_SEARCH_RESULTS) return;
1556
+ const entries = readdirSync(dir, { withFileTypes: true });
1557
+ for (const entry of entries) {
1558
+ if (entry.name === "node_modules" || entry.name === ".git" || entry.name === "dist") continue;
1559
+ const abs = join3(dir, entry.name);
1560
+ if (entry.isDirectory()) {
1561
+ walk(abs);
1562
+ } else if (!fileGlob || entry.name.endsWith(fileGlob.replace("*", ""))) {
1563
+ try {
1564
+ const content = readFileSync3(abs, "utf8");
1565
+ const lines = content.split("\n");
1566
+ for (let i = 0; i < lines.length; i++) {
1567
+ const line = lines[i];
1568
+ const match = regex ? regex.test(line) : line.includes(pattern);
1569
+ if (match) {
1570
+ const rel = relative(repoRoot, abs);
1571
+ results.push(`${rel}:${i + 1}: ${line.trim()}`);
1572
+ if (results.length >= MAX_SEARCH_RESULTS) return;
1573
+ }
1574
+ }
1575
+ } catch {
1576
+ }
1577
+ }
1578
+ }
1579
+ }
1580
+ walk(repoRoot);
1581
+ return results.length > 0 ? results.join("\n") : "No matches found.";
1582
+ }
1583
+ var applyPatchTool = {
1584
+ name: "apply_patch",
1585
+ description: "Apply a unified diff patch to repository files.",
1586
+ parameters: z5.object({
1587
+ patch: z5.string().describe("Unified diff patch content")
1588
+ })
1589
+ };
1590
+ function applyPatch(_repoRoot, patch) {
1591
+ const lines = patch.split("\n");
1592
+ if (lines.length === 0) return "Empty patch";
1593
+ return `Patch received (${lines.length} lines). Use write_file for precise edits or shell git-apply.`;
1594
+ }
1595
+
1596
+ // src/agent/tools/run.ts
1597
+ import { z as z6 } from "zod";
1598
+ var DENYLISTED_COMMANDS = [
1599
+ /^rm\s+-rf\s+\/\s*/,
1600
+ /^dd\s+if=/,
1601
+ /^mkfs/,
1602
+ /^format\s+c:/i,
1603
+ />\s*\/dev\/sda/
1604
+ ];
1605
+ var MAX_OUTPUT_LENGTH = 2e4;
1606
+ var runCommandTool = {
1607
+ name: "run_command",
1608
+ description: "Run a shell command in the repository sandbox.",
1609
+ parameters: z6.object({
1610
+ command: z6.string().describe("Shell command to execute"),
1611
+ timeoutSec: z6.number().optional().describe("Timeout in seconds (default 60)")
1612
+ })
1613
+ };
1614
+ async function runCommand(sandbox, command, timeoutSec = 60) {
1615
+ for (const pattern of DENYLISTED_COMMANDS) {
1616
+ if (pattern.test(command.trim())) {
1617
+ return `[BLOCKED] Command rejected by denylist: ${command}`;
1618
+ }
1619
+ }
1620
+ const result = await sandbox.run(command, { timeoutSec });
1621
+ const combined = `${result.stdout}
1622
+ ${result.stderr}`.trim();
1623
+ const redacted = redact(combined);
1624
+ const truncated = redacted.length > MAX_OUTPUT_LENGTH ? redacted.slice(0, MAX_OUTPUT_LENGTH) + `
1625
+ [Output truncated at ${MAX_OUTPUT_LENGTH} characters]` : redacted;
1626
+ return `exit=${result.code}
1627
+ ${truncated}`;
1628
+ }
1629
+
1630
+ // src/agent/index.ts
1631
+ function createAgent(llm) {
1632
+ return {
1633
+ async runBuiltin(goal, systemPrompt, ctx) {
1634
+ const tools = [readFileTool, writeFileTool, listDirTool, searchTextTool, applyPatchTool, runCommandTool];
1635
+ const registry = {
1636
+ read_file: (args) => readFile(ctx.repoRoot, String(args.path ?? "")),
1637
+ write_file: (args) => {
1638
+ writeFile(ctx.repoRoot, String(args.path ?? ""), String(args.content ?? ""));
1639
+ return "File written.";
1640
+ },
1641
+ list_dir: (args) => listDir(ctx.repoRoot, String(args.path ?? "")),
1642
+ search_text: (args) => searchText(ctx.repoRoot, String(args.pattern ?? ""), args.fileGlob ? String(args.fileGlob) : void 0, args.isRegex ? String(args.isRegex) : void 0),
1643
+ apply_patch: (args) => applyPatch(ctx.repoRoot, String(args.patch ?? "")),
1644
+ run_command: (args) => runCommand(ctx.sandbox, String(args.command ?? ""), args.timeoutSec ? Number(args.timeoutSec) : 60)
1645
+ };
1646
+ return runAgentLoop({ llm, tools, registry, systemPrompt, goal });
1647
+ }
1648
+ };
1649
+ }
1650
+
1651
+ // src/util/log.ts
1652
+ function createLogger() {
1653
+ return {
1654
+ info(message, meta) {
1655
+ console.info(JSON.stringify({ level: "info", message, meta }));
1656
+ },
1657
+ warn(message, meta) {
1658
+ console.warn(JSON.stringify({ level: "warn", message, meta }));
1659
+ },
1660
+ error(message, meta) {
1661
+ console.error(JSON.stringify({ level: "error", message, meta }));
1662
+ }
1663
+ };
1664
+ }
1665
+
1666
+ // src/report/model.ts
1667
+ function createRunReport(partial) {
1668
+ return {
1669
+ projectType: "library",
1670
+ projectPackageManager: "npm",
1671
+ branch: "",
1672
+ baseBranch: "main",
1673
+ tasks: [],
1674
+ deployments: [],
1675
+ outstandingWork: [],
1676
+ ...partial
1677
+ };
1678
+ }
1679
+
1680
+ // src/notify/platform.ts
1681
+ var PlatformNotifier = class {
1682
+ constructor(provider, activePr) {
1683
+ this.provider = provider;
1684
+ this.activePr = activePr;
1685
+ }
1686
+ provider;
1687
+ activePr;
1688
+ async send(event) {
1689
+ if (!this.provider.capabilities.comments && !this.provider.capabilities.pullRequests) {
1690
+ console.log(`[notify/platform] ${event.type}: ${event.title}`);
1691
+ return;
1692
+ }
1693
+ const body = `**${event.title}**
1694
+
1695
+ ${event.body}`;
1696
+ if (this.activePr && this.provider.capabilities.comments) {
1697
+ await this.provider.comment(this.activePr, body);
1698
+ }
1699
+ }
1700
+ };
1701
+
1702
+ // src/notify/email.ts
1703
+ function getEnvOrThrow(name, label) {
1704
+ if (!name) throw new SecretError(`Email notifier missing config field: ${label}`);
1705
+ const val = process.env[name];
1706
+ if (!val) throw new SecretError(`Email notifier: env var ${name} (${label}) is not set`);
1707
+ return val;
1708
+ }
1709
+ var EmailNotifier = class {
1710
+ constructor(config) {
1711
+ this.config = config;
1712
+ }
1713
+ config;
1714
+ async send(event) {
1715
+ const to = getEnvOrThrow(this.config.toEnv, "toEnv");
1716
+ const from = getEnvOrThrow(this.config.fromEnv, "fromEnv");
1717
+ const smtpHost = getEnvOrThrow(this.config.smtp?.hostEnv, "smtp.hostEnv");
1718
+ const smtpPort = Number(getEnvOrThrow(this.config.smtp?.portEnv, "smtp.portEnv") || "587");
1719
+ const smtpUser = getEnvOrThrow(this.config.smtp?.userEnv, "smtp.userEnv");
1720
+ const smtpPass = getEnvOrThrow(this.config.smtp?.passEnv, "smtp.passEnv");
1721
+ const nodemailer = await import("nodemailer").catch(() => null);
1722
+ if (!nodemailer) {
1723
+ console.log(`[notify/email] nodemailer not installed \u2014 skipping email to ${redact(to)}`);
1724
+ return;
1725
+ }
1726
+ const transport = nodemailer.default.createTransport({
1727
+ host: smtpHost,
1728
+ port: smtpPort,
1729
+ secure: smtpPort === 465,
1730
+ auth: { user: smtpUser, pass: smtpPass }
1731
+ });
1732
+ await transport.sendMail({
1733
+ from,
1734
+ to,
1735
+ subject: `[afterhours] ${event.title}`,
1736
+ text: event.body,
1737
+ html: `<pre>${event.body}</pre>`
1738
+ });
1739
+ }
1740
+ };
1741
+
1742
+ // src/notify/webhook.ts
1743
+ import { createHmac } from "crypto";
1744
+ var MAX_RETRIES = 3;
1745
+ var RETRY_DELAY_MS = 1e3;
1746
+ var WebhookNotifier = class {
1747
+ urlEnv;
1748
+ secretEnv;
1749
+ constructor(config) {
1750
+ this.urlEnv = config.urlEnv;
1751
+ this.secretEnv = config.secretEnv;
1752
+ }
1753
+ async send(event) {
1754
+ const url = process.env[this.urlEnv];
1755
+ if (!url) throw new SecretError(`Webhook notifier: env var ${this.urlEnv} is not set`);
1756
+ const payload = JSON.stringify({ type: event.type, title: event.title, body: redact(event.body), data: event.data });
1757
+ const secret = this.secretEnv ? process.env[this.secretEnv] : void 0;
1758
+ const headers = { "Content-Type": "application/json" };
1759
+ if (secret) {
1760
+ const sig = createHmac("sha256", secret).update(payload).digest("hex");
1761
+ headers["X-Afterhours-Signature"] = `sha256=${sig}`;
1762
+ }
1763
+ let lastError = null;
1764
+ for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
1765
+ if (attempt > 0) await new Promise((r) => setTimeout(r, RETRY_DELAY_MS * attempt));
1766
+ try {
1767
+ const res = await fetch(url, { method: "POST", headers, body: payload });
1768
+ if (res.ok || res.status < 500) return;
1769
+ lastError = new Error(`Webhook returned ${res.status}`);
1770
+ } catch (err) {
1771
+ lastError = err instanceof Error ? err : new Error(String(err));
1772
+ }
1773
+ }
1774
+ throw lastError ?? new Error("Webhook failed after retries");
1775
+ }
1776
+ };
1777
+
1778
+ // src/notify/index.ts
1779
+ var logger = createLogger();
1780
+ function createNotifiers(config) {
1781
+ const notifiers = [];
1782
+ for (const n of config.notifications) {
1783
+ switch (n.type) {
1784
+ case "github":
1785
+ if (config.provider) {
1786
+ notifiers.push(new PlatformNotifier(config.provider, config.activePr));
1787
+ }
1788
+ break;
1789
+ case "email":
1790
+ notifiers.push(new EmailNotifier({ toEnv: n.toEnv, fromEnv: n.fromEnv, smtp: n.smtp }));
1791
+ break;
1792
+ case "webhook":
1793
+ if (n.urlEnv) notifiers.push(new WebhookNotifier({ urlEnv: n.urlEnv }));
1794
+ break;
1795
+ }
1796
+ }
1797
+ return notifiers;
1798
+ }
1799
+ async function dispatch(notifiers, event) {
1800
+ const redactedBody = redact(event.body);
1801
+ const redactedEvent = { ...event, body: redactedBody };
1802
+ await Promise.allSettled(
1803
+ notifiers.map(async (n) => {
1804
+ try {
1805
+ await n.send(redactedEvent);
1806
+ } catch (err) {
1807
+ logger.warn(`Notifier send failed (type: ${redactedEvent.type}): ${err}`);
1808
+ }
1809
+ })
1810
+ );
1811
+ }
1812
+
1813
+ // src/git/index.ts
1814
+ import { simpleGit } from "simple-git";
1815
+ function createGit(cwd) {
1816
+ return simpleGit(cwd ? { baseDir: cwd } : {});
1817
+ }
1818
+ async function createBranch(name, from, cwd) {
1819
+ const g = createGit(cwd);
1820
+ if (from) {
1821
+ await g.checkoutBranch(name, from);
1822
+ } else {
1823
+ await g.checkoutLocalBranch(name);
1824
+ }
1825
+ }
1826
+ async function checkout(name, cwd) {
1827
+ await createGit(cwd).checkout(name);
1828
+ }
1829
+ async function stageAll(cwd) {
1830
+ await createGit(cwd).add(".");
1831
+ }
1832
+ async function commit(msg, cwd) {
1833
+ await createGit(cwd).commit(msg);
1834
+ }
1835
+ async function resetHard(ref, cwd) {
1836
+ await createGit(cwd).reset(["--hard", ref]);
1837
+ }
1838
+ async function push(remote, branch, setUpstream, cwd) {
1839
+ const args = setUpstream ? ["-u", remote, branch] : [remote, branch];
1840
+ await createGit(cwd).push(args);
1841
+ }
1842
+ async function headSha(cwd) {
1843
+ return (await createGit(cwd).revparse(["HEAD"])).trim();
1844
+ }
1845
+
1846
+ // src/orchestrator/run.ts
1847
+ import { join as join4 } from "path";
1848
+
1849
+ // src/orchestrator/verify-gate.ts
1850
+ async function runVerifyGate(ctx) {
1851
+ const steps = [];
1852
+ if (ctx.buildCmd !== "none") {
1853
+ const result = await ctx.sandbox.run(ctx.buildCmd);
1854
+ const step = {
1855
+ name: "build",
1856
+ ok: result.code === 0,
1857
+ code: result.code,
1858
+ stdout: result.stdout,
1859
+ stderr: result.stderr
1860
+ };
1861
+ steps.push(step);
1862
+ if (!step.ok) {
1863
+ return { ok: false, steps };
1864
+ }
1865
+ }
1866
+ if (ctx.projectType !== "library" && ctx.runCmd) {
1867
+ const healthcheck = ctx.runHealthcheck;
1868
+ let smokeOk = false;
1869
+ if (healthcheck?.type === "http" && healthcheck.url) {
1870
+ const handle = await ctx.sandbox.start(ctx.runCmd);
1871
+ const timeoutSec = healthcheck.timeoutSec ?? 30;
1872
+ smokeOk = await waitForHttpHealthcheck(healthcheck.url, timeoutSec);
1873
+ await handle.stop();
1874
+ } else if (healthcheck?.type === "exit") {
1875
+ const result = await ctx.sandbox.run(ctx.runCmd, { timeoutSec: healthcheck.timeoutSec });
1876
+ smokeOk = result.code === 0;
1877
+ } else {
1878
+ const handle = await ctx.sandbox.start(ctx.runCmd);
1879
+ await new Promise((r) => setTimeout(r, 1e3));
1880
+ await handle.stop();
1881
+ smokeOk = true;
1882
+ }
1883
+ steps.push({ name: "smoke", ok: smokeOk });
1884
+ if (!smokeOk) {
1885
+ return { ok: false, steps };
1886
+ }
1887
+ } else {
1888
+ steps.push({ name: "smoke", ok: true, skipped: true });
1889
+ }
1890
+ if (ctx.testCmd && ctx.testCmd !== "none") {
1891
+ const result = await ctx.sandbox.run(ctx.testCmd);
1892
+ const step = {
1893
+ name: "test",
1894
+ ok: result.code === 0,
1895
+ code: result.code,
1896
+ stdout: result.stdout,
1897
+ stderr: result.stderr
1898
+ };
1899
+ steps.push(step);
1900
+ if (!step.ok) {
1901
+ return { ok: false, steps };
1902
+ }
1903
+ } else {
1904
+ steps.push({ name: "test", ok: true, skipped: true });
1905
+ }
1906
+ return { ok: true, steps };
1907
+ }
1908
+ async function waitForHttpHealthcheck(url, timeoutSec) {
1909
+ const deadline = Date.now() + timeoutSec * 1e3;
1910
+ while (Date.now() < deadline) {
1911
+ try {
1912
+ const res = await fetch(url);
1913
+ if (res.ok) return true;
1914
+ } catch {
1915
+ }
1916
+ await new Promise((r) => setTimeout(r, 500));
1917
+ }
1918
+ return false;
1919
+ }
1920
+
1921
+ // src/agent/prompts.ts
1922
+ var CODE_OPTIMIZATION_PROMPT = `You are afterhours, an autonomous maintenance agent.
1923
+ Your task is to implement code improvements from a pre-analyzed findings list.
1924
+ Each finding has already been graded; you are applying the fixes.
1925
+
1926
+ Guidelines:
1927
+ - Make MINIMAL changes required to address each finding. Do not gold-plate.
1928
+ - Preserve existing behavior. Do not change APIs or interfaces unless the finding explicitly asks.
1929
+ - Do not leak secrets. Never print API keys or tokens.
1930
+ - After applying a fix, verify it by running the tests if possible.
1931
+ - When you finish a finding, respond with a summary. Then stop if all findings are addressed.
1932
+ `;
1933
+ var ISSUE_RESOLUTION_PROMPT = `You are afterhours, an autonomous maintenance agent.
1934
+ Your task is to resolve a single issue reported in this repository's issue tracker.
1935
+ You have access to the repository files and a sandbox to run commands.
1936
+
1937
+ Guidelines:
1938
+ - Make MINIMAL, reversible changes that directly address the issue. Do not refactor unrelated code.
1939
+ - After each change, run the build/tests if possible to verify correctness.
1940
+ - Do not leak secrets. Never print API keys or tokens.
1941
+ - When you are done, stop by responding with a plain text summary of what was changed.
1942
+ - If you cannot resolve the issue, stop and explain what is blocking you; do not guess or fabricate a fix.
1943
+ `;
1944
+
1945
+ // src/tasks/issue-triage/match.ts
1946
+ function isAuthorAllowed(author, allowedAuthors) {
1947
+ if (allowedAuthors.some((a) => a.trim() === "*")) return true;
1948
+ return allowedAuthors.some((a) => a.toLowerCase() === author.toLowerCase());
1949
+ }
1950
+
1951
+ // src/tasks/issue-triage/index.ts
1952
+ async function runIssueTriage(ctx, opts = {}) {
1953
+ const { config, provider, sandbox, agent, projectInfo, logger: logger2, report, repoRoot, mainBranch } = ctx;
1954
+ const triageConfig = config.tasks.issueTriage;
1955
+ if (!triageConfig.enabled) return;
1956
+ if (!provider.capabilities.issues) {
1957
+ logger2.info(`Platform provider "${provider.name}" does not support issues; skipping issue triage.`);
1958
+ return;
1959
+ }
1960
+ let issues;
1961
+ try {
1962
+ issues = await provider.listIssues();
1963
+ } catch (err) {
1964
+ logger2.warn(`Issue triage: failed to list issues: ${err}`);
1965
+ return;
1966
+ }
1967
+ const matched = issues.filter((issue) => isAuthorAllowed(issue.author, triageConfig.allowedAuthors));
1968
+ if (matched.length === 0) {
1969
+ logger2.info("Issue triage: no open issues match the configured authors.");
1970
+ return;
1971
+ }
1972
+ logger2.info(`Issue triage: ${matched.length} issue(s) matched configured authors.`);
1973
+ const notifiers = createNotifiers({ notifications: config.notifications, provider });
1974
+ for (const issue of matched) {
1975
+ await triageOneIssue(issue, {
1976
+ config,
1977
+ provider,
1978
+ sandbox,
1979
+ agent,
1980
+ projectInfo,
1981
+ logger: logger2,
1982
+ report,
1983
+ repoRoot,
1984
+ mainBranch,
1985
+ notifiers,
1986
+ dryRun: opts.dryRun ?? false
1987
+ });
1988
+ }
1989
+ }
1990
+ async function triageOneIssue(issue, ctx) {
1991
+ const { config, provider, sandbox, agent, projectInfo, logger: logger2, report, repoRoot, mainBranch, notifiers, dryRun } = ctx;
1992
+ const triageConfig = config.tasks.issueTriage;
1993
+ const taskId = `issue-${issue.number}`;
1994
+ const title = `Issue #${issue.number}: ${issue.title}`;
1995
+ if (dryRun) {
1996
+ logger2.info(`[dry-run] Would attempt to resolve issue #${issue.number}: ${issue.title}`);
1997
+ report.tasks.push({ taskId, title, status: "skipped", gateSteps: [], summary: "Dry-run: issue triage skipped." });
1998
+ return;
1999
+ }
2000
+ const branchName = `${config.platform.branchPrefix}issue-${issue.number}`;
2001
+ logger2.info(`Issue triage: attempting to resolve issue #${issue.number} on branch ${branchName}`);
2002
+ try {
2003
+ await createBranch(branchName, config.platform.baseBranch, repoRoot);
2004
+ const goal = `Resolve the following issue in this repository.
2005
+
2006
+ Title: ${issue.title}
2007
+
2008
+ Description:
2009
+ ${issue.body || "(no description provided)"}
2010
+
2011
+ Make the minimal code changes necessary to fix or implement this issue.`;
2012
+ const result = await agent.runBuiltin(goal, ISSUE_RESOLUTION_PROMPT, { repoRoot, sandbox });
2013
+ if (result.filesChanged.length === 0) {
2014
+ await checkout(mainBranch, repoRoot);
2015
+ const reason = "Agent made no changes while attempting to resolve this issue.";
2016
+ await provider.commentOnIssue(issue, `Automated resolution attempt did not produce any changes.
2017
+
2018
+ ${result.summary}`);
2019
+ report.tasks.push({ taskId, title, status: "failed", gateSteps: [], summary: reason });
2020
+ report.outstandingWork.push(`${title}: ${reason}`);
2021
+ if (triageConfig.notifyOnFailure) {
2022
+ await dispatch(notifiers, { type: "task-failed", title: `${title}: no changes made`, body: reason });
2023
+ }
2024
+ return;
2025
+ }
2026
+ const gate = await runVerifyGate({
2027
+ sandbox,
2028
+ buildCmd: projectInfo.build,
2029
+ testCmd: projectInfo.test,
2030
+ projectType: projectInfo.type
2031
+ });
2032
+ if (!gate.ok) {
2033
+ await checkout(mainBranch, repoRoot);
2034
+ const failedStep = gate.steps.find((s) => !s.ok && !s.skipped);
2035
+ const reason = `Verify gate failed at step: ${failedStep?.name ?? "unknown"}`;
2036
+ await provider.commentOnIssue(issue, `Automated resolution attempt failed verification (${reason}).
2037
+
2038
+ ${result.summary}`);
2039
+ report.tasks.push({ taskId, title, status: "outstanding", gateSteps: gate.steps, summary: result.summary, outstandingReason: reason });
2040
+ report.outstandingWork.push(`${title}: ${reason}`);
2041
+ if (triageConfig.notifyOnFailure) {
2042
+ await dispatch(notifiers, { type: "task-failed", title: `${title}: verify gate failed`, body: reason });
2043
+ }
2044
+ return;
2045
+ }
2046
+ await stageAll(repoRoot);
2047
+ await commit(`afterhours(issue-${issue.number}): ${issue.title}`, repoRoot);
2048
+ const commitSha = await headSha(repoRoot);
2049
+ await push(config.platform.remote, branchName, true, repoRoot);
2050
+ let prUrl;
2051
+ if (provider.capabilities.pullRequests) {
2052
+ const pr = await provider.openPullRequest({
2053
+ title: `Fix #${issue.number}: ${issue.title}`,
2054
+ body: `Closes #${issue.number}
2055
+
2056
+ ${result.summary}`,
2057
+ head: branchName,
2058
+ base: config.platform.baseBranch
2059
+ });
2060
+ prUrl = pr.url;
2061
+ if (config.platform.autoMerge) {
2062
+ await provider.mergePullRequest(pr, config.platform.mergeStrategy);
2063
+ }
2064
+ }
2065
+ await provider.closeIssue(issue, `Resolved automatically by afterhours.${prUrl ? ` See ${prUrl}.` : ""}
2066
+
2067
+ ${result.summary}`);
2068
+ await checkout(mainBranch, repoRoot);
2069
+ report.tasks.push({ taskId, title, status: "changed", commit: commitSha, gateSteps: gate.steps, summary: result.summary });
2070
+ await dispatch(notifiers, { type: "task-succeeded", title: `${title}: resolved`, body: result.summary });
2071
+ } catch (err) {
2072
+ logger2.warn(`Issue triage: unexpected error resolving issue #${issue.number}: ${err}`);
2073
+ try {
2074
+ await provider.commentOnIssue(issue, `Automated resolution attempt encountered an error: ${err}`);
2075
+ } catch (commentErr) {
2076
+ logger2.warn(`Issue triage: failed to comment on issue #${issue.number}: ${commentErr}`);
2077
+ }
2078
+ try {
2079
+ await checkout(mainBranch, repoRoot);
2080
+ } catch {
2081
+ }
2082
+ report.tasks.push({ taskId, title, status: "failed", gateSteps: [], summary: `Error: ${err}` });
2083
+ report.outstandingWork.push(`${title}: unexpected error: ${err}`);
2084
+ }
2085
+ }
2086
+
2087
+ // src/orchestrator/run.ts
2088
+ async function runOrchestrator(tasks, opts = {}) {
2089
+ const repoRoot = opts.repoRoot ?? process.cwd();
2090
+ const config = loadConfig(repoRoot);
2091
+ const logger2 = createLogger();
2092
+ const runId = `run-${Date.now()}`;
2093
+ logger2.info(`Starting afterhours run ${runId}`, { dryRun: opts.dryRun });
2094
+ const pm = config.project.packageManager !== "auto" ? config.project.packageManager : detectPackageManager(repoRoot);
2095
+ const type = config.project.type !== "auto" ? config.project.type : detectProjectType(repoRoot);
2096
+ const cmds = detectCommands(repoRoot, pm);
2097
+ const projectInfo = {
2098
+ type,
2099
+ packageManager: pm,
2100
+ build: typeof config.project.build !== "string" || config.project.build === "auto" ? cmds.build : config.project.build,
2101
+ test: typeof config.project.test !== "string" || config.project.test === "auto" ? cmds.test : config.project.test,
2102
+ run: typeof config.project.run !== "string" || config.project.run === "auto" ? cmds.run : config.project.run
2103
+ };
2104
+ const branchName = `${config.platform.branchPrefix}${runId}`;
2105
+ const report = createRunReport({
2106
+ id: runId,
2107
+ startedAt: (/* @__PURE__ */ new Date()).toISOString(),
2108
+ projectType: type,
2109
+ projectPackageManager: pm,
2110
+ branch: branchName,
2111
+ baseBranch: config.platform.baseBranch
2112
+ });
2113
+ if (opts.dryRun) {
2114
+ logger2.info("Dry-run mode: skipping branch creation and commits");
2115
+ } else {
2116
+ await createBranch(branchName, config.platform.baseBranch, repoRoot);
2117
+ }
2118
+ const provider = createProvider({ platform: config.platform });
2119
+ const sandbox = createSandbox({ sandbox: config.sandbox, mountDir: repoRoot });
2120
+ const llm = createLlm({ llm: config.llm });
2121
+ const agent = createAgent(llm);
2122
+ const notifiers = createNotifiers({ notifications: config.notifications, provider });
2123
+ await dispatch(notifiers, { type: "run-started", title: `afterhours run ${runId} started`, body: `Branch: ${branchName}` });
2124
+ const ctx = { config, sandbox, provider, agent, projectInfo, logger: logger2, report, repoRoot };
2125
+ let hasSharedBranchCommits = false;
2126
+ const enabledTasks = tasks.filter((t) => {
2127
+ if (opts.taskFilter && t.id !== opts.taskFilter) return false;
2128
+ if (opts.noDeploy && t.id === "deployment") return false;
2129
+ return t.isEnabled(config);
2130
+ });
2131
+ for (const task of enabledTasks) {
2132
+ logger2.info(`Running task: ${task.title}`);
2133
+ const snapshotSha = opts.dryRun ? "" : await headSha(repoRoot);
2134
+ let outcome;
2135
+ try {
2136
+ outcome = await task.execute(ctx);
2137
+ } catch (err) {
2138
+ outcome = { status: "failed", summary: `Task threw: ${err}`, details: String(err) };
2139
+ }
2140
+ if (outcome.status === "changed" && !opts.dryRun) {
2141
+ const gate = await runVerifyGate({
2142
+ sandbox,
2143
+ buildCmd: projectInfo.build,
2144
+ testCmd: projectInfo.test,
2145
+ projectType: type
2146
+ });
2147
+ if (gate.ok) {
2148
+ await stageAll(repoRoot);
2149
+ const commitSha = await (async () => {
2150
+ await commit(`afterhours(${task.id}): ${task.title}`, repoRoot);
2151
+ return headSha(repoRoot);
2152
+ })();
2153
+ report.tasks.push({
2154
+ taskId: task.id,
2155
+ title: task.title,
2156
+ status: "changed",
2157
+ commit: commitSha,
2158
+ gateSteps: gate.steps,
2159
+ summary: outcome.summary,
2160
+ details: outcome.details
2161
+ });
2162
+ await dispatch(notifiers, { type: "task-succeeded", title: `${task.title}: succeeded`, body: outcome.summary });
2163
+ hasSharedBranchCommits = true;
2164
+ } else {
2165
+ if (snapshotSha) await resetHard(snapshotSha, repoRoot);
2166
+ const failedStep = gate.steps.find((s) => !s.ok && !s.skipped);
2167
+ const reason = `Verify gate failed at step: ${failedStep?.name ?? "unknown"}`;
2168
+ report.tasks.push({
2169
+ taskId: task.id,
2170
+ title: task.title,
2171
+ status: "outstanding",
2172
+ gateSteps: gate.steps,
2173
+ summary: outcome.summary,
2174
+ outstandingReason: reason
2175
+ });
2176
+ report.outstandingWork.push(`${task.title}: ${reason}`);
2177
+ await dispatch(notifiers, { type: "task-failed", title: `${task.title}: verify gate failed`, body: reason });
2178
+ }
2179
+ } else {
2180
+ report.tasks.push({
2181
+ taskId: task.id,
2182
+ title: task.title,
2183
+ status: outcome.status,
2184
+ gateSteps: [],
2185
+ summary: outcome.summary
2186
+ });
2187
+ if (outcome.status === "failed") {
2188
+ await dispatch(notifiers, { type: "task-failed", title: `${task.title}: failed`, body: outcome.summary });
2189
+ }
2190
+ }
2191
+ }
2192
+ if (!opts.taskFilter || opts.taskFilter === "issue-triage") {
2193
+ await runIssueTriage(
2194
+ { config, provider, sandbox, agent, projectInfo, logger: logger2, report, repoRoot, mainBranch: branchName },
2195
+ { dryRun: opts.dryRun }
2196
+ );
2197
+ }
2198
+ report.completedAt = (/* @__PURE__ */ new Date()).toISOString();
2199
+ const hasCommits = hasSharedBranchCommits;
2200
+ if (hasCommits && !opts.dryRun) {
2201
+ await push(config.platform.remote, branchName, true, repoRoot);
2202
+ if (provider.capabilities.pullRequests) {
2203
+ const { renderMarkdown } = await import("./report-2P72ZPRH.js");
2204
+ const pr = await provider.openPullRequest({
2205
+ title: `afterhours: automated maintenance (${runId})`,
2206
+ body: renderMarkdown(report),
2207
+ head: branchName,
2208
+ base: config.platform.baseBranch
2209
+ });
2210
+ report.pr = { id: pr.id, url: pr.url, number: pr.number };
2211
+ if (config.platform.autoMerge && report.tasks.every((t) => t.status !== "failed" && t.status !== "outstanding")) {
2212
+ await provider.mergePullRequest(pr, config.platform.mergeStrategy);
2213
+ report.pr.merged = true;
2214
+ }
2215
+ }
2216
+ }
2217
+ const reportsDir = join4(repoRoot, ".afterhours", "reports");
2218
+ writeReports(report, reportsDir);
2219
+ await dispatch(notifiers, {
2220
+ type: "run-summary",
2221
+ title: `afterhours run ${runId} complete`,
2222
+ body: `Tasks: ${report.tasks.length}, outstanding: ${report.outstandingWork.length}`,
2223
+ data: { reportId: runId }
2224
+ });
2225
+ return report;
2226
+ }
2227
+
2228
+ // src/tasks/dependency-audit/audit.ts
2229
+ async function collectAuditData(sandbox, pm) {
2230
+ const [auditOut, outdatedOut] = await Promise.all([
2231
+ sandbox.run(`${pm === "npm" ? "npm" : pm} audit --json`),
2232
+ sandbox.run(`${pm === "npm" ? "npm" : pm} outdated --json`)
2233
+ ]);
2234
+ const vulnerabilities = parseNpmAudit(auditOut.stdout);
2235
+ const outdated = parseNpmOutdated(outdatedOut.stdout);
2236
+ return { vulnerabilities, outdated };
2237
+ }
2238
+ function parseNpmAudit(json) {
2239
+ try {
2240
+ const data = JSON.parse(json);
2241
+ if (!data.vulnerabilities) return [];
2242
+ return Object.entries(data.vulnerabilities).map(([name, vuln]) => ({
2243
+ name,
2244
+ severity: vuln.severity ?? "low",
2245
+ vulnerableVersions: vuln.range ?? "",
2246
+ recommendation: Array.isArray(vuln.via) ? vuln.via[0]?.recommendation ?? "" : "",
2247
+ reason: Array.isArray(vuln.via) ? vuln.via[0]?.title ?? "vulnerability" : "vulnerability"
2248
+ }));
2249
+ } catch {
2250
+ return [];
2251
+ }
2252
+ }
2253
+ function parseNpmOutdated(json) {
2254
+ try {
2255
+ const data = JSON.parse(json);
2256
+ return Object.entries(data).map(([name, info]) => ({
2257
+ name,
2258
+ current: info.current ?? "0.0.0",
2259
+ wanted: info.wanted ?? info.current ?? "0.0.0",
2260
+ latest: info.latest ?? info.wanted ?? "0.0.0",
2261
+ deprecated: info.deprecated
2262
+ }));
2263
+ } catch {
2264
+ return [];
2265
+ }
2266
+ }
2267
+
2268
+ // src/tasks/dependency-audit/planner.ts
2269
+ function semverMajor(version) {
2270
+ return parseInt(version.split(".")[0] ?? "0", 10);
2271
+ }
2272
+ function planUpgrades(data, config) {
2273
+ const safe = [];
2274
+ const risky = [];
2275
+ const vulnNames = new Set(data.vulnerabilities.map((v) => v.name));
2276
+ for (const dep of data.outdated) {
2277
+ if (dep.current === dep.latest) continue;
2278
+ const isVulnerable = vulnNames.has(dep.name) || dep.deprecated !== void 0;
2279
+ if (config.securityOnly && !isVulnerable) continue;
2280
+ const isMajor = semverMajor(dep.current) < semverMajor(dep.latest);
2281
+ if (isMajor && !config.allowMajor) {
2282
+ risky.push({ name: dep.name, fromVersion: dep.current, toVersion: dep.latest, isMajor: true, reason: "major version bump blocked by allowMajor:false", severity: isVulnerable ? "high" : void 0 });
2283
+ continue;
2284
+ }
2285
+ const item = {
2286
+ name: dep.name,
2287
+ fromVersion: dep.current,
2288
+ toVersion: isMajor ? dep.latest : dep.wanted,
2289
+ isMajor,
2290
+ reason: dep.deprecated ? "deprecated" : isVulnerable ? "vulnerability" : "outdated",
2291
+ severity: data.vulnerabilities.find((v) => v.name === dep.name)?.severity
2292
+ };
2293
+ if (isMajor) {
2294
+ if (config.allowMajor) {
2295
+ safe.push(item);
2296
+ } else {
2297
+ risky.push(item);
2298
+ }
2299
+ } else {
2300
+ safe.push(item);
2301
+ }
2302
+ }
2303
+ for (const vuln of data.vulnerabilities) {
2304
+ if (!data.outdated.find((o) => o.name === vuln.name)) {
2305
+ safe.push({ name: vuln.name, fromVersion: "unknown", toVersion: vuln.recommendation || "latest", isMajor: false, reason: "vulnerability", severity: vuln.severity });
2306
+ }
2307
+ }
2308
+ const severityOrder = ["critical", "high", "moderate", "low"];
2309
+ const sortBySeverity = (a, b) => severityOrder.indexOf(a.severity ?? "low") - severityOrder.indexOf(b.severity ?? "low");
2310
+ safe.sort(sortBySeverity);
2311
+ return { safe, risky };
2312
+ }
2313
+
2314
+ // src/tasks/dependency-audit/index.ts
2315
+ var DependencyAuditTask = class {
2316
+ id = "dependency-audit";
2317
+ title = "Dependency Audit & Upgrade";
2318
+ isEnabled(config) {
2319
+ return config.tasks.dependencyAudit.enabled;
2320
+ }
2321
+ async execute(ctx) {
2322
+ const { config, sandbox, projectInfo, logger: logger2, report, agent } = ctx;
2323
+ const auditConfig = config.tasks.dependencyAudit;
2324
+ logger2.info("Collecting audit data...");
2325
+ const auditData = await collectAuditData(sandbox, projectInfo.packageManager);
2326
+ if (auditData.vulnerabilities.length === 0 && auditData.outdated.length === 0) {
2327
+ return { status: "noop", summary: "No vulnerabilities or outdated dependencies found." };
2328
+ }
2329
+ const plan = planUpgrades(auditData, { securityOnly: auditConfig.securityOnly, allowMajor: auditConfig.allowMajor });
2330
+ logger2.info(`Upgrade plan: ${plan.safe.length} safe, ${plan.risky.length} risky`);
2331
+ const upgraded = [];
2332
+ const failed = [];
2333
+ if (plan.safe.length > 0) {
2334
+ const installCmd = buildInstallCmd(plan.safe, projectInfo.packageManager);
2335
+ const installResult = await sandbox.run(installCmd, { timeoutSec: 300 });
2336
+ if (installResult.code !== 0) {
2337
+ logger2.warn("Safe batch install failed; trying to fix with agent...");
2338
+ const agentResult = await agent.runBuiltin(
2339
+ `The following packages failed to install: ${plan.safe.map((u) => `${u.name}@${u.toVersion}`).join(", ")}. Error: ${installResult.stderr}. Fix the package.json/code breaking changes.`,
2340
+ "You are afterhours. Fix the dependency install failure. Make minimal changes.",
2341
+ { repoRoot: ctx.repoRoot, sandbox }
2342
+ );
2343
+ if (agentResult.stopReason !== "done") {
2344
+ for (const item of plan.safe) {
2345
+ failed.push({ item, reason: `Install failed: ${installResult.stderr.slice(0, 200)}` });
2346
+ }
2347
+ } else {
2348
+ upgraded.push(...plan.safe);
2349
+ }
2350
+ } else {
2351
+ upgraded.push(...plan.safe);
2352
+ }
2353
+ }
2354
+ if (failed.length > 0 && auditConfig.notifyOnFailure) {
2355
+ const notifiers = createNotifiers({ notifications: config.notifications, provider: ctx.provider });
2356
+ for (const { item, reason } of failed) {
2357
+ if (item.reason === "vulnerability" || item.reason === "deprecated") {
2358
+ await dispatch(notifiers, {
2359
+ type: "migration-failed",
2360
+ title: `Migration failed: ${item.name}`,
2361
+ body: `Package: ${item.name}
2362
+ Target: ${item.toVersion}
2363
+ Reason needed: ${item.reason} (${item.severity ?? "unknown severity"})
2364
+ Error: ${reason}`,
2365
+ data: { package: item.name, targetVersion: item.toVersion, reason: item.reason }
2366
+ });
2367
+ }
2368
+ }
2369
+ for (const item of failed) {
2370
+ report.outstandingWork.push(`${item.item.name}@${item.item.toVersion}: ${item.reason}`);
2371
+ }
2372
+ }
2373
+ if (upgraded.length === 0) {
2374
+ return { status: "noop", summary: "Audit complete; no safe upgrades could be applied." };
2375
+ }
2376
+ return {
2377
+ status: "changed",
2378
+ summary: `Upgraded ${upgraded.length} package(s): ${upgraded.map((u) => `${u.name}@${u.toVersion}`).join(", ")}`,
2379
+ details: failed.length > 0 ? `Failed: ${failed.map((f) => f.item.name).join(", ")}` : void 0
2380
+ };
2381
+ }
2382
+ };
2383
+ function buildInstallCmd(items, pm) {
2384
+ const packages = items.map((u) => `${u.name}@${u.toVersion}`).join(" ");
2385
+ switch (pm) {
2386
+ case "pnpm":
2387
+ return `pnpm add ${packages}`;
2388
+ case "yarn":
2389
+ return `yarn add ${packages}`;
2390
+ case "bun":
2391
+ return `bun add ${packages}`;
2392
+ default:
2393
+ return `npm install ${packages}`;
2394
+ }
2395
+ }
2396
+
2397
+ // src/tasks/code-optimization/findings.ts
2398
+ import { z as z7 } from "zod";
2399
+ var FindingSchema = z7.object({
2400
+ title: z7.string(),
2401
+ area: z7.string(),
2402
+ kind: z7.enum(["optimization", "weakness", "vulnerability"]),
2403
+ severity: z7.enum(["low", "medium", "high", "severe"]),
2404
+ rationale: z7.string(),
2405
+ suggestedFix: z7.string(),
2406
+ files: z7.array(z7.string())
2407
+ });
2408
+ var FindingsSchema = z7.array(FindingSchema);
2409
+ var SEVERITY_ORDER = ["severe", "high", "medium", "low"];
2410
+ function rankSeverity(s) {
2411
+ return SEVERITY_ORDER.indexOf(s);
2412
+ }
2413
+ function selectFindings(findings, threshold) {
2414
+ const thresholdRank = rankSeverity(threshold);
2415
+ return findings.filter((f) => rankSeverity(f.severity) <= thresholdRank).sort((a, b) => rankSeverity(a.severity) - rankSeverity(b.severity));
2416
+ }
2417
+
2418
+ // src/tasks/code-optimization/index.ts
2419
+ var ANALYZE_SYSTEM_PROMPT = `You are afterhours. Analyze the codebase and produce a structured JSON array of findings.
2420
+ Each finding must have: title, area, kind (optimization|weakness|vulnerability), severity (low|medium|high|severe), rationale, suggestedFix, files.
2421
+ Respond ONLY with a valid JSON array, no markdown fences.`;
2422
+ var CodeOptimizationTask = class {
2423
+ id = "code-optimization";
2424
+ title = "Code Optimization";
2425
+ isEnabled(config) {
2426
+ return config.tasks.codeOptimization.enabled;
2427
+ }
2428
+ async execute(ctx) {
2429
+ const { config, sandbox, agent, projectInfo, logger: logger2, report } = ctx;
2430
+ const optConfig = config.tasks.codeOptimization;
2431
+ logger2.info("Collecting code intake for analysis...");
2432
+ const intakeResult = await agent.runBuiltin(
2433
+ "List the repository structure and read key source files (src/ directory, README, package.json). Summarize the project.",
2434
+ ANALYZE_SYSTEM_PROMPT,
2435
+ { repoRoot: ctx.repoRoot, sandbox }
2436
+ );
2437
+ logger2.info("Analyzing codebase for findings...");
2438
+ const analysisResult = await agent.runBuiltin(
2439
+ `Based on the project overview:
2440
+
2441
+ ${intakeResult.summary}
2442
+
2443
+ Produce a JSON array of findings (max 20). Focus on: security vulnerabilities, performance, error handling, code quality. Return ONLY JSON.`,
2444
+ ANALYZE_SYSTEM_PROMPT,
2445
+ { repoRoot: ctx.repoRoot, sandbox }
2446
+ );
2447
+ let findings = [];
2448
+ try {
2449
+ const parsed = JSON.parse(analysisResult.summary);
2450
+ const validated = FindingsSchema.parse(Array.isArray(parsed) ? parsed : []);
2451
+ findings = validated;
2452
+ } catch {
2453
+ logger2.warn("Could not parse findings from analysis; no changes will be made.");
2454
+ return { status: "noop", summary: "Analysis did not return valid findings." };
2455
+ }
2456
+ if (findings.length === 0) {
2457
+ return { status: "noop", summary: "No findings from code analysis." };
2458
+ }
2459
+ const selected = selectFindings(findings, optConfig.priorityThreshold);
2460
+ logger2.info(`Selected ${selected.length} finding(s) at or above threshold "${optConfig.priorityThreshold}"`);
2461
+ if (selected.length === 0) {
2462
+ return { status: "noop", summary: `Analysis found ${findings.length} finding(s) but none at/above threshold "${optConfig.priorityThreshold}".` };
2463
+ }
2464
+ const fixed = [];
2465
+ const outstanding = [];
2466
+ for (const finding of selected) {
2467
+ logger2.info(`Fixing: [${finding.severity}] ${finding.title}`);
2468
+ const fixResult = await agent.runBuiltin(
2469
+ `Fix this finding:
2470
+
2471
+ Title: ${finding.title}
2472
+ Area: ${finding.area}
2473
+ Severity: ${finding.severity}
2474
+ Rationale: ${finding.rationale}
2475
+ Suggested fix: ${finding.suggestedFix}
2476
+ Files: ${finding.files.join(", ")}
2477
+
2478
+ Make the minimal change required.`,
2479
+ CODE_OPTIMIZATION_PROMPT,
2480
+ { repoRoot: ctx.repoRoot, sandbox }
2481
+ );
2482
+ if (fixResult.filesChanged.length > 0) {
2483
+ const gate = await runVerifyGate({
2484
+ sandbox,
2485
+ buildCmd: projectInfo.build,
2486
+ testCmd: projectInfo.test,
2487
+ projectType: projectInfo.type
2488
+ });
2489
+ if (gate.ok) {
2490
+ fixed.push(finding);
2491
+ } else {
2492
+ outstanding.push(finding);
2493
+ report.outstandingWork.push(`[${finding.severity}] ${finding.title}: fix broke build/tests`);
2494
+ }
2495
+ } else {
2496
+ outstanding.push(finding);
2497
+ report.outstandingWork.push(`[${finding.severity}] ${finding.title}: agent made no changes`);
2498
+ }
2499
+ }
2500
+ if (optConfig.notifyOnComplete) {
2501
+ const notifiers = createNotifiers({ notifications: config.notifications, provider: ctx.provider });
2502
+ await dispatch(notifiers, {
2503
+ type: "run-summary",
2504
+ title: "Code optimization complete",
2505
+ body: `Fixed: ${fixed.length}, Outstanding: ${outstanding.length}
2506
+
2507
+ Fixed:
2508
+ ${fixed.map((f) => `- [${f.severity}] ${f.title}`).join("\n")}
2509
+
2510
+ Outstanding:
2511
+ ${outstanding.map((f) => `- [${f.severity}] ${f.title}`).join("\n")}`
2512
+ });
2513
+ }
2514
+ if (fixed.length === 0) {
2515
+ return { status: "noop", summary: `Analyzed ${findings.length} findings; none could be safely applied.` };
2516
+ }
2517
+ return {
2518
+ status: "changed",
2519
+ summary: `Fixed ${fixed.length} finding(s): ${fixed.map((f) => f.title).join(", ")}`,
2520
+ details: outstanding.length > 0 ? `Outstanding: ${outstanding.map((f) => f.title).join(", ")}` : void 0
2521
+ };
2522
+ }
2523
+ };
2524
+
2525
+ // src/tasks/deployment/targets.ts
2526
+ var DockerTarget = class {
2527
+ constructor(config) {
2528
+ this.config = config;
2529
+ }
2530
+ config;
2531
+ type = "docker";
2532
+ validate() {
2533
+ if (!process.env[this.config.registryUserEnv]) throw new Error(`Missing env var ${this.config.registryUserEnv}`);
2534
+ if (!process.env[this.config.registryPassEnv]) throw new Error(`Missing env var ${this.config.registryPassEnv}`);
2535
+ }
2536
+ async deploy(ctx) {
2537
+ const user = process.env[this.config.registryUserEnv] ?? "";
2538
+ const pass = process.env[this.config.registryPassEnv] ?? "";
2539
+ const tag = this.config.tag.includes("${env:") ? process.env[this.config.tag.match(/\$\{env:([^}]+)\}/)?.[1] ?? ""] ?? "latest" : this.config.tag;
2540
+ const fullImage = `${this.config.image}:${tag}`;
2541
+ const context = this.config.context ?? ".";
2542
+ const dockerfile = this.config.dockerfile ? `-f ${this.config.dockerfile}` : "";
2543
+ const build = await ctx.sandbox.run(`docker build ${dockerfile} -t ${fullImage} ${context}`, { cwd: ctx.repoRoot, timeoutSec: 600 });
2544
+ if (build.code !== 0) return { targetType: "docker", status: "failed", error: build.stderr };
2545
+ const login = await ctx.sandbox.run(`echo "${pass}" | docker login --username "${user}" --password-stdin`, { cwd: ctx.repoRoot });
2546
+ if (login.code !== 0) return { targetType: "docker", status: "failed", error: "Docker login failed" };
2547
+ const push2 = await ctx.sandbox.run(`docker push ${fullImage}`, { cwd: ctx.repoRoot, timeoutSec: 300 });
2548
+ if (push2.code !== 0) return { targetType: "docker", status: "failed", error: push2.stderr };
2549
+ return { targetType: "docker", status: "succeeded", url: fullImage };
2550
+ }
2551
+ describe() {
2552
+ return `docker push ${this.config.image}:${this.config.tag}`;
2553
+ }
2554
+ };
2555
+ var NpmTarget = class {
2556
+ constructor(config) {
2557
+ this.config = config;
2558
+ }
2559
+ config;
2560
+ type = "npm";
2561
+ validate() {
2562
+ if (!process.env[this.config.tokenEnv]) throw new Error(`Missing env var ${this.config.tokenEnv}`);
2563
+ }
2564
+ async deploy(ctx) {
2565
+ const token = process.env[this.config.tokenEnv] ?? "";
2566
+ const registry = this.config.registry ?? "https://registry.npmjs.org";
2567
+ const access = this.config.access ?? "public";
2568
+ const pkgJson = JSON.parse((await ctx.sandbox.run("cat package.json", { cwd: ctx.repoRoot })).stdout);
2569
+ const check = await ctx.sandbox.run(`npm view ${pkgJson.name}@${pkgJson.version} version --registry ${registry}`, { cwd: ctx.repoRoot });
2570
+ if (check.code === 0 && check.stdout.trim() === pkgJson.version) {
2571
+ return { targetType: "npm", status: "skipped", url: `${registry}/${pkgJson.name}` };
2572
+ }
2573
+ const npmrc = `//${new URL(registry).host}/:_authToken=${token}
2574
+ `;
2575
+ await ctx.sandbox.run(`echo '${npmrc}' >> .npmrc`, { cwd: ctx.repoRoot });
2576
+ const pub = await ctx.sandbox.run(`npm publish --access ${access} --registry ${registry}`, { cwd: ctx.repoRoot });
2577
+ await ctx.sandbox.run("rm -f .npmrc", { cwd: ctx.repoRoot });
2578
+ if (pub.code !== 0) return { targetType: "npm", status: "failed", error: pub.stderr };
2579
+ return { targetType: "npm", status: "succeeded", url: `${registry}/${pkgJson.name}` };
2580
+ }
2581
+ describe() {
2582
+ return `npm publish --access ${this.config.access ?? "public"}`;
2583
+ }
2584
+ };
2585
+ var GitHubReleaseTarget = class {
2586
+ constructor(config, provider) {
2587
+ this.config = config;
2588
+ this.provider = provider;
2589
+ }
2590
+ config;
2591
+ provider;
2592
+ type = "github-release";
2593
+ validate() {
2594
+ if (!this.provider.capabilities.releases) throw new Error("Provider does not support releases");
2595
+ }
2596
+ async deploy(ctx) {
2597
+ const pkgJson = JSON.parse((await ctx.sandbox.run("cat package.json", { cwd: ctx.repoRoot })).stdout);
2598
+ const tag = `v${pkgJson.version}`;
2599
+ const tagCheck = await ctx.sandbox.run(`git tag -l ${tag}`, { cwd: ctx.repoRoot });
2600
+ if (!tagCheck.stdout.includes(tag)) {
2601
+ await ctx.sandbox.run(`git tag -a ${tag} -m "Release ${tag}"`, { cwd: ctx.repoRoot });
2602
+ await ctx.sandbox.run(`git push origin ${tag}`, { cwd: ctx.repoRoot });
2603
+ }
2604
+ const rel = await this.provider.createRelease({ tag, name: `Release ${tag}`, body: "Automated release by afterhours" });
2605
+ return { targetType: "github-release", status: "succeeded", url: rel.url };
2606
+ }
2607
+ describe() {
2608
+ return `github-release from package.json version`;
2609
+ }
2610
+ };
2611
+ var VercelTarget = class {
2612
+ constructor(config) {
2613
+ this.config = config;
2614
+ }
2615
+ config;
2616
+ type = "vercel";
2617
+ validate() {
2618
+ if (!process.env[this.config.tokenEnv]) throw new Error(`Missing env var ${this.config.tokenEnv}`);
2619
+ if (!process.env[this.config.projectEnv]) throw new Error(`Missing env var ${this.config.projectEnv}`);
2620
+ if (!process.env[this.config.orgEnv]) throw new Error(`Missing env var ${this.config.orgEnv}`);
2621
+ }
2622
+ async deploy(ctx) {
2623
+ const token = process.env[this.config.tokenEnv] ?? "";
2624
+ const projectId = process.env[this.config.projectEnv] ?? "";
2625
+ const orgId = process.env[this.config.orgEnv] ?? "";
2626
+ const prod = this.config.prod ? "--prod" : "";
2627
+ const result = await ctx.sandbox.run(
2628
+ `VERCEL_TOKEN="${token}" VERCEL_PROJECT_ID="${projectId}" VERCEL_ORG_ID="${orgId}" npx vercel deploy ${prod} --yes --token "${token}"`,
2629
+ { cwd: ctx.repoRoot, timeoutSec: 300 }
2630
+ );
2631
+ if (result.code !== 0) return { targetType: "vercel", status: "failed", error: result.stderr };
2632
+ const url = result.stdout.trim().split("\n").find((l) => l.startsWith("https://")) ?? "";
2633
+ return { targetType: "vercel", status: "succeeded", url };
2634
+ }
2635
+ describe() {
2636
+ return `vercel deploy${this.config.prod ? " --prod" : ""}`;
2637
+ }
2638
+ };
2639
+ var RenderTarget = class {
2640
+ constructor(config) {
2641
+ this.config = config;
2642
+ }
2643
+ config;
2644
+ type = "render";
2645
+ validate() {
2646
+ if (!process.env[this.config.apiKeyEnv]) throw new Error(`Missing env var ${this.config.apiKeyEnv}`);
2647
+ if (!process.env[this.config.serviceIdEnv]) throw new Error(`Missing env var ${this.config.serviceIdEnv}`);
2648
+ }
2649
+ async deploy(_ctx) {
2650
+ const apiKey = process.env[this.config.apiKeyEnv] ?? "";
2651
+ const serviceId = process.env[this.config.serviceIdEnv] ?? "";
2652
+ const triggerRes = await fetch(`https://api.render.com/v1/services/${serviceId}/deploys`, {
2653
+ method: "POST",
2654
+ headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" },
2655
+ body: JSON.stringify({ clearCache: "do_not_clear" })
2656
+ });
2657
+ if (!triggerRes.ok) {
2658
+ return { targetType: "render", status: "failed", error: `Render deploy trigger failed: ${triggerRes.status}` };
2659
+ }
2660
+ const deployData = await triggerRes.json();
2661
+ const deployId = deployData.id;
2662
+ for (let i = 0; i < 30; i++) {
2663
+ await new Promise((r) => setTimeout(r, 1e4));
2664
+ const statusRes = await fetch(`https://api.render.com/v1/services/${serviceId}/deploys/${deployId}`, {
2665
+ headers: { Authorization: `Bearer ${apiKey}` }
2666
+ });
2667
+ if (!statusRes.ok) continue;
2668
+ const status = await statusRes.json();
2669
+ if (status.status === "live") {
2670
+ return { targetType: "render", status: "succeeded", url: status.serviceDetails?.url };
2671
+ }
2672
+ if (status.status === "failed" || status.status === "canceled") {
2673
+ return { targetType: "render", status: "failed", error: `Render deploy status: ${status.status}` };
2674
+ }
2675
+ }
2676
+ return { targetType: "render", status: "failed", error: "Render deploy timed out waiting for live status" };
2677
+ }
2678
+ describe() {
2679
+ return `render deploy service ${this.config.serviceIdEnv}`;
2680
+ }
2681
+ };
2682
+ var CloudStubTarget = class {
2683
+ constructor(config) {
2684
+ this.config = config;
2685
+ }
2686
+ config;
2687
+ get type() {
2688
+ return this.config.type;
2689
+ }
2690
+ validate() {
2691
+ const requiredEnvs = [];
2692
+ if (this.config.type === "aws") requiredEnvs.push("AWS_REGION", "AWS_ACCESS_KEY_ID", "AWS_SECRET_ACCESS_KEY");
2693
+ if (this.config.type === "azure") requiredEnvs.push("AZURE_SUBSCRIPTION_ID", "AZURE_TENANT_ID");
2694
+ if (this.config.type === "gcp") requiredEnvs.push("GCP_PROJECT_ID", "GCP_SERVICE_ACCOUNT_KEY");
2695
+ for (const name of requiredEnvs) {
2696
+ if (!process.env[name]) throw new Error(`Missing env var ${name} for ${this.config.type} target`);
2697
+ }
2698
+ }
2699
+ async deploy(_ctx) {
2700
+ return {
2701
+ targetType: this.config.type,
2702
+ status: "not-implemented",
2703
+ error: `Cloud target "${this.config.type}" is not yet fully implemented. Config was validated. Please deploy manually or contribute an implementation.`
2704
+ };
2705
+ }
2706
+ describe() {
2707
+ return `${this.config.type} cloud deployment (stub \u2014 not implemented)`;
2708
+ }
2709
+ };
2710
+
2711
+ // src/tasks/deployment/index.ts
2712
+ function createDeployTarget(config, ctx) {
2713
+ switch (config.type) {
2714
+ case "docker":
2715
+ return new DockerTarget(config);
2716
+ case "npm":
2717
+ return new NpmTarget(config);
2718
+ case "github-release":
2719
+ return new GitHubReleaseTarget(config, ctx.provider);
2720
+ case "vercel":
2721
+ return new VercelTarget(config);
2722
+ case "render":
2723
+ return new RenderTarget(config);
2724
+ case "aws":
2725
+ case "azure":
2726
+ case "gcp":
2727
+ return new CloudStubTarget(config);
2728
+ default:
2729
+ throw new ConfigError(`Unknown deployment target type: "${config.type}"`);
2730
+ }
2731
+ }
2732
+ var DeploymentTask = class {
2733
+ id = "deployment";
2734
+ title = "Deployment";
2735
+ isEnabled(config) {
2736
+ return config.tasks.deployment.enabled;
2737
+ }
2738
+ async execute(ctx) {
2739
+ const { config, logger: logger2, report } = ctx;
2740
+ const deployConfig = config.tasks.deployment;
2741
+ if (!deployConfig.enabled) {
2742
+ return { status: "skipped", summary: "Deployment is disabled in config." };
2743
+ }
2744
+ const targets = deployConfig.targets ?? [];
2745
+ if (targets.length === 0) {
2746
+ return { status: "noop", summary: "No deployment targets configured." };
2747
+ }
2748
+ const results = [];
2749
+ let anyFailed = false;
2750
+ for (const targetConfig of targets) {
2751
+ const target = createDeployTarget(targetConfig, ctx);
2752
+ logger2.info(`Deploying: ${target.describe()}`);
2753
+ try {
2754
+ target.validate();
2755
+ } catch (err) {
2756
+ const result2 = { targetType: target.type, status: "failed", error: String(err) };
2757
+ results.push(result2);
2758
+ report.deployments.push(result2);
2759
+ anyFailed = true;
2760
+ continue;
2761
+ }
2762
+ const result = await target.deploy({ sandbox: ctx.sandbox, repoRoot: ctx.repoRoot });
2763
+ results.push(result);
2764
+ report.deployments.push(result);
2765
+ if (result.status === "failed") anyFailed = true;
2766
+ }
2767
+ const succeeded = results.filter((r) => r.status === "succeeded").length;
2768
+ const failed = results.filter((r) => r.status === "failed").length;
2769
+ if (anyFailed) {
2770
+ return { status: "failed", summary: `Deployment: ${succeeded} succeeded, ${failed} failed.`, details: results.map((r) => `${r.targetType}: ${r.status}${r.error ? " - " + r.error : ""}`).join("\n") };
2771
+ }
2772
+ return { status: succeeded > 0 ? "changed" : "noop", summary: `Deployment: ${succeeded} target(s) deployed.` };
2773
+ }
2774
+ };
2775
+
2776
+ // src/cli/run.ts
2777
+ function registerRun(program) {
2778
+ program.command("run").description("Run the afterhours maintenance orchestrator").option("--dry-run", "Preview what would happen without making changes").option("--task <id>", "Run only the specified task").option("--no-deploy", "Skip the deployment task").action(async (opts) => {
2779
+ try {
2780
+ const report = await runOrchestrator(
2781
+ [new DependencyAuditTask(), new CodeOptimizationTask(), new DeploymentTask()],
2782
+ {
2783
+ dryRun: opts.dryRun,
2784
+ taskFilter: opts.task,
2785
+ noDeploy: opts.deploy === false
2786
+ }
2787
+ );
2788
+ console.log(`Run complete: ${report.tasks.length} task(s), ${report.outstandingWork.length} outstanding.`);
2789
+ process.exit(0);
2790
+ } catch (err) {
2791
+ console.error(`Run failed: ${err}`);
2792
+ process.exit(1);
2793
+ }
2794
+ });
2795
+ }
2796
+
2797
+ // src/cli/report.ts
2798
+ import { existsSync as existsSync4, readdirSync as readdirSync2, readFileSync as readFileSync4 } from "fs";
2799
+ import { join as join5 } from "path";
2800
+ function registerReport(program) {
2801
+ program.command("report").description("Print the latest run report or a specific run by ID").argument("[runId]", "Optional run ID").action((runId) => {
2802
+ const reportsDir = join5(process.cwd(), ".afterhours", "reports");
2803
+ if (!existsSync4(reportsDir)) {
2804
+ console.log("No reports found. Run afterhours run first.");
2805
+ return;
2806
+ }
2807
+ let target = runId;
2808
+ if (!target) {
2809
+ const files = readdirSync2(reportsDir).filter((f) => f.endsWith(".md")).sort();
2810
+ if (files.length === 0) {
2811
+ console.log("No reports found.");
2812
+ return;
2813
+ }
2814
+ target = files[files.length - 1].replace(".md", "");
2815
+ }
2816
+ const mdPath = join5(reportsDir, `${target}.md`);
2817
+ if (!existsSync4(mdPath)) {
2818
+ console.error(`Report not found: ${target}`);
2819
+ process.exit(1);
2820
+ }
2821
+ console.log(readFileSync4(mdPath, "utf8"));
2822
+ });
2823
+ }
2824
+
2825
+ // src/cli/init.ts
2826
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync2, mkdirSync, appendFileSync } from "fs";
2827
+ import { join as join6 } from "path";
2828
+ function registerInit(program) {
2829
+ program.command("init").description("Scaffold afterhours config and an optional scheduler adapter in this repo").option("--force", "Overwrite existing .afterhours/config.yml").option("--scheduler <type>", "Scheduler adapter to install: github | docker-cron | none", "none").action(async (opts) => {
2830
+ const cwd = process.cwd();
2831
+ const configDir = join6(cwd, ".afterhours");
2832
+ const configPath = join6(configDir, "config.yml");
2833
+ const templateDir = getTemplateDir();
2834
+ mkdirSync(configDir, { recursive: true });
2835
+ mkdirSync(join6(configDir, "reports"), { recursive: true });
2836
+ if (!existsSync5(configPath) || opts.force) {
2837
+ let template = readFileSync5(join6(templateDir, "config.yml"), "utf8");
2838
+ const pm = detectPackageManager(cwd);
2839
+ const type = detectProjectType(cwd);
2840
+ if (pm !== "npm") template = template.replace("packageManager: auto", `packageManager: ${pm}`);
2841
+ if (type !== "library") template = template.replace("type: auto", `type: ${type}`);
2842
+ writeFileSync2(configPath, template, "utf8");
2843
+ console.log(`\u2705 Created .afterhours/config.yml`);
2844
+ } else {
2845
+ console.log(`\u23ED .afterhours/config.yml already exists (use --force to overwrite)`);
2846
+ }
2847
+ const gitignorePath = join6(cwd, ".gitignore");
2848
+ const additions = [".env", ".afterhours/reports/"];
2849
+ if (existsSync5(gitignorePath)) {
2850
+ const existing = readFileSync5(gitignorePath, "utf8");
2851
+ for (const line of additions) {
2852
+ if (!existing.includes(line)) {
2853
+ appendFileSync(gitignorePath, `
2854
+ ${line}`);
2855
+ }
2856
+ }
2857
+ } else {
2858
+ writeFileSync2(gitignorePath, additions.join("\n") + "\n");
2859
+ }
2860
+ const envExampleSrc = join6(templateDir, "..", ".env.example");
2861
+ const envExampleDst = join6(cwd, ".env.example");
2862
+ if (!existsSync5(envExampleDst) && existsSync5(envExampleSrc)) {
2863
+ writeFileSync2(envExampleDst, readFileSync5(envExampleSrc, "utf8"), "utf8");
2864
+ console.log(`\u2705 Created .env.example`);
2865
+ }
2866
+ if (opts.scheduler === "github") {
2867
+ const ghDir = join6(cwd, ".github", "workflows");
2868
+ mkdirSync(ghDir, { recursive: true });
2869
+ const dst = join6(ghDir, "afterhours.yml");
2870
+ if (!existsSync5(dst)) {
2871
+ writeFileSync2(dst, readFileSync5(join6(templateDir, "github-actions.yml"), "utf8"), "utf8");
2872
+ console.log(`\u2705 Created .github/workflows/afterhours.yml`);
2873
+ } else {
2874
+ console.log(`\u23ED .github/workflows/afterhours.yml already exists`);
2875
+ }
2876
+ } else if (opts.scheduler === "docker-cron") {
2877
+ const dstDir = join6(cwd, ".afterhours", "docker-cron");
2878
+ mkdirSync(dstDir, { recursive: true });
2879
+ const srcDir = join6(templateDir, "docker-cron");
2880
+ for (const file of ["Dockerfile", "crontab", "README.md"]) {
2881
+ const dst = join6(dstDir, file);
2882
+ if (!existsSync5(dst)) {
2883
+ writeFileSync2(dst, readFileSync5(join6(srcDir, file), "utf8"), "utf8");
2884
+ }
2885
+ }
2886
+ console.log(`\u2705 Created .afterhours/docker-cron/ adapter`);
2887
+ }
2888
+ console.log("\nafterhours is ready! Next steps:");
2889
+ console.log(" 1. Review .afterhours/config.yml");
2890
+ console.log(" 2. Copy .env.example \u2192 .env and fill in your secrets");
2891
+ console.log(" 3. Run: afterhours doctor");
2892
+ });
2893
+ }
2894
+ function getTemplateDir() {
2895
+ const candidates = [
2896
+ join6(new URL(import.meta.url).pathname, "..", "..", "..", "templates"),
2897
+ join6(process.cwd(), "templates")
2898
+ ];
2899
+ for (const c of candidates) {
2900
+ if (existsSync5(c)) return c;
2901
+ }
2902
+ throw new Error("Templates directory not found. Is afterhours installed?");
2903
+ }
2904
+
2905
+ // src/cli/main.ts
2906
+ function main() {
2907
+ const program = new Command();
2908
+ program.name("afterhours").description("afterhours CLI").version("0.1.0");
2909
+ registerInit(program);
2910
+ registerRun(program);
2911
+ program.command("audit").action(() => console.log("<audit>: not implemented yet"));
2912
+ program.command("optimize").action(() => console.log("<optimize>: not implemented yet"));
2913
+ program.command("deploy").action(() => console.log("<deploy>: not implemented yet"));
2914
+ registerReport(program);
2915
+ registerDoctor(program);
2916
+ void program.parseAsync(process.argv);
2917
+ }
2918
+
2919
+ // src/index.ts
2920
+ main();