@dev-loops/core 0.8.0 → 0.9.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -57,6 +57,11 @@
57
57
  "./loop/timeout-policy": "./src/loop/timeout-policy.mjs",
58
58
  "./loop/tracker-pr-state": "./src/loop/tracker-pr-state.mjs",
59
59
  "./loop/ui-e2e-scoping": "./src/loop/ui-e2e-scoping.mjs",
60
+ "./loop/ui-review-provision": "./src/loop/ui-review-provision.mjs",
61
+ "./loop/ui-review-drive": "./src/loop/ui-review-drive.mjs",
62
+ "./loop/ui-review-diagnose": "./src/loop/ui-review-diagnose.mjs",
63
+ "./loop/ui-review-report": "./src/loop/ui-review-report.mjs",
64
+ "./loop/ui-review-teardown": "./src/loop/ui-review-teardown.mjs",
60
65
  "./projects/list-queue-items": "./src/projects/list-queue-items.mjs",
61
66
  "./projects/move-queue-item": "./src/projects/move-queue-item.mjs",
62
67
  "./projects/resolve-project": "./src/projects/resolve-project.mjs",
@@ -88,6 +88,28 @@ export function rewriteCliInvocation(body, version) {
88
88
  return String(body).split("node <dev-loops-package-root>/cli/index.mjs").join(`npx dev-loops@${version}`);
89
89
  }
90
90
 
91
+ /**
92
+ * Rewrite repo-root `../docs/…` *inline* markdown links `](../docs/…)` in a generated *command*
93
+ * body so they resolve from the generated file's deeper location. Only the inline `](…)` link form
94
+ * is rewritten (reference-style `[label]: …` and HTML `<a href>` links are left as-is) — command
95
+ * bodies only use inline links, so that is the sole form that occurs. Source commands live at
96
+ * `commands/<name>.command.md`, so `../docs/x` resolves to repo-root `docs/x`; the generated
97
+ * wrapper lives one level deeper at `.claude/commands/<name>.md`, where `../docs/x` would wrongly
98
+ * resolve to `.claude/docs/x` (there is no such dir). Repo-root `docs/` is NOT mirrored into
99
+ * `.claude/`, so the link must gain one `../` to reach repo-root: `../docs/x` → `../../docs/x`.
100
+ *
101
+ * Scoped to `../docs/` on purpose. Other `../…` command links point at subtrees the generator
102
+ * mirrors under `.claude/` (e.g. `../skills/docs/x` → the bundled `.claude/skills/docs/x`), whose
103
+ * relative depth is preserved verbatim — shifting those would break them. Skills need no rewrite
104
+ * at all for the same reason (their `../docs/x` targets the bundled `.claude/skills/docs/x`).
105
+ *
106
+ * @param {string} body
107
+ * @returns {string}
108
+ */
109
+ export function rewriteCommandRepoLinks(body) {
110
+ return String(body).replace(/(\]\(<?)(\.\.\/docs\/)/g, "$1../$2");
111
+ }
112
+
91
113
  /**
92
114
  * Map a single Pi tool name to its Claude tool name(s).
93
115
  * @param {string} name
@@ -180,7 +202,7 @@ export function transformAgent({ source, raw, version = "latest" }) {
180
202
  */
181
203
  export function transformCommand({ source, raw, version = "latest" }) {
182
204
  const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
183
- const body = rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version);
205
+ const body = rewriteCommandRepoLinks(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version));
184
206
 
185
207
  const lines = ["---"];
