@dev-loops/core 0.9.0 → 1.0.0-rc.2

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.
@@ -13,52 +13,123 @@ import { z } from "zod";
13
13
  // ============================================================================
14
14
 
15
15
  const StrategyConfig = z.strictObject({
16
- default: z.enum(["local-first", "github-first"]),
16
+ default: z.enum(["local-first", "github-first"]).describe("Default work-intake strategy: local-first starts from a repo plan file, github-first from a tracked issue."),
17
17
  });
18
18
 
19
19
  const InputSourceConfig = z.strictObject({
20
- default: z.enum(["tracker", "phase-docs"]),
20
+ default: z.enum(["tracker", "phase-docs"]).describe("Where local-first work reads its spec: the tracker issue body, or repo phase docs."),
21
21
  });
22
22
 
23
- const ModelsConfig = z.strictObject({
24
- conductor: z.string().trim().min(1).optional(),
25
- roles: z.record(z.string(), z.string().trim().min(1)).optional(),
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({
85
+ conductor: z.string().trim().min(1).describe("Model override for the conductor (dev-loop) session; absent = inherit the session model.").optional(),
86
+ roles: z.record(z.string(), z.string().trim().min(1)).describe("Concrete per-role/angle model overrides (highest precedence, above tiers).").optional(),
87
+ // Tier alias → per-harness concrete model (null = inherit / no-op).
88
+ tiers: z.record(z.string().min(1), ModelTierMapping).describe("Tier alias → per-harness concrete model; null on a harness means inherit (no override).").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)).describe("Role or gate angle → tier alias: a built-in alias (low, high), a custom models.tiers alias, or \"inherit\".").optional(),
26
91
  });
27
92
 
93
+ const ModelsConfig = ModelsConfigBase.superRefine(refineRoleTiers);
94
+
28
95
  const RefinementConfig = z.strictObject({
29
- fanOut: z.number().int().min(1).max(10),
30
- mode: z.enum(["parallel", "sequential"]),
31
- maxCopilotRounds: z.number().int().nonnegative().default(5),
32
- stopOnLowSignal: z.boolean().default(false),
33
- lowSignalRoundThreshold: z.number().int().nonnegative().default(3),
34
- lowSignalMaxComments: z.number().int().nonnegative().default(2),
35
- roles: z.array(z.string().trim().min(1)).optional(),
96
+ fanOut: z.number().int().min(1).max(10).describe("Parallel reviewers per refinement round."),
97
+ mode: z.enum(["parallel", "sequential"]).describe("Whether refinement reviewers run in parallel or one after another."),
98
+ maxCopilotRounds: z.number().int().nonnegative().default(5).describe("Automated Copilot review rounds before converging; 0 disables Copilot review."),
99
+ stopOnLowSignal: z.boolean().default(false).describe("Stop Copilot rounds early once they stop producing signal."),
100
+ lowSignalRoundThreshold: z.number().int().nonnegative().default(3).describe("Rounds counted toward the low-signal stop decision."),
101
+ lowSignalMaxComments: z.number().int().nonnegative().default(2).describe("A round with at most this many comments counts as low-signal."),
102
+ roles: z.array(z.string().trim().min(1)).describe("Review lenses the refinement fan-out dispatches.").optional(),
36
103
  });
37
104
 
