phoebe-agent 0.0.0 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +77 -8
  2. package/bootstrap/bin.mjs +42 -0
  3. package/bootstrap/boot.ts +383 -0
  4. package/bootstrap/cli.ts +29 -0
  5. package/bootstrap/crash-loop.ts +391 -0
  6. package/bootstrap/define-config.ts +20 -0
  7. package/bootstrap/engine-source.ts +83 -0
  8. package/bootstrap/github-engine.ts +169 -0
  9. package/bootstrap/index.mjs +9 -0
  10. package/bootstrap/index.ts +24 -0
  11. package/bootstrap/materialize.mjs +53 -0
  12. package/bootstrap/reconcile.ts +314 -0
  13. package/bootstrap/spawn-engine.mjs +78 -0
  14. package/package.json +26 -24
  15. package/prompts/checks-prompt.md +21 -6
  16. package/prompts/conflict-prompt.md +12 -1
  17. package/prompts/research-prompt.md +61 -0
  18. package/src/agent-env.ts +32 -0
  19. package/src/branded.ts +19 -0
  20. package/src/cli.ts +187 -0
  21. package/src/config-schema.ts +278 -0
  22. package/src/drain.ts +74 -0
  23. package/src/execution-gate.ts +27 -0
  24. package/src/git-model.ts +113 -0
  25. package/src/init.ts +277 -0
  26. package/src/load-config.ts +168 -0
  27. package/src/main.ts +1636 -0
  28. package/src/orchestrator.ts +953 -0
  29. package/src/prompt.ts +98 -0
  30. package/src/providers/providers.ts +222 -0
  31. package/src/providers/run-agent.ts +90 -0
  32. package/src/providers/types.ts +26 -0
  33. package/src/resolved-config.ts +55 -0
  34. package/templates/.env.example +11 -5
  35. package/templates/container/Dockerfile +41 -19
  36. package/templates/container/compose.local.yml +37 -0
  37. package/templates/container/compose.yml +34 -13
  38. package/templates/phoebe.config.ts +30 -6
  39. package/dist/phoebe.config.d.ts +0 -2
  40. package/dist/phoebe.config.js +0 -43
  41. package/dist/src/agent-env.d.ts +0 -6
  42. package/dist/src/agent-env.js +0 -24
  43. package/dist/src/cli.d.ts +0 -25
  44. package/dist/src/cli.js +0 -161
  45. package/dist/src/config-schema.d.ts +0 -163
  46. package/dist/src/config-schema.js +0 -140
  47. package/dist/src/execution-gate.d.ts +0 -9
  48. package/dist/src/execution-gate.js +0 -17
  49. package/dist/src/git-model.d.ts +0 -30
  50. package/dist/src/git-model.js +0 -71
  51. package/dist/src/index.d.ts +0 -2
  52. package/dist/src/index.js +0 -12
  53. package/dist/src/init.d.ts +0 -82
  54. package/dist/src/init.js +0 -207
  55. package/dist/src/load-config.d.ts +0 -88
  56. package/dist/src/load-config.js +0 -153
  57. package/dist/src/main.d.ts +0 -7
  58. package/dist/src/main.js +0 -1180
  59. package/dist/src/orchestrator.d.ts +0 -275
  60. package/dist/src/orchestrator.js +0 -579
  61. package/dist/src/prompt.d.ts +0 -13
  62. package/dist/src/prompt.js +0 -55
  63. package/dist/src/providers/providers.d.ts +0 -3
  64. package/dist/src/providers/providers.js +0 -210
  65. package/dist/src/providers/run-agent.d.ts +0 -34
  66. package/dist/src/providers/run-agent.js +0 -57
  67. package/dist/src/providers/types.d.ts +0 -27
  68. package/dist/src/providers/types.js +0 -6
  69. package/dist/src/resolved-config.d.ts +0 -8
  70. package/dist/src/resolved-config.js +0 -49
  71. package/dist/src/supervisor-decision.d.ts +0 -70
  72. package/dist/src/supervisor-decision.js +0 -94
  73. package/templates/container/compose.daemon.yml +0 -16
  74. package/templates/container/supervisor.sh +0 -50
  75. /package/prompts/{prompt.md → issues-prompt.md} +0 -0
