@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 +6 -1
- package/src/claude/asset-generation.mjs +23 -1
- package/src/config/config.mjs +277 -0
- package/src/config/extension-defaults.yaml +0 -1
- package/src/loop/handoff-envelope.mjs +27 -0
- package/src/loop/issue-refinement-artifact.mjs +10 -5
- package/src/loop/public-dev-loop-routing-contract.mjs +9 -0
- package/src/loop/public-dev-loop-routing.mjs +42 -2
- package/src/loop/ui-review-diagnose.mjs +291 -0
- package/src/loop/ui-review-drive.mjs +348 -0
- package/src/loop/ui-review-provision.mjs +264 -0
- package/src/loop/ui-review-report.mjs +287 -0
- package/src/loop/ui-review-teardown.mjs +250 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "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) {
|
package/src/config/config.mjs
CHANGED
|
@@ -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);
|