@valescoagency/runway 0.14.3 → 0.15.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/github.js CHANGED
@@ -18,6 +18,12 @@ export class PrCreateFailed extends Data.TaggedError("PrCreateFailed") {
18
18
  // be running. We accept that limitation for Step 2.
19
19
  export class GithubTimeout extends Data.TaggedError("GithubTimeout") {
20
20
  }
21
+ // VA-461: read failures (gh pr view / git merge-tree). Kept distinct
22
+ // from PushFailed / PrCreateFailed because the recovery is different
23
+ // — a read failure on the shepherd's poll is recoverable (try again
24
+ // next tick); a push or pr-create failure interrupts the workflow.
25
+ export class GithubReadFailed extends Data.TaggedError("GithubReadFailed") {
26
+ }
21
27
  // VA-357: same jittered exponential shape as the Linear policy.
22
28
  export const githubRetrySchedule = Schedule.exponential("1 second").pipe(Schedule.compose(Schedule.recurs(5)), Schedule.jittered);
23
29
  /**
@@ -145,5 +151,349 @@ export function createGithubGateway() {
145
151
  // and a retry would create a duplicate.
146
152
  });
147
153
  },
154
+ getPullRequest({ repoPath, prNumber }) {
155
+ return applyGithubPolicy(Effect.tryPromise({
156
+ try: async () => {
157
+ const { stdout } = await execa("gh", [
158
+ "pr",
159
+ "view",
160
+ String(prNumber),
161
+ "--json",
162
+ "number,mergeable,mergeStateStatus,merged,headRefOid,headRefName,baseRefName",
163
+ ], { cwd: repoPath });
164
+ const parsed = JSON.parse(stdout);
165
+ // `mergeStateStatus` is the v4 enum runway maps to its
166
+ // own `MergeableState`. gh returns it ALL_CAPS; runway
167
+ // canonicalises to lowercase. Anything we don't
168
+ // recognise becomes `unknown` rather than throwing — the
169
+ // shepherd loop treats unknown as quiet, which is the
170
+ // safe fallback.
171
+ const mergeableState = canonicaliseMergeableState(parsed.mergeStateStatus);
172
+ return {
173
+ number: parsed.number,
174
+ mergeableState,
175
+ merged: parsed.merged,
176
+ headSha: parsed.headRefOid,
177
+ headRefName: parsed.headRefName,
178
+ baseRefName: parsed.baseRefName,
179
+ };
180
+ },
181
+ catch: (err) => {
182
+ if (isCommandMissing(err)) {
183
+ return new GhCliMissing({
184
+ message: `gh CLI not found on PATH (${err instanceof Error ? err.message : String(err)})`,
185
+ });
186
+ }
187
+ return new GithubReadFailed({
188
+ call: `getPullRequest(${prNumber})`,
189
+ stderr: execaStderr(err),
190
+ message: err instanceof Error
191
+ ? err.message
192
+ : `gh pr view failed: ${String(err)}`,
193
+ });
194
+ },
195
+ }), {
196
+ call: `getPullRequest(${prNumber})`,
197
+ timeoutMs: 15_000,
198
+ retryOn: (err) => err._tag === "GithubTimeout",
199
+ });
200
+ },
201
+ getCheckRuns({ repoPath, sha }) {
202
+ return applyGithubPolicy(Effect.tryPromise({
203
+ try: async () => {
204
+ const { stdout } = await execa("gh", [
205
+ "api",
206
+ `repos/{owner}/{repo}/commits/${sha}/check-runs`,
207
+ "--paginate",
208
+ "--jq",
209
+ ".check_runs[] | {id, name, status, conclusion, run_id: (.details_url | capture(\"/runs/(?<id>[0-9]+)\") | .id | tonumber? // null)}",
210
+ ], { cwd: repoPath });
211
+ // `--jq` with the streaming form returns one JSON object
212
+ // per line. Empty stdout → no check runs yet.
213
+ const lines = stdout.split("\n").filter((s) => s.trim().length > 0);
214
+ return lines.map((line) => {
215
+ const o = JSON.parse(line);
216
+ return {
217
+ id: o.id,
218
+ name: o.name,
219
+ status: canonicaliseCheckStatus(o.status),
220
+ conclusion: canonicaliseCheckConclusion(o.conclusion),
221
+ runId: typeof o.run_id === "number" ? o.run_id : null,
222
+ };
223
+ });
224
+ },
225
+ catch: (err) => {
226
+ if (isCommandMissing(err)) {
227
+ return new GhCliMissing({
228
+ message: `gh CLI not found on PATH (${err instanceof Error ? err.message : String(err)})`,
229
+ });
230
+ }
231
+ return new GithubReadFailed({
232
+ call: `getCheckRuns(${sha})`,
233
+ stderr: execaStderr(err),
234
+ message: err instanceof Error
235
+ ? err.message
236
+ : `gh api check-runs failed: ${String(err)}`,
237
+ });
238
+ },
239
+ }), {
240
+ call: `getCheckRuns(${sha})`,
241
+ timeoutMs: 15_000,
242
+ retryOn: (err) => err._tag === "GithubTimeout",
243
+ });
244
+ },
245
+ getRequiredChecks({ repoPath, baseBranch }) {
246
+ return applyGithubPolicy(Effect.tryPromise({
247
+ try: async () => {
248
+ try {
249
+ const { stdout } = await execa("gh", [
250
+ "api",
251
+ `repos/{owner}/{repo}/branches/${baseBranch}/protection/required_status_checks`,
252
+ "--jq",
253
+ ".contexts // []",
254
+ ], { cwd: repoPath });
255
+ const trimmed = stdout.trim();
256
+ if (!trimmed)
257
+ return null;
258
+ const parsed = JSON.parse(trimmed);
259
+ return parsed;
260
+ }
261
+ catch (err) {
262
+ // 404 (branch not protected) and 403 (no admin) are
263
+ // both expected — fall back to "filter unknown" by
264
+ // returning null. The watcher treats null as
265
+ // "conservatively require all checks" so a missing
266
+ // protection rule can't silently hide failures.
267
+ const stderr = execaStderr(err);
268
+ if (stderr.includes("404") ||
269
+ stderr.includes("403") ||
270
+ stderr.includes("Branch not protected")) {
271
+ return null;
272
+ }
273
+ throw err;
274
+ }
275
+ },
276
+ catch: (err) => {
277
+ if (isCommandMissing(err)) {
278
+ return new GhCliMissing({
279
+ message: `gh CLI not found on PATH (${err instanceof Error ? err.message : String(err)})`,
280
+ });
281
+ }
282
+ return new GithubReadFailed({
283
+ call: `getRequiredChecks(${baseBranch})`,
284
+ stderr: execaStderr(err),
285
+ message: err instanceof Error
286
+ ? err.message
287
+ : `gh api branch-protection failed: ${String(err)}`,
288
+ });
289
+ },
290
+ }), {
291
+ call: `getRequiredChecks(${baseBranch})`,
292
+ timeoutMs: 15_000,
293
+ retryOn: (err) => err._tag === "GithubTimeout",
294
+ });
295
+ },
296
+ getCheckRunLogTail({ repoPath, runId, lines }) {
297
+ return applyGithubPolicy(Effect.tryPromise({
298
+ try: async () => {
299
+ try {
300
+ // `gh run view <id> --log-failed` is the supported way
301
+ // to fetch only the failed-step logs of an Actions run.
302
+ // Non-Actions checks have no runId and don't reach this
303
+ // path; the gateway caller maps `runId === null` to an
304
+ // empty tail upstream.
305
+ const { stdout } = await execa("gh", ["run", "view", String(runId), "--log-failed"], { cwd: repoPath });
306
+ const all = stdout.split("\n");
307
+ if (all.length <= lines)
308
+ return stdout;
309
+ return all.slice(-lines).join("\n");
310
+ }
311
+ catch (err) {
312
+ const stderr = execaStderr(err);
313
+ // 404 (run gone) or "no logs" → empty tail rather than
314
+ // throwing; the agent gets the check name only.
315
+ if (stderr.includes("404") ||
316
+ stderr.includes("no logs") ||
317
+ stderr.includes("expired")) {
318
+ return "";
319
+ }
320
+ throw err;
321
+ }
322
+ },
323
+ catch: (err) => {
324
+ if (isCommandMissing(err)) {
325
+ return new GhCliMissing({
326
+ message: `gh CLI not found on PATH (${err instanceof Error ? err.message : String(err)})`,
327
+ });
328
+ }
329
+ return new GithubReadFailed({
330
+ call: `getCheckRunLogTail(${runId})`,
331
+ stderr: execaStderr(err),
332
+ message: err instanceof Error
333
+ ? err.message
334
+ : `gh run view failed: ${String(err)}`,
335
+ });
336
+ },
337
+ }), {
338
+ call: `getCheckRunLogTail(${runId})`,
339
+ timeoutMs: 30_000,
340
+ retryOn: (err) => err._tag === "GithubTimeout",
341
+ });
342
+ },
343
+ getPullRequestComments({ repoPath, prNumber }) {
344
+ return applyGithubPolicy(Effect.tryPromise({
345
+ try: async () => {
346
+ // Three endpoints, three flavors of comment. We fetch
347
+ // them in parallel and merge by createdAt — keeping
348
+ // source labels so the watcher knows where each one
349
+ // came from if it ever needs to round-trip a reply.
350
+ const fetchJson = async (path, jq) => {
351
+ const { stdout } = await execa("gh", ["api", path, "--paginate", "--jq", jq], { cwd: repoPath });
352
+ const lines = stdout
353
+ .split("\n")
354
+ .filter((s) => s.trim().length > 0);
355
+ return lines.map((line) => JSON.parse(line));
356
+ };
357
+ const projection = ".[] | {id, body, login: (.user.login // \"\"), created_at}";
358
+ const [issueComments, reviewComments, reviewSummaries] = await Promise.all([
359
+ fetchJson(`repos/{owner}/{repo}/issues/${prNumber}/comments`, projection),
360
+ fetchJson(`repos/{owner}/{repo}/pulls/${prNumber}/comments`, projection),
361
+ // Review summaries omit empty bodies (a review can be
362
+ // submitted with no top-level body — only inline
363
+ // comments). The `select(.body != "")` filter drops
364
+ // those so they don't show up as ghost entries.
365
+ fetchJson(`repos/{owner}/{repo}/pulls/${prNumber}/reviews`, ".[] | select(.body != \"\" and .body != null) | {id, body, login: (.user.login // \"\"), submitted_at}"),
366
+ ]);
367
+ const norm = (o, source, tsField) => ({
368
+ id: Number(o.id ?? 0),
369
+ authorLogin: String(o.login ?? ""),
370
+ body: String(o.body ?? ""),
371
+ createdAtUnixSeconds: Math.floor(new Date(String(o[tsField] ?? 0)).getTime() / 1000),
372
+ source,
373
+ });
374
+ const merged = [
375
+ ...issueComments.map((c) => norm(c, "issue", "created_at")),
376
+ ...reviewComments.map((c) => norm(c, "review", "created_at")),
377
+ ...reviewSummaries.map((c) => norm(c, "review-summary", "submitted_at")),
378
+ ];
379
+ merged.sort((a, b) => a.createdAtUnixSeconds - b.createdAtUnixSeconds);
380
+ return merged;
381
+ },
382
+ catch: (err) => {
383
+ if (isCommandMissing(err)) {
384
+ return new GhCliMissing({
385
+ message: `gh CLI not found on PATH (${err instanceof Error ? err.message : String(err)})`,
386
+ });
387
+ }
388
+ return new GithubReadFailed({
389
+ call: `getPullRequestComments(${prNumber})`,
390
+ stderr: execaStderr(err),
391
+ message: err instanceof Error
392
+ ? err.message
393
+ : `gh api comments failed: ${String(err)}`,
394
+ });
395
+ },
396
+ }), {
397
+ call: `getPullRequestComments(${prNumber})`,
398
+ timeoutMs: 30_000,
399
+ retryOn: (err) => err._tag === "GithubTimeout",
400
+ });
401
+ },
402
+ getConflictFiles({ repoPath, baseBranch, branch }) {
403
+ return applyGithubPolicy(Effect.tryPromise({
404
+ try: async () => {
405
+ // Step 1: refresh origin/<base> so the merge-tree below
406
+ // reasons about the latest remote state, not whatever
407
+ // the cwd happened to have fetched last.
408
+ await execa("git", ["fetch", "origin", baseBranch], {
409
+ cwd: repoPath,
410
+ });
411
+ // Step 2: simulate the merge. `git merge-tree --name-only`
412
+ // (git >= 2.38) prints only the conflicted paths, one per
413
+ // line, on stdout. Exit code is non-zero when there ARE
414
+ // conflicts, so we read stdout regardless of status.
415
+ const result = await execa("git", [
416
+ "merge-tree",
417
+ "--name-only",
418
+ "--no-messages",
419
+ `origin/${baseBranch}`,
420
+ branch,
421
+ ], { cwd: repoPath, reject: false });
422
+ const files = result.stdout
423
+ .split("\n")
424
+ .map((s) => s.trim())
425
+ .filter(Boolean);
426
+ return files;
427
+ },
428
+ catch: (err) => {
429
+ if (isCommandMissing(err)) {
430
+ return new GhCliMissing({
431
+ message: `git not found on PATH (${err instanceof Error ? err.message : String(err)})`,
432
+ });
433
+ }
434
+ return new GithubReadFailed({
435
+ call: `getConflictFiles(${branch}→${baseBranch})`,
436
+ stderr: execaStderr(err),
437
+ message: err instanceof Error
438
+ ? err.message
439
+ : `git merge-tree failed: ${String(err)}`,
440
+ });
441
+ },
442
+ }), {
443
+ call: `getConflictFiles(${branch})`,
444
+ timeoutMs: 30_000,
445
+ retryOn: (err) => err._tag === "GithubTimeout",
446
+ });
447
+ },
148
448
  };
149
449
  }
450
+ function canonicaliseCheckStatus(raw) {
451
+ switch (raw.toLowerCase()) {
452
+ case "queued":
453
+ return "queued";
454
+ case "in_progress":
455
+ return "in_progress";
456
+ case "completed":
457
+ return "completed";
458
+ default:
459
+ // Unknown status — treat as queued so the watcher waits for a
460
+ // final conclusion rather than acting on intermediate state.
461
+ return "queued";
462
+ }
463
+ }
464
+ function canonicaliseCheckConclusion(raw) {
465
+ if (raw === null)
466
+ return null;
467
+ switch (raw.toLowerCase()) {
468
+ case "success":
469
+ case "failure":
470
+ case "cancelled":
471
+ case "timed_out":
472
+ case "neutral":
473
+ case "skipped":
474
+ case "action_required":
475
+ case "stale":
476
+ case "startup_failure":
477
+ return raw.toLowerCase();
478
+ default:
479
+ return null;
480
+ }
481
+ }
482
+ function canonicaliseMergeableState(raw) {
483
+ switch (raw.toLowerCase()) {
484
+ case "clean":
485
+ return "clean";
486
+ case "dirty":
487
+ return "dirty";
488
+ case "behind":
489
+ return "behind";
490
+ case "blocked":
491
+ return "blocked";
492
+ case "unstable":
493
+ return "unstable";
494
+ case "draft":
495
+ return "draft";
496
+ default:
497
+ return "unknown";
498
+ }
499
+ }
@@ -7,6 +7,7 @@ import { flagHitl, handleProcessFailure } from "./hitl.js";
7
7
  import { runImplementLoop } from "./implement.js";
8
8
  import { formatInRunReviewerFeedback, formatPriorFeedback, } from "./prompts.js";
9
9
  import { runReviewPass } from "./review.js";
10
+ import { makeCiWatcher, makeIsMerged, makeMergeabilityWatcher, makeReviewerWatcher, runShepherdPass, } from "./shepherd.js";
10
11
  import { finalize, formatRebaseConflictReason, formatRebaseFailureReason, } from "./finalize.js";
11
12
  // Re-exports so existing callers (commands/run.ts) and tests
12
13
  // (orchestrator.test.ts) keep working without import churn. The
@@ -302,6 +303,49 @@ const processIssue = (issue, deps) => Effect.gen(function* () {
302
303
  yield* flagHitl(issue, deps, reason);
303
304
  return { kind: "hitl", detail: reason };
304
305
  }
306
+ // VA-460: post-finalize shepherd loop. Gated off by default —
307
+ // first ship lands the scaffolding only; VA-461..463 wire the
308
+ // mergeability / CI / reviewer subscriptions, VA-464 the
309
+ // budget knobs. When gated off the composer returns the
310
+ // `opened` outcome verbatim, preserving the historical
311
+ // behavior.
312
+ if (deps.config.shepherdEnabled) {
313
+ const shepherd = yield* runShepherdPass(issue, {
314
+ config: deps.config,
315
+ cwd: deps.cwd,
316
+ baseBranch: deps.baseBranch,
317
+ github: deps.github,
318
+ // VA-461 / VA-462 / VA-463: three real subscriptions
319
+ // wired in precedence order. Mergeability runs first
320
+ // (a dirty base would prevent CI from even running);
321
+ // CI second; reviewer feedback last (CI fixes that
322
+ // address reviewer concerns push commits which will
323
+ // be seen on the next tick).
324
+ isMerged: makeIsMerged(deps.github),
325
+ subscriptions: [
326
+ makeMergeabilityWatcher({
327
+ github: deps.github,
328
+ config: deps.config,
329
+ }),
330
+ makeCiWatcher({
331
+ github: deps.github,
332
+ config: deps.config,
333
+ }),
334
+ makeReviewerWatcher({
335
+ github: deps.github,
336
+ config: deps.config,
337
+ }),
338
+ ],
339
+ }, branch, finalized.detail);
340
+ if (shepherd.kind === "hitl") {
341
+ const reason = `Shepherd routed PR to HITL: ${shepherd.reason}`;
342
+ yield* flagHitl(issue, deps, reason);
343
+ return { kind: "hitl", detail: reason };
344
+ }
345
+ // `merged` and `ready` both preserve the `opened` outcome —
346
+ // the PR exists; whether it's merged yet doesn't change the
347
+ // per-issue verdict the operator sees in the exit summary.
348
+ }
305
349
  return finalized;
306
350
  }
307
351
  if (review.kind === "hitl") {
package/dist/prompts.js CHANGED
@@ -31,6 +31,43 @@ export async function renderReviewPrompt(args) {
31
31
  const template = await loadReviewPrompt();
32
32
  return renderPrompt(template, reviewVars(args));
33
33
  }
34
+ /**
35
+ * VA-461: render the shepherd-rebase prompt. The agent receives the
36
+ * branch / base / mergeable_state and an optional list of files
37
+ * `getConflictFiles` pre-detected — it executes the rebase inside
38
+ * sandcastle and signals IMPL: DONE or IMPL: BLOCKED.
39
+ */
40
+ export async function renderShepherdRebasePrompt(args) {
41
+ const template = await loadShepherdRebasePrompt();
42
+ return renderPrompt(template, shepherdRebaseVars(args));
43
+ }
44
+ /**
45
+ * VA-462: render the shepherd-ci-fix prompt. The agent receives the
46
+ * failing check name and the trailing log lines and executes a
47
+ * targeted fix on the impl branch (commit + push, not force-push).
48
+ */
49
+ export async function renderShepherdCiFixPrompt(args) {
50
+ const template = await loadShepherdCiFixPrompt();
51
+ return renderPrompt(template, shepherdCiFixVars(args));
52
+ }
53
+ /**
54
+ * VA-463: render the prompt for a `REVIEW: CHANGES-REQUESTED — <fix>`
55
+ * verdict from the adversarial PR-reviewer agent. The fix line is
56
+ * inserted verbatim; the agent applies the change and pushes.
57
+ */
58
+ export async function renderShepherdReviewFixPrompt(args) {
59
+ const template = await loadShepherdReviewFixPrompt();
60
+ return renderPrompt(template, shepherdReviewFixVars(args));
61
+ }
62
+ /**
63
+ * VA-463: render the prompt for a verbatim human comment on the PR.
64
+ * The agent decides whether the comment is actionable; non-actionable
65
+ * comments resolve to IMPL: DONE with no commit.
66
+ */
67
+ export async function renderShepherdReviewRespondPrompt(args) {
68
+ const template = await loadShepherdReviewRespondPrompt();
69
+ return renderPrompt(template, shepherdReviewRespondVars(args));
70
+ }
34
71
  // ---------------------------------------------------------------------------
35
72
  // Internal helpers
36
73
  // ---------------------------------------------------------------------------
@@ -40,6 +77,18 @@ async function loadImplementPrompt() {
40
77
  async function loadReviewPrompt() {
41
78
  return readFile(join(PROMPT_DIR, "review.md"), "utf8");
42
79
  }
80
+ async function loadShepherdRebasePrompt() {
81
+ return readFile(join(PROMPT_DIR, "shepherd-rebase.md"), "utf8");
82
+ }
83
+ async function loadShepherdCiFixPrompt() {
84
+ return readFile(join(PROMPT_DIR, "shepherd-ci-fix.md"), "utf8");
85
+ }
86
+ async function loadShepherdReviewFixPrompt() {
87
+ return readFile(join(PROMPT_DIR, "shepherd-review-fix.md"), "utf8");
88
+ }
89
+ async function loadShepherdReviewRespondPrompt() {
90
+ return readFile(join(PROMPT_DIR, "shepherd-review-respond.md"), "utf8");
91
+ }
43
92
  /**
44
93
  * Replace all `{{KEY}}` placeholders with values from `vars`. Done
45
94
  * here (not in sandcastle's promptArgs) so the prompt can be passed
@@ -77,6 +126,56 @@ function reviewVars(args) {
77
126
  COMMITS: args.commits || "(no commits)",
78
127
  };
79
128
  }
129
+ function shepherdCiFixVars(args) {
130
+ return {
131
+ ISSUE_IDENTIFIER: args.issue.identifier,
132
+ ISSUE_TITLE: args.issue.title,
133
+ ISSUE_DESCRIPTION: args.issue.description || "(no description)",
134
+ BRANCH: args.branch,
135
+ CHECK_NAME: args.checkName,
136
+ LOG_TAIL: args.logTail.trim() || "(log unavailable)",
137
+ };
138
+ }
139
+ function shepherdReviewFixVars(args) {
140
+ return {
141
+ ISSUE_IDENTIFIER: args.issue.identifier,
142
+ ISSUE_TITLE: args.issue.title,
143
+ ISSUE_DESCRIPTION: args.issue.description || "(no description)",
144
+ BRANCH: args.branch,
145
+ FIX_REQUEST: args.fixRequest.trim() || "(empty fix request)",
146
+ };
147
+ }
148
+ function shepherdReviewRespondVars(args) {
149
+ return {
150
+ ISSUE_IDENTIFIER: args.issue.identifier,
151
+ ISSUE_TITLE: args.issue.title,
152
+ ISSUE_DESCRIPTION: args.issue.description || "(no description)",
153
+ BRANCH: args.branch,
154
+ COMMENT_AUTHOR: args.commentAuthor || "(unknown author)",
155
+ COMMENT_SOURCE: args.commentSource,
156
+ COMMENT_BODY: args.commentBody.trim() || "(empty body)",
157
+ };
158
+ }
159
+ function shepherdRebaseVars(args) {
160
+ // The conflict-files block is one of two shapes: a bulleted list
161
+ // (when `getConflictFiles` returned hits) or a single line saying
162
+ // the branch is "behind" with no expected conflicts. The template
163
+ // substitutes the whole block via `{{CONFLICT_FILES_BLOCK}}` so
164
+ // either shape renders cleanly without dangling headers.
165
+ const filesBlock = args.conflictFiles.length > 0
166
+ ? `Pre-detected conflicting files:\n${args.conflictFiles
167
+ .map((f) => ` - ${f}`)
168
+ .join("\n")}`
169
+ : `No conflicts pre-detected — the branch is behind base, expect a clean fast-forward rebase`;
170
+ return {
171
+ ISSUE_IDENTIFIER: args.issue.identifier,
172
+ ISSUE_TITLE: args.issue.title,
173
+ BRANCH: args.branch,
174
+ BASE_BRANCH: args.baseBranch,
175
+ MERGEABLE_STATE: args.mergeableState,
176
+ CONFLICT_FILES_BLOCK: filesBlock,
177
+ };
178
+ }
80
179
  /**
81
180
  * VA-383: known orchestrator-emitted comment prefixes that are
82
181
  * bookkeeping noise, not feedback the impl agent should learn from.