@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.
- 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 +265 -67
- 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.2",
|
|
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) {
|