38
105
  const GateConfig = z.strictObject({
39
- angles: z.array(z.string().trim().min(1)).optional(),
40
- excludeAngles: z.array(z.string().trim().min(1)).default([]),
41
- mandatoryAngles: z.array(z.string().trim().min(1)).default([]),
42
- required: z.boolean().default(true),
43
- requireCi: z.boolean().default(true),
106
+ angles: z.array(z.string().trim().min(1)).describe("Review lenses this gate fans out to.").optional(),
107
+ excludeAngles: z.array(z.string().trim().min(1)).default([]).describe("Angles removed from the resolved angle list."),
108
+ mandatoryAngles: z.array(z.string().trim().min(1)).default([]).describe("Angles that always run, regardless of diff-based dynamic selection."),
109
+ required: z.boolean().default(true).describe("Whether this gate must run."),
110
+ requireCi: z.boolean().default(true).describe("Per-gate CI prerequisite (default true): the gate requires green CI on the current head; false opts this gate out of the CI precondition entirely, including a real failure."),
44
111
  blockCleanOnFindingSeverities: z
45
112
  .array(z.enum(["must-fix", "worth-fixing-now", "defer"]))
46
113
  .min(1)
47
- .default(["must-fix"]),
48
- dynamicAngles: z.boolean().default(false),
114
+ .default(["must-fix"])
115
+ .describe("Finding severities that block a clean gate verdict."),
116
+ dynamicAngles: z.boolean().default(false).describe("Enable diff-driven dynamic angle resolution for this gate."),
49
117
  // Additive counterpart to the subtractive dynamicAngles path (#1048): when
50
118
  // true, the context-builder may also ADD catalog angles — from
51
119
  // resolveAnglePool() (gates.anglePool, or else the union of the persona
52
120
  // registry and this config's own configured angles) — that change-category
53
121
  // heuristics recommend but that are not already in this gate's configured
54
122
  // pool. Default false preserves today's subtractive-only behavior exactly.
55
- additiveAngles: z.boolean().default(false),
123
+ additiveAngles: z.boolean().default(false).describe("Allow diff-driven addition of catalog angles beyond this gate's configured pool."),
56
124
  });
57
125
 
