@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 +3 -1
- package/src/analysis/change-classifier.mjs +15 -3
- package/src/analysis/diff-analyzer.mjs +112 -6
- package/src/claude/asset-generation.mjs +34 -17
- package/src/config/config.mjs +178 -6
- package/src/config/extension-defaults.yaml +7 -0
- package/src/debt/shape.mjs +0 -12
- package/src/loop/copilot-loop-state.mjs +38 -6
- package/src/loop/gate-carry-forward.mjs +244 -0
- package/src/loop/policy-constants.mjs +0 -3
- package/src/loop/pr-gate-coordination.mjs +12 -7
- package/src/loop/queue-state.mjs +0 -9
- package/src/loop/steering.mjs +4 -2
- package/src/loop/ui-review-drive.mjs +28 -4
- package/src/loop/ui-review-report.mjs +19 -17
- package/src/loop/ui-review-teardown.mjs +52 -10
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "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
|
-
|
|
72
|
-
|
|
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
|
-
|
|
100
|
-
|
|
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 `](…)`
|
|
94
|
-
* is rewritten (reference-style `[label]: …` and HTML `<a href>` links are left as-is) —
|
|
95
|
-
* bodies only use inline links, so that is the sole form that occurs. Source
|
|
96
|
-
* `commands/<name>.command.md`, so `../docs/x`
|
|
97
|
-
* wrapper lives one level deeper at
|
|
98
|
-
*
|
|
99
|
-
* `.claude
|
|
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 `../…`
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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 =
|
|
222
|
+
const body = rewriteGeneratedRepoDocLinks(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version));
|
|
206
223
|
|
|
207
224
|
const lines = ["---"];
|
|
208
225
|
if (frontmatter.description != null) {
|
package/src/config/config.mjs
CHANGED
|
@@ -20,11 +20,78 @@ const InputSourceConfig = z.strictObject({
|
|
|
20
20
|
default: z.enum(["tracker", "phase-docs"]),
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
-
|
|
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
|
|
61
|
-
//
|
|
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:
|
|
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
|
-
|
|
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: >-
|
package/src/debt/shape.mjs
CHANGED
|
@@ -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
|
-
}
|