186
208
  if (frontmatter.description != null) {
@@ -178,6 +178,179 @@ const WorktreeConfig = z.strictObject({
178
178
  linkOnInit: z.array(z.string().trim().min(1)).optional(),
179
179
  });
180
180
 
181
+ /**
182
+ * Dev-DB migration sub-recipe for the ui-review run recipe. `statusCommand`
183
+ * lists pending migrations (one per line); `applyCommand` applies them.
184
+ *
185
+ * Destructive detection is EXPLICIT and status-format-dependent: the
186
+ * `destructivePattern` regex is matched (case-insensitive, per line) against the
187
+ * STATUS OUTPUT — not against the migration files. The shipped default
188
+ * (DEFAULT_DESTRUCTIVE_MIGRATION_PATTERN) assumes SQL-bearing status output
189
+ * (DROP/TRUNCATE/DELETE FROM ...); against a status command that emits migration
190
+ * identifiers or filenames instead, it matches nothing and the destructive guard
191
+ * is inert. A project whose status output is NOT SQL therefore MUST set a
192
+ * `destructivePattern` that matches its own status format (e.g. a `destructive`/
193
+ * `down` marker), or make `statusCommand` emit the destructive SQL/marker — the
194
+ * default cannot detect what its status output never prints.
195
+ */
196
+ const UiReviewMigrateConfig = z.strictObject({
197
+ statusCommand: z.string().trim().min(1),
198
+ applyCommand: z.string().trim().min(1),
199
+ destructivePattern: z
200
+ .string()
201
+ .trim()
202
+ .min(1)
203
+ .refine((p) => {
204
+ try {
205
+ // Validate under the exact flags the runtime compile uses at the
206
+ // destructive-migration safety boundary (inspectMigrations), so a
207
+ // pattern valid bare but invalid under `u` is rejected at load time.
208
+ new RegExp(p, "iu");
209
+ return true;
210
+ } catch {
211
+ return false;
212
+ }
213
+ }, "destructivePattern must be a valid regex")
214
+ .optional(),
215
+ });
216
+
217
+ /**
218
+ * Per-project boot recipe: a shell `command` that starts the branch's app and a
219
+ * `readyUrl` an HTTP readiness probe polls until the app is up (never a fixed
220
+ * sleep). No app is hard-coded — a project declares its own recipe. `cwd` is an
221
+ * optional worktree-relative subdir to run in.
222
+ */
223
+ const UiReviewRunConfig = z.strictObject({
224
+ command: z.string().trim().min(1),
225
+ readyUrl: z
226
+ .string()
227
+ .trim()
228
+ .url()
229
+ .refine((u) => {
230
+ try {
231
+ const p = new URL(u).protocol;
232
+ return p === "http:" || p === "https:";
233
+ } catch {
234
+ return false;
235
+ }
236
+ }, "readyUrl must be an http(s) URL"),
237
+ readyTimeoutMs: z.number().int().min(1).max(600000).default(60000),
238
+ readyIntervalMs: z.number().int().min(1).max(60000).default(1000),
239
+ cwd: z.string().trim().min(1).optional(),
240
+ migrate: UiReviewMigrateConfig.optional(),
241
+ });
242
+
243
+ /**
244
+ * Per-project dev-login recipe (Stage 2). The drive stage obtains a session for
245
+ * the change's target role by driving this login form in the browser. Nothing
246
+ * is hard-coded here — a project declares its own login URL, field selectors,
247
+ * and the shared dev credential (never a real user secret; a dev-only password
248
+ * or role). `successSelector` is what proves the session was established;
249
+ * without it the drive stage cannot confirm auth and fails closed.
250
+ */
251
+ const UiReviewLoginConfig = z.strictObject({
252
+ loginUrl: z
253
+ .string()
254
+ .trim()
255
+ .url()
256
+ .refine((u) => {
257
+ try {
258
+ const p = new URL(u).protocol;
259
+ return p === "http:" || p === "https:";
260
+ } catch {
261
+ return false;
262
+ }
263
+ }, "loginUrl must be an http(s) URL"),
264
+ usernameSelector: z.string().trim().min(1).optional(),
265
+ usernameValue: z.string().min(1).optional(),
266
+ passwordSelector: z.string().trim().min(1).optional(),
267
+ passwordValue: z.string().min(1).optional(),
268
+ submitSelector: z.string().trim().min(1),
269
+ successSelector: z.string().trim().min(1),
270
+ });
271
+
272
+ /** A config-declared interstitial (cookie consent etc.) dismissed ONCE per
273
+ * browser context. */
274
+ const UiReviewInterstitialConfig = z.strictObject({
275
+ selector: z.string().trim().min(1),
276
+ });
277
+
278
+ /** One driven step. The action set is deliberately small and maps 1:1 to a
279
+ * Playwright page call in the harness — enough to render a page and exercise the
280
+ * create/edit/reorder/upload/toggle interactions plus dispatch a real event. */
281
+ const UiReviewFlowStepConfig = z.strictObject({
282
+ name: z.string().trim().min(1).optional(),
283
+ action: z.enum(["goto", "click", "fill", "select", "upload", "dispatch"]),
284
+ selector: z.string().trim().min(1).optional(),
285
+ path: z.string().trim().min(1).optional(),
286
+ value: z.string().optional(),
287
+ event: z.string().trim().min(1).optional(),
288
+ }).superRefine((step, ctx) => {
289
+ // Every action but `goto` targets an element, so a missing selector is a
290
+ // config error, not a runtime step-failure. (`goto` uses `path`/url.)
291
+ if (step.action !== "goto" && (step.selector == null || step.selector.trim().length === 0)) {
292
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["selector"], message: `step action "${step.action}" requires a selector` });
293
+ }
294
+ // Action-specific required fields. Rejecting these at parse time turns a silent
295
+ // wrong drive into a clear config error: a missing `goto.path` would drive "/",
296
+ // and a missing `upload.value` becomes setInputFiles(sel, "") which throws mid
297
+ // walk as a step-failure rather than a config problem.
298
+ if (step.action === "goto" && (step.path == null || step.path.trim().length === 0)) {
299
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["path"], message: `step action "goto" requires a path` });
300
+ }
301
+ if (step.action === "upload" && (step.value == null || step.value.trim().length === 0)) {
302
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["value"], message: `step action "upload" requires a value (the file path to upload)` });
303
+ }
304
+ });
305
+
306
+ /** An allowlisted changed flow. `pathPatterns` are plain substrings matched
307
+ * against the PR's changed file paths to decide whether the flow is in scope
308
+ * (the bounded changed-flow heuristic); a flow with none is always driven. */
309
+ const UiReviewFlowConfig = z.strictObject({
310
+ name: z.string().trim().min(1),
311
+ pathPatterns: z.array(z.string().trim().min(1)).optional(),
312
+ steps: z.array(UiReviewFlowStepConfig).min(1),
313
+ });
314
+
315
+ /** Bounded drive caps (Stage 2). Every field is optional and clamped to a
316
+ * ceiling at resolve time — a project may only tighten a cap, never loosen it. */
317
+ const UiReviewCapsConfig = z.strictObject({
318
+ maxScreenshots: z.number().int().min(1).optional(),
319
+ maxFlows: z.number().int().min(1).optional(),
320
+ maxStepsPerFlow: z.number().int().min(1).optional(),
321
+ });
322
+
323
+ /**
324
+ * UI-review route config: the generic, per-project provision+boot recipe (Stage
325
+ * 1) plus the drive recipe (Stage 2: login, interstitials, changed-flow
326
+ * allowlist, and an optional server-log path/pattern for tailing). Absent (the
327
+ * default) means no recipe is declared — the corresponding stage stops with that
328
+ * as a stated reason rather than guessing how to run or drive the app.
329
+ */
330
+ const UiReviewConfig = z.strictObject({
331
+ run: UiReviewRunConfig.optional(),
332
+ login: UiReviewLoginConfig.optional(),
333
+ interstitials: z.array(UiReviewInterstitialConfig).optional(),
334
+ flows: z.array(UiReviewFlowConfig).optional(),
335
+ caps: UiReviewCapsConfig.optional(),
336
+ // Filesystem path (worktree-relative or absolute) to the project's server log.
337
+ // The drive stage tails it so a swallowed 500 the UI hid is still recorded.
338
+ serverLogPath: z.string().trim().min(1).optional(),
339
+ serverLogExceptionPattern: z
340
+ .string()
341
+ .trim()
342
+ .min(1)
343
+ .refine((p) => {
344
+ try {
345
+ new RegExp(p, "iu");
346
+ return true;
347
+ } catch {
348
+ return false;
349
+ }
350
+ }, "serverLogExceptionPattern must be a valid regex")
351
+ .optional(),
352
+ });
353
+
181
354
  /** Internal path whitelist for internal-only PR detection — flat array of regex strings */