58
126
  const GatesConfig = z.strictObject({
59
127
  draft: GateConfig.optional(),
60
- // `requireCi` is only behaviorally configurable for the draft gate.
61
- // preApproval always requires CI even if config repeats `requireCi`.
128
+ // `requireCi` is honored on both gates: default true keeps CI a precondition,
129
+ // false is an opt-out escape hatch so a repo with no CI is not held at the
130
+ // gate. The pre-approval gate mirrors the draft gate's `requireCi` semantics —
131
+ // when false the CI verdict is ignored entirely at that boundary, including a
132
+ // real failure (not merely "green optional").
62
133
  preApproval: GateConfig.optional(),
63
134
  // Relaxed spike gate profile (#965). A spike's deliverable is a findings doc,
64
135
  // not production code, so it should not carry the full draft → pre-approval →
@@ -109,12 +180,12 @@ const GatesConfig = z.strictObject({
109
180
  const AutonomyConfig = z.strictObject({
110
181
  stopAt: z.array(
111
182
  z.enum(["refinement", "draft-pr", "pre-approval", "merge"])
112
- ),
183
+ ).describe("Checkpoints that require operator confirmation before the loop proceeds (default: [\"merge\"])."),
113
184
  // When true, merge is a fixed, non-overridable human action: the agent never
114
185
  // runs `gh pr merge`, `resolveAutonomyStopAt` always includes "merge", and
115
186
  // any per-run merge authorization (envelope flag / explicit instruction) is
116
187
  // ignored — it fails closed. See resolveHumanMergeOnly / resolveEffectiveMergeAuthorized.
117
- humanMergeOnly: z.boolean().optional(),
188
+ humanMergeOnly: z.boolean().describe("Merge stays a fixed human-only action: the agent never merges and any per-run merge authorization is ignored (fails closed).").optional(),
118
189
  });
119
190
 
120
191
  /**
@@ -137,33 +208,42 @@ const ApprovalConfig = z.strictObject({
137
208
  });
138
209
 
139
210
  const WorkflowConfig = z.strictObject({
140
- asyncStartMode: z.enum(["required", "allowed"]).default("required"),
141
- requireRetrospective: z.boolean(),
142
- requireDraftFirst: z.boolean(),
143
- devModeDefault: z.boolean(),
211
+ asyncStartMode: z.enum(["required", "allowed"]).default("required").describe("Whether the async start contract is required or merely allowed."),
212
+ requireRetrospective: z.boolean().describe("Require a retrospective checkpoint before a loop completes."),
213
+ requireDraftFirst: z.boolean().describe("Open pull requests as drafts and promote via the draft gate."),
214
+ devModeDefault: z.boolean().describe("Default new loops to dev mode."),
144
215
  });
145
216
 
146
217
  const LocalImplementationConfig = z.strictObject({
147
218
  /** Opt into light mode for small scoped changes */
148
219
  lightMode: z.strictObject({
149
- enabled: z.boolean(),
150
- maxFiles: z.number().int().min(1),
151
- maxLines: z.number().int().min(1),
220
+ enabled: z.boolean().describe("Opt small scoped changes into the lightweight dispatch path."),
221
+ maxFiles: z.number().int().min(1).describe("Light mode applies only when the change touches at most this many files."),
222
+ maxLines: z.number().int().min(1).describe("Light mode applies only when the change stays within this many lines."),
152
223
  // Copilot review round cap for light-dispatched PRs (#1210). Composes with
153
224
  // (does not replace) refinement.maxCopilotRounds — see
154
225
  // resolveEffectiveCopilotRoundCap.
155
- maxCopilotRounds: z.number().int().nonnegative().default(1),
226
+ maxCopilotRounds: z.number().int().nonnegative().default(1).describe("Copilot round cap for light-dispatched PRs; composes as min(this, refinement.maxCopilotRounds)."),
227
+ }).optional(),
228
+ /**
229
+ * Opt into issue-less PR-first (`--lightweight` with no --issue) at ANY
230
+ * change scope. Decoupled from lightMode: gate dispatch still resolves
231
+ * inline vs full_fanout from scope on its own, so over-threshold issue-less
232
+ * PRs get the full fan-out and the full-PR Copilot round cap.
233
+ */
234
+ issueless: z.strictObject({
235
+ enabled: z.boolean().describe("Opt into issue-less PR-first dispatch at any change scope; gate dispatch still resolves inline vs full fan-out from scope on its own."),
156
236
  }).optional(),
157
237
  });
158
238
 
159
239
  /** Queue mode config */
160
240
  const QueueConfig = z.strictObject({
161
- maxParallel: z.number().int().min(1).max(10).default(3),
162
- maxAutoFiledIssues: z.number().int().min(0).max(100).default(10),
163
- reDispatchMaxRetries: z.number().int().min(0).max(10).default(1),
164
- projectNumber: z.number().int().positive().optional(),
165
- boardTitle: z.string().trim().min(1).optional(),
166
- archiveOlderThanDays: z.number().int().positive().optional(),
241
+ maxParallel: z.number().int().min(1).max(10).default(3).describe("Maximum queue items worked in parallel."),
242
+ maxAutoFiledIssues: z.number().int().min(0).max(100).default(10).describe("Cap on auto-filed issues per run."),
243
+ reDispatchMaxRetries: z.number().int().min(0).max(10).default(1).describe("Retries when re-dispatching a failed queue item."),
244
+ projectNumber: z.number().int().positive().describe("GitHub Projects board number (explicit opt-in to Projects-based queue ordering).").optional(),
245
+ boardTitle: z.string().trim().min(1).describe("GitHub Projects board title (explicit opt-in to Projects-based queue ordering).").optional(),
246
+ archiveOlderThanDays: z.number().int().positive().describe("Archive done board items older than this many days.").optional(),
167
247
  });
168
248
 
169
249
  /**
@@ -174,8 +254,8 @@ const QueueConfig = z.strictObject({
174
254
  * data). Both optional; empty/absent is a valid no-op.
175
255
  */
176
256
  const WorktreeConfig = z.strictObject({
177
- copyOnInit: z.array(z.string().trim().min(1)).optional(),
178
- linkOnInit: z.array(z.string().trim().min(1)).optional(),
257
+ copyOnInit: z.array(z.string().trim().min(1)).describe("Repo-relative paths/globs copied into a fresh worktree (isolated per worktree — use for mutable files).").optional(),
258
+ linkOnInit: z.array(z.string().trim().min(1)).describe("Repo-relative paths/globs symlinked to the main checkout (shared — read-only data only).").optional(),
179
259
  });
180
260
 
181
261
  /**
@@ -214,6 +294,18 @@ const UiReviewMigrateConfig = z.strictObject({
214
294
  .optional(),
215
295
  });
216
296
 
297
+ /**
298
+ * Per-project dev-DB row-teardown recipe (Stage 5). The drive stamps each
299
+ * mutating step it drives with a drive-session id (advertised to the app on the
300
+ * DRIVE_SESSION_HEADER request header); this `deleteCommand` deletes exactly the
301
+ * rows the app tagged with that session — the id is passed in the
302
+ * UI_REVIEW_DRIVE_SESSION env var and the command runs in the provisioned
303
+ * worktree (dev DB only). Teardown runs it only on explicit confirmation.
304
+ */
305
+ const UiReviewRowTeardownConfig = z.strictObject({
306
+ deleteCommand: z.string().trim().min(1),
307
+ });
308
+
217
309
  /**
218
310
  * Per-project boot recipe: a shell `command` that starts the branch's app and a
219
311
  * `readyUrl` an HTTP readiness probe polls until the app is up (never a fixed
@@ -238,6 +330,7 @@ const UiReviewRunConfig = z.strictObject({
238
330
  readyIntervalMs: z.number().int().min(1).max(60000).default(1000),
239
331
  cwd: z.string().trim().min(1).optional(),
240
332
  migrate: UiReviewMigrateConfig.optional(),
333
+ rowTeardown: UiReviewRowTeardownConfig.optional(),
241
334
  });
242
335
 
243
336
  /**
@@ -285,6 +378,12 @@ const UiReviewFlowStepConfig = z.strictObject({
285
378
  path: z.string().trim().min(1).optional(),
286
379
  value: z.string().optional(),
287
380
  event: z.string().trim().min(1).optional(),
381
+ // Responsive/stateful captures: a declared viewport resizes the page before the
382
+ // step and bakes into the named-state slug, so the mobile vs desktop (or
383
+ // default vs error) render lands in a distinct reviewable directory. The route
384
+ // NAMES its interaction states — the drive never enumerates them itself.
385
+ viewport: z.strictObject({ width: z.number().int().positive(), height: z.number().int().positive() }).optional(),
386
+ interactionState: z.enum(["none", "focus", "hover", "error"]).optional(),
288
387
  }).superRefine((step, ctx) => {
289
388
  // Every action but `goto` targets an element, so a missing selector is a
290
389
  // config error, not a runtime step-failure. (`goto` uses `path`/url.)
@@ -366,17 +465,19 @@ const PersonasConfig = z.record(z.string().min(1), PersonaEntry);
366
465
 
367
466
  // Partial nested gate entries for file-level config (allows overriding only
368
467
  // requireCi/required/angles without restating the whole gate object).
369
- const FileGateConfig = GateConfig.partial();
370
468
  const FileGatesConfig = z.strictObject({
371
- draft: FileGateConfig.optional(),
372
- preApproval: FileGateConfig.optional(),
373
- spike: FileGateConfig.optional(),
374
- requireFanoutEvidence: z.boolean().optional(),
375
- requireFanoutProvenance: z.boolean().optional(),
376
- maxFanoutReviewers: z.number().int().min(1).max(64).optional(),
377
- postFindingsComments: z.boolean().optional(),
378
- anglePool: z.array(z.string().trim().min(1)).optional(),
379
- rejectForeignAngles: z.boolean().optional(),
469
+ // Each gate gets its own GateConfig.partial() instance rather than three
470
+ // .describe() clones of one shared partial, so no underlying def is shared
471
+ // and per-gate metadata renders unambiguously.
472
+ draft: GateConfig.partial().describe("Draft gate config (runs before a PR leaves draft).").optional(),
473
+ preApproval: GateConfig.partial().describe("Pre-approval gate config (final re-review before the merge handoff).").optional(),
474
+ spike: GateConfig.partial().describe("Relaxed spike gate profile; applies only to spike-mode work.").optional(),
475
+ requireFanoutEvidence: z.boolean().describe("Require fan-out/fan-in review evidence on gate verdicts; inline single-agent verdicts are rejected except under the strict light-mode exception (under-threshold scope, no gate:full label, recorded inline reason).").optional(),
476
+ requireFanoutProvenance: z.boolean().describe("Additionally require recorded, internally-consistent fan-out provenance (distinct reviewer count + per-angle dispatch).").optional(),
477
+ maxFanoutReviewers: z.number().int().min(1).max(64).describe("Cap on parallel gate fan-out reviewers; overflow runs in sequential batches.").optional(),
478
+ postFindingsComments: z.boolean().describe("Post consolidated gate findings as a marker-tagged PR comment (default true).").optional(),
479
+ anglePool: z.array(z.string().trim().min(1)).describe("Explicit global lens catalog for additive angle selection.").optional(),
480
+ rejectForeignAngles: z.boolean().describe("Reject fan-out provenance naming angles outside the gate's configured pool (default true).").optional(),
380
481
  });
381
482
 
382
483
  // Partial persona entries for file-level config (allows omitting fields)
@@ -438,6 +539,7 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
438
539
  }),
439
540
  localImplementation: Object.freeze({
440
541
  lightMode: Object.freeze({ enabled: false, maxFiles: 3, maxLines: 200, maxCopilotRounds: 1 }),
542
+ issueless: Object.freeze({ enabled: false }),
441
543
  }),
442
544
  queue: Object.freeze({
443
545
  maxParallel: 3,
@@ -464,21 +566,21 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
464
566
  // ============================================================================
465
567
 
466
568
  export const FileConfigSchema = z.strictObject({
467
- version: z.literal(1),
468
- strategy: StrategyConfig.partial().optional(),
469
- inputSource: InputSourceConfig.partial().optional(),
470
- models: ModelsConfig.partial().optional(),
471
- refinement: RefinementConfig.partial().optional(),
472
- gates: FileGatesConfig.optional(),
473
- autonomy: AutonomyConfig.partial().optional(),
474
- approval: ApprovalConfig.partial().optional(),
475
- workflow: WorkflowConfig.partial().optional(),
476
- localImplementation: LocalImplementationConfig.partial().optional(),
477
- queue: QueueConfig.partial().optional(),
478
- personas: FilePersonasConfig.optional(),
479
- internalPathPatterns: InternalPatternsConfig.optional(),
480
- worktree: WorktreeConfig.partial().optional(),
481
- uiReview: UiReviewConfig.partial().optional(),
569
+ version: z.literal(1).describe("Config format version; always 1."),
570
+ strategy: StrategyConfig.partial().describe("Work-intake strategy defaults.").optional(),
571
+ inputSource: InputSourceConfig.partial().describe("Spec source for local-first work.").optional(),
572
+ models: ModelsConfigBase.partial().superRefine(refineRoleTiers).describe("Model routing: conductor override, per-role/angle overrides, tier aliases, and role→tier policy.").optional(),
573
+ refinement: RefinementConfig.partial().describe("Refinement fan-out and Copilot review-round behavior.").optional(),
574
+ gates: FileGatesConfig.describe("Gate review configuration: per-gate angle sets plus fan-out enforcement knobs.").optional(),
575
+ autonomy: AutonomyConfig.partial().describe("How far the loop proceeds without operator confirmation.").optional(),
576
+ approval: ApprovalConfig.partial().describe("Approval / merge-handoff behavior (human-handoff offer).").optional(),
577
+ workflow: WorkflowConfig.partial().describe("Workflow posture: draft-first, retrospectives, dev mode, async start.").optional(),
578
+ localImplementation: LocalImplementationConfig.partial().describe("Local implementation dispatch (light mode for small scoped changes).").optional(),
579
+ queue: QueueConfig.partial().describe("Queue mode: parallelism, auto-filing caps, and Projects board opt-in.").optional(),
580
+ personas: FilePersonasConfig.describe("Gate-angle → reviewer persona registry overrides (angle name → persona, prompt, default model).").optional(),
581
+ internalPathPatterns: InternalPatternsConfig.describe("Regex whitelist for internal-only PR detection.").optional(),
582
+ worktree: WorktreeConfig.partial().describe("Worktree provisioning: gitignored files/dirs copied or symlinked into fresh worktrees.").optional(),
583
+ uiReview: UiReviewConfig.partial().describe("UI-review route recipes: per-project run/boot, dev-login, driven flows, and caps.").optional(),
482
584
  // Deprecated (removed in #1088): tolerated so consumer .devloops files that
483
585
  // still carry a localPlanning block keep parsing. Accepted, never read.
484
586
  localPlanning: z.unknown().optional(),
@@ -516,6 +618,7 @@ const BUILTIN_PERSONAS = Object.freeze({
516
618
  yagni: { persona: "review", defaultModel: null },
517
619
  "contract-surface": { persona: "review", defaultModel: null },
518
620
  "input-validation": { persona: "review", defaultModel: null },
621
+ "threat-model": { persona: "review", defaultModel: null },
519
622
  "packaging-runtime": { persona: "review", defaultModel: null },
520
623
  "state-concurrency": { persona: "review", defaultModel: null },
521
624
  "renderer-security": { persona: "review", defaultModel: null },
@@ -587,6 +690,76 @@ export function resolveReviewerRole(config, angle) {
587
690
  };
588
691
  }
589
692
 
693
+ /**
694
+ * Resolve the concrete model for a subagent role/angle on a given harness, or
695
+ * `null` (inherit → pass no model override).
696
+ *
697
+ * Precedence:
698
+ * 1. `models.roles[role]` — concrete per-role/angle override (highest).
699
+ * 2. Tier alias, mapped through `models.tiers[tier][harness]` (or built-in
700
+ * tiers); `inherit`/absent/null → `null`. The alias depends on `kind`:
701
+ * - `kind: "angle"` (gate review dispatch): an explicit
702
+ * `models.roleTiers[role]` override, else the `review` tier. A gate
703
+ * review runs at review quality even when the angle's name collides with
704
+ * a routine role — e.g. the `docs` angle resolves via the `review` tier
705
+ * (high), not the `docs` writer role's low tier. (Its persona/agent still
706
+ * comes from `resolveReviewerRole`; only the tier is forced to review.)
707
+ * - `kind: "role"`/absent (routine subagent): `models.roleTiers[role]` (or
708
+ * the built-in role tier), else — when the name is not a named role — the
709
+ * tier for its review persona (so a non-colliding gate angle passed
710
+ * without `kind` still resolves high via `review`).
711
+ *
712
+ * Callers dispatching a gate review angle whose name may collide with a routine
713
+ * role (only `docs` today) MUST pass `kind: "angle"` to avoid the silent
714
+ * downgrade; role dispatch leaves `kind` unset.
715
+ *
716
+ * Zero-config is a genuine no-op on Pi (built-in tiers are null for pi) and
717
+ * reproduces the standing policy on Claude (routine=low, refiner/review=high,
718
+ * dev-loop=inherit).
719
+ *
720
+ * @param {DevLoopConfig} config
721
+ * @param {{ role: string, harness: "claude"|"pi", kind?: "role"|"angle" }} params
722
+ * @returns {string|null}
723
+ */
724
+ export function resolveRoleModel(config, { role, harness, kind } = {}) {
725
+ if (!role || (harness !== "claude" && harness !== "pi")) return null;
726
+
727
+ // 1. Concrete per-role/angle override wins outright (over any tier).
728
+ const concrete = config?.models?.roles?.[role];
729
+ if (typeof concrete === "string" && concrete.trim().length > 0) {
730
+ return concrete.trim();
731
+ }
732
+
733
+ // 2. Resolve a tier alias for this role/angle.
734
+ const roleTiers = { ...BUILTIN_ROLE_TIERS, ...(config?.models?.roleTiers ?? {}) };
735
+ let tierAlias;
736
+ if (kind === "angle") {
737
+ // Gate review angle: an explicit per-angle override wins, else the review
738
+ // tier — a gate review is review-quality regardless of a coincidental
739
+ // routine-role persona name (the `docs` angle must not inherit `docs`→low).
740
+ tierAlias = config?.models?.roleTiers?.[role] ?? roleTiers.review;
741
+ } else {
742
+ tierAlias = roleTiers[role];
743
+ if (tierAlias === undefined) {
744
+ // Not a named role — treat as a gate angle and inherit its review
745
+ // persona's tier (critical angles resolve high via the `review` persona).
746
+ const { persona } = resolveReviewerRole(config, role);
747
+ tierAlias = roleTiers[persona];
748
+ }
749
+ }
750
+ if (!tierAlias || tierAlias === "inherit") return null;
751
+
752
+ // Deep-merge the alias mapping so a partial override (e.g. `{ pi: "..." }`,
753
+ // which the schema allows) preserves the untouched built-in harness key rather
754
+ // than erasing the whole {claude,pi} mapping and resolving null for that harness.
755
+ const builtinMapping = BUILTIN_TIERS[tierAlias];
756
+ const configMapping = config?.models?.tiers?.[tierAlias];
757
+ if (!builtinMapping && !configMapping) return null;
758
+ const mapping = { ...builtinMapping, ...configMapping };
759
+ const model = mapping[harness];
760
+ return typeof model === "string" && model.trim().length > 0 ? model.trim() : null;
761
+ }
762
+
590
763
  // ============================================================================
591
764
  // Error types
592
765
  // ============================================================================
@@ -1127,7 +1300,13 @@ export function resolveRefinement(config) {
1127
1300
  const stopOnLowSignal = /** @type {boolean} */ (resolveRefinementConfig(config, "stopOnLowSignal"));
1128
1301
  const lowSignalRoundThreshold = /** @type {number} */ (resolveRefinementConfig(config, "lowSignalRoundThreshold"));
1129
1302
  const lowSignalMaxComments = /** @type {number} */ (resolveRefinementConfig(config, "lowSignalMaxComments"));
1130
- return { fanOut, mode, roles, maxCopilotRounds, stopOnLowSignal, lowSignalRoundThreshold, lowSignalMaxComments };
1303
+ // #1337: centralize the pre-approval CI opt-out here so every caller that
1304
+ // builds its interpreter refinement config from `resolveRefinement(config)`
1305
+ // (detect-copilot-loop-state, copilot-pr-handoff, gate coordination, etc.)
1306
+ // reliably honors `gates.preApproval.requireCi: false` — otherwise a CI-less
1307
+ // repo would still be interpreted as waiting_for_ci / blocked in those tools.
1308
+ const preApprovalRequireCi = resolveGateConfig(config, "preApproval").requireCi;
1309
+ return { fanOut, mode, roles, maxCopilotRounds, stopOnLowSignal, lowSignalRoundThreshold, lowSignalMaxComments, preApprovalRequireCi };
1131
1310
  }
1132
1311
 
1133
1312
  /**
@@ -1258,6 +1437,19 @@ export function resolveLightMode(config) {
1258
1437
  };
1259
1438
  }
1260
1439
 
1440
+ /**
1441
+ * Resolve the issue-less PR-first any-scope opt-in (#1349).
1442
+ *
1443
+ * True only when `localImplementation.issueless.enabled` is exactly `true`;
1444
+ * absent, false, or malformed values resolve to false (fail closed).
1445
+ *
1446
+ * @param {DevLoopConfig} config
1447
+ * @returns {boolean}
1448
+ */
1449
+ export function resolveIssuelessEnabled(config) {
1450
+ return config?.localImplementation?.issueless?.enabled === true;
1451
+ }
1452
+
1261
1453
  /**
1262
1454
  * Resolve the effective Copilot review round cap for a PR (#1210).
1263
1455
  *
@@ -1586,7 +1778,8 @@ export const DEFAULT_DESTRUCTIVE_MIGRATION_PATTERN =
1586
1778
  * @param {DevLoopConfig} config
1587
1779
  * @returns {null | { command: string, readyUrl: string, readyTimeoutMs: number,
1588
1780
  * readyIntervalMs: number, cwd: string|null,
1589
- * migrate: null | { statusCommand: string, applyCommand: string, destructivePattern: string } }}
1781
+ * migrate: null | { statusCommand: string, applyCommand: string, destructivePattern: string },
1782
+ * rowTeardown: null | { deleteCommand: string } }}
1590
1783
  */
1591
1784
  export function resolveUiReviewRunRecipe(config) {
1592
1785
  const run = config?.uiReview?.run;
@@ -1599,6 +1792,10 @@ export function resolveUiReviewRunRecipe(config) {
1599
1792
  destructivePattern: run.migrate.destructivePattern ?? DEFAULT_DESTRUCTIVE_MIGRATION_PATTERN,
1600
1793
  }
1601
1794
  : null;
1795
+ const rowTeardown =
1796
+ run.rowTeardown && typeof run.rowTeardown.deleteCommand === "string" && run.rowTeardown.deleteCommand.trim().length > 0
1797
+ ? { deleteCommand: run.rowTeardown.deleteCommand.trim() }
1798
+ : null;
1602
1799
  return {
1603
1800
  command: run.command.trim(),
1604
1801
  readyUrl: run.readyUrl.trim(),
@@ -1606,6 +1803,7 @@ export function resolveUiReviewRunRecipe(config) {
1606
1803
  readyIntervalMs: Number.isInteger(run.readyIntervalMs) ? run.readyIntervalMs : 1000,
1607
1804
  cwd: typeof run.cwd === "string" && run.cwd.trim().length > 0 ? run.cwd.trim() : null,
1608
1805
  migrate,
1806
+ rowTeardown,
1609
1807
  };
1610
1808
  }
1611
1809
 
@@ -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: >-
@@ -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;