@@ -1,579 +0,0 @@
1
- // Pure selection + base-resolution logic for Phoebe's orchestrator.
2
- // Kept separate from main.ts so it can be unit-tested without Docker/gh.
3
- import { config } from "./resolved-config.js";
4
- const PRIORITY_ORDER = ["bug", "tracer", "polish", "refactor"];
5
- /**
6
- * Parse blocker references from issue body text (and optional comments).
7
- * The pattern is configurable via `config.blockedByPattern`; capture group 1
8
- * must yield the blocker issue number.
9
- */
10
- export function parseBlockedBy(...texts) {
11
- const blockers = [];
12
- const pattern = new RegExp(config.blockedByPattern, "gi");
13
- for (const text of texts) {
14
- for (const match of text.matchAll(pattern)) {
15
- blockers.push(Number(match[1]));
16
- }
17
- }
18
- return [...new Set(blockers)];
19
- }
20
- export function classifyPriority(issue) {
21
- const text = `${issue.title} ${issue.body}`.toLowerCase();
22
- if (/\b(bug|broken|crash|regression|fix)\b/.test(text))
23
- return "bug";
24
- if (/\b(tracer|wire|poc)\b/.test(text))
25
- return "tracer";
26
- if (/\brefactor\b/.test(text))
27
- return "refactor";
28
- return "polish";
29
- }
30
- export function compareIssues(a, b) {
31
- const pa = PRIORITY_ORDER.indexOf(classifyPriority(a));
32
- const pb = PRIORITY_ORDER.indexOf(classifyPriority(b));
33
- if (pa !== pb)
34
- return pa - pb;
35
- const ta = Date.parse(a.createdAt);
36
- const tb = Date.parse(b.createdAt);
37
- if (ta !== tb)
38
- return ta - tb;
39
- return a.number - b.number;
40
- }
41
- export function issueBranch(issueNumber) {
42
- return `${config.branchPrefix}issue-${issueNumber}`;
43
- }
44
- /**
45
- * Resolve the worktree base for an issue.
46
- * Returns `null` when the issue should be skipped this cycle (blocked with no
47
- * open/merged blocker PR).
48
- */
49
- export function resolveWorktreeBase(issue, blockerStates, phoebeBase) {
50
- if (phoebeBase) {
51
- return { worktreeBase: phoebeBase, stacked: false };
52
- }
53
- const blockers = parseBlockedBy(issue.body);
54
- if (blockers.length === 0) {
55
- return { worktreeBase: "origin/main", stacked: false };
56
- }
57
- const blockerIssueNumber = blockers[0];
58
- const state = blockerStates.get(blockerIssueNumber);
59
- if (!state) {
60
- return null;
61
- }
62
- if (state.hasOpenPr) {
63
- return {
64
- worktreeBase: `origin/${issueBranch(blockerIssueNumber)}`,
65
- stacked: true,
66
- blockerIssueNumber,
67
- blockerPrNumber: state.openPrNumber,
68
- };
69
- }
70
- if (state.hasMergedPr) {
71
- return { worktreeBase: "origin/main", stacked: false };
72
- }
73
- return null;
74
- }
75
- /** Pick the highest-priority workable issue, or `null` when none qualify. */
76
- export function selectIssue(issues, blockerStates, phoebeBase) {
77
- const sorted = [...issues].sort(compareIssues);
78
- for (const issue of sorted) {
79
- const resolution = resolveWorktreeBase(issue, blockerStates, phoebeBase);
80
- if (resolution) {
81
- return { issue, resolution };
82
- }
83
- }
84
- return null;
85
- }
86
- export function stackedPrComment(blockerIssueNumber, blockerPrNumber) {
87
- return (`⛓️ Blocked by #${blockerIssueNumber} (PR #${blockerPrNumber}). ` +
88
- `Its commits appear in this diff until #${blockerPrNumber} merges. ` +
89
- `**Do not merge this PR before #${blockerPrNumber}** — doing so would pull ` +
90
- `#${blockerIssueNumber}'s work into \`main\` ahead of its own review.`);
91
- }
92
- const CONFLICT_FAIL_WATERMARK_RE = /<!--\s*phoebe-conflict-fail:\s*prHead=([0-9a-f]+)\s+mainHead=([0-9a-f]+)\s*-->/i;
93
- export function buildConflictFailWatermarkMarker(watermark) {
94
- return `<!-- phoebe-conflict-fail: prHead=${watermark.prHead} mainHead=${watermark.mainHead} -->`;
95
- }
96
- export function parseConflictFailWatermark(text) {
97
- const match = CONFLICT_FAIL_WATERMARK_RE.exec(text);
98
- if (!match) {
99
- return null;
100
- }
101
- return { prHead: match[1], mainHead: match[2] };
102
- }
103
- /** Latest marker wins when several failure comments exist on one PR. */
104
- export function parseConflictFailWatermarkFromComments(comments) {
105
- for (let i = comments.length - 1; i >= 0; i--) {
106
- const watermark = parseConflictFailWatermark(comments[i]);
107
- if (watermark) {
108
- return watermark;
109
- }
110
- }
111
- return null;
112
- }
113
- export function shouldSkipWatermarkConflictFix(opts) {
114
- if (!opts.watermark) {
115
- return false;
116
- }
117
- return (opts.watermark.prHead === opts.currentPrHead && opts.watermark.mainHead === opts.currentMainHead);
118
- }
119
- const escapeRegExp = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
120
- const ISSUE_BRANCH_RE = new RegExp(`^${escapeRegExp(config.branchPrefix)}issue-(\\d+)$`);
121
- export function isPhoebeHeadBranch(branch) {
122
- return branch.startsWith(config.branchPrefix);
123
- }
124
- const defaultPrScopeConfig = () => ({
125
- branchPrefix: config.branchPrefix,
126
- prScope: config.prScope,
127
- draftPrs: config.draftPrs,
128
- prOptOutLabel: config.prOptOutLabel,
129
- });
130
- /** Whether an open PR is eligible for conflicts/checks/reviews scanning. */
131
- export function isPrInScope(pr, scopeConfig = defaultPrScopeConfig()) {
132
- if (pr.isCrossRepository) {
133
- return false;
134
- }
135
- if (pr.labels.includes(scopeConfig.prOptOutLabel)) {
136
- return false;
137
- }
138
- const isPhoebe = pr.headRefName.startsWith(scopeConfig.branchPrefix);
139
- if (scopeConfig.prScope === "phoebe" && !isPhoebe) {
140
- return false;
141
- }
142
- if (pr.isDraft) {
143
- if (scopeConfig.draftPrs === "skip-all") {
144
- return false;
145
- }
146
- if (scopeConfig.draftPrs === "skip-non-phoebe" && !isPhoebe) {
147
- return false;
148
- }
149
- }
150
- return true;
151
- }
152
- export function parseIssueNumberFromBranch(branch) {
153
- const match = ISSUE_BRANCH_RE.exec(branch);
154
- return match ? Number(match[1]) : null;
155
- }
156
- /** GitHub may return UNKNOWN while mergeability is still computing. */
157
- export function isPrMergeConflicting(mergeable, mergeStateStatus) {
158
- if (mergeable === "CONFLICTING")
159
- return true;
160
- if (mergeable === "UNKNOWN" && mergeStateStatus === "DIRTY")
161
- return true;
162
- return false;
163
- }
164
- /**
165
- * Skip idle conflict-fix when the PR's issue is still stacked on a blocker with
166
- * an open PR — its divergence from `main` is expected, not a real conflict.
167
- */
168
- export function shouldSkipStackedConflictFix(issueBody, blockerStates) {
169
- for (const blockerIssueNumber of parseBlockedBy(issueBody)) {
170
- const state = blockerStates.get(blockerIssueNumber);
171
- if (state?.hasOpenPr) {
172
- return true;
173
- }
174
- }
175
- return false;
176
- }
177
- /** Merged blocker PR numbers for lazy catch-up (bottom-up stack order). */
178
- export function getMergedBlockerPrNumbers(issueBody, blockerStates) {
179
- const merged = [];
180
- for (const blockerIssueNumber of parseBlockedBy(issueBody)) {
181
- const state = blockerStates.get(blockerIssueNumber);
182
- if (state?.hasMergedPr && state.mergedPrNumber !== undefined) {
183
- merged.push(state.mergedPrNumber);
184
- }
185
- }
186
- return merged;
187
- }
188
- export function stackedCatchUpRetractionComment(blockerPrNumbers) {
189
- if (blockerPrNumbers.length === 1) {
190
- return (`Blocker #${blockerPrNumbers[0]} merged; this branch has been caught up to \`main\` ` +
191
- `and is now independently mergeable.`);
192
- }
193
- const list = blockerPrNumbers.map((n) => `#${n}`).join(", ");
194
- return (`Blockers ${list} merged; this branch has been caught up to \`main\` ` +
195
- `and is now independently mergeable.`);
196
- }
197
- export function selectConflictFixCandidates(prs, issueBodies, blockerStates, opts) {
198
- return prs.filter((pr) => {
199
- const issueNumber = pr.issueNumber ?? parseIssueNumberFromBranch(pr.headRefName);
200
- if (issueNumber !== null) {
201
- const body = issueBodies.get(issueNumber) ?? "";
202
- if (shouldSkipStackedConflictFix(body, blockerStates)) {
203
- return false;
204
- }
205
- }
206
- if (opts?.currentMainHead && pr.headSha) {
207
- if (shouldSkipWatermarkConflictFix({
208
- watermark: pr.failureWatermark ?? null,
209
- currentPrHead: pr.headSha,
210
- currentMainHead: opts.currentMainHead,
211
- })) {
212
- return false;
213
- }
214
- }
215
- return true;
216
- });
217
- }
218
- export function checkItemName(item) {
219
- return item.name ?? item.context ?? "unknown";
220
- }
221
- export function isCheckItemFailing(item) {
222
- if (item.__typename === "CheckRun" || item.status !== undefined) {
223
- const conclusion = item.conclusion ?? "";
224
- return (conclusion === "FAILURE" ||
225
- conclusion === "CANCELLED" ||
226
- conclusion === "TIMED_OUT" ||
227
- conclusion === "ACTION_REQUIRED");
228
- }
229
- const state = item.state ?? "";
230
- return state === "FAILURE" || state === "ERROR";
231
- }
232
- export function isCheckItemPending(item) {
233
- if (item.__typename === "CheckRun" || item.status !== undefined) {
234
- const status = item.status ?? "";
235
- return (status === "QUEUED" ||
236
- status === "IN_PROGRESS" ||
237
- status === "WAITING" ||
238
- status === "PENDING" ||
239
- status === "REQUESTED");
240
- }
241
- const state = item.state ?? "";
242
- return state === "PENDING" || state === "EXPECTED";
243
- }
244
- /** Combined rollup: FAILURE when at least one check failed and none are pending. */
245
- export function statusCheckRollupState(checks) {
246
- if (checks.length === 0) {
247
- return "NONE";
248
- }
249
- if (checks.some(isCheckItemPending)) {
250
- return "PENDING";
251
- }
252
- if (checks.some(isCheckItemFailing)) {
253
- return "FAILURE";
254
- }
255
- return "SUCCESS";
256
- }
257
- /**
258
- * Map `gh run list` rows onto StatusCheckItem. The REST Actions API is the
259
- * check-state source usable by fine-grained PATs — GraphQL statusCheckRollup
260
- * is GitHub-App/OAuth only. REST enums are lowercase; rows arrive newest
261
- * first, and only the newest run per workflow counts.
262
- */
263
- export function workflowRunsToCheckItems(runs) {
264
- const seen = new Set();
265
- const items = [];
266
- for (const run of runs) {
267
- const name = run.workflowName ?? run.name ?? "unknown";
268
- if (seen.has(name)) {
269
- continue;
270
- }
271
- seen.add(name);
272
- items.push({
273
- name,
274
- status: (run.status ?? "").toUpperCase(),
275
- conclusion: run.conclusion ? run.conclusion.toUpperCase() : null,
276
- });
277
- }
278
- return items;
279
- }
280
- export function listFailingChecks(checks) {
281
- return checks.filter(isCheckItemFailing).map((item) => ({
282
- name: checkItemName(item),
283
- conclusion: item.conclusion ?? item.state ?? "",
284
- }));
285
- }
286
- const CHECKS_FAIL_WATERMARK_RE = /<!--\s*phoebe-checks-fail:\s*prHead=([0-9a-f]+)\s*-->/i;
287
- export function buildChecksFailWatermarkMarker(watermark) {
288
- return `<!-- phoebe-checks-fail: prHead=${watermark.prHead} -->`;
289
- }
290
- export function parseChecksFailWatermark(text) {
291
- const match = CHECKS_FAIL_WATERMARK_RE.exec(text);
292
- if (!match) {
293
- return null;
294
- }
295
- return { prHead: match[1] };
296
- }
297
- export function parseChecksFailWatermarkFromComments(comments) {
298
- for (let i = comments.length - 1; i >= 0; i--) {
299
- const watermark = parseChecksFailWatermark(comments[i]);
300
- if (watermark) {
301
- return watermark;
302
- }
303
- }
304
- return null;
305
- }
306
- export function shouldSkipWatermarkChecksFix(opts) {
307
- if (!opts.watermark) {
308
- return false;
309
- }
310
- return opts.watermark.prHead === opts.currentPrHead;
311
- }
312
- /** Reuse stacked-blocker skip logic — stacked PR red CI is handled at blocker merge. */
313
- export const shouldSkipStackedChecksFix = shouldSkipStackedConflictFix;
314
- export function selectChecksCandidates(prs, issueBodies, blockerStates) {
315
- return prs.filter((pr) => {
316
- if (isPrMergeConflicting(pr.mergeable, pr.mergeStateStatus)) {
317
- return false;
318
- }
319
- const issueNumber = pr.issueNumber ?? parseIssueNumberFromBranch(pr.headRefName);
320
- if (issueNumber !== null) {
321
- const body = issueBodies.get(issueNumber) ?? "";
322
- if (shouldSkipStackedChecksFix(body, blockerStates)) {
323
- return false;
324
- }
325
- }
326
- if (pr.headSha) {
327
- if (shouldSkipWatermarkChecksFix({
328
- watermark: pr.failureWatermark ?? null,
329
- currentPrHead: pr.headSha,
330
- })) {
331
- return false;
332
- }
333
- }
334
- return true;
335
- });
336
- }
337
- /** Pick the single checks unit — oldest PR number among eligible failing-CI candidates. */
338
- export function selectChecksUnit(prs, issueBodies, blockerStates) {
339
- const candidates = selectChecksCandidates(prs, issueBodies, blockerStates);
340
- if (candidates.length === 0) {
341
- return null;
342
- }
343
- return candidates.reduce((oldest, pr) => (pr.prNumber < oldest.prNumber ? pr : oldest));
344
- }
345
- const REVIEWS_HANDLED_WATERMARK_RE = /<!--\s*phoebe-reviews-handled:\s*latest=([^\s>]+)\s*-->/i;
346
- export function buildReviewsHandledMarker(watermark) {
347
- return `<!-- phoebe-reviews-handled: latest=${watermark.latest} -->`;
348
- }
349
- export function parseReviewsHandledWatermark(text) {
350
- const match = REVIEWS_HANDLED_WATERMARK_RE.exec(text);
351
- if (!match) {
352
- return null;
353
- }
354
- return { latest: match[1] };
355
- }
356
- export function parseReviewsHandledWatermarkFromComments(comments) {
357
- for (let i = comments.length - 1; i >= 0; i--) {
358
- const watermark = parseReviewsHandledWatermark(comments[i]);
359
- if (watermark) {
360
- return watermark;
361
- }
362
- }
363
- return null;
364
- }
365
- export function isReviewSummaryComment(body) {
366
- return body.includes(config.reviewsSuccessHeading);
367
- }
368
- export function isActivityNewerThanWatermark(createdAt, watermark) {
369
- if (!watermark) {
370
- return true;
371
- }
372
- return createdAt > watermark.latest;
373
- }
374
- export function newestReviewThreadCommentCreatedAt(threads) {
375
- let newest = null;
376
- for (const thread of threads) {
377
- for (const comment of thread.comments) {
378
- if (newest === null || comment.createdAt > newest) {
379
- newest = comment.createdAt;
380
- }
381
- }
382
- }
383
- return newest;
384
- }
385
- export function hasNewNonPhoebeReviewActivity(opts) {
386
- for (const thread of opts.threads) {
387
- if (thread.isResolved || thread.isOutdated) {
388
- continue;
389
- }
390
- for (const comment of thread.comments) {
391
- if (comment.authorLogin === opts.phoebeLogin) {
392
- continue;
393
- }
394
- if (opts.authorLogin !== undefined && comment.authorLogin === opts.authorLogin) {
395
- continue;
396
- }
397
- if (isActivityNewerThanWatermark(comment.createdAt, opts.watermark)) {
398
- return true;
399
- }
400
- }
401
- }
402
- return false;
403
- }
404
- /** Reuse stacked-blocker skip logic — stacked PR review comments are often about blocker code. */
405
- export const shouldSkipStackedReviewsFix = shouldSkipStackedConflictFix;
406
- export function selectReviewsCandidates(prs, issueBodies, blockerStates, phoebeLogin) {
407
- return prs.filter((pr) => {
408
- if (isPrMergeConflicting(pr.mergeable, pr.mergeStateStatus)) {
409
- return false;
410
- }
411
- const issueNumber = pr.issueNumber ?? parseIssueNumberFromBranch(pr.headRefName);
412
- if (issueNumber !== null) {
413
- const body = issueBodies.get(issueNumber) ?? "";
414
- if (shouldSkipStackedReviewsFix(body, blockerStates)) {
415
- return false;
416
- }
417
- }
418
- return hasNewNonPhoebeReviewActivity({
419
- threads: pr.threads,
420
- phoebeLogin,
421
- authorLogin: pr.authorLogin,
422
- watermark: pr.handledWatermark ?? null,
423
- });
424
- });
425
- }
426
- /** Pick the single reviews unit — oldest PR number among eligible review-feedback candidates. */
427
- export function selectReviewsUnit(prs, issueBodies, blockerStates, phoebeLogin) {
428
- const candidates = selectReviewsCandidates(prs, issueBodies, blockerStates, phoebeLogin);
429
- if (candidates.length === 0) {
430
- return null;
431
- }
432
- return candidates.reduce((oldest, pr) => (pr.prNumber < oldest.prNumber ? pr : oldest));
433
- }
434
- export function buildReviewsHandledComment(opts) {
435
- const latest = opts.latestActivityAt ?? "1970-01-01T00:00:00Z";
436
- const marker = buildReviewsHandledMarker({ latest });
437
- if (opts.failed) {
438
- return ("Phoebe attempted to handle review feedback and failed; will retry on new review activity.\n\n" +
439
- marker);
440
- }
441
- return marker;
442
- }
443
- export const WORK_KIND_NAMES = ["conflicts", "checks", "reviews", "issues"];
444
- /** Whether a work-kind may run under `--run-once`. Janitor kinds are persistent-mode only. */
445
- export const WORK_KIND_ONE_SHOT_ELIGIBLE = {
446
- conflicts: false,
447
- checks: false,
448
- reviews: false,
449
- issues: true,
450
- };
451
- export const RUN_ONCE_NOTHING_MESSAGE = "[phoebe] Nothing to do under --run-once (janitor kinds are persistent-mode only).";
452
- export function oneShotWorkKinds(workOrder) {
453
- return workOrder.filter((kind) => WORK_KIND_ONE_SHOT_ELIGIBLE[kind]);
454
- }
455
- /** Fail fast when `WORK_ORDER` is empty or names an unknown kind. */
456
- export function validateWorkOrder(order) {
457
- if (order.length === 0) {
458
- throw new Error("WORK_ORDER must not be empty. Include at least one of: conflicts, checks, reviews, issues.");
459
- }
460
- const validated = [];
461
- for (const kind of order) {
462
- if (!WORK_KIND_NAMES.includes(kind)) {
463
- throw new Error(`Unknown work kind "${kind}" in WORK_ORDER. Use one of: ${WORK_KIND_NAMES.join(", ")}.`);
464
- }
465
- validated.push(kind);
466
- }
467
- return validated;
468
- }
469
- /** Pick the single conflict unit — oldest PR number among unblocked candidates. */
470
- export function selectConflictUnit(prs, issueBodies, blockerStates, opts) {
471
- const candidates = selectConflictFixCandidates(prs, issueBodies, blockerStates, opts);
472
- if (candidates.length === 0) {
473
- return null;
474
- }
475
- return candidates.reduce((oldest, pr) => (pr.prNumber < oldest.prNumber ? pr : oldest));
476
- }
477
- /** Oldest conflicting PR by number that is eligible for an idle conflict fix. */
478
- export function selectConflictFixUnit(prs, issueBodies, blockerStates, opts) {
479
- return selectConflictUnit(prs, issueBodies, blockerStates, opts);
480
- }
481
- function conflictSelectionOpts(currentMainHead) {
482
- return currentMainHead ? { currentMainHead } : undefined;
483
- }
484
- /** Walk `workOrder` and return the first kind that has a unit of work. */
485
- export function selectFirstWorkUnit(workOrder, data, opts) {
486
- for (const kind of workOrder) {
487
- if (opts?.oneShotOnly && !WORK_KIND_ONE_SHOT_ELIGIBLE[kind]) {
488
- continue;
489
- }
490
- if (kind === "conflicts") {
491
- const unit = selectConflictUnit(data.conflictingPrs, data.issueBodies, data.blockerStates, conflictSelectionOpts(data.currentMainHead));
492
- if (unit) {
493
- return { kind: "conflicts", unit };
494
- }
495
- }
496
- else if (kind === "checks") {
497
- const unit = selectChecksUnit(data.failingCheckPrs, data.issueBodies, data.blockerStates);
498
- if (unit) {
499
- return { kind: "checks", unit };
500
- }
501
- }
502
- else if (kind === "reviews") {
503
- if (!data.phoebeLogin) {
504
- continue;
505
- }
506
- const unit = selectReviewsUnit(data.reviewActivityPrs, data.issueBodies, data.blockerStates, data.phoebeLogin);
507
- if (unit) {
508
- return { kind: "reviews", unit };
509
- }
510
- }
511
- else if (kind === "issues") {
512
- const unit = selectIssue(data.issues, data.blockerStates, data.phoebeBase);
513
- if (unit) {
514
- return { kind: "issues", unit };
515
- }
516
- }
517
- }
518
- return null;
519
- }
520
- export function conflictFixFailureComment(prNumber, watermark) {
521
- const parts = [
522
- `Phoebe attempted an idle merge-conflict fix (merge \`origin/main\` into this branch) ` +
523
- `for PR #${prNumber} but could not resolve it cleanly. The branch was left unchanged ` +
524
- `(\`git merge --abort\`). A human should resolve the conflicts manually.`,
525
- ];
526
- if (watermark) {
527
- parts.push("", buildConflictFailWatermarkMarker(watermark));
528
- }
529
- return parts.join("\n");
530
- }
531
- /**
532
- * After a sandbox conflict fix, the host may see 0 unpushed commits even when the sandbox
533
- * already pushed. Only post a failure comment when origin is unchanged and the PR still
534
- * conflicts.
535
- */
536
- export function shouldPostConflictFixFailure(opts) {
537
- if (opts.hostCommitCount > 0) {
538
- return false;
539
- }
540
- if (opts.originShaAfter !== opts.originShaBefore) {
541
- return false;
542
- }
543
- return isPrMergeConflicting(opts.mergeable, opts.mergeStateStatus);
544
- }
545
- export function checksFixFailureComment(prNumber, watermark) {
546
- const parts = [
547
- `Phoebe attempted an idle CI fix for PR #${prNumber} but could not resolve the failing ` +
548
- `checks. The branch was left unchanged. A human should investigate the CI failures.`,
549
- ];
550
- if (watermark) {
551
- parts.push("", buildChecksFailWatermarkMarker(watermark));
552
- }
553
- return parts.join("\n");
554
- }
555
- /**
556
- * After a checks fix agent run, post a failure comment only when the agent made
557
- * no commits and did not push.
558
- */
559
- export function shouldPostChecksFixFailure(opts) {
560
- if (opts.hostCommitCount > 0) {
561
- return false;
562
- }
563
- return opts.originShaAfter === opts.originShaBefore;
564
- }
565
- export function formatFailingChecksForPrompt(checks) {
566
- return checks.map((c) => `${c.name}: ${c.conclusion}`).join("\n");
567
- }
568
- export function buildInitialPrBody(opts) {
569
- const parts = [`Closes #${opts.issueNumber}`, "", "Automated PR from Phoebe.", ""];
570
- if (opts.stacked) {
571
- parts.push(stackedPrComment(opts.stacked.blockerIssueNumber, opts.stacked.blockerPrNumber), "");
572
- }
573
- parts.push(`Commits: ${opts.commitCount}`);
574
- return parts.join("\n");
575
- }
576
- /** Incremental note for follow-up pushes — no stacked-PR banner. */
577
- export function followUpPrComment(issueNumber, commitCount) {
578
- return `Phoebe update for #${issueNumber}: ${commitCount} new commit(s) pushed to this branch.`;
579
- }
@@ -1,13 +0,0 @@
1
- import type { PhoebeConfig } from "./config-schema.ts";
2
- export type PromptArgs = Record<string, string>;
3
- /**
4
- * The standard placeholder set every default prompt template can reference —
5
- * derived once per run from the resolved config so callers can retarget the
6
- * toolchain by editing `phoebe.config.ts` alone. Per-callsite args
7
- * (`ISSUE_NUMBER`, `PR_NUMBER`, …) are merged on top by `runAgentInWorktree`.
8
- */
9
- export declare function buildDefaultPromptArgs(config: PhoebeConfig): PromptArgs;
10
- export declare function substitutePromptArgs(template: string, args: PromptArgs): string;
11
- /** Execute marked shell blocks and splice their trimmed stdout into the prompt. */
12
- export declare function expandShellBlocks(prompt: string, execShell: (command: string) => string): string;
13
- export declare function renderPrompt(template: string, args: PromptArgs, execShell: (command: string) => string): string;
@@ -1,55 +0,0 @@
1
- // Prompt template rendering: {{KEY}} argument substitution plus !`command`
2
- // shell expansion, executed in the work unit's worktree. The marker trick is
3
- // ported from Sandcastle's PromptPreprocessor: shell blocks present in the raw
4
- // template are marked *before* argument substitution, so `!`...`` patterns
5
- // arriving via substituted values are treated as data, never executed.
6
- /**
7
- * The standard placeholder set every default prompt template can reference —
8
- * derived once per run from the resolved config so callers can retarget the
9
- * toolchain by editing `phoebe.config.ts` alone. Per-callsite args
10
- * (`ISSUE_NUMBER`, `PR_NUMBER`, …) are merged on top by `runAgentInWorktree`.
11
- */
12
- export function buildDefaultPromptArgs(config) {
13
- return {
14
- INSTALL_COMMAND: config.installCommand,
15
- CHECK_COMMAND: config.checkCommand,
16
- TEST_COMMAND: config.testCommand,
17
- READY_COMMAND: config.readyCommand,
18
- DEFAULT_BRANCH: config.defaultBranch,
19
- BRANCH_PREFIX: config.branchPrefix,
20
- READY_LABEL: config.readyLabel,
21
- PROCESSING_LABEL: config.processingLabel,
22
- REVIEWS_SUCCESS_HEADING: config.reviewsSuccessHeading,
23
- };
24
- }
25
- /**
26
- * Marker inserted between `!` and the opening backtick for shell blocks that
27
- * appear in the raw template. Only marked blocks are executed.
28
- */
29
- const SHELL_BLOCK_MARKER = "\x01";
30
- const SHELL_BLOCK_PATTERN = /!`([^`]+)`/g;
31
- const MARKED_SHELL_BLOCK_PATTERN = new RegExp(`!${SHELL_BLOCK_MARKER}\`([^\`]+)\``, "g");
32
- const PLACEHOLDER_PATTERN = /\{\{\s*([A-Za-z_][A-Za-z0-9_]*)\s*\}\}/g;
33
- export function substitutePromptArgs(template, args) {
34
- const marked = template.replace(SHELL_BLOCK_PATTERN, (_m, cmd) => {
35
- return `!${SHELL_BLOCK_MARKER}\`${cmd}\``;
36
- });
37
- return marked.replace(PLACEHOLDER_PATTERN, (match, key) => {
38
- const value = args[key];
39
- if (value === undefined) {
40
- throw new Error(`Prompt placeholder {{${key}}} has no value.`);
41
- }
42
- return value;
43
- });
44
- }
45
- /** Execute marked shell blocks and splice their trimmed stdout into the prompt. */
46
- export function expandShellBlocks(prompt, execShell) {
47
- return prompt
48
- .replace(MARKED_SHELL_BLOCK_PATTERN, (_m, command) => {
49
- return execShell(command).trimEnd();
50
- })
51
- .replaceAll(SHELL_BLOCK_MARKER, "");
52
- }
53
- export function renderPrompt(template, args, execShell) {
54
- return expandShellBlocks(substitutePromptArgs(template, args), execShell);
55
- }
@@ -1,3 +0,0 @@
1
- import type { Provider } from "./types.ts";
2
- import type { ProviderName } from "../config-schema.ts";
3
- export declare const PROVIDERS: Record<ProviderName, Provider>;