@dev-loops/core 0.9.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.
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "0.9.0",
3
+ "version": "1.0.0-rc.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
7
7
  },
8
8
  "description": "Shared deterministic support package for dev-loop skills, repo-local scripts, and GitHub automation.",
9
9
  "exports": {
10
+ "./analysis/diff-analyzer": "./src/analysis/diff-analyzer.mjs",
10
11
  "./bash-exit-one": "./src/bash-exit-one.mjs",
11
12
  "./cli/helpers": "./src/cli/helpers.mjs",
12
13
  "./cli/primitives": "./src/cli/primitives.mjs",
@@ -28,6 +29,7 @@
28
29
  "./loop/copilot-ci-status": "./src/loop/copilot-ci-status.mjs",
29
30
  "./loop/copilot-loop-iterations": "./src/loop/copilot-loop-iterations.mjs",
30
31
  "./loop/copilot-loop-state": "./src/loop/copilot-loop-state.mjs",
32
+ "./loop/gate-carry-forward": "./src/loop/gate-carry-forward.mjs",
31
33
  "./loop/gate-fanin": "./src/loop/gate-fanin.mjs",
32
34
  "./loop/handoff-envelope": "./src/loop/handoff-envelope.mjs",
33
35
  "./loop/lifecycle-state": "./src/loop/lifecycle-state.mjs",
@@ -18,6 +18,10 @@ export const ChangeCategory = Object.freeze({
18
18
  CI_ONLY: "CI_ONLY",
19
19
  COMMENT_ONLY: "COMMENT_ONLY",
20
20
  LOGIC_CHANGE: "LOGIC_CHANGE",
21
+ // #1336: the diff touches a security-sensitive seam (browser automation,
22
+ // child_process/shell exec, untrusted network fetch, destructive filesystem
23
+ // ops / local-file upload). Triggers an up-front adversarial threat-model angle.
24
+ SECURITY_SENSITIVE_SEAM: "SECURITY_SENSITIVE_SEAM",
21
25
  });
22
26
 
23
27
  // ---------------------------------------------------------------------------
@@ -32,7 +36,7 @@ export const ChangeCategory = Object.freeze({
32
36
  *
33
37
  * @type {Record<string, string[]>}
34
38
  */
35
- const CATEGORY_ANGLE_MAP = {
39
+ export const CATEGORY_ANGLE_MAP = {
36
40
  [ChangeCategory.RENAME_ONLY]: [
37
41
  "scope", "correctness", "contract-surface", "docs", "link-check",
38
42
  ],
@@ -54,8 +58,16 @@ const CATEGORY_ANGLE_MAP = {
54
58
  // Core review subset for any non-trivial code change. Peripheral lenses
55
59
  // (ci-guard, link-check, packaging-runtime, config-drift, etc.) are pulled in
56
60
  // only when the diff's other categories implicate them, not by logic alone.
61
+ // input-validation is included (#1336): it was pool-only and never auto-
62
+ // recommended, so entrypoint/input drift went unreviewed unless hand-picked.
57
63
  [ChangeCategory.LOGIC_CHANGE]: [
58
- "scope", "correctness", "coverage", "determinism", "contract-surface",
64
+ "scope", "correctness", "coverage", "determinism", "contract-surface", "input-validation",
65
+ ],
66
+ // #1336: security-sensitive seam → up-front adversarial threat-model, plus
67
+ // input-validation and the core correctness/scope lenses. threat-model is
68
+ // never dropped for such a diff (a seam is dangerous regardless of size).
69
+ [ChangeCategory.SECURITY_SENSITIVE_SEAM]: [
70
+ "threat-model", "input-validation", "scope", "correctness",
59
71
  ],
60
72
  };
61
73
 
@@ -64,7 +76,7 @@ const CATEGORY_ANGLE_MAP = {
64
76
  *
65
77
  * @type {Set<string>}
66
78
  */
67
- const ALWAYS_INCLUDE = new Set(["gate-evidence", "renderer-security", "pr-description"]);
79
+ export const ALWAYS_INCLUDE = new Set(["gate-evidence", "renderer-security", "pr-description"]);
68
80
 
69
81
  // ---------------------------------------------------------------------------
70
82
  // Resolution
@@ -68,9 +68,10 @@ export function analyzeT0(nameStatusOutput) {
68
68
  }
69
69
 
70
70
  const renameOnly = lines.length > 0 && renameCount === lines.length;
71
- const allDocs = lines.length > 0 && files.every(
72
- (f) => normalizeSep(f).startsWith("docs/") || f.endsWith(".md") || f === "README.md",
73
- );
71
+ // Derive from the shared classifier so this predicate can't drift from it: a
72
+ // code/config/test file hosted under docs/ is not prose, so a mixed diff that
73
+ // includes one is not docs-only (it still gets the code-review surface).
74
+ const allDocs = lines.length > 0 && files.every((f) => classifyFile(f) === "docs");
74
75
 
75
76
  return {
76
77
  files,
@@ -96,9 +97,9 @@ export function classifyFile(filePath) {
96
97
  if (fp.startsWith(".github/")) {
97
98
  return "ci";
98
99
  }
99
- if (fp.startsWith("docs/") || fp.endsWith(".md") || fp === "README.md") {
100
- return "docs";
101
- }
100
+ // A known code/config/test extension wins over the docs/ directory-prefix
101
+ // fallback: a code/config/test file hosted under docs/ is still that surface,
102
+ // not prose. Extension checks run before the prefix fallbacks below.
102
103
  if (
103
104
  fp.endsWith(".yml") || fp.endsWith(".yaml") ||
104
105
  fp.endsWith(".json") || fp === "package.json"
@@ -114,6 +115,9 @@ export function classifyFile(filePath) {
114
115
  ) {
115
116
  return "code";
116
117
  }
118
+ if (fp.startsWith("docs/") || fp.endsWith(".md") || fp === "README.md") {
119
+ return "docs";
120
+ }
117
121
  return "unknown";
118
122
  }
119
123
  // ---------------------------------------------------------------------------
@@ -142,6 +146,97 @@ function isNonLogicLine(content) {
142
146
  return false;
143
147
  }
144
148
 
149
+ // Security-sensitive seams (#1336): touching these primitives on caller-/plan-
150
+ // influenced input is where trust-boundary bugs concentrate (drove #1335's 8
151
+ // serial Copilot rounds). A changed line matching any of these triggers the
152
+ // SECURITY_SENSITIVE_SEAM category so an up-front adversarial threat-model angle
153
+ // is selected. Fail-safe by design — over-selection just adds one review lens.
154
+ // Plain readFile/writeFile are deliberately excluded (ubiquitous JSON I/O would
155
+ // flag nearly every script diff); the browser/process/network/destructive-fs/
156
+ // upload seams below cover the genuinely dangerous surface, including #1335's
157
+ // Playwright driver.
158
+ const SECURITY_SEAM_PATTERNS = [
159
+ // Browser automation (driving a real browser over semi-trusted navigation)
160
+ /\b(playwright|webkit|chromium|puppeteer)\b/i,
161
+ /\bpage\.(goto|click|fill|evaluate|type|press|selectOption|setInputFiles|route|addInitScript)\b/,
162
+ /\.newPage\s*\(/,
163
+ /\bbrowser\.newContext\b/,
164
+ // Child-process / shell execution
165
+ /\bchild_process\b/,
166
+ /\b(exec|execSync|execFile|execFileSync|spawn|spawnSync)\s*\(/,
167
+ /\bshell\s*:\s*true\b/,
168
+ // Untrusted network fetch
169
+ /\bfetch\s*\(/,
170
+ /\bhttps?\.(get|request)\s*\(/,
171
+ /\b(axios|node-fetch|undici)\b/,
172
+ // Destructive filesystem ops + local-file upload (caller-path removal/read)
173
+ /\b(rm|rmSync|unlink|unlinkSync|rmdir|rmdirSync)\s*\(/,
174
+ /\bsetInputFiles\s*\(/,
175
+ ];
176
+
177
+ /**
178
+ * Whether a changed diff line (content, prefix stripped) touches a
179
+ * security-sensitive seam (#1336).
180
+ *
181
+ * @param {string} content — trimmed line content (without + / - prefix)
182
+ * @returns {boolean}
183
+ */
184
+ function isSecuritySensitiveSeamLine(content) {
185
+ return SECURITY_SEAM_PATTERNS.some((re) => re.test(content));
186
+ }
187
+
188
+ /**
189
+ * Scan a unified diff for a security-sensitive seam (#1336) on any added/removed
190
+ * LOGIC line of a CODE file. Two gates keep it precise: (1) file-gate — only a
191
+ * file that `classifyFile()` calls `code` is scanned, so a yaml/markdown/json
192
+ * line that merely names a primitive (e.g. `shell: true` in a persona prompt, or
193
+ * `child_process` in a doc) never triggers; (2) `!isNonLogicLine` — within a code
194
+ * file, a comment/blank line that names a primitive (e.g. `// spawn( a child`)
195
+ * does not trigger either. Runs independently of the T0/T1 category path so it
196
+ * also covers a pure-code diff (all files classify as `code`), which is the MOST
197
+ * concentrated seam case (e.g. editing a Playwright/child_process driver) and the
198
+ * one #1336 targets.
199
+ *
200
+ * @param {string} diffOutput — raw unified diff output
201
+ * @returns {boolean}
202
+ */
203
+ export function diffHasSecuritySeam(diffOutput) {
204
+ if (!diffOutput) return false;
205
+ let inHunk = false;
206
+ // Only CODE files can carry an executable seam — a YAML/markdown/JSON line that
207
+ // merely names a primitive (e.g. `shell: true` in a persona prompt) is not a
208
+ // seam. Track the current file from the unified-diff `--- a/`/`+++ b/` headers
209
+ // and gate the scan on `classifyFile(...) === "code"`. Bare-hunk input (no file
210
+ // header — used in tests / direct hunk analysis) defaults to code so it still
211
+ // scans; a real `git diff` always carries headers, so it is gated per file.
212
+ let currentFileIsCode = true;
213
+ let fromPath = null;
214
+ for (const line of diffOutput.split("\n")) {
215
+ if (line.startsWith("--- ")) {
216
+ const p = line.slice(4).trim().replace(/^a\//, "");
217
+ fromPath = p === "/dev/null" ? null : p;
218
+ inHunk = false;
219
+ continue;
220
+ }
221
+ if (line.startsWith("+++ ")) {
222
+ const p = line.slice(4).trim().replace(/^b\//, "");
223
+ const effective = p === "/dev/null" ? fromPath : p;
224
+ currentFileIsCode = effective != null && classifyFile(effective) === "code";
225
+ inHunk = false;
226
+ continue;
227
+ }
228
+ if (line.startsWith("@@")) { inHunk = true; continue; }
229
+ if (!inHunk || !currentFileIsCode) continue;
230
+ const isAdd = line.startsWith("+") && !line.startsWith("+++");
231
+ const isDel = line.startsWith("-") && !line.startsWith("---");
232
+ if (!isAdd && !isDel) continue;
233
+ const content = line.slice(1).trim();
234
+ if (isNonLogicLine(content)) continue;
235
+ if (isSecuritySensitiveSeamLine(content)) return true;
236
+ }
237
+ return false;
238
+ }
239
+
145
240
  /**
146
241
  * Analyze unified diff hunks to classify change types.
147
242
  *
@@ -202,6 +297,9 @@ export function analyzeT1(diffOutput, t0) {
202
297
  // Build categories from T0 (shared with inferCategoriesFromT0) + hunk analysis.
203
298
  for (const c of t0FileCategories(t0)) categories.add(c);
204
299
  if (hasLogicChange) categories.add("LOGIC_CHANGE");
300
+ // #1336: a diff touching a security-sensitive seam gets an up-front adversarial
301
+ // threat-model angle, batched at draft time instead of drip-fed via Copilot.
302
+ if (diffHasSecuritySeam(diffOutput)) categories.add("SECURITY_SENSITIVE_SEAM");
205
303
  // Mixed diffs never satisfy the exclusive `_ONLY` checks above (some files are
206
304
  // code), so their peripheral surfaces would be dropped. In this hunk-level path
207
305
  // (only reached for genuinely mixed diffs), also union each surface by PRESENCE
@@ -328,6 +426,14 @@ export function analyzeDiff({ nameStatusOutput, diffOutput }) {
328
426
  };
329
427
  }
330
428
 
429
+ // #1336: seam detection runs on the raw diff regardless of the T0/T1 path, so a
430
+ // pure-code diff (single `code` category, T1 skipped) editing a browser/exec/
431
+ // fetch/fs-mutation driver still triggers the up-front threat-model angle — the
432
+ // most concentrated seam case, and the one this feature targets.
433
+ if (!t1.changeCategories.includes("SECURITY_SENSITIVE_SEAM") && diffHasSecuritySeam(diffOutput)) {
434
+ t1.changeCategories.push("SECURITY_SENSITIVE_SEAM");
435
+ }
436
+
331
437
  // `ambiguous` flags one specific case: a diff T0 could not classify (mixed file
332
438
  // categories, so t0Ambiguous) AND whose hunk analysis still produced no
333
439
  // category. It is NOT the only fallback trigger — resolveDynamicAngles also
@@ -26,6 +26,8 @@
26
26
 
27
27
  import { parse as parseYaml } from "yaml";
28
28
 
29
+ import { resolveRoleModel } from "../config/config.mjs";
30
+
29
31
  /** Pi→Claude tool-name map. A Pi name may expand to multiple Claude tools (search→Grep,Glob). */
30
32
  export const TOOL_NAME_MAP = Object.freeze({
31
33
  read: ["Read"],
@@ -89,24 +91,26 @@ export function rewriteCliInvocation(body, version) {
89
91
  }
90
92
 
91
93
  /**
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`.
94
+ * Rewrite repo-root `../docs/…` *inline* markdown links `](../docs/…)` in a generated *command* or
95
+ * *agent* body so they resolve from the generated file's deeper location. Only the inline `](…)`
96
+ * link form is rewritten (reference-style `[label]: …` and HTML `<a href>` links are left as-is) —
97
+ * command/agent bodies only use inline links, so that is the sole form that occurs. Source
98
+ * commands/agents live at `commands/<name>.command.md` / `agents/<name>.agent.md`, so `../docs/x`
99
+ * resolves to repo-root `docs/x`; the generated wrapper lives one level deeper at
100
+ * `.claude/commands/<name>.md` / `.claude/agents/<name>.md`, where `../docs/x` would wrongly resolve
101
+ * to `.claude/docs/x` (there is no such dir). Repo-root `docs/` is NOT mirrored into `.claude/`, so
102
+ * the link must gain one `../` to reach repo-root: `../docs/x` → `../../docs/x`. Both trees sit one
103
+ * level under `.claude/`, so the same single-level shift applies to each.
100
104
  *
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
+ * Scoped to `../docs/` on purpose. Other `../…` links point at subtrees the generator mirrors under
106
+ * `.claude/` (e.g. `../skills/docs/x` → the bundled `.claude/skills/docs/x`), whose relative depth
107
+ * is preserved verbatim — shifting those would break them. Skills need no rewrite at all for the
108
+ * same reason (their `../docs/x` targets the bundled `.claude/skills/docs/x`).
105
109
  *
106
110
  * @param {string} body
107
111
  * @returns {string}
108
112
  */
109
- export function rewriteCommandRepoLinks(body) {
113
+ export function rewriteGeneratedRepoDocLinks(body) {
110
114
  return String(body).replace(/(\]\(<?)(\.\.\/docs\/)/g, "$1../$2");
111
115
  }
112
116
 
@@ -169,13 +173,20 @@ function normalizeToolList(value) {
169
173
 
170
174
  /**
171
175
  * Transform a canonical `agents/*.agent.md` into a Claude `.claude/agents/*.md` document.
172
- * @param {{ source: string, raw: string, version?: string }} input
176
+ *
177
+ * Stamps the model-tier policy into `model:` frontmatter: the agent's role (its
178
+ * `name`) is resolved via `resolveRoleModel(config, { role, harness: "claude" })`;
179
+ * a concrete model is written, `inherit`/null omits the field. `config` defaults
180
+ * to `{}` so the committed tree bakes the zero-config built-in policy; pass a
181
+ * loaded config to tune the generated tree per repo.
182
+ * @param {{ source: string, raw: string, version?: string, config?: object }} input
173
183
  * @returns {string} Full generated file content.
174
184
  */
175
- export function transformAgent({ source, raw, version = "latest" }) {
185
+ export function transformAgent({ source, raw, version = "latest", config = {} }) {
176
186
  const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
177
- const body = rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version);
187
+ const body = rewriteGeneratedRepoDocLinks(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version));
178
188
  const tools = mapTools(normalizeToolList(frontmatter.tools));
189
+ const model = resolveRoleModel(config, { role: String(frontmatter.name ?? ""), harness: "claude" });
179
190
 
180
191
  const lines = ["---"];
181
192
  lines.push(`name: ${JSON.stringify(String(frontmatter.name ?? ""))}`);
@@ -185,6 +196,12 @@ export function transformAgent({ source, raw, version = "latest" }) {
185
196
  if (tools.length > 0) {
186
197
  lines.push(`tools: ${tools.join(", ")}`);
187
198
  }
199
+ if (model != null) {
200
+ // Quote the stamped model id: resolveRoleModel can return any operator-provided
201
+ // id, and a value with YAML-significant chars would break frontmatter parsing.
202
+ // JSON string literals are valid YAML double-quoted scalars.
203
+ lines.push(`model: ${JSON.stringify(model)}`);
204
+ }
188
205
  lines.push("---");
189
206
  lines.push(GENERATED_NOTE(source));
190
207
  lines.push("");
@@ -202,7 +219,7 @@ export function transformAgent({ source, raw, version = "latest" }) {
202
219
  */
203
220
  export function transformCommand({ source, raw, version = "latest" }) {
204
221
  const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
205
- const body = rewriteCommandRepoLinks(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version));
222
+ const body = rewriteGeneratedRepoDocLinks(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version));
206
223
 
207
224
  const lines = ["---"];
208
225
  if (frontmatter.description != null) {
@@ -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 →
@@ -214,6 +284,18 @@ const UiReviewMigrateConfig = z.strictObject({
214
284
  .optional(),
215
285
  });
216
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
+
217
299
  /**
218
300
  * Per-project boot recipe: a shell `command` that starts the branch's app and a
219
301
  * `readyUrl` an HTTP readiness probe polls until the app is up (never a fixed
@@ -238,6 +320,7 @@ const UiReviewRunConfig = z.strictObject({
238
320
  readyIntervalMs: z.number().int().min(1).max(60000).default(1000),
239
321
  cwd: z.string().trim().min(1).optional(),
240
322
  migrate: UiReviewMigrateConfig.optional(),
323
+ rowTeardown: UiReviewRowTeardownConfig.optional(),
241
324
  });
242
325
 
243
326
  /**
@@ -285,6 +368,12 @@ const UiReviewFlowStepConfig = z.strictObject({
285
368
  path: z.string().trim().min(1).optional(),
286
369
  value: z.string().optional(),
287
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(),
288
377
  }).superRefine((step, ctx) => {
289
378
  // Every action but `goto` targets an element, so a missing selector is a
290
379
  // config error, not a runtime step-failure. (`goto` uses `path`/url.)
@@ -467,7 +556,7 @@ export const FileConfigSchema = z.strictObject({
467
556
  version: z.literal(1),
468
557
  strategy: StrategyConfig.partial().optional(),
469
558
  inputSource: InputSourceConfig.partial().optional(),
470
- models: ModelsConfig.partial().optional(),
559
+ models: ModelsConfigBase.partial().superRefine(refineRoleTiers).optional(),
471
560
  refinement: RefinementConfig.partial().optional(),
472
561
  gates: FileGatesConfig.optional(),
473
562
  autonomy: AutonomyConfig.partial().optional(),
@@ -516,6 +605,7 @@ const BUILTIN_PERSONAS = Object.freeze({
516
605
  yagni: { persona: "review", defaultModel: null },
517
606
  "contract-surface": { persona: "review", defaultModel: null },
518
607
  "input-validation": { persona: "review", defaultModel: null },
608
+ "threat-model": { persona: "review", defaultModel: null },
519
609
  "packaging-runtime": { persona: "review", defaultModel: null },
520
610
  "state-concurrency": { persona: "review", defaultModel: null },
521
611
  "renderer-security": { persona: "review", defaultModel: null },
@@ -587,6 +677,76 @@ export function resolveReviewerRole(config, angle) {
587
677
  };
588
678
  }
589
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
+
590
750
  // ============================================================================
591
751
  // Error types
592
752
  // ============================================================================
@@ -1127,7 +1287,13 @@ export function resolveRefinement(config) {
1127
1287
  const stopOnLowSignal = /** @type {boolean} */ (resolveRefinementConfig(config, "stopOnLowSignal"));
1128
1288
  const lowSignalRoundThreshold = /** @type {number} */ (resolveRefinementConfig(config, "lowSignalRoundThreshold"));
1129
1289
  const lowSignalMaxComments = /** @type {number} */ (resolveRefinementConfig(config, "lowSignalMaxComments"));
1130
- 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 };
1131
1297
  }
1132
1298
 
1133
1299
  /**
@@ -1586,7 +1752,8 @@ export const DEFAULT_DESTRUCTIVE_MIGRATION_PATTERN =
1586
1752
  * @param {DevLoopConfig} config
1587
1753
  * @returns {null | { command: string, readyUrl: string, readyTimeoutMs: number,
1588
1754
  * readyIntervalMs: number, cwd: string|null,
1589
- * migrate: null | { statusCommand: string, applyCommand: string, destructivePattern: string } }}
1755
+ * migrate: null | { statusCommand: string, applyCommand: string, destructivePattern: string },
1756
+ * rowTeardown: null | { deleteCommand: string } }}
1590
1757
  */
1591
1758
  export function resolveUiReviewRunRecipe(config) {
1592
1759
  const run = config?.uiReview?.run;
@@ -1599,6 +1766,10 @@ export function resolveUiReviewRunRecipe(config) {
1599
1766
  destructivePattern: run.migrate.destructivePattern ?? DEFAULT_DESTRUCTIVE_MIGRATION_PATTERN,
1600
1767
  }
1601
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;
1602
1773
  return {
1603
1774
  command: run.command.trim(),
1604
1775
  readyUrl: run.readyUrl.trim(),
@@ -1606,6 +1777,7 @@ export function resolveUiReviewRunRecipe(config) {
1606
1777
  readyIntervalMs: Number.isInteger(run.readyIntervalMs) ? run.readyIntervalMs : 1000,
1607
1778
  cwd: typeof run.cwd === "string" && run.cwd.trim().length > 0 ? run.cwd.trim() : null,
1608
1779
  migrate,
1780
+ rowTeardown,
1609
1781
  };
1610
1782
  }
1611
1783
 
@@ -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
- }