@dev-loops/core 0.8.0 → 1.0.0-rc.1

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.
@@ -20,11 +20,78 @@ const InputSourceConfig = z.strictObject({
20
20
  default: z.enum(["tracker", "phase-docs"]),
21
21
  });
22
22
 
23
- const ModelsConfig = z.strictObject({
23
+ // Built-in tier aliases shipped with zero config. A tier alias maps a
24
+ // harness-neutral name (low/high) to a concrete per-harness model id; `null`
25
+ // means "inherit" (pass no model override → genuine no-op on that harness).
26
+ // Pi ships null on every built-in tier, so zero-config resolution is a no-op on
27
+ // Pi until an operator sets concrete Pi ids.
28
+ export const BUILTIN_TIER_ALIASES = Object.freeze(["low", "high"]);
29
+
30
+ const BUILTIN_TIERS = Object.freeze({
31
+ low: Object.freeze({ claude: "sonnet", pi: null }),
32
+ high: Object.freeze({ claude: "opus", pi: null }),
33
+ });
34
+
35
+ // Built-in role→tier policy: routine subagents run on the low tier, planning
36
+ // (refiner) and critical review (review, incl. gate fan-out angles via their
37
+ // review persona) run high, and the conductor (dev-loop) inherits (no override).
38
+ const BUILTIN_ROLE_TIERS = Object.freeze({
39
+ developer: "low",
40
+ docs: "low",
41
+ fixer: "low",
42
+ quality: "low",
43
+ refiner: "high",
44
+ review: "high",
45
+ "dev-loop": "inherit",
46
+ });
47
+
48
+ // A tier alias's per-harness concrete model. Either harness may be a concrete
49
+ // model id or `null` (inherit / no-op on that harness). strictObject rejects
50
+ // unknown harness keys.
51
+ const ModelTierMapping = z
52
+ .strictObject({
53
+ claude: z.string().trim().min(1).nullable().optional(),
54
+ pi: z.string().trim().min(1).nullable().optional(),
55
+ })
56
+ // A tier mapping with both harnesses absent/null resolves to a null no-op on
57
+ // every harness — a silent dead alias that roleTiers could reference. Require
58
+ // at least one concrete harness model so an empty/all-null tier fails closed.
59
+ .refine((m) => typeof m.claude === "string" || typeof m.pi === "string", {
60
+ message: "tier mapping must set at least one of claude/pi to a non-null model id",
61
+ });
62
+
63
+ /**
64
+ * Reject `models.roleTiers` entries that reference a tier alias which is neither
65
+ * a built-in alias (low/high), the literal "inherit", nor defined in this
66
+ * config's own `models.tiers`. Applied to both the merged and file-level
67
+ * ModelsConfig so a typo'd alias fails closed with a clear message.
68
+ * @param {Record<string, unknown>|undefined} models
69
+ * @param {z.RefinementCtx} ctx
70
+ */
71
+ function refineRoleTiers(models, ctx) {
72
+ const known = new Set([...BUILTIN_TIER_ALIASES, ...Object.keys(models?.tiers ?? {})]);
73
+ for (const [role, tier] of Object.entries(models?.roleTiers ?? {})) {
74
+ if (tier !== "inherit" && !known.has(tier)) {
75
+ ctx.addIssue({
76
+ code: z.ZodIssueCode.custom,
77
+ path: ["roleTiers", role],
78
+ message: `unknown model tier alias "${tier}" — define it under models.tiers, use a built-in alias (${BUILTIN_TIER_ALIASES.join(", ")}), or "inherit"`,
79
+ });
80
+ }
81
+ }
82
+ }
83
+
84
+ const ModelsConfigBase = z.strictObject({
24
85
  conductor: z.string().trim().min(1).optional(),
25
86
  roles: z.record(z.string(), z.string().trim().min(1)).optional(),
87
+ // Tier alias → per-harness concrete model (null = inherit / no-op).
88
+ tiers: z.record(z.string().min(1), ModelTierMapping).optional(),
89
+ // Role / angle → tier alias (a built-in/custom alias or "inherit").
90
+ roleTiers: z.record(z.string().min(1), z.string().trim().min(1)).optional(),
26
91
  });
27
92
 
93
+ const ModelsConfig = ModelsConfigBase.superRefine(refineRoleTiers);
94
+
28
95
  const RefinementConfig = z.strictObject({
29
96
  fanOut: z.number().int().min(1).max(10),
30
97
  mode: z.enum(["parallel", "sequential"]),
@@ -57,8 +124,11 @@ const GateConfig = z.strictObject({
57
124
 
58
125
  const GatesConfig = z.strictObject({
59
126
  draft: GateConfig.optional(),
60
- // `requireCi` is only behaviorally configurable for the draft gate.
61
- // preApproval always requires CI even if config repeats `requireCi`.
127
+ // `requireCi` is honored on both gates: default true keeps CI a precondition,
128
+ // false is an opt-out escape hatch so a repo with no CI is not held at the
129
+ // gate. The pre-approval gate mirrors the draft gate's `requireCi` semantics —
130
+ // when false the CI verdict is ignored entirely at that boundary, including a
131
+ // real failure (not merely "green optional").
62
132
  preApproval: GateConfig.optional(),
63
133
  // Relaxed spike gate profile (#965). A spike's deliverable is a findings doc,
64
134
  // not production code, so it should not carry the full draft → pre-approval →
@@ -178,6 +248,198 @@ const WorktreeConfig = z.strictObject({
178
248
  linkOnInit: z.array(z.string().trim().min(1)).optional(),
179
249
  });
180
250
 
251
+ /**
252
+ * Dev-DB migration sub-recipe for the ui-review run recipe. `statusCommand`
253
+ * lists pending migrations (one per line); `applyCommand` applies them.
254
+ *
255
+ * Destructive detection is EXPLICIT and status-format-dependent: the
256
+ * `destructivePattern` regex is matched (case-insensitive, per line) against the
257
+ * STATUS OUTPUT — not against the migration files. The shipped default
258
+ * (DEFAULT_DESTRUCTIVE_MIGRATION_PATTERN) assumes SQL-bearing status output
259
+ * (DROP/TRUNCATE/DELETE FROM ...); against a status command that emits migration
260
+ * identifiers or filenames instead, it matches nothing and the destructive guard
261
+ * is inert. A project whose status output is NOT SQL therefore MUST set a
262
+ * `destructivePattern` that matches its own status format (e.g. a `destructive`/
263
+ * `down` marker), or make `statusCommand` emit the destructive SQL/marker — the
264
+ * default cannot detect what its status output never prints.
265
+ */
266
+ const UiReviewMigrateConfig = z.strictObject({
267
+ statusCommand: z.string().trim().min(1),
268
+ applyCommand: z.string().trim().min(1),
269
+ destructivePattern: z
270
+ .string()
271
+ .trim()
272
+ .min(1)
273
+ .refine((p) => {
274
+ try {
275
+ // Validate under the exact flags the runtime compile uses at the
276
+ // destructive-migration safety boundary (inspectMigrations), so a
277
+ // pattern valid bare but invalid under `u` is rejected at load time.
278
+ new RegExp(p, "iu");
279
+ return true;
280
+ } catch {
281
+ return false;
282
+ }
283
+ }, "destructivePattern must be a valid regex")
284
+ .optional(),
285
+ });
286
+
287
+ /**
288
+ * Per-project dev-DB row-teardown recipe (Stage 5). The drive stamps each
289
+ * mutating step it drives with a drive-session id (advertised to the app on the
290
+ * DRIVE_SESSION_HEADER request header); this `deleteCommand` deletes exactly the
291
+ * rows the app tagged with that session — the id is passed in the
292
+ * UI_REVIEW_DRIVE_SESSION env var and the command runs in the provisioned
293
+ * worktree (dev DB only). Teardown runs it only on explicit confirmation.
294
+ */
295
+ const UiReviewRowTeardownConfig = z.strictObject({
296
+ deleteCommand: z.string().trim().min(1),
297
+ });
298
+
299
+ /**
300
+ * Per-project boot recipe: a shell `command` that starts the branch's app and a
301
+ * `readyUrl` an HTTP readiness probe polls until the app is up (never a fixed
302
+ * sleep). No app is hard-coded — a project declares its own recipe. `cwd` is an
303
+ * optional worktree-relative subdir to run in.
304
+ */
305
+ const UiReviewRunConfig = z.strictObject({
306
+ command: z.string().trim().min(1),
307
+ readyUrl: z
308
+ .string()
309
+ .trim()
310
+ .url()
311
+ .refine((u) => {
312
+ try {
313
+ const p = new URL(u).protocol;
314
+ return p === "http:" || p === "https:";
315
+ } catch {
316
+ return false;
317
+ }
318
+ }, "readyUrl must be an http(s) URL"),
319
+ readyTimeoutMs: z.number().int().min(1).max(600000).default(60000),
320
+ readyIntervalMs: z.number().int().min(1).max(60000).default(1000),
321
+ cwd: z.string().trim().min(1).optional(),
322
+ migrate: UiReviewMigrateConfig.optional(),
323
+ rowTeardown: UiReviewRowTeardownConfig.optional(),
324
+ });
325
+
326
+ /**
327
+ * Per-project dev-login recipe (Stage 2). The drive stage obtains a session for
328
+ * the change's target role by driving this login form in the browser. Nothing
329
+ * is hard-coded here — a project declares its own login URL, field selectors,
330
+ * and the shared dev credential (never a real user secret; a dev-only password
331
+ * or role). `successSelector` is what proves the session was established;
332
+ * without it the drive stage cannot confirm auth and fails closed.
333
+ */
334
+ const UiReviewLoginConfig = z.strictObject({
335
+ loginUrl: z
336
+ .string()
337
+ .trim()
338
+ .url()
339
+ .refine((u) => {
340
+ try {
341
+ const p = new URL(u).protocol;
342
+ return p === "http:" || p === "https:";
343
+ } catch {
344
+ return false;
345
+ }
346
+ }, "loginUrl must be an http(s) URL"),
347
+ usernameSelector: z.string().trim().min(1).optional(),
348
+ usernameValue: z.string().min(1).optional(),
349
+ passwordSelector: z.string().trim().min(1).optional(),
350
+ passwordValue: z.string().min(1).optional(),
351
+ submitSelector: z.string().trim().min(1),
352
+ successSelector: z.string().trim().min(1),
353
+ });
354
+
355
+ /** A config-declared interstitial (cookie consent etc.) dismissed ONCE per
356
+ * browser context. */
357
+ const UiReviewInterstitialConfig = z.strictObject({
358
+ selector: z.string().trim().min(1),
359
+ });
360
+
361
+ /** One driven step. The action set is deliberately small and maps 1:1 to a
362
+ * Playwright page call in the harness — enough to render a page and exercise the
363
+ * create/edit/reorder/upload/toggle interactions plus dispatch a real event. */
364
+ const UiReviewFlowStepConfig = z.strictObject({
365
+ name: z.string().trim().min(1).optional(),
366
+ action: z.enum(["goto", "click", "fill", "select", "upload", "dispatch"]),
367
+ selector: z.string().trim().min(1).optional(),
368
+ path: z.string().trim().min(1).optional(),
369
+ value: z.string().optional(),
370
+ event: z.string().trim().min(1).optional(),
371
+ // Responsive/stateful captures: a declared viewport resizes the page before the
372
+ // step and bakes into the named-state slug, so the mobile vs desktop (or
373
+ // default vs error) render lands in a distinct reviewable directory. The route
374
+ // NAMES its interaction states — the drive never enumerates them itself.
375
+ viewport: z.strictObject({ width: z.number().int().positive(), height: z.number().int().positive() }).optional(),
376
+ interactionState: z.enum(["none", "focus", "hover", "error"]).optional(),
377
+ }).superRefine((step, ctx) => {
378
+ // Every action but `goto` targets an element, so a missing selector is a
379
+ // config error, not a runtime step-failure. (`goto` uses `path`/url.)
380
+ if (step.action !== "goto" && (step.selector == null || step.selector.trim().length === 0)) {
381
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["selector"], message: `step action "${step.action}" requires a selector` });
382
+ }
383
+ // Action-specific required fields. Rejecting these at parse time turns a silent
384
+ // wrong drive into a clear config error: a missing `goto.path` would drive "/",
385
+ // and a missing `upload.value` becomes setInputFiles(sel, "") which throws mid
386
+ // walk as a step-failure rather than a config problem.
387
+ if (step.action === "goto" && (step.path == null || step.path.trim().length === 0)) {
388
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["path"], message: `step action "goto" requires a path` });
389
+ }
390
+ if (step.action === "upload" && (step.value == null || step.value.trim().length === 0)) {
391
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ["value"], message: `step action "upload" requires a value (the file path to upload)` });
392
+ }
393
+ });
394
+
395
+ /** An allowlisted changed flow. `pathPatterns` are plain substrings matched
396
+ * against the PR's changed file paths to decide whether the flow is in scope
397
+ * (the bounded changed-flow heuristic); a flow with none is always driven. */
398
+ const UiReviewFlowConfig = z.strictObject({
399
+ name: z.string().trim().min(1),
400
+ pathPatterns: z.array(z.string().trim().min(1)).optional(),
401
+ steps: z.array(UiReviewFlowStepConfig).min(1),
402
+ });
403
+
404
+ /** Bounded drive caps (Stage 2). Every field is optional and clamped to a
405
+ * ceiling at resolve time — a project may only tighten a cap, never loosen it. */
406
+ const UiReviewCapsConfig = z.strictObject({
407
+ maxScreenshots: z.number().int().min(1).optional(),
408
+ maxFlows: z.number().int().min(1).optional(),
409
+ maxStepsPerFlow: z.number().int().min(1).optional(),
410
+ });
411
+
412
+ /**
413
+ * UI-review route config: the generic, per-project provision+boot recipe (Stage
414
+ * 1) plus the drive recipe (Stage 2: login, interstitials, changed-flow
415
+ * allowlist, and an optional server-log path/pattern for tailing). Absent (the
416
+ * default) means no recipe is declared — the corresponding stage stops with that
417
+ * as a stated reason rather than guessing how to run or drive the app.
418
+ */
419
+ const UiReviewConfig = z.strictObject({
420
+ run: UiReviewRunConfig.optional(),
421
+ login: UiReviewLoginConfig.optional(),
422
+ interstitials: z.array(UiReviewInterstitialConfig).optional(),
423
+ flows: z.array(UiReviewFlowConfig).optional(),
424
+ caps: UiReviewCapsConfig.optional(),
425
+ // Filesystem path (worktree-relative or absolute) to the project's server log.
426
+ // The drive stage tails it so a swallowed 500 the UI hid is still recorded.
427
+ serverLogPath: z.string().trim().min(1).optional(),
428
+ serverLogExceptionPattern: z
429
+ .string()
430
+ .trim()
431
+ .min(1)
432
+ .refine((p) => {
433
+ try {
434
+ new RegExp(p, "iu");
435
+ return true;
436
+ } catch {
437
+ return false;
438
+ }
439
+ }, "serverLogExceptionPattern must be a valid regex")
440
+ .optional(),
441
+ });
442
+
181
443
  /** Internal path whitelist for internal-only PR detection — flat array of regex strings */
182
444
  const InternalPatternsConfig = z.array(z.string().trim().min(1)).min(1);
183
445
 
@@ -232,6 +494,7 @@ export const DevLoopConfigSchema = z.strictObject({
232
494
  personas: PersonasConfig.optional(),
233
495
  internalPathPatterns: InternalPatternsConfig.optional(),
234
496
  worktree: WorktreeConfig.optional(),
497
+ uiReview: UiReviewConfig.optional(),
235
498
  // Deprecated (removed in #1088): tolerated so consumer .devloops files that
236
499
  // still carry a localPlanning block keep parsing. Accepted, never read.
237
500
  localPlanning: z.unknown().optional(),
@@ -293,7 +556,7 @@ export const FileConfigSchema = z.strictObject({
293
556
  version: z.literal(1),
294
557
  strategy: StrategyConfig.partial().optional(),
295
558
  inputSource: InputSourceConfig.partial().optional(),
296
- models: ModelsConfig.partial().optional(),
559
+ models: ModelsConfigBase.partial().superRefine(refineRoleTiers).optional(),
297
560
  refinement: RefinementConfig.partial().optional(),
298
561
  gates: FileGatesConfig.optional(),
299
562
  autonomy: AutonomyConfig.partial().optional(),
@@ -304,6 +567,7 @@ export const FileConfigSchema = z.strictObject({
304
567
  personas: FilePersonasConfig.optional(),
305
568
  internalPathPatterns: InternalPatternsConfig.optional(),
306
569
  worktree: WorktreeConfig.partial().optional(),
570
+ uiReview: UiReviewConfig.partial().optional(),
307
571
  // Deprecated (removed in #1088): tolerated so consumer .devloops files that
308
572
  // still carry a localPlanning block keep parsing. Accepted, never read.
309
573
  localPlanning: z.unknown().optional(),
@@ -341,6 +605,7 @@ const BUILTIN_PERSONAS = Object.freeze({
341
605
  yagni: { persona: "review", defaultModel: null },
342
606
  "contract-surface": { persona: "review", defaultModel: null },
343
607
  "input-validation": { persona: "review", defaultModel: null },
608
+ "threat-model": { persona: "review", defaultModel: null },
344
609
  "packaging-runtime": { persona: "review", defaultModel: null },
345
610
  "state-concurrency": { persona: "review", defaultModel: null },
346
611
  "renderer-security": { persona: "review", defaultModel: null },
@@ -412,6 +677,76 @@ export function resolveReviewerRole(config, angle) {
412
677
  };
413
678
  }
414
679
 
680
+ /**
681
+ * Resolve the concrete model for a subagent role/angle on a given harness, or
682
+ * `null` (inherit → pass no model override).
683
+ *
684
+ * Precedence:
685
+ * 1. `models.roles[role]` — concrete per-role/angle override (highest).
686
+ * 2. Tier alias, mapped through `models.tiers[tier][harness]` (or built-in
687
+ * tiers); `inherit`/absent/null → `null`. The alias depends on `kind`:
688
+ * - `kind: "angle"` (gate review dispatch): an explicit
689
+ * `models.roleTiers[role]` override, else the `review` tier. A gate
690
+ * review runs at review quality even when the angle's name collides with
691
+ * a routine role — e.g. the `docs` angle resolves via the `review` tier
692
+ * (high), not the `docs` writer role's low tier. (Its persona/agent still
693
+ * comes from `resolveReviewerRole`; only the tier is forced to review.)
694
+ * - `kind: "role"`/absent (routine subagent): `models.roleTiers[role]` (or
695
+ * the built-in role tier), else — when the name is not a named role — the
696
+ * tier for its review persona (so a non-colliding gate angle passed
697
+ * without `kind` still resolves high via `review`).
698
+ *
699
+ * Callers dispatching a gate review angle whose name may collide with a routine
700
+ * role (only `docs` today) MUST pass `kind: "angle"` to avoid the silent
701
+ * downgrade; role dispatch leaves `kind` unset.
702
+ *
703
+ * Zero-config is a genuine no-op on Pi (built-in tiers are null for pi) and
704
+ * reproduces the standing policy on Claude (routine=low, refiner/review=high,
705
+ * dev-loop=inherit).
706
+ *
707
+ * @param {DevLoopConfig} config
708
+ * @param {{ role: string, harness: "claude"|"pi", kind?: "role"|"angle" }} params
709
+ * @returns {string|null}
710
+ */
711
+ export function resolveRoleModel(config, { role, harness, kind } = {}) {
712
+ if (!role || (harness !== "claude" && harness !== "pi")) return null;
713
+
714
+ // 1. Concrete per-role/angle override wins outright (over any tier).
715
+ const concrete = config?.models?.roles?.[role];
716
+ if (typeof concrete === "string" && concrete.trim().length > 0) {
717
+ return concrete.trim();
718
+ }
719
+
720
+ // 2. Resolve a tier alias for this role/angle.
721
+ const roleTiers = { ...BUILTIN_ROLE_TIERS, ...(config?.models?.roleTiers ?? {}) };
722
+ let tierAlias;
723
+ if (kind === "angle") {
724
+ // Gate review angle: an explicit per-angle override wins, else the review
725
+ // tier — a gate review is review-quality regardless of a coincidental
726
+ // routine-role persona name (the `docs` angle must not inherit `docs`→low).
727
+ tierAlias = config?.models?.roleTiers?.[role] ?? roleTiers.review;
728
+ } else {
729
+ tierAlias = roleTiers[role];
730
+ if (tierAlias === undefined) {
731
+ // Not a named role — treat as a gate angle and inherit its review
732
+ // persona's tier (critical angles resolve high via the `review` persona).
733
+ const { persona } = resolveReviewerRole(config, role);
734
+ tierAlias = roleTiers[persona];
735
+ }
736
+ }
737
+ if (!tierAlias || tierAlias === "inherit") return null;
738
+
739
+ // Deep-merge the alias mapping so a partial override (e.g. `{ pi: "..." }`,
740
+ // which the schema allows) preserves the untouched built-in harness key rather
741
+ // than erasing the whole {claude,pi} mapping and resolving null for that harness.
742
+ const builtinMapping = BUILTIN_TIERS[tierAlias];
743
+ const configMapping = config?.models?.tiers?.[tierAlias];
744
+ if (!builtinMapping && !configMapping) return null;
745
+ const mapping = { ...builtinMapping, ...configMapping };
746
+ const model = mapping[harness];
747
+ return typeof model === "string" && model.trim().length > 0 ? model.trim() : null;
748
+ }
749
+
415
750
  // ============================================================================
416
751
  // Error types
417
752
  // ============================================================================
@@ -952,7 +1287,13 @@ export function resolveRefinement(config) {
952
1287
  const stopOnLowSignal = /** @type {boolean} */ (resolveRefinementConfig(config, "stopOnLowSignal"));
953
1288
  const lowSignalRoundThreshold = /** @type {number} */ (resolveRefinementConfig(config, "lowSignalRoundThreshold"));
954
1289
  const lowSignalMaxComments = /** @type {number} */ (resolveRefinementConfig(config, "lowSignalMaxComments"));
955
- return { fanOut, mode, roles, maxCopilotRounds, stopOnLowSignal, lowSignalRoundThreshold, lowSignalMaxComments };
1290
+ // #1337: centralize the pre-approval CI opt-out here so every caller that
1291
+ // builds its interpreter refinement config from `resolveRefinement(config)`
1292
+ // (detect-copilot-loop-state, copilot-pr-handoff, gate coordination, etc.)
1293
+ // reliably honors `gates.preApproval.requireCi: false` — otherwise a CI-less
1294
+ // repo would still be interpreted as waiting_for_ci / blocked in those tools.
1295
+ const preApprovalRequireCi = resolveGateConfig(config, "preApproval").requireCi;
1296
+ return { fanOut, mode, roles, maxCopilotRounds, stopOnLowSignal, lowSignalRoundThreshold, lowSignalMaxComments, preApprovalRequireCi };
956
1297
  }
957
1298
 
958
1299
  /**
@@ -1385,6 +1726,114 @@ export function resolveWorktreeConfig(config) {
1385
1726
  return { copyOnInit: list(wt?.copyOnInit), linkOnInit: list(wt?.linkOnInit) };
1386
1727
  }
1387
1728
 
1729
+ /**
1730
+ * Default destructive-migration signal: SQL statements that drop or wipe data.
1731
+ * Matched (case-insensitive, per line) against the migration STATUS OUTPUT. This
1732
+ * default only detects destructive intent when the status output is itself
1733
+ * SQL-bearing; against status output that lists migration identifiers/filenames
1734
+ * it matches nothing and the guard is inert (no false positives, but also no
1735
+ * protection). Such a project MUST override via
1736
+ * `uiReview.run.migrate.destructivePattern` to match its own status format (or
1737
+ * emit the destructive SQL/marker from `statusCommand`).
1738
+ */
1739
+ export const DEFAULT_DESTRUCTIVE_MIGRATION_PATTERN =
1740
+ "\\b(DROP\\s+(TABLE|COLUMN|DATABASE|SCHEMA)|TRUNCATE|DELETE\\s+FROM|ALTER\\s+TABLE\\s+.*\\bDROP\\b)";
1741
+
1742
+ /**
1743
+ * Resolve the ui-review provision+boot run recipe from the merged config.
1744
+ *
1745
+ * Returns null when no `uiReview.run.command` is declared — the provision+boot
1746
+ * stage treats that as a stated stop reason (no app is ever guessed). Numeric
1747
+ * probe bounds fall back to sane defaults defensively: zod `.partial()` is
1748
+ * shallow (it does not drop nested numeric defaults), so a schema-validated
1749
+ * config already carries them — the fallback covers programmatically-built
1750
+ * config objects that bypass schema defaulting, not the `.partial()` path.
1751
+ *
1752
+ * @param {DevLoopConfig} config
1753
+ * @returns {null | { command: string, readyUrl: string, readyTimeoutMs: number,
1754
+ * readyIntervalMs: number, cwd: string|null,
1755
+ * migrate: null | { statusCommand: string, applyCommand: string, destructivePattern: string },
1756
+ * rowTeardown: null | { deleteCommand: string } }}
1757
+ */
1758
+ export function resolveUiReviewRunRecipe(config) {
1759
+ const run = config?.uiReview?.run;
1760
+ if (!run || typeof run.command !== "string" || run.command.trim().length === 0) return null;
1761
+ if (typeof run.readyUrl !== "string" || run.readyUrl.trim().length === 0) return null;
1762
+ const migrate = run.migrate
1763
+ ? {
1764
+ statusCommand: run.migrate.statusCommand,
1765
+ applyCommand: run.migrate.applyCommand,
1766
+ destructivePattern: run.migrate.destructivePattern ?? DEFAULT_DESTRUCTIVE_MIGRATION_PATTERN,
1767
+ }
1768
+ : null;
1769
+ const rowTeardown =
1770
+ run.rowTeardown && typeof run.rowTeardown.deleteCommand === "string" && run.rowTeardown.deleteCommand.trim().length > 0
1771
+ ? { deleteCommand: run.rowTeardown.deleteCommand.trim() }
1772
+ : null;
1773
+ return {
1774
+ command: run.command.trim(),
1775
+ readyUrl: run.readyUrl.trim(),
1776
+ readyTimeoutMs: Number.isInteger(run.readyTimeoutMs) ? run.readyTimeoutMs : 60000,
1777
+ readyIntervalMs: Number.isInteger(run.readyIntervalMs) ? run.readyIntervalMs : 1000,
1778
+ cwd: typeof run.cwd === "string" && run.cwd.trim().length > 0 ? run.cwd.trim() : null,
1779
+ migrate,
1780
+ rowTeardown,
1781
+ };
1782
+ }
1783
+
1784
+ /**
1785
+ * Default server-log exception signal for the drive stage's log tail. Matched
1786
+ * (case-insensitive, per line) against the tailed server-log text. This is a
1787
+ * HEURISTIC default tuned for common framework logs (a 5xx status, an
1788
+ * uncaught/unhandled marker, an exception/traceback). A project whose log format
1789
+ * these miss MUST override `uiReview.serverLogExceptionPattern` to match its own
1790
+ * server log — the default cannot detect what its log never prints.
1791
+ */
1792
+ export const DEFAULT_SERVER_LOG_EXCEPTION_PATTERN =
1793
+ "\\b(5\\d{2}\\b|Internal Server Error|Unhandled|Uncaught|Traceback|Exception|FATAL|\\bERROR\\b)";
1794
+
1795
+ /**
1796
+ * Resolve the ui-review drive recipe (Stage 2) from the merged config.
1797
+ *
1798
+ * Returns null when no `uiReview.login` is declared — the drive stage treats
1799
+ * that as a stated stop reason (it cannot authenticate, so it drives nothing).
1800
+ * The server-log exception pattern falls back to the shipped heuristic default
1801
+ * when a `serverLogPath` is set without an explicit pattern.
1802
+ *
1803
+ * @param {DevLoopConfig} config
1804
+ * @returns {null | { login: object, interstitials: object[], flows: object[],
1805
+ * caps: object, serverLogPath: string|null, serverLogExceptionPattern: string }}
1806
+ */
1807
+ export function resolveUiReviewDriveRecipe(config) {
1808
+ const ui = config?.uiReview;
1809
+ const login = ui?.login;
1810
+ if (!login || typeof login.loginUrl !== "string" || login.loginUrl.trim().length === 0) return null;
1811
+ if (typeof login.submitSelector !== "string" || login.submitSelector.trim().length === 0) return null;
1812
+ if (typeof login.successSelector !== "string" || login.successSelector.trim().length === 0) return null;
1813
+ const serverLogPath = typeof ui.serverLogPath === "string" && ui.serverLogPath.trim().length > 0 ? ui.serverLogPath.trim() : null;
1814
+ return {
1815
+ login: {
1816
+ loginUrl: login.loginUrl.trim(),
1817
+ usernameSelector: login.usernameSelector ?? null,
1818
+ usernameValue: login.usernameValue ?? null,
1819
+ passwordSelector: login.passwordSelector ?? null,
1820
+ passwordValue: login.passwordValue ?? null,
1821
+ submitSelector: login.submitSelector.trim(),
1822
+ successSelector: login.successSelector.trim(),
1823
+ },
1824
+ interstitials: Array.isArray(ui.interstitials)
1825
+ ? ui.interstitials.map((i) => ({ selector: i.selector }))
1826
+ : [],
1827
+ flows: Array.isArray(ui.flows) ? ui.flows : [],
1828
+ caps: ui.caps ?? {},
1829
+ serverLogPath,
1830
+ serverLogExceptionPattern:
1831
+ typeof ui.serverLogExceptionPattern === "string" && ui.serverLogExceptionPattern.trim().length > 0
1832
+ ? ui.serverLogExceptionPattern.trim()
1833
+ : DEFAULT_SERVER_LOG_EXCEPTION_PATTERN,
1834
+ };
1835
+ }
1836
+
1388
1837
  /**
1389
1838
  * Resolve the human-handoff config from the merged dev-loop config (#920).
1390
1839
  *
@@ -39,6 +39,7 @@ gates:
39
39
  - gate-evidence
40
40
  - no-op
41
41
  - input-validation
42
+ - threat-model
42
43
  - packaging-runtime
43
44
  - state-concurrency
44
45
  - renderer-security
@@ -352,6 +353,12 @@ personas:
352
353
  Review this change for renderer security. Check HTML text escaping, URL encoding, attribute encoding, JSON/script embedding, and rendering of user-controlled content. Treat titles, names, URLs, statuses, errors, and external payload fields as untrusted. Flag raw interpolation into HTML or attributes and tests that expect unsafe output.
353
354
  defaultModel: null
354
355
 
356
+ threat-model:
357
+ persona: review
358
+ prompt: >-
359
+ Adversarially threat-model this change end to end — you are an attacker with control over every caller-/plan-influenced input (descriptors, paths, URLs, flags, env, fixture data). Do NOT spot-check; return a trust-boundary CHECKLIST and a verdict per item. Enumerate exhaustively for every seam the diff touches: (1) INPUT ALLOWLISTS — are actions/commands/schemes/hosts allowlisted (not denylisted), and enforced BEFORE any dangerous use (browser launch, exec, read)? (2) NAVIGATION/ORIGIN CONFINEMENT — same-origin/scheme enforced both pre-launch AND at runtime after every redirect / click / server-response (a pre-check the runtime can defeat is a hole). (3) RESOURCE/LOOP BOUNDS — step/size/time/recursion caps on attacker-influenced counts. (4) DATA-AT-REST + CLEANUP — sensitive intermediate artifacts minimized and removed on EVERY fail-closed path (not just the happy path); no off-origin/partial artifact left on disk on error. (5) EXPORTED/ENTRY-POINT TRUST — does every exported function / alternate entry self-validate, or can it bypass the parse-time validation the CLI does? (6) ERROR/TEARDOWN SAFETY — a throw in rm/close/teardown must not break the fail-closed envelope or leak state. (7) PATH TRAVERSAL / DESERIALIZATION — reject absolute/`..`/escape-base paths before read; no unsafe deserialization of untrusted data. (8) SHELL/PROCESS — no unescaped interpolation into a shell; prefer argv arrays; no `shell:true` with caller input. For each category that applies, state whether the change is safe and cite the guarding code (file:line) or flag the specific abuse and a failing-input example. If a category does not apply to the touched seam, say so explicitly rather than skipping it.
360
+ defaultModel: null
361
+
355
362
  determinism:
356
363
  persona: review
357
364
  prompt: >-
@@ -396,7 +403,6 @@ personas:
396
403
  The PR body is the implementation contract — it must have:
397
404
  - A Summary section explaining what changed and why
398
405
  - 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
406
  - An Acceptance criteria section with the linked issue acceptance criteria
401
407
  - A Definition of done section
402
408
  - A Non-goals section
@@ -200,15 +200,3 @@ export function shapeFindings(findings) {
200
200
  return { outcome, artifact, findingId: f.id };
201
201
  });
202
202
  }
203
-
204
- /**
205
- * Run the full pipeline: cluster → score → shape, return shaped artifacts.
206
- *
207
- * @param {Array<object>} signals — debt_signal-compatible array
208
- * @returns {Array<{ outcome: ShapeOutcome, artifact: object|null, findingId: string }>}
209
- */
210
- export async function runPipeline(signals) {
211
- const { clusterSignalsEnriched } = await import("./cluster.mjs");
212
- const findings = clusterSignalsEnriched(signals);
213
- return shapeFindings(findings);
214
- }
@@ -136,7 +136,25 @@ export const NEXT_ACTIONS = Object.freeze({
136
136
 
137
137
  const SAME_HEAD_CLEAN_CONVERGED_NEXT_ACTION = "Current head already has a clean submitted Copilot review; suppress automatic same-head re-request unless a meaningful remediation event occurs, or explicitly request another Copilot pass";
138
138
 
139
- const VALID_REVIEW_REQUEST_STATUSES = new Set(["requested", "already-requested", "unavailable", "none", "failed"]);
139
+ /**
140
+ * Canonical snapshot request-status enum (single source of truth). Any request
141
+ * outcome plumbed into the shared loop contract MUST normalize to one of these;
142
+ * richer request-tool outcomes (round_cap_reached, suppressed_*, etc.) collapse
143
+ * to "none" here because they mean "no active Copilot request is in flight".
144
+ */
145
+ export const VALID_REVIEW_REQUEST_STATUSES = new Set(["requested", "already-requested", "unavailable", "none", "failed"]);
146
+
147
+ /**
148
+ * Collapse an arbitrary request-tool outcome to the canonical snapshot
149
+ * request-status enum. Unrecognized statuses (round cap / suppression
150
+ * diagnostics) map to "none" so they never leak into the shared contract.
151
+ *
152
+ * @param {string|undefined} status
153
+ * @returns {string} a member of VALID_REVIEW_REQUEST_STATUSES
154
+ */
155
+ export function toSharedRequestStatus(status) {
156
+ return VALID_REVIEW_REQUEST_STATUSES.has(status) ? status : "none";
157
+ }
140
158
  const VALID_CI_STATUSES = new Set(["success", "failure", "pending", "none", "crediblyGreen"]);
141
159
  const ACTIVE_REQUEST_STATUSES = new Set(["requested", "already-requested"]);
142
160
 
@@ -341,6 +359,9 @@ export function applyConfirmedReviewRequest(snapshot, reviewRequestStatus) {
341
359
  * @param {number} [refinementConfig.lowSignalRoundThreshold]
342
360
  * @param {number} [refinementConfig.lowSignalMaxComments]
343
361
  * @param {number} [refinementConfig.maxCopilotRounds]
362
+ * @param {boolean} [refinementConfig.preApprovalRequireCi] - #1337: default true. When false,
363
+ * the pre-approval CI precondition is opted out, so a non-draft PR with a pending/none/failure
364
+ * CI verdict is not routed to waiting_for_ci / blocked_needs_user_decision (it is past the draft gate).
344
365
  * @returns {{
345
366
  * state: string,
346
367
  * allowedTransitions: string[],
@@ -353,6 +374,17 @@ export function applyConfirmedReviewRequest(snapshot, reviewRequestStatus) {
353
374
  export function interpretLoopState(snapshot, refinementConfig) {
354
375
  const s = normalizeSnapshot(snapshot);
355
376
 
377
+ // Pre-approval CI opt-out (#1337): when `gates.preApproval.requireCi` is false,
378
+ // the CI verdict must not gate progression at the pre-approval boundary. A
379
+ // non-draft PR is past the draft gate, so this is the applicable knob — treat
380
+ // pending/none/failure CI as non-blocking here so a repo with no CI is not
381
+ // routed to WAITING_FOR_CI / BLOCKED_NEEDS_USER_DECISION before the downstream
382
+ // gate-coordination guards (which already honor this flag) are ever reached.
383
+ // Default true preserves current behavior for every caller that does not thread it.
384
+ const preApprovalRequireCi = refinementConfig?.preApprovalRequireCi !== false;
385
+ const ciBlocks = preApprovalRequireCi && isBlockedCiStatus(s.ciStatus);
386
+ const ciWaits = preApprovalRequireCi && isWaitingCiStatus(s.ciStatus);
387
+
356
388
  let state;
357
389
 
358
390
  if (!s.prExists) {
@@ -403,7 +435,7 @@ export function interpretLoopState(snapshot, refinementConfig) {
403
435
  && state !== STATE.NO_PR && state !== STATE.DONE
404
436
  && state !== STATE.PR_DRAFT && state !== STATE.REVIEW_REQUEST_UNAVAILABLE
405
437
  && state !== STATE.BLOCKED_NEEDS_USER_DECISION) {
406
- const ciClean = s.ciStatus === "success" || s.ciStatus === "crediblyGreen";
438
+ const ciClean = s.ciStatus === "success" || s.ciStatus === "crediblyGreen" || !preApprovalRequireCi;
407
439
  const cleanThreads = s.unresolvedThreadCount === 0;
408
440
  if (cleanThreads && ciClean) {
409
441
  // Clean PR at the cap: proceed to the pre_approval_gate fallback regardless of a
@@ -430,18 +462,18 @@ export function interpretLoopState(snapshot, refinementConfig) {
430
462
  state = STATE.WAITING_FOR_COPILOT_REVIEW;
431
463
  } else if (s.copilotReviewPresent) {
432
464
  // Copilot has reviewed at least once; all threads resolved
433
- if (isBlockedCiStatus(s.ciStatus)) {
465
+ if (ciBlocks) {
434
466
  state = STATE.BLOCKED_NEEDS_USER_DECISION;
435
- } else if (isWaitingCiStatus(s.ciStatus)) {
467
+ } else if (ciWaits) {
436
468
  state = STATE.WAITING_FOR_CI;
437
469
  } else {
438
470
  state = STATE.READY_TO_REREQUEST_REVIEW;
439
471
  }
440
472
  } else {
441
473
  // No Copilot review yet; not currently requested
442
- if (isBlockedCiStatus(s.ciStatus)) {
474
+ if (ciBlocks) {
443
475
  state = STATE.BLOCKED_NEEDS_USER_DECISION;
444
- } else if (isWaitingCiStatus(s.ciStatus)) {
476
+ } else if (ciWaits) {
445
477
  state = STATE.WAITING_FOR_CI;
446
478
  } else {
447
479
  state = STATE.PR_READY_NO_FEEDBACK;