@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/README.md CHANGED
@@ -218,6 +218,13 @@ export LINEAR_API_KEY=lin_api_...
218
218
  # export RUNWAY_READY_LABEL="ready-for-agent"
219
219
  # export RUNWAY_HITL_LABEL="ready-for-human"
220
220
  # export RUNWAY_MAX_ITERATIONS=5
221
+ # export RUNWAY_SHEPHERD_ENABLED=true # VA-460: opt into post-PR shepherd loop (default off)
222
+ # export RUNWAY_SHEPHERD_MAX_ITERATIONS=3 # VA-464: max fix attempts per PR before HITL
223
+ # export RUNWAY_SHEPHERD_MAX_WALL_SECONDS=3600 # VA-464: wall-clock budget per PR before HITL
224
+ # export RUNWAY_SHEPHERD_POLL_INTERVAL_SECONDS=15 # VA-460: polling cadence for the shepherd loop
225
+ # export RUNWAY_PR_REVIEWER_BOT_LOGIN="runway-reviewer-bot"
226
+ # VA-463: GitHub login of the adversarial PR-reviewer agent.
227
+ # Unset → all PR comments routed to the human-feedback path.
221
228
  # export RUNWAY_COMMENT_AUTHOR_ALLOWLIST="Reviewer Bot,Jane Reviewer"
222
229
  # optional, comma-separated Linear user names whose comments on a
223
230
  # re-queued issue surface as "Review feedback from prior attempts"
@@ -541,7 +548,7 @@ These are tractable, just not v1.
541
548
 
542
549
  ## Status
543
550
 
544
- 0.14.3 — production-shaped and dogfooded against live Linear queues.
551
+ 0.15.0 — production-shaped and dogfooded against live Linear queues.
545
552
  The end-to-end pipeline (init → run → review → PR) is stable; surface
546
553
  may still shift as the orchestrator's policy and iteration mechanics
547
554
  mature. See [CHANGELOG.md](./CHANGELOG.md) for per-release detail.