182
355
  const InternalPatternsConfig = z.array(z.string().trim().min(1)).min(1);
183
356
 
@@ -232,6 +405,7 @@ export const DevLoopConfigSchema = z.strictObject({
232
405
  personas: PersonasConfig.optional(),
233
406
  internalPathPatterns: InternalPatternsConfig.optional(),
234
407
  worktree: WorktreeConfig.optional(),
408
+ uiReview: UiReviewConfig.optional(),
235
409
  // Deprecated (removed in #1088): tolerated so consumer .devloops files that
236
410
  // still carry a localPlanning block keep parsing. Accepted, never read.
237
411
  localPlanning: z.unknown().optional(),
@@ -304,6 +478,7 @@ export const FileConfigSchema = z.strictObject({
304
478
  personas: FilePersonasConfig.optional(),
305
479
  internalPathPatterns: InternalPatternsConfig.optional(),
306
480
  worktree: WorktreeConfig.partial().optional(),
481
+ uiReview: UiReviewConfig.partial().optional(),
307
482
  // Deprecated (removed in #1088): tolerated so consumer .devloops files that
308
483
  // still carry a localPlanning block keep parsing. Accepted, never read.
309
484
  localPlanning: z.unknown().optional(),
@@ -1385,6 +1560,108 @@ export function resolveWorktreeConfig(config) {
1385
1560
  return { copyOnInit: list(wt?.copyOnInit), linkOnInit: list(wt?.linkOnInit) };
1386
1561
  }
1387
1562
 
1563
+ /**
1564
+ * Default destructive-migration signal: SQL statements that drop or wipe data.
1565
+ * Matched (case-insensitive, per line) against the migration STATUS OUTPUT. This
1566
+ * default only detects destructive intent when the status output is itself
1567
+ * SQL-bearing; against status output that lists migration identifiers/filenames
1568
+ * it matches nothing and the guard is inert (no false positives, but also no
1569
+ * protection). Such a project MUST override via
1570
+ * `uiReview.run.migrate.destructivePattern` to match its own status format (or
1571
+ * emit the destructive SQL/marker from `statusCommand`).
1572
+ */
1573
+ export const DEFAULT_DESTRUCTIVE_MIGRATION_PATTERN =
1574
+ "\\b(DROP\\s+(TABLE|COLUMN|DATABASE|SCHEMA)|TRUNCATE|DELETE\\s+FROM|ALTER\\s+TABLE\\s+.*\\bDROP\\b)";
1575
+
1576
+ /**
1577
+ * Resolve the ui-review provision+boot run recipe from the merged config.
1578
+ *
1579
+ * Returns null when no `uiReview.run.command` is declared — the provision+boot
1580
+ * stage treats that as a stated stop reason (no app is ever guessed). Numeric
1581
+ * probe bounds fall back to sane defaults defensively: zod `.partial()` is
1582
+ * shallow (it does not drop nested numeric defaults), so a schema-validated
1583
+ * config already carries them — the fallback covers programmatically-built
1584
+ * config objects that bypass schema defaulting, not the `.partial()` path.
1585
+ *
1586
+ * @param {DevLoopConfig} config
1587
+ * @returns {null | { command: string, readyUrl: string, readyTimeoutMs: number,
1588
+ * readyIntervalMs: number, cwd: string|null,
1589
+ * migrate: null | { statusCommand: string, applyCommand: string, destructivePattern: string } }}
1590
+ */
1591
+ export function resolveUiReviewRunRecipe(config) {
1592
+ const run = config?.uiReview?.run;
1593
+ if (!run || typeof run.command !== "string" || run.command.trim().length === 0) return null;
1594
+ if (typeof run.readyUrl !== "string" || run.readyUrl.trim().length === 0) return null;
1595
+ const migrate = run.migrate
1596
+ ? {
1597
+ statusCommand: run.migrate.statusCommand,
1598
+ applyCommand: run.migrate.applyCommand,
1599
+ destructivePattern: run.migrate.destructivePattern ?? DEFAULT_DESTRUCTIVE_MIGRATION_PATTERN,
1600
+ }
1601
+ : null;
1602
+ return {
1603
+ command: run.command.trim(),
1604
+ readyUrl: run.readyUrl.trim(),
1605
+ readyTimeoutMs: Number.isInteger(run.readyTimeoutMs) ? run.readyTimeoutMs : 60000,
1606
+ readyIntervalMs: Number.isInteger(run.readyIntervalMs) ? run.readyIntervalMs : 1000,
1607
+ cwd: typeof run.cwd === "string" && run.cwd.trim().length > 0 ? run.cwd.trim() : null,
1608
+ migrate,
1609
+ };
1610
+ }
1611
+
1612
+ /**
1613
+ * Default server-log exception signal for the drive stage's log tail. Matched
1614
+ * (case-insensitive, per line) against the tailed server-log text. This is a
1615
+ * HEURISTIC default tuned for common framework logs (a 5xx status, an
1616
+ * uncaught/unhandled marker, an exception/traceback). A project whose log format
1617
+ * these miss MUST override `uiReview.serverLogExceptionPattern` to match its own
1618
+ * server log — the default cannot detect what its log never prints.
1619
+ */
1620
+ export const DEFAULT_SERVER_LOG_EXCEPTION_PATTERN =
1621
+ "\\b(5\\d{2}\\b|Internal Server Error|Unhandled|Uncaught|Traceback|Exception|FATAL|\\bERROR\\b)";
1622
+
1623
+ /**
1624
+ * Resolve the ui-review drive recipe (Stage 2) from the merged config.
1625
+ *
1626
+ * Returns null when no `uiReview.login` is declared — the drive stage treats
1627
+ * that as a stated stop reason (it cannot authenticate, so it drives nothing).
1628
+ * The server-log exception pattern falls back to the shipped heuristic default
1629
+ * when a `serverLogPath` is set without an explicit pattern.
1630
+ *
1631
+ * @param {DevLoopConfig} config
1632
+ * @returns {null | { login: object, interstitials: object[], flows: object[],
1633
+ * caps: object, serverLogPath: string|null, serverLogExceptionPattern: string }}
1634
+ */
1635
+ export function resolveUiReviewDriveRecipe(config) {
1636
+ const ui = config?.uiReview;
1637
+ const login = ui?.login;
1638
+ if (!login || typeof login.loginUrl !== "string" || login.loginUrl.trim().length === 0) return null;
1639
+ if (typeof login.submitSelector !== "string" || login.submitSelector.trim().length === 0) return null;
1640
+ if (typeof login.successSelector !== "string" || login.successSelector.trim().length === 0) return null;
1641
+ const serverLogPath = typeof ui.serverLogPath === "string" && ui.serverLogPath.trim().length > 0 ? ui.serverLogPath.trim() : null;
1642
+ return {
1643
+ login: {
1644
+ loginUrl: login.loginUrl.trim(),
1645
+ usernameSelector: login.usernameSelector ?? null,
1646
+ usernameValue: login.usernameValue ?? null,
1647
+ passwordSelector: login.passwordSelector ?? null,
1648
+ passwordValue: login.passwordValue ?? null,
1649
+ submitSelector: login.submitSelector.trim(),
1650
+ successSelector: login.successSelector.trim(),
1651
+ },
1652
+ interstitials: Array.isArray(ui.interstitials)
1653
+ ? ui.interstitials.map((i) => ({ selector: i.selector }))
1654
+ : [],
1655
+ flows: Array.isArray(ui.flows) ? ui.flows : [],
1656
+ caps: ui.caps ?? {},
1657
+ serverLogPath,
1658
+ serverLogExceptionPattern:
1659
+ typeof ui.serverLogExceptionPattern === "string" && ui.serverLogExceptionPattern.trim().length > 0
1660
+ ? ui.serverLogExceptionPattern.trim()
1661
+ : DEFAULT_SERVER_LOG_EXCEPTION_PATTERN,
1662
+ };
1663
+ }
1664
+
1388
1665
  /**
1389
1666
  * Resolve the human-handoff config from the merged dev-loop config (#920).
1390
1667
  *
@@ -396,7 +396,6 @@ personas:
396
396
  The PR body is the implementation contract — it must have:
397
397
  - A Summary section explaining what changed and why
398
398
  - A Scope and context section defining the boundary of the change
399
- - A File-by-file changes section listing every touched file and what changed in it
400
399
  - An Acceptance criteria section with the linked issue acceptance criteria
401
400
  - A Definition of done section
402
401
  - A Non-goals section
@@ -44,6 +44,13 @@ const STRATEGY_DEFAULT_STOP_RULES = Object.freeze({
44
44
  [INTERNAL_DEV_LOOP_STRATEGY.WAIT_WATCH]: ["merge"],
45
45
  [INTERNAL_DEV_LOOP_STRATEGY.FINAL_APPROVAL]: ["merge"],
46
46
  [INTERNAL_DEV_LOOP_STRATEGY.LOCAL_IMPLEMENTATION]: [],
47
+ [INTERNAL_DEV_LOOP_STRATEGY.UI_REVIEW]: [
48
+ "no-product-code-writes",
49
+ "worktree-only",
50
+ "outward-review-pending",
51
+ "ack-destructive-migrations",
52
+ "merge",
53
+ ],
47
54
  });
48
55
 
49
56
  // ---------------------------------------------------------------------------
@@ -134,6 +141,26 @@ register(INTERNAL_DEV_LOOP_STRATEGY.WAIT_WATCH, "default", {
134
141
  activeNoticeAfterMs: WATCH_ACTIVE_NOTICE_MS,
135
142
  });
136
143
 
144
+ // ui_review — running-app review sibling of reviewer/fixer. Scaffold slice:
145
+ // self-validation only, no drive/report/provision/boot logic. The criteria
146
+ // capture the route-specific review boundaries (no product-code writes,
147
+ // worktree isolation, outward review stays pending/draft, destructive
148
+ // migrations acknowledged before running) so the dispatched agent self-checks
149
+ // them; the generic finalization stop rules (e.g. merge) are layered on
150
+ // separately and are not restated here.
151
+ register(INTERNAL_DEV_LOOP_STRATEGY.UI_REVIEW, "default", {
152
+ criteria: [
153
+ { id: "no-product-code-writes", must: "No product code is written; the UI-review route only observes and reports on the running app.", severity: "required" },
154
+ { id: "worktree-only", must: "All work stays inside the isolated worktree; nothing is written outside it.", severity: "required" },
155
+ { id: "outward-review-pending", must: "Any outward review stays pending/draft; no approval or merge is emitted from the UI-review route.", severity: "required" },
156
+ { id: "ack-destructive-migrations", must: "Destructive migrations are explicitly acknowledged before they are run.", severity: "required" },
157
+ ],
158
+ evidence: ["commands-run", "validation-output"],
159
+ maxFinalizationTurns: 4,
160
+ needsAttentionAfterMs: DEFAULT_NEEDS_ATTENTION_MS,
161
+ activeNoticeAfterMs: DEFAULT_ACTIVE_NOTICE_MS,
162
+ });
163
+
137
164
  // Remaining strategies get a generic acceptance template
138
165
  function registerGeneric(strategy) {
139
166
  register(strategy, "default", {
@@ -29,6 +29,15 @@ export const REFINEMENT_SOURCE = Object.freeze({
29
29
 
30
30
  const REFINEMENT_ARTIFACT_FINDING = "missing_refinement_artifact";
31
31
 
32
+ // The three artifact sources, any ONE of which satisfies the refinement gate.
33
+ // Single source of truth for the "missing" vocabulary reported when none is
34
+ // present — consumed by the enqueue gate and the parked-unrefined discovery.
35
+ export const REFINEMENT_ARTIFACT_SOURCES = Object.freeze([
36
+ "Acceptance criteria section",
37
+ "Definition of done section",
38
+ "linked refinement doc",
39
+ ]);
40
+
32
41
  /**
33
42
  * Canonical list of section headings that satisfy the refinement check.
34
43
  * Matching is case-insensitive and tolerates trailing/leading whitespace.
@@ -524,11 +533,7 @@ export function decideEnqueueRefinementGate({ artifact, targetIsPickup, auto = f
524
533
  if (!targetIsPickup || artifact.finding === null) {
525
534
  return { action: "enqueue" };
526
535
  }
527
- const missing = [
528
- "Acceptance criteria section",
529
- "Definition of done section",
530
- "linked refinement doc",
531
- ];
536
+ const missing = [...REFINEMENT_ARTIFACT_SOURCES];
532
537
  const reason =
533
538
  `Issue has no refinement artifact (none of: ${missing.join(", ")}). ` +
534
539
  "Add at least ONE of them — an Acceptance criteria section, a Definition of done section, or a linked refinement doc " +
@@ -23,6 +23,7 @@ export const DEV_LOOP_PUBLIC_INTENT = Object.freeze({
23
23
  CONTINUE_CURRENT: "continue_current",
24
24
  AUTO_CONTINUE_CURRENT: "auto_continue_current",
25
25
  INSPECT_STATE: "inspect_state",
26
+ REVIEW_PR_UI: "review_pr_ui",
26
27
  });
27
28
 
28
29
  export const DEV_LOOP_TARGET_KIND = Object.freeze({
@@ -76,6 +77,7 @@ export const DEV_LOOP_GATE = Object.freeze({
76
77
  EXTERNAL_PR_FOLLOWUP: "external_pr_followup",
77
78
  REVIEWER_FIXER: "reviewer_fixer",
78
79
  COPILOT_PR_FOLLOWUP: "copilot_pr_followup",
80
+ UI_REVIEW: "ui_review",
79
81
  FAIL_CLOSED_RECONCILE: "fail_closed_reconcile",
80
82
  });
81
83
 
@@ -87,6 +89,7 @@ export const INTERNAL_DEV_LOOP_STRATEGY = Object.freeze({
87
89
  REVIEWER_FIXER: "reviewer_fixer",
88
90
  WAIT_WATCH: "wait_watch",
89
91
  FINAL_APPROVAL: "final_approval",
92
+ UI_REVIEW: "ui_review",
90
93
  NONE: null,
91
94
  });
92
95
 
@@ -267,6 +270,12 @@ export const PUBLIC_DEV_LOOP_GATE_CONTRACT = Object.freeze([
267
270
  selectedStrategy: INTERNAL_DEV_LOOP_STRATEGY.COPILOT_PR_FOLLOWUP,
268
271
  summary: "Copilot-owned PR state routes to Copilot PR follow-up; an already-linked open PR stays the canonical artifact for that issue until reconciled",
269
272
  }),
273
+ Object.freeze({
274
+ gate: DEV_LOOP_GATE.UI_REVIEW,
275
+ routeKind: DEV_LOOP_ROUTE_KIND.ROUTE,
276
+ selectedStrategy: INTERNAL_DEV_LOOP_STRATEGY.UI_REVIEW,
277
+ summary: "an explicit UI-review request on a PR target routes to the ui_review running-app review strategy",
278
+ }),
270
279
  Object.freeze({
271
280
  gate: DEV_LOOP_GATE.FAIL_CLOSED_RECONCILE,
272
281
  routeKind: DEV_LOOP_ROUTE_KIND.NEEDS_RECONCILE,
@@ -461,7 +461,7 @@ function toRoutableCanonicalState(canonicalState) {
461
461
  };
462
462
  }
463
463
 
464
- function selectGateForState(canonicalState) {
464
+ function selectGateForState(canonicalState, { uiReviewRequested = false } = {}) {
465
465
  if (canonicalState.status === DEV_LOOP_STATUS.BLOCKED || canonicalState.authorization === DEV_LOOP_AUTHORIZATION.NOT_AUTHORIZED) {
466
466
  return DEV_LOOP_GATE.STOP_BLOCKED_OR_NOT_AUTHORIZED;
467
467
  }
@@ -499,6 +499,15 @@ function selectGateForState(canonicalState) {
499
499
  return DEV_LOOP_GATE.ISSUE_INTAKE;
500
500
  }
501
501
 
502
+ // An explicit UI-review request intercepts a PR target ahead of the
503
+ // ownership-derived PR gates: the running-app review is requested regardless
504
+ // of who owns the PR. It stays after the authoritative lifecycle stop/terminal/
505
+ // approval/waiting gates so it can never bypass them. Absent the signal this
506
+ // branch is inert, so existing routes stay byte-identical.
507
+ if (uiReviewRequested && canonicalState.target.kind === DEV_LOOP_TARGET_KIND.PR) {
508
+ return DEV_LOOP_GATE.UI_REVIEW;
509
+ }
510
+
502
511
  if (canonicalState.target.kind === DEV_LOOP_TARGET_KIND.PR && canonicalState.ownership === DEV_LOOP_ACTOR.EXTERNAL_HUMAN) {
503
512
  return DEV_LOOP_GATE.EXTERNAL_PR_FOLLOWUP;
504
513
  }
@@ -581,10 +590,11 @@ function routeForState(
581
590
  issueAssignmentState = null,
582
591
  gateReviewEvidence = null,
583
592
  targetPreference = null,
593
+ uiReviewRequested = false,
584
594
  } = {},
585
595
  ) {
586
596
  const routableCanonicalState = toRoutableCanonicalState(canonicalState);
587
- const selectedGate = selectGateForState(routableCanonicalState);
597
+ const selectedGate = selectGateForState(routableCanonicalState, { uiReviewRequested });
588
598
  if (
589
599
  selectedGate === DEV_LOOP_GATE.FINAL_APPROVAL
590
600
  && routableCanonicalState.target.kind === DEV_LOOP_TARGET_KIND.PR
@@ -763,6 +773,18 @@ function routeForState(
763
773
  });
764
774
  }
765
775
 
776
+ if (selectedGate === DEV_LOOP_GATE.UI_REVIEW) {
777
+ return buildResult({
778
+ selectedGate,
779
+ routeKind: DEV_LOOP_ROUTE_KIND.ROUTE,
780
+ selectedStrategy: INTERNAL_DEV_LOOP_STRATEGY.UI_REVIEW,
781
+ executionMode,
782
+ canonicalState: routableCanonicalState,
783
+ nextAction: "Run the UI-review route for the current PR: prove the change in the running app from an isolated worktree. Do not write product code; keep any outward review pending/draft; acknowledge destructive migrations before running them.",
784
+ reason: "An explicit UI-review request on a PR target routes to the ui_review strategy — the running-app review sibling of the reviewer/fixer route.",
785
+ });
786
+ }
787
+
766
788
  return buildReconcile(
767
789
  "The canonical current state does not map cleanly to any first-slice internal strategy.",
768
790
  routableCanonicalState,
@@ -1171,6 +1193,7 @@ export function resolveAuthoritativeStartupResumeBundle(input = {}) {
1171
1193
  issueAssignmentState,
1172
1194
  gateReviewEvidence,
1173
1195
  targetPreference,
1196
+ uiReviewRequested: intent === DEV_LOOP_PUBLIC_INTENT.REVIEW_PR_UI,
1174
1197
  });
1175
1198
  if (routed.routeKind === DEV_LOOP_ROUTE_KIND.NEEDS_RECONCILE) {
1176
1199
  return buildStartupResumeBundleReconcile({
@@ -1703,6 +1726,23 @@ export function evaluatePublicDevLoopRouting(input = {}) {
1703
1726
  ));
1704
1727
  }
1705
1728
 
1729
+ if (intent === DEV_LOOP_PUBLIC_INTENT.REVIEW_PR_UI) {
1730
+ if (!explicitTarget || explicitTarget.kind !== DEV_LOOP_TARGET_KIND.PR) {
1731
+ return buildInputReconcile("`review_pr_ui` requires a PR target.", null, effectiveMode);
1732
+ }
1733
+ if (!explicitState || explicitState.target.kind !== DEV_LOOP_TARGET_KIND.PR) {
1734
+ return buildInputReconcile("`review_pr_ui` requires a valid canonical PR state.", explicitState, effectiveMode);
1735
+ }
1736
+ if (explicitState.target.pr !== explicitTarget.pr) {
1737
+ return buildInputReconcile("`review_pr_ui` target conflicts with the canonical current PR state.", explicitState, effectiveMode);
1738
+ }
1739
+
1740
+ return finalizeRoutingResult(applyWatchValidation(
1741
+ routeForState(explicitState, { ...routingOptions, executionMode: effectiveMode, uiReviewRequested: true }),
1742
+ watchRequested,
1743
+ ));
1744
+ }
1745
+
1706
1746
  if (intent === DEV_LOOP_PUBLIC_INTENT.CONTINUE_CURRENT) {
1707
1747
  if (!explicitState) {
1708
1748
  return buildInputReconcile("`continue_current` requires a valid canonical current state.", null, effectiveMode);