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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "0.8.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",
@@ -57,6 +59,11 @@
57
59
  "./loop/timeout-policy": "./src/loop/timeout-policy.mjs",
58
60
  "./loop/tracker-pr-state": "./src/loop/tracker-pr-state.mjs",
59
61
  "./loop/ui-e2e-scoping": "./src/loop/ui-e2e-scoping.mjs",
62
+ "./loop/ui-review-provision": "./src/loop/ui-review-provision.mjs",
63
+ "./loop/ui-review-drive": "./src/loop/ui-review-drive.mjs",
64
+ "./loop/ui-review-diagnose": "./src/loop/ui-review-diagnose.mjs",
65
+ "./loop/ui-review-report": "./src/loop/ui-review-report.mjs",
66
+ "./loop/ui-review-teardown": "./src/loop/ui-review-teardown.mjs",
60
67
  "./projects/list-queue-items": "./src/projects/list-queue-items.mjs",
61
68
  "./projects/move-queue-item": "./src/projects/move-queue-item.mjs",
62
69
  "./projects/resolve-project": "./src/projects/resolve-project.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"],
@@ -88,6 +90,30 @@ export function rewriteCliInvocation(body, version) {
88
90
  return String(body).split("node <dev-loops-package-root>/cli/index.mjs").join(`npx dev-loops@${version}`);
89
91
  }
90
92
 
93
+ /**
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.
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`).
109
+ *
110
+ * @param {string} body
111
+ * @returns {string}
112
+ */
113
+ export function rewriteGeneratedRepoDocLinks(body) {
114
+ return String(body).replace(/(\]\(<?)(\.\.\/docs\/)/g, "$1../$2");
115
+ }
116
+
91
117
  /**
92
118
  * Map a single Pi tool name to its Claude tool name(s).
93
119
  * @param {string} name
@@ -147,13 +173,20 @@ function normalizeToolList(value) {
147
173
 
148
174
  /**
149
175
  * Transform a canonical `agents/*.agent.md` into a Claude `.claude/agents/*.md` document.
150
- * @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
151
183
  * @returns {string} Full generated file content.
152
184
  */
153
- export function transformAgent({ source, raw, version = "latest" }) {
185
+ export function transformAgent({ source, raw, version = "latest", config = {} }) {
154
186
  const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
155
- const body = rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version);
187
+ const body = rewriteGeneratedRepoDocLinks(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version));
156
188
  const tools = mapTools(normalizeToolList(frontmatter.tools));
189
+ const model = resolveRoleModel(config, { role: String(frontmatter.name ?? ""), harness: "claude" });
157
190
 
158
191
  const lines = ["---"];
159
192
  lines.push(`name: ${JSON.stringify(String(frontmatter.name ?? ""))}`);
@@ -163,6 +196,12 @@ export function transformAgent({ source, raw, version = "latest" }) {
163
196
  if (tools.length > 0) {
164
197
  lines.push(`tools: ${tools.join(", ")}`);
165
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
+ }
166
205
  lines.push("---");
167
206
  lines.push(GENERATED_NOTE(source));
168
207
  lines.push("");
@@ -180,7 +219,7 @@ export function transformAgent({ source, raw, version = "latest" }) {
180
219
  */
181
220
  export function transformCommand({ source, raw, version = "latest" }) {
182
221
  const { frontmatter, body: rawBody } = splitFrontmatter(raw, source);
183
- const body = rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version);
222
+ const body = rewriteGeneratedRepoDocLinks(rewriteCliInvocation(stripPiOnlyBlocks(rawBody), version));
184
223
 
185
224
  const lines = ["---"];
186
225
  if (frontmatter.description != null) {