package/dist/config.js CHANGED
@@ -45,6 +45,23 @@ const configEffect = EConfig.all({
45
45
  metaFilterThresholdsJson: EConfig.option(EConfig.string("RUNWAY_META_FILTER_THRESHOLDS")),
46
46
  runwayRepoProjectName: EConfig.option(EConfig.string("RUNWAY_REPO_PROJECT_NAME")),
47
47
  metaWeeklyModel: EConfig.option(EConfig.string("RUNWAY_META_WEEKLY_MODEL")),
48
+ // VA-460: feature gate; default `false` for the first ship so the
49
+ // composer's post-finalize behavior is unchanged until VA-461..463
50
+ // wire the subscriptions.
51
+ shepherdEnabled: EConfig.boolean("RUNWAY_SHEPHERD_ENABLED").pipe(EConfig.withDefault(false)),
52
+ shepherdPollIntervalSeconds: EConfig.integer("RUNWAY_SHEPHERD_POLL_INTERVAL_SECONDS").pipe(EConfig.withDefault(15), EConfig.validate({
53
+ message: "RUNWAY_SHEPHERD_POLL_INTERVAL_SECONDS must be a positive integer",
54
+ validation: (n) => n > 0,
55
+ })),
56
+ prReviewerBotLogin: EConfig.option(EConfig.string("RUNWAY_PR_REVIEWER_BOT_LOGIN")),
57
+ shepherdMaxIterations: EConfig.integer("RUNWAY_SHEPHERD_MAX_ITERATIONS").pipe(EConfig.withDefault(3), EConfig.validate({
58
+ message: "RUNWAY_SHEPHERD_MAX_ITERATIONS must be a positive integer",
59
+ validation: (n) => n > 0,
60
+ })),
61
+ shepherdMaxWallSeconds: EConfig.integer("RUNWAY_SHEPHERD_MAX_WALL_SECONDS").pipe(EConfig.withDefault(3600), EConfig.validate({
62
+ message: "RUNWAY_SHEPHERD_MAX_WALL_SECONDS must be a positive integer",
63
+ validation: (n) => n > 0,
64
+ })),
48
65
  }).pipe(Effect.map((raw) => ({
49
66
  linearApiKey: raw.linearApiKey,
50
67
  opServiceAccountToken: Option.getOrUndefined(raw.opServiceAccountToken),
@@ -66,6 +83,11 @@ const configEffect = EConfig.all({
66
83
  metaFilterThresholds: parseMetaFilterThresholdsJson(Option.getOrUndefined(raw.metaFilterThresholdsJson)),
67
84
  runwayRepoProjectName: Option.getOrUndefined(raw.runwayRepoProjectName),
68
85
  metaWeeklyModel: Option.getOrUndefined(raw.metaWeeklyModel),
86
+ shepherdEnabled: raw.shepherdEnabled,
87
+ shepherdPollIntervalSeconds: raw.shepherdPollIntervalSeconds,
88
+ prReviewerBotLogin: Option.getOrUndefined(raw.prReviewerBotLogin),
89
+ shepherdMaxIterations: raw.shepherdMaxIterations,
90
+ shepherdMaxWallSeconds: raw.shepherdMaxWallSeconds,
69
91
  })));
70
92
  /**
71
93
  * VA-359: Context tag for the resolved RunwayConfig. Provided by
@@ -148,6 +148,16 @@ function strArrayAttr(v) {
148
148
  * in lock-step with `orchestrator.ts`.
149
149
  */
150
150
  export const DRAIN_STARTED_LOG = "drain.started";
151
+ /**
152
+ * VA-465: log body strings the shepherd loop emits at three points.
153
+ * Each carries structured attributes the projector reads to update
154
+ * the `shepherding_prs` table. Kept in lock-step with `shepherd.ts`'s
155
+ * `Effect.logInfo` call sites — drift between the two means the
156
+ * sub-card stops rendering for the affected lifecycle event.
157
+ */
158
+ export const SHEPHERD_ITERATION_START_LOG = "shepherd.iteration.start";
159
+ export const SHEPHERD_ITERATION_END_LOG = "shepherd.iteration.end";
160
+ export const SHEPHERD_ENDED_LOG = "shepherd.ended";
151
161
  /**
152
162
  * VA-455: scan an OTLP logs payload for `drain.started` markers.
153
163
  * Each match becomes an `ActiveDrainMarker` carrying the drain's
@@ -188,6 +198,86 @@ export function extractActiveDrainMarkers(payload) {
188
198
  }
189
199
  return out;
190
200
  }
201
+ /**
202
+ * VA-465: scan an OTLP logs payload for shepherd lifecycle markers.
203
+ * Three body strings produce three marker kinds; each is tagged so
204
+ * the server can dispatch to the right storage method. Records
205
+ * missing trace_id, a recognised body, or a timestamp are dropped —
206
+ * we won't fabricate any of those.
207
+ *
208
+ * Attribute reading is lenient: the gateway encodes numbers as
209
+ * strings via OTLP's `stringValue`, so we re-parse with
210
+ * `Number.parseInt`. Missing attributes get sensible defaults
211
+ * (e.g., empty subscription, null max_iterations) rather than
212
+ * causing the marker to be dropped — a partial marker is still
213
+ * useful for the dashboard.
214
+ */
215
+ export function extractShepherdMarkers(payload) {
216
+ const out = [];
217
+ for (const rl of payload.resourceLogs ?? []) {
218
+ for (const sl of rl.scopeLogs ?? []) {
219
+ for (const rec of sl.logRecords ?? []) {
220
+ const body = rec.body?.stringValue;
221
+ if (body !== SHEPHERD_ITERATION_START_LOG &&
222
+ body !== SHEPHERD_ITERATION_END_LOG &&
223
+ body !== SHEPHERD_ENDED_LOG) {
224
+ continue;
225
+ }
226
+ const traceId = rec.traceId?.trim();
227
+ if (!traceId)
228
+ continue;
229
+ const ts = rec.timeUnixNano ?? rec.observedTimeUnixNano;
230
+ if (!ts)
231
+ continue;
232
+ const attrs = attributesToStringMap(rec.attributes);
233
+ const prNumber = Number.parseInt(attrs.pr ?? "", 10);
234
+ if (!Number.isFinite(prNumber))
235
+ continue;
236
+ if (body === SHEPHERD_ITERATION_START_LOG) {
237
+ const iteration = Number.parseInt(attrs.iteration ?? "", 10);
238
+ if (!Number.isFinite(iteration))
239
+ continue;
240
+ const maxIter = Number.parseInt(attrs.maxIterations ?? "", 10);
241
+ out.push({
242
+ kind: "iteration.start",
243
+ traceId,
244
+ prNumber,
245
+ subscription: attrs.subscription ?? "",
246
+ iteration,
247
+ maxIterations: Number.isFinite(maxIter) ? maxIter : null,
248
+ description: attrs.description ?? "",
249
+ timestampUnixNano: ts,
250
+ });
251
+ }
252
+ else if (body === SHEPHERD_ITERATION_END_LOG) {
253
+ const outcome = attrs.outcome === "hitl" ? "hitl" : "fixed";
254
+ out.push({
255
+ kind: "iteration.end",
256
+ traceId,
257
+ prNumber,
258
+ outcome,
259
+ detail: attrs.detail ?? "",
260
+ timestampUnixNano: ts,
261
+ });
262
+ }
263
+ else {
264
+ const raw = attrs.outcome;
265
+ const outcome = raw === "merged" || raw === "ready" || raw === "hitl"
266
+ ? raw
267
+ : "hitl"; // Conservative fallback — terminal hitl is the safe default.
268
+ out.push({
269
+ kind: "ended",
270
+ traceId,
271
+ prNumber,
272
+ outcome,
273
+ timestampUnixNano: ts,
274
+ });
275
+ }
276
+ }
277
+ }
278
+ }
279
+ return out;
280
+ }
191
281
  /**
192
282
  * VA-388: project an OTLP logs payload into `LogRecordRow`s. Records
193
283
  * without a trace_id are dropped — every Effect log emitted under
@@ -1,12 +1,21 @@
1
1
  import { createServer } from "node:http";
2
2
  import { createLinearAdapter, startLinearSync, } from "./linear-sync.js";
3
3
  import { createEventBus } from "./events.js";
4
- import { extractActiveDrainMarkers, projectLogs, projectPayload, } from "./projector.js";
4
+ import { extractActiveDrainMarkers, extractShepherdMarkers, projectLogs, projectPayload, } from "./projector.js";
5
5
  import { createStorage, } from "./storage.js";
6
6
  import { META_REVIEW_PAGE_SIZE, renderDetailView, renderIssueProcessRows, renderListView, renderLogsSection, renderMetaReviewDetailView, renderMetaReviewListView, } from "./views.js";
7
7
  // VA-389: phase spans we surface on the detail page's timeline.
8
8
  // Anything else stays in raw_spans for debugging but isn't rendered.
9
- const DETAIL_PHASE_NAMES = ["review", "pushBranch", "openPullRequest"];
9
+ const DETAIL_PHASE_NAMES = [
10
+ "review",
11
+ "pushBranch",
12
+ "openPullRequest",
13
+ // VA-460: shepherd span — emitted by runShepherdPass when
14
+ // `config.shepherdEnabled=true`. Surfaced alongside review so the
15
+ // operator sees post-finalize loop time on the timeline. Missing
16
+ // for any issue process whose drain ran without the feature gate.
17
+ "shepherd",
18
+ ];
10
19
  const ISSUE_DETAIL_RE = /^\/issue\/([^/?#]+)\/([^/?#]+)\/?$/;
11
20
  // VA-387: canonical detail route. `:id` is the issue process span_id;
12
21
  // the lookup falls back to the (trace_id, span_id) pair only for
@@ -247,6 +256,39 @@ async function handleOtlpLogs(req, res, storage, events) {
247
256
  for (const m of extractActiveDrainMarkers(payload)) {
248
257
  storage.markDrainActive(m);
249
258
  }
259
+ // VA-465: shepherd lifecycle markers drive the per-PR sub-card.
260
+ // Three log bodies map to three storage methods — see
261
+ // `extractShepherdMarkers` for the body strings.
262
+ for (const m of extractShepherdMarkers(payload)) {
263
+ if (m.kind === "iteration.start") {
264
+ storage.markShepherdIterationStart({
265
+ traceId: m.traceId,
266
+ prNumber: m.prNumber,
267
+ subscription: m.subscription,
268
+ iteration: m.iteration,
269
+ maxIterations: m.maxIterations,
270
+ description: m.description,
271
+ timestampUnixNano: m.timestampUnixNano,
272
+ });
273
+ }
274
+ else if (m.kind === "iteration.end") {
275
+ storage.markShepherdIterationEnd({
276
+ traceId: m.traceId,
277
+ prNumber: m.prNumber,
278
+ outcome: m.outcome,
279
+ detail: m.detail,
280
+ timestampUnixNano: m.timestampUnixNano,
281
+ });
282
+ }
283
+ else {
284
+ storage.markShepherdEnded({
285
+ traceId: m.traceId,
286
+ prNumber: m.prNumber,
287
+ outcome: m.outcome,
288
+ timestampUnixNano: m.timestampUnixNano,
289
+ });
290
+ }
291
+ }
250
292
  for (const r of projectLogs(payload)) {
251
293
  storage.appendLogRecord(r);
252
294
  // VA-391: the SSE detail-pane stream live-tails the Logs section
@@ -380,12 +422,19 @@ function handleListView(req, res, storage, linearEnabled, linearWorkspace) {
380
422
  // `drains` row). `null` becomes the "Idle — no active drain"
381
423
  // state in views.
382
424
  const activeDrain = storage.getActiveDrain();
425
+ // VA-465: when a drain is active, surface its in-flight shepherding
426
+ // PRs as a sub-strip under the drain strip. Idle drain → no
427
+ // shepherd rows to query.
428
+ const activeShepherding = activeDrain
429
+ ? storage.getActiveShepherding(activeDrain.traceId)
430
+ : [];
383
431
  const html = renderListView({
384
432
  rows,
385
433
  linearEnabled,
386
434
  todoQueue,
387
435
  snapshotsByIdentifier,
388
436
  activeDrain,
437
+ activeShepherding,
389
438
  filterState,
390
439
  recentDrains,
391
440
  isFiltered: isFilteredState(filterState),
@@ -159,6 +159,31 @@ const SCHEMA = `
159
159
 
160
160
  CREATE INDEX IF NOT EXISTS idx_meta_reviews_kind_created
161
161
  ON meta_reviews(kind, created_at DESC);
162
+
163
+ -- VA-465: per-PR shepherding state surfaced as a sub-card under the
164
+ -- active-drain card. One row per (trace_id, pr_number); the watcher
165
+ -- loop may iterate multiple times per PR before exiting. State
166
+ -- transitions acting -> ready/merged/hitl; the latter three are
167
+ -- terminal and set ended_unix_nano. The projector's three
168
+ -- shepherd.* log markers feed this table; the list view reads
169
+ -- getActiveShepherding(traceId) (non-terminal rows only).
170
+ CREATE TABLE IF NOT EXISTS shepherding_prs (
171
+ trace_id TEXT NOT NULL,
172
+ pr_number INTEGER NOT NULL,
173
+ subscription TEXT,
174
+ iteration INTEGER,
175
+ max_iterations INTEGER,
176
+ state TEXT NOT NULL,
177
+ description TEXT,
178
+ started_unix_nano TEXT NOT NULL,
179
+ last_seen_unix_nano TEXT NOT NULL,
180
+ ended_unix_nano TEXT,
181
+ inserted_at TEXT NOT NULL DEFAULT (datetime('now')),
182
+ PRIMARY KEY (trace_id, pr_number)
183
+ );
184
+
185
+ CREATE INDEX IF NOT EXISTS idx_shepherding_prs_trace_active
186
+ ON shepherding_prs(trace_id, ended_unix_nano);
162
187
  `;
163
188
  const DEFAULT_AGGREGATE_WINDOW = 30;
164
189
  /**
@@ -673,6 +698,98 @@ export function createStorage(path, opts = {}) {
673
698
  const markDrainActive = (m) => {
674
699
  markActiveDrainStmt.run(m.traceId, m.spanId, m.startTimeUnixNano, m.lastSeenUnixNano);
675
700
  };
701
+ // VA-465: shepherd-state prepared statements.
702
+ //
703
+ // `markShepherdIterationStartStmt` inserts on first iteration or
704
+ // updates an existing row to bump the iteration / subscription /
705
+ // description / last_seen. The CONFLICT branch only overwrites
706
+ // when (a) the row isn't already terminated AND (b) the incoming
707
+ // timestamp is newer than the stored last_seen — protects against
708
+ // out-of-order batch arrival.
709
+ const markShepherdIterationStartStmt = db.prepare(`
710
+ INSERT INTO shepherding_prs (
711
+ trace_id, pr_number, subscription, iteration, max_iterations,
712
+ state, description, started_unix_nano, last_seen_unix_nano
713
+ ) VALUES (?, ?, ?, ?, ?, 'acting', ?, ?, ?)
714
+ ON CONFLICT (trace_id, pr_number) DO UPDATE SET
715
+ subscription = excluded.subscription,
716
+ iteration = excluded.iteration,
717
+ max_iterations = COALESCE(excluded.max_iterations, shepherding_prs.max_iterations),
718
+ state = 'acting',
719
+ description = excluded.description,
720
+ last_seen_unix_nano = excluded.last_seen_unix_nano
721
+ WHERE shepherding_prs.ended_unix_nano IS NULL
722
+ AND CAST(shepherding_prs.last_seen_unix_nano AS INTEGER)
723
+ < CAST(excluded.last_seen_unix_nano AS INTEGER)
724
+ `);
725
+ // `markShepherdIterationEndStmt` bumps last_seen + description on
726
+ // a row that exists. UPDATE-only (no INSERT) — an end without a
727
+ // start is dropped silently.
728
+ const markShepherdIterationEndStmt = db.prepare(`
729
+ UPDATE shepherding_prs
730
+ SET description = ?, last_seen_unix_nano = ?
731
+ WHERE trace_id = ? AND pr_number = ?
732
+ AND ended_unix_nano IS NULL
733
+ AND CAST(last_seen_unix_nano AS INTEGER) < CAST(? AS INTEGER)
734
+ `);
735
+ // `markShepherdEndedStmt` terminates the row. The CASE WHEN guard
736
+ // protects against a stale end-marker arriving after a fresher
737
+ // start (which can happen with batched OTLP flushes).
738
+ const markShepherdEndedStmt = db.prepare(`
739
+ INSERT INTO shepherding_prs (
740
+ trace_id, pr_number, state, started_unix_nano,
741
+ last_seen_unix_nano, ended_unix_nano
742
+ ) VALUES (?, ?, ?, ?, ?, ?)
743
+ ON CONFLICT (trace_id, pr_number) DO UPDATE SET
744
+ state = excluded.state,
745
+ last_seen_unix_nano = excluded.last_seen_unix_nano,
746
+ ended_unix_nano = excluded.ended_unix_nano
747
+ WHERE shepherding_prs.ended_unix_nano IS NULL
748
+ OR CAST(shepherding_prs.ended_unix_nano AS INTEGER)
749
+ < CAST(excluded.ended_unix_nano AS INTEGER)
750
+ `);
751
+ const getActiveShepherdingStmt = db.prepare(`
752
+ SELECT
753
+ trace_id, pr_number, subscription, iteration, max_iterations,
754
+ state, description, started_unix_nano, last_seen_unix_nano,
755
+ ended_unix_nano
756
+ FROM shepherding_prs
757
+ WHERE trace_id = ? AND ended_unix_nano IS NULL
758
+ ORDER BY CAST(started_unix_nano AS INTEGER) ASC
759
+ `);
760
+ const markShepherdIterationStart = (m) => {
761
+ markShepherdIterationStartStmt.run(m.traceId, m.prNumber, m.subscription, m.iteration, m.maxIterations, m.description, m.timestampUnixNano, m.timestampUnixNano);
762
+ };
763
+ const markShepherdIterationEnd = (m) => {
764
+ markShepherdIterationEndStmt.run(m.detail, m.timestampUnixNano, m.traceId, m.prNumber, m.timestampUnixNano);
765
+ };
766
+ const markShepherdEnded = (m) => {
767
+ // VA-465: only `merged` terminates the row (sets ended_unix_nano
768
+ // → excluded from getActiveShepherding). `ready` and `hitl`
769
+ // leave ended_unix_nano NULL so the sub-strip continues to
770
+ // render them as "ready, awaiting human merge" / "HITL — needs
771
+ // human attention" rows while the drain is still active. The
772
+ // rows naturally disappear when the drain ends, since the list
773
+ // view only queries getActiveShepherding when activeDrain is
774
+ // non-null.
775
+ const endedNano = m.outcome === "merged" ? m.timestampUnixNano : null;
776
+ markShepherdEndedStmt.run(m.traceId, m.prNumber, m.outcome, m.timestampUnixNano, m.timestampUnixNano, endedNano);
777
+ };
778
+ const getActiveShepherding = (traceId) => {
779
+ const rows = getActiveShepherdingStmt.all(traceId);
780
+ return rows.map((r) => ({
781
+ traceId: r.trace_id,
782
+ prNumber: r.pr_number,
783
+ subscription: r.subscription,
784
+ iteration: r.iteration,
785
+ maxIterations: r.max_iterations,
786
+ state: r.state,
787
+ description: r.description,
788
+ startedUnixNano: r.started_unix_nano,
789
+ lastSeenUnixNano: r.last_seen_unix_nano,
790
+ endedUnixNano: r.ended_unix_nano,
791
+ }));
792
+ };
676
793
  const saveIssueProcess = (p) => {
677
794
  insertIssueProcess.run(p.traceId, p.spanId, p.parentSpanId, p.issueIdentifier, p.issueId, p.issueTitle,
678
795
  // VA-387: labels round-trip as a JSON array string. Keeping them
@@ -950,6 +1067,10 @@ export function createStorage(path, opts = {}) {
950
1067
  appendLogRecord,
951
1068
  streamLogsFor,
952
1069
  getActiveDrain,
1070
+ markShepherdIterationStart,
1071
+ markShepherdIterationEnd,
1072
+ markShepherdEnded,
1073
+ getActiveShepherding,
953
1074
  saveMetaReview,
954
1075
  listMetaReviews,
955
1076
  countMetaReviews,
@@ -69,7 +69,11 @@ export function renderListView(input) {
69
69
  : "";
70
70
  // VA-391: drain summary strip renders ABOVE the queue + run list
71
71
  // so the operator sees in-flight progress at the top of the page.
72
- const drainStrip = renderDrainStrip(vm.activeDrain ?? null);
72
+ // VA-465: when the drain is active AND has shepherding PRs, a
73
+ // sub-strip lists each PR's current state below the drain strip.
74
+ const activeShepherding = vm.activeShepherding ?? [];
75
+ const drainStrip = renderDrainStrip(vm.activeDrain ?? null) +
76
+ renderShepherdSubStrip(vm.activeDrain ?? null, activeShepherding);
73
77
  // VA-392: filter chips render between the drain strip and the table
74
78
  // so the operator can narrow the run list without leaving the page.
75
79
  const filterChips = renderFilterChips(filterState, recentDrains);
@@ -100,6 +104,20 @@ export function renderListView(input) {
100
104
  .drain-strip .strip-counter strong { color: #e5e5e5; }
101
105
  .drain-strip .counter-hitl strong { color: #facc15; }
102
106
  .drain-strip .counter-err strong { color: #f87171; }
107
+ /* VA-465: shepherd sub-strip — one row per PR being shepherded. */
108
+ .shepherd-strip { margin: -10px 0 16px; padding: 0; display: flex; flex-direction: column; gap: 4px; }
109
+ .shepherd-row { padding: 6px 12px; border: 1px solid #2a2a30;
110
+ border-top: none; border-radius: 0 0 4px 4px;
111
+ background: #0a0f1c; display: flex; gap: 12px;
112
+ align-items: center; font-size: 0.9em; }
113
+ .shepherd-row .strip-label { color: #9ca3af; }
114
+ .shepherd-row .shepherd-pr { color: #93c5fd; font-weight: 600; }
115
+ .shepherd-row .shepherd-iter { color: #d4d4d8; }
116
+ .shepherd-row .shepherd-state { color: #e5e5e5; }
117
+ .shepherd-state-ready { border-color: #4ade80; }
118
+ .shepherd-state-merged { border-color: #4ade80; opacity: 0.7; }
119
+ .shepherd-state-hitl { border-color: #facc15; }
120
+ .shepherd-state-acting { border-color: #60a5fa; }
103
121
  .filters { margin: 0 0 16px; display: flex; flex-direction: column;
104
122
  gap: 6px; font-size: 12px; }
105
123
  .filter-row { display: flex; align-items: center; gap: 8px;
@@ -166,6 +184,51 @@ export function renderListView(input) {
166
184
  * when one is active; otherwise emits the explicit "Idle" indicator
167
185
  * so the operator can tell at a glance that no drain is running.
168
186
  */
187
+ /**
188
+ * VA-465: sub-strip under the active-drain card showing per-PR
189
+ * shepherding state. One row per PR. Empty when no drain is active
190
+ * or no shepherding rows exist — returns an empty string so the
191
+ * markup has no orphan container.
192
+ */
193
+ function renderShepherdSubStrip(active, shepherding) {
194
+ if (!active || shepherding.length === 0)
195
+ return "";
196
+ const lines = shepherding
197
+ .map((s) => {
198
+ const stateLabel = shepherdStateLabel(s);
199
+ return `<div class="shepherd-row shepherd-state-${s.state}">
200
+ <span class="strip-label">Shepherding</span>
201
+ <span class="shepherd-pr">PR #${s.prNumber}</span>
202
+ ${s.iteration !== null
203
+ ? `<span class="shepherd-iter">iter ${s.iteration}${s.maxIterations !== null ? `/${s.maxIterations}` : ""}</span>`
204
+ : ""}
205
+ <span class="shepherd-state">${escapeHtml(stateLabel)}</span>
206
+ </div>`;
207
+ })
208
+ .join("");
209
+ return `<div class="shepherd-strip">${lines}</div>`;
210
+ }
211
+ function shepherdStateLabel(s) {
212
+ switch (s.state) {
213
+ case "acting":
214
+ // The subscription + description tell the operator WHAT is
215
+ // happening — a rebase, a CI re-impl, a reviewer-feedback fix.
216
+ // Both are best-effort; missing ones collapse to the bare verb.
217
+ if (s.description)
218
+ return s.description;
219
+ return s.subscription
220
+ ? `acting on ${s.subscription}`
221
+ : "acting";
222
+ case "ready":
223
+ return "ready, awaiting human merge";
224
+ case "merged":
225
+ return "merged";
226
+ case "hitl":
227
+ return "HITL — needs human attention";
228
+ default:
229
+ return s.state;
230
+ }
231
+ }
169
232
  function renderDrainStrip(active) {
170
233
  if (!active) {
171
234
  return `<div class="drain-strip idle">Idle — no active drain</div>`;
@@ -454,6 +517,8 @@ export function renderDetailView(vm) {
454
517
  .timeline .phase-implement { background: #60a5fa; }
455
518
  .timeline .phase-review { background: #facc15; }
456
519
  .timeline .phase-finalize { background: #4ade80; }
520
+ /* VA-460: shepherd phase (post-finalize PR loop). */
521
+ .timeline .phase-shepherd { background: #a78bfa; }
457
522
  .timeline-legend { font-size: 11px; color: #9ca3af; margin-bottom: 16px; }
458
523
  .iter-row td { background: #16161a; padding-left: 32px; }
459
524
  .iter-status { font-weight: 600; }
@@ -569,6 +634,17 @@ function computePhases(vm) {
569
634
  const endNs = maxBigInt(finalize.map((p) => parseNs(p.endTimeUnixNano)));
570
635
  phases.push({ name: "finalize", startNs, endNs });
571
636
  }
637
+ // VA-460: shepherd phase runs after finalize when the feature gate
638
+ // is on. Only present for drains with `shepherdEnabled=true`; pre-
639
+ // gate issue processes show the original three phases unchanged.
640
+ const shepherd = vm.phaseSpans.find((p) => p.name === "shepherd");
641
+ if (shepherd) {
642
+ phases.push({
643
+ name: "shepherd",
644
+ startNs: parseNs(shepherd.startTimeUnixNano),
645
+ endNs: parseNs(shepherd.endTimeUnixNano),
646
+ });
647
+ }
572
648
  return phases;
573
649
  }
574
650
  function renderPhaseTimeline(phases) {