@dev-loops/core 1.0.3 → 1.0.4-pre.0

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,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "1.0.3",
3
+ "version": "1.0.4-pre.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -117,6 +117,13 @@ export const ALWAYS_INCLUDE = new Set(["gate-evidence", "renderer-security", "pr
117
117
  * When `anglePool` is omitted, additive mode is off and `addedAngles` is
118
118
  * always empty.
119
119
  *
120
+ * A configured angle that is NOT in CATEGORY_ANGLE_MAP (a consumer-defined
121
+ * angle) can still be recommended BY change category or file kind when it
122
+ * declares `categories`/`kinds` via `angleDeclarations`. This is purely
123
+ * additive: it can only SELECT such an angle when the diff intersects its
124
+ * declaration; it never drops any angle the catalog map, ALWAYS_INCLUDE, or the
125
+ * fallback-to-all path would otherwise recommend.
126
+ *
120
127
  * @param {object} options
121
128
  * @param {string[]} options.configuredAngles — all angles configured for this gate
122
129
  * @param {string[]} options.changeCategories — from diff analysis
@@ -124,6 +131,11 @@ export const ALWAYS_INCLUDE = new Set(["gate-evidence", "renderer-security", "pr
124
131
  * @param {string[]} [options.anglePool] — catalog of angles eligible for additive
125
132
  * selection (caller pre-filters this against excludeAngles); when undefined,
126
133
  * additive selection is disabled
134
+ * @param {Record<string, {categories?: string[], kinds?: string[]}>} [options.angleDeclarations]
135
+ * — per-angle category/file-kind bindings for consumer angles absent from
136
+ * CATEGORY_ANGLE_MAP; a match recommends the angle deterministically
137
+ * @param {string[]} [options.fileKinds] — file kinds present in the diff
138
+ * (classifyFile output), used to honor an angle's `kinds` declaration
127
139
  * @returns {DynamicAngleResult}
128
140
  */
129
141
  export function resolveDynamicAngles({
@@ -131,6 +143,8 @@ export function resolveDynamicAngles({
131
143
  changeCategories,
132
144
  ambiguous = false,
133
145
  anglePool,
146
+ angleDeclarations = {},
147
+ fileKinds = [],
134
148
  }) {
135
149
  // Fallback: ambiguous diff → all angles
136
150
  if (ambiguous) {
@@ -177,6 +191,27 @@ export function resolveDynamicAngles({
177
191
  }
178
192
  }
179
193
 
194
+ // Consumer angles bound by declaration: a configured angle absent from
195
+ // CATEGORY_ANGLE_MAP can name the change-categories / file-kinds that select
196
+ // it. Recommend it when the diff intersects that declaration, so it need not
197
+ // be forced `mandatory` to survive dynamic pruning. Additive only: never
198
+ // removes an angle already recommended above.
199
+ const changeCatSet = new Set(changeCategories);
200
+ const fileKindSet = new Set(fileKinds);
201
+ for (const angle of configuredAngles) {
202
+ if (recommended.has(angle)) continue;
203
+ const decl = angleDeclarations[angle];
204
+ if (!decl) continue;
205
+ const catHit = (decl.categories ?? []).some((c) => changeCatSet.has(c));
206
+ const kindHit = (decl.kinds ?? []).some((k) => fileKindSet.has(k));
207
+ if (catHit || kindHit) {
208
+ recommended.add(angle);
209
+ if (!triggers.has(angle)) {
210
+ triggers.set(angle, catHit ? "declared-category" : "declared-kind");
211
+ }
212
+ }
213
+ }
214
+
180
215
  // Filter to only angles that are configured
181
216
  const recommendedAngles = configuredAngles.filter((a) => recommended.has(a));
182
217
  const skippedAngles = configuredAngles.filter((a) => !recommended.has(a));
@@ -16,6 +16,57 @@
16
16
  // closed to a full re-review.
17
17
  const DOTFILE_CONFIG_BASENAMES = new Set([".devloops"]);
18
18
 
19
+ // Generic classifier tables. Files are classified by principle (broad extension
20
+ // tables + directory/basename conventions), not by a bespoke per-language rule.
21
+ // A new mainstream language is covered by adding its extension here, not by a new
22
+ // branch. Deliberate fail-closed exceptions (`.ruby-version`, `.nvmrc`,
23
+ // stylesheets) are simply absent from every table, so they fall through to
24
+ // "unknown".
25
+
26
+ // Source extensions across the common languages. A file with one of these is
27
+ // executable/product logic → "code".
28
+ const CODE_EXTENSIONS = new Set([
29
+ // JS/TS
30
+ ".mjs", ".cjs", ".js", ".jsx", ".ts", ".mts", ".cts", ".tsx",
31
+ // Ruby (source + Rails view templates, which embed control flow / XSS surface)
32
+ ".rb", ".rake", ".erb", ".haml", ".slim", ".jbuilder",
33
+ // Python
34
+ ".py", ".pyi",
35
+ // Go
36
+ ".go",
37
+ // Rust
38
+ ".rs",
39
+ // Java / Kotlin / Scala
40
+ ".java", ".kt", ".kts", ".scala", ".sc",
41
+ // C / C++ / C#
42
+ ".c", ".h", ".cc", ".cpp", ".cxx", ".hpp", ".hh", ".hxx", ".cs",
43
+ // PHP / Swift / Elixir / Lua / Dart
44
+ ".php", ".swift", ".ex", ".exs", ".lua", ".dart",
45
+ // Shell
46
+ ".sh", ".bash", ".zsh", ".fish",
47
+ ]);
48
+ // Basename-only source files (no discriminating extension).
49
+ const CODE_BASENAMES = new Set(["Rakefile"]);
50
+
51
+ // Data/config/manifest extensions. `.ru` (rackup) and `.gemspec` are Ruby
52
+ // packaging surface. `.lock` covers every lockfile (Gemfile.lock, Cargo.lock,
53
+ // poetry.lock, yarn.lock, …).
54
+ const CONFIG_EXTENSIONS = new Set([
55
+ ".yml", ".yaml", ".json", ".toml", ".ini", ".cfg", ".lock", ".ru", ".gemspec",
56
+ ]);
57
+ // Manifest basenames without a config extension.
58
+ const CONFIG_BASENAMES = new Set([
59
+ "Gemfile", "go.mod", "go.sum", "requirements.txt", "pom.xml", "Dockerfile", "Makefile",
60
+ ]);
61
+
62
+ // Prose/documentation extensions.
63
+ const DOCS_EXTENSIONS = new Set([".md", ".markdown", ".rst", ".adoc", ".txt"]);
64
+
65
+ // Test conventions. A directory segment named any of these, or a basename token
66
+ // (`*_test.*`, `*_spec.*`, `test_*`, `*.test.*`, `*.spec.*`), marks a test file.
67
+ const TEST_DIR_SEGMENTS = new Set(["test", "tests", "spec", "specs", "__tests__"]);
68
+ const TEST_BASENAME_RE = /(\.test\.|\.spec\.|_test\.|_spec\.|^test_)/;
69
+
19
70
  // Prose surface that arms the required `deslop` gate angle.
20
71
  // skills/docs/** is excluded via SKILLS_DOCS_EXEMPT_RE: those are normative
21
72
  // contracts, not prose.
@@ -135,34 +186,56 @@ export function analyzeT0(nameStatusOutput) {
135
186
  */
136
187
  export function classifyFile(filePath) {
137
188
  const fp = normalizeSep(filePath);
189
+ const base = fp.split("/").pop();
190
+ // Extension excludes a leading-dot dotfile (`.nvmrc` → "", not ".nvmrc"), so
191
+ // runtime-version dotfiles never match an extension table and fall through to
192
+ // "unknown".
193
+ const dot = base.lastIndexOf(".");
194
+ const ext = dot > 0 ? base.slice(dot).toLowerCase() : "";
195
+
138
196
  if (fp.startsWith(".github/")) {
139
197
  return "ci";
140
198
  }
141
- // A known code/config/test extension wins over the docs/ directory-prefix
142
- // fallback: a code/config/test file hosted under docs/ is still that surface,
143
- // not prose. Extension checks run before the prefix fallbacks below.
199
+ // Config wins over every later surface: a manifest/data file (even one hosted
200
+ // under docs/, or a build.gradle.kts that also has a code extension) is config,
201
+ // not prose or code. `.ruby-version`/`.nvmrc` are absent from all tables and so
202
+ // stay "unknown" — a runtime bump must re-run ci-guard/determinism, not carry a
203
+ // stale clean verdict.
144
204
  if (
145
- fp.endsWith(".yml") || fp.endsWith(".yaml") ||
146
- fp.endsWith(".json") || fp === "package.json"
205
+ CONFIG_EXTENSIONS.has(ext) ||
206
+ CONFIG_BASENAMES.has(base) ||
207
+ DOTFILE_CONFIG_BASENAMES.has(base) ||
208
+ base.startsWith("build.gradle")
147
209
  ) {
148
210
  return "config";
149
211
  }
150
- if (DOTFILE_CONFIG_BASENAMES.has(fp.split("/").pop())) {
151
- return "config";
212
+ // Generic test convention, part 1: a basename carrying a test/spec token
213
+ // (`*_test.*`, `*_spec.*`, `test_*`, `*.test.*`, `*.spec.*`) is a test wherever
214
+ // it lives — a strong per-file signal that subsumes the old `.test.` and Ruby
215
+ // `*_spec.rb`/`*_test.rb` rules and wins even under `docs/`.
216
+ if (TEST_BASENAME_RE.test(base)) {
217
+ return "test";
152
218
  }
153
- if (fp.includes(".test.") || fp.startsWith("test/")) {
219
+ // Generic test convention, part 2: a `test`/`tests`/`spec`/`specs`/`__tests__`
220
+ // path segment at any depth. Subsumes the old root-anchored `test/`/`spec/`
221
+ // rules and broadens them to nested suites (`packages/core/test/foo.mjs`).
222
+ // Excluded under `docs/`: the old rule anchored test dirs at the ROOT, so a
223
+ // docs-tree prose file (`docs/specs/queue-mode/SPEC.md`) was never a test — the
224
+ // widened any-depth scan must not reclassify it. This only skips the
225
+ // DIRECTORY-based classification; a code/config file or a test-token basename
226
+ // under docs/ is still classified by the extension/basename rules above and
227
+ // below (`docs/example.mjs` → code, `docs/x.test.mjs` → test).
228
+ const dirs = fp.split("/").slice(0, -1);
229
+ if (!fp.startsWith("docs/") && dirs.some((seg) => TEST_DIR_SEGMENTS.has(seg))) {
154
230
  return "test";
155
231
  }
156
- if (
157
- fp.endsWith(".mjs") || fp.endsWith(".js") ||
158
- fp.endsWith(".ts") || fp.endsWith(".mts")
159
- ) {
232
+ // Broad source table: a file in any covered language is code, not prose, even
233
+ // under docs/. Stylesheets (`.scss`/`.sass`) are deliberately absent — a
234
+ // style/asset kind is out of scope.
235
+ if (CODE_EXTENSIONS.has(ext) || CODE_BASENAMES.has(base)) {
160
236
  return "code";
161
237
  }
162
- if (
163
- fp.startsWith("docs/") || fp.endsWith(".md") || fp.endsWith(".markdown") ||
164
- fp === "README.md"
165
- ) {
238
+ if (fp.startsWith("docs/") || DOCS_EXTENSIONS.has(ext)) {
166
239
  return "docs";
167
240
  }
168
241
  return "unknown";
@@ -32,6 +32,7 @@ import {
32
32
  commandContainsCopilotSummonComment,
33
33
  commandContainsDetachedWaitTool,
34
34
  commandContainsInlineInterpreter,
35
+ commandContainsCodeVerificationEntrypoint,
35
36
  } from "../loop/bash-command-classify.mjs";
36
37
 
37
38
  /**
@@ -61,6 +62,24 @@ function commandContainsEvidenceWrite(command) {
61
62
  */
62
63
  export const DEV_LOOP_AGENT_TYPE = "dev-loop";
63
64
 
65
+ /**
66
+ * Normalize a Claude `agent_type` hook-payload value that may be PLUGIN-NAMESPACED
67
+ * (`<plugin-name>:<agent-name>`, e.g. `dev-loops:dev-loop`) to the bare agent name the coordinator
68
+ * deciders compare against `DEV_LOOP_AGENT_TYPE`. Returns the substring after the last `:` when
69
+ * present, else `agentType` unchanged (including `null`/non-string, passed through as-is).
70
+ *
71
+ * Applied in the coordinator-scoped deciders (`decideBashGate`, `decideCoordinatorWriteGuard`).
72
+ * Deliberately NOT applied in `decideWriteGuard` — its main-agent allow-set boundary is covered by
73
+ * the `DEVLOOPS_RUN_ID` run-id check first, and broadening that decider's comparison is out of
74
+ * scope for the coordinator→worker delegation boundary.
75
+ * @param {string|null|undefined} agentType @returns {string|null|undefined}
76
+ */
77
+ export function normalizeAgentType(agentType) {
78
+ if (typeof agentType !== "string") return agentType;
79
+ const idx = agentType.lastIndexOf(":");
80
+ return idx === -1 ? agentType : agentType.slice(idx + 1);
81
+ }
82
+
64
83
  /**
65
84
  * Decide whether a PreToolUse Bash command must be blocked by a dev-loop gate boundary.
66
85
  *
@@ -68,8 +87,9 @@ export const DEV_LOOP_AGENT_TYPE = "dev-loop";
68
87
  * - `gh pr create` — blocked outright; PR creation must flow through the canonical wrapper
69
88
  * (`scripts/github/create-pr.mjs` / `dev-loops pr create`), which always drafts and self-assigns.
70
89
  * - `gh pr ready` — blocked without clean draft_gate evidence.
71
- * - `gh pr merge` — blocked without full pre-merge gate evidence (clean current-head draft_gate +
72
- * pre_approval_gate).
90
+ * - `gh pr merge` — blocked outright; use scripts/github/merge-pr.mjs. Its gate evidence check
91
+ * requires a clean draft_gate transition record + current-head pre_approval_gate
92
+ * (GATE-COMMENT-DRAFT-REQUIREMENTS in skills/docs/gate-review-comment-contract.md).
73
93
  * - raw `gh issue create` / `gh issue comment` / `gh issue edit` / `gh pr comment` — blocked ONLY
74
94
  * from a SUBAGENT context (`agentType` non-null) on the target repo. Sanctioned external writes
75
95
  * flow through node wrappers; the MAIN AGENT / operator (agentType null) retains direct access.
@@ -92,6 +112,9 @@ export const DEV_LOOP_AGENT_TYPE = "dev-loop";
92
112
  * (`resolveHumanMergeOnly`); when true, `gh pr merge` is refused actor-independently
93
113
  * (STOP-HUMAN-MERGE-001), because the main agent is the actor that performs GitHub writes and a
94
114
  * subagent-only deny would enforce nothing.
115
+ * @param {boolean} [params.enforceCoordinator] - Strict mode for the COORDINATOR-VERIFY-DELEGATION
116
+ * boundary, derived by the hook from `DEVLOOPS_COORDINATOR_READONLY=1` — the SAME flag that
117
+ * gates `decideCoordinatorWriteGuard`. Default fail-open (mirrors that boundary).
95
118
  * @returns {HookDecision}
96
119
  */
97
120
  export function decideBashGate({
@@ -103,10 +126,30 @@ export function decideBashGate({
103
126
  gateError = null,
104
127
  agentType = null,
105
128
  humanMergeOnly = false,
129
+ enforceCoordinator = false,
106
130
  }) {
107
131
  if (typeof command !== "string") {
108
132
  return ALLOW;
109
133
  }
134
+
135
+ // COORDINATOR-VERIFY-DELEGATION: a known code-verification/build entrypoint (bun run
136
+ // verify/test, vitest, npm test/run test/run build, ...) run inline by the dev-loop COORDINATOR
137
+ // itself (agent_type "dev-loop"). WORKER subagents (developer/fixer/quality/review) may run these
138
+ // freely — only the coordinator is scoped out, mirroring `decideCoordinatorWriteGuard`'s
139
+ // agent_type discriminator. Opt-in via the same `DEVLOOPS_COORDINATOR_READONLY=1` flag as the
140
+ // write-guard boundary; default fail-open. Not scoped to `inManagedRepo` — this is a local
141
+ // command-invocation boundary (which binary ran), not a GitHub-repo-targeting one.
142
+ if (enforceCoordinator && normalizeAgentType(agentType) === DEV_LOOP_AGENT_TYPE && commandContainsCodeVerificationEntrypoint(command)) {
143
+ return {
144
+ decision: "deny",
145
+ reason:
146
+ "COORDINATOR-VERIFY-DELEGATION: the dev-loop coordinator must not run code-verification/build " +
147
+ "commands inline. Delegate the verification run to a fresh worker subagent (developer/fixer/" +
148
+ "quality/review), which reports back a compact pass/fail plus any failing-test names — or, when " +
149
+ "checking a pushed commit, prefer CI's structured conclusion (`gh pr checks` / " +
150
+ "scripts/github/detect-checkpoint-evidence.mjs) over a local run. See skills/docs/main-agent-contract.md.",
151
+ };
152
+ }
110
153
  // Normalize (trim + case-fold) so a divergent slug (surrounding whitespace, casing) does not
111
154
  // silently fail OPEN. A repo is dev-loops-managed when inManagedContext is true (a .devloops
112
155
  // config exists at its root); the managed slug is that repo's resolved identity, which may be
@@ -130,6 +173,29 @@ export function decideBashGate({
130
173
  };
131
174
  }
132
175
 
176
+ // COPILOT-FOLLOWUP-WAIT-TOOLS: banned detached/polling wait wrappers. Actor-independent: the
177
+ // coordinator/main agent — not subagents only — is the actor that leaves backgrounded
178
+ // `until`/`while … sleep … done` poll loops and bare-`&` backgrounded probe shells orphaned
179
+ // under the Claude Code harness (no async wake to join them), so the gate must deny its
180
+ // backgrounding too. Evaluated HERE, before the `gh pr ready`/`merge`/`create` classification,
181
+ // so a compound command that pairs a lifecycle verb with a backgrounded wait
182
+ // (`gh pr create --repo other/x && node …/probe-copilot-review.mjs … &`) cannot short-circuit
183
+ // past it via the create/ready ALLOW paths. The sanctioned wait is always a bounded FOREGROUND
184
+ // inline probe (`probe-copilot-review.mjs` / `wait-pr-checks.mjs` with an explicit
185
+ // --timeout/--timeout-ms; `gh run watch`; the watch-cycle CLIs).
186
+ if (inManagedRepo && commandContainsDetachedWaitTool(command)) {
187
+ return {
188
+ decision: "deny",
189
+ reason:
190
+ "COPILOT-FOLLOWUP-WAIT-TOOLS: wait only through a bounded FOREGROUND probe (scripts/github/" +
191
+ "probe-copilot-review.mjs or scripts/github/wait-pr-checks.mjs with an explicit --timeout/" +
192
+ "--timeout-ms; scripts/loop/detect-copilot-loop-state.mjs one-shot; dev-loops loop watch-cycle; " +
193
+ "gh run watch) — nohup/disown/tmux/screen detach, while-sleep-poll loops, and bare-`&` " +
194
+ "backgrounding of a probe/wait script are barred for the coordinator and every subagent (a " +
195
+ "backgrounded wait orphans under Claude Code, which has no async wake to join it).",
196
+ };
197
+ }
198
+
133
199
  // SUBISSUE-NO-ADHOC-BYPASS: ad-hoc `gh api` writes to the target repo's sub-issue endpoints.
134
200
  // Actor-independent (no reserved direct path). Gated on the target repo: the absolute slug-embedded
135
201
  // form identifies the target repo; the bare relative form (`gh api issues/5/sub_issues`) resolves
@@ -237,20 +303,9 @@ export function decideBashGate({
237
303
  }
238
304
 
239
305
  if (!isReady && !isMerge && !isCreate) {
240
- // COPILOT-FOLLOWUP-WAIT-TOOLS: banned detached/polling wait wrappers. Subagent-only — the
241
- // rule is classified `agent` (behavioral guidance for the dev-loop driving agent); the main
242
- // agent/operator retains manual wait tooling. The main agent's own sanctioned wait path is still
243
- // the deterministic tools.
244
- if (typeof agentType === "string" && inManagedRepo && commandContainsDetachedWaitTool(command)) {
245
- return {
246
- decision: "deny",
247
- reason:
248
- "COPILOT-FOLLOWUP-WAIT-TOOLS: wait only through deterministic tools (scripts/loop/detect-copilot-" +
249
- "loop-state.mjs one-shot, dev-loops loop watch-cycle persistent, scripts/github/wait-pr-checks.mjs, " +
250
- "gh run watch) — nohup/disown/tmux/screen detach and while-sleep-poll loops are barred for the " +
251
- "dev-loop driving agent.",
252
- };
253
- }
306
+ // The detached-wait deny (COPILOT-FOLLOWUP-WAIT-TOOLS) is evaluated earlier actor-independently
307
+ // and BEFORE this lifecycle-verb classification so a compound command pairing a lifecycle verb
308
+ // with a backgrounded wait cannot short-circuit past it through the create/ready ALLOW paths.
254
309
  return ALLOW;
255
310
  }
256
311
 
@@ -291,8 +346,7 @@ export function decideBashGate({
291
346
  return ALLOW;
292
347
  }
293
348
  }
294
- // When both verbs appear in a compound command, apply the stricter merge gate — if it passes,
295
- // the draft_gate (a subset of the pre-merge evidence check) is also satisfied.
349
+ // When both verbs appear in a compound command, the unconditional raw-merge refusal wins.
296
350
  const verb = isMerge ? "gh pr merge" : "gh pr ready";
297
351
  // Pass through only when EVERY gated verb segment is PROVEN foreign (explicit repo, managed slug
298
352
  // resolves, and demonstrably differs). A segment with no explicit repo, or an unresolvable managed
@@ -411,6 +465,51 @@ export function decideWriteGuard({ filePath, isRepoMutation, enforce = false, en
411
465
  };
412
466
  }
413
467
 
468
+ /**
469
+ * Decide whether a PreToolUse Write/Edit must be blocked by the coordinator→worker delegation
470
+ * boundary — the INVERSE of `decideWriteGuard`, one level down. Under the Claude Code
471
+ * harness the dev-loop agent itself (Claude `agent_type === "dev-loop"`) acts as a delegating
472
+ * COORDINATOR: it MUST NOT mutate TRACKED repo files directly — that work is delegated to a fresh
473
+ * WORKER subagent (`developer`/`fixer`/`quality`/`docs`). `agent_type` is the only discriminator:
474
+ * `DEVLOOPS_RUN_ID` does not distinguish coordinator from worker (the coordinator mints it and
475
+ * propagates it to the workers it dispatches), so — unlike `decideWriteGuard` — this decider does
476
+ * not key on run id at all.
477
+ *
478
+ * Denies only when ALL of: strict enforcement is on, the target is a tracked repo mutation, AND
479
+ * the caller's `agent_type` is the coordinator's (`"dev-loop"`). Every other `agent_type` —
480
+ * including `null` (the Pi main agent / an interactive Claude session with no subagent context,
481
+ * which is `decideWriteGuard`'s boundary, not this one) and any worker role — is allowed here.
482
+ * Strict enforcement is opt-in via `enforce` (the hook derives it from
483
+ * `DEVLOOPS_COORDINATOR_READONLY=1`); default is fail-open, mirroring `decideWriteGuard`'s
484
+ * adopt-safe precedent so enabling this boundary does not retroactively break a repo's own
485
+ * interactive Claude Code dev.
486
+ *
487
+ * @param {Object} params
488
+ * @param {string} params.filePath - Target file path.
489
+ * @param {boolean} params.isRepoMutation - True if inside the repo working tree AND not gitignored.
490
+ * @param {boolean} [params.enforce] - Strict mode (DEVLOOPS_COORDINATOR_READONLY=1).
491
+ * @param {string|null} [params.agentType] - Claude `agent_type` from the hook payload, if any.
492
+ * @returns {HookDecision}
493
+ */
494
+ export function decideCoordinatorWriteGuard({ filePath, isRepoMutation, enforce = false, agentType = null }) {
495
+ if (!enforce) {
496
+ return ALLOW; // strict enforcement not enabled — fail open
497
+ }
498
+ if (!isRepoMutation) {
499
+ return ALLOW; // non-repo or gitignored path (tmp/, the scratchpad, sanctioned ledger paths)
500
+ }
501
+ if (normalizeAgentType(agentType) !== DEV_LOOP_AGENT_TYPE) {
502
+ return ALLOW; // not the coordinator — a worker subagent, or the main agent (the other boundary)
503
+ }
504
+ return {
505
+ decision: "deny",
506
+ reason:
507
+ `Coordinator→worker delegation boundary: refusing to mutate repository path "${filePath}" as the ` +
508
+ "dev-loop coordinator. Delegate this tracked-file edit to a fresh worker subagent (developer/fixer/" +
509
+ "quality/docs) instead of writing it directly. See skills/docs/main-agent-contract.md.",
510
+ };
511
+ }
512
+
414
513
  /**
415
514
  * Env var that authorizes a deliberate main-checkout mutation while a worktree
416
515
  * cycle is active. Reuses the existing default-branch-guard override
@@ -6,6 +6,7 @@ import { parse as parseYaml } from "yaml";
6
6
  import { fileURLToPath } from "node:url";
7
7
  import { z } from "zod";
8
8
  import { classifyFile } from "../analysis/diff-analyzer.mjs";
9
+ import { ChangeCategory } from "../analysis/change-classifier.mjs";
9
10
  import { isDevLoopConfigSourcePath } from "../loop/gate-carry-forward.mjs";
10
11
  import { isClaudeHarness } from "../loop/run-context.mjs";
11
12
  import { trimmedOrNull } from "../loop/normalize.mjs";
@@ -45,6 +46,12 @@ const BUILTIN_ROLE_TIERS = Object.freeze({
45
46
  quality: "low",
46
47
  refiner: "high",
47
48
  review: "high",
49
+ // The pre-PR review pass (skills/docs/pre-pr-review-contract.md) runs one
50
+ // fresh-context general-purpose reviewer before the first push. Default tier
51
+ // is high (strongest): with zero config that resolves to opus on Claude and
52
+ // null (inherit) on Pi. Operators opt into a concrete strong model per
53
+ // harness via models.tiers/roleTiers.
54
+ "pre-PR-reviewer": "high",
48
55
  "dev-loop": "inherit",
49
56
  });
50
57
 
@@ -113,6 +120,13 @@ const RefinementConfig = z.strictObject({
113
120
  // cost saving, never a silently-enforced information cut.
114
121
  export const GATE_ANGLE_SCOPES = Object.freeze(["full", "changed-files", "docs-only"]);
115
122
 
123
+ // Change-category and file-kind vocabularies a consumer angle can bind to.
124
+ // CHANGE_CATEGORY_NAMES mirrors ChangeCategory; FILE_KIND_NAMES mirrors
125
+ // classifyFile()'s output range. Both feed z.enum so an unknown name is
126
+ // rejected fail-closed at validation instead of silently never matching.
127
+ const CHANGE_CATEGORY_NAMES = Object.freeze(Object.values(ChangeCategory));
128
+ const FILE_KIND_NAMES = Object.freeze(["code", "docs", "config", "test", "ci", "unknown"]);
129
+
116
130
  // One review angle: a bare string is sugar for `{ name }`; the fields are
117
131
  // documented on the schema below. mergeConfigLayers merges these arrays BY
118
132
  // `name` across config layers, so a later layer can add or disable a single
@@ -132,13 +146,15 @@ const GateAngleEntry = z.preprocess(
132
146
  model: z.string().trim().min(1).optional().describe("Concrete model override for this angle (highest precedence)."),
133
147
  tier: z.string().trim().min(1).optional().describe("Model tier alias for this angle (used when `model` is absent)."),
134
148
  scope: z.enum(GATE_ANGLE_SCOPES).optional().describe("Surface scope this angle needs: full (default), changed-files (diff without the adjacent-code bundle or its changed-files/adjacent-file summary section), or docs-only (doc-file hunks only). Unknown/omitted resolves to full."),
149
+ categories: z.array(z.enum(CHANGE_CATEGORY_NAMES)).min(1).optional().describe("Change categories (e.g. LOGIC_CHANGE, CONFIG_ONLY, SECURITY_SENSITIVE_SEAM) that dynamically SELECT this consumer angle by diff, so it need not be forced mandatory. Unknown names are rejected fail-closed."),
150
+ kinds: z.array(z.enum(FILE_KIND_NAMES)).min(1).optional().describe("File kinds (code/config/test/ci/docs/unknown, classifyFile output) that dynamically SELECT this consumer angle by diff. Unknown names are rejected fail-closed."),
135
151
  }),
136
152
  );
137
153
 
138
154
  // Diff-class kinds a tier's `match` can name — exactly classifyFile()'s
139
155
  // output range (../analysis/diff-analyzer.mjs), so a tier config can never
140
156
  // name a kind the classifier could not produce.
141
- const GateTierMatchKind = z.enum(["code", "docs", "config", "test", "ci", "unknown"]);
157
+ const GateTierMatchKind = z.enum(FILE_KIND_NAMES);
142
158
 
143
159
  // A tier's match conditions: EVERY changed file's kind must be in `kinds`
144
160
  // (when set) AND the change must stay within `maxFiles`/`maxLines` (when
@@ -216,7 +232,7 @@ function formatConfigValue(value) {
216
232
  const GATE_KEYS_WITH_BLOCKING_SEVERITIES = /** @type {const} */ (["draft", "preApproval", "spike"]);
217
233
 
218
234
  const GateConfig = z.strictObject({
219
- angles: z.array(GateAngleEntry).optional().describe("Review lenses this gate fans out to. A bare string is sugar for { name }; an object may set mandatory/enabled/persona/prompt/model/tier."),
235
+ angles: z.array(GateAngleEntry).optional().describe("Review lenses this gate fans out to. A bare string is sugar for { name }; an object may set mandatory/enabled/persona/prompt/model/tier/scope/categories/kinds."),
220
236
  dynamic: GateDynamicConfig.optional().describe("Diff-driven dynamic angle selection policy for this gate."),
221
237
  required: z.boolean().default(true).describe("Whether this gate must run."),
222
238
  requireCi: z.boolean().default(true).describe("Per-gate CI prerequisite (default true): the gate requires green CI on the current head; false opts this gate out of the CI precondition entirely, including a real failure."),
@@ -234,6 +250,13 @@ const GateConfig = z.strictObject({
234
250
  // resolveGateConfig applies the built-in fallback (3) after checking both.
235
251
  mediumFixWindow: z.number().int().nonnegative().optional().describe("Per-gate medium fix window: an open medium finding stays in the in-gate fix loop through this many rounds of this gate's chain before deferral. high is exempt (never defers). Default 3."),
236
252
  worthFixingNowFixWindow: z.number().int().nonnegative().optional().describe("Deprecated alias for mediumFixWindow (pre-rename key name); mediumFixWindow wins when both are set."),
253
+ // No schema-level `.default()` for the same reason as mediumFixWindow above:
254
+ // a default would fill this key on every config layer independently and
255
+ // shadow a layer that sets only this key. resolveGateConfig applies the
256
+ // built-in fallback ("medium") when the key is absent on the resolved gate.
257
+ inlineSeverityFloor: z.enum(["medium", "low", "nit"]).optional().describe(
258
+ "Lowest defect severity still posted as an inline resolvable review thread. Valid values: \"medium\" (default), \"low\", \"nit\" — the floor can never be raised above \"medium\", so medium/high/question always post inline and only low/nit can ever fold. Findings BELOW this floor are folded into a collapsed <details> block in the verdict-marker body instead of posting inline (they create no gate-authored thread); this enforces the \"never suppress medium/high\" non-goal, keeping the folded-summary \"low/nit\" label accurate by construction. A \"question\" always posts inline regardless of this floor (it must keep its resolvable thread to block gate-close until answered). Lower it (e.g. \"low\" or \"nit\") to restore inline posting of lower severities."
259
+ ),
237
260
  // Ordered, first-match-wins diff-class angle tiers (see resolveGateTier).
238
261
  // Absent/empty = tiers never apply.
239
262
  tiers: z.array(GateTier).min(1).describe("Ordered, first-match-wins diff-class angle tiers for this gate. When the first-matching tier's angle set is inside the gate's angle pool, it replaces dynamic angle reduction for that diff class.").optional(),
@@ -916,13 +939,13 @@ const DEFAULT_REVIEWER_PERSONA = "default-reviewer";
916
939
  /**
917
940
  * Normalize one raw `gates.<gate>.angles[]` entry (string sugar or object,
918
941
  * possibly hand-built and never zod-validated — e.g. a test config object) to
919
- * `{ name, mandatory?, enabled?, persona?, prompt?, model?, tier?, scope? }`.
942
+ * `{ name, mandatory?, enabled?, persona?, prompt?, model?, tier?, scope?, categories?, kinds? }`.
920
943
  * Returns null for a malformed/empty entry so callers can filter it out. An
921
944
  * invalid `scope` (not one of GATE_ANGLE_SCOPES) is dropped rather than
922
945
  * kept verbatim — resolveGateAngleScope's fail-open default only ever needs
923
946
  * to handle an ABSENT field, never a foreign value.
924
947
  * @param {unknown} a
925
- * @returns {{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string, scope?: string}|null}
948
+ * @returns {{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string, scope?: string, categories?: string[], kinds?: string[]}|null}
926
949
  */
927
950
  function normalizeAngleEntry(a) {
928
951
  if (typeof a === "string") {
@@ -940,6 +963,17 @@ function normalizeAngleEntry(a) {
940
963
  if (typeof a.model === "string" && a.model.trim().length > 0) entry.model = a.model.trim();
941
964
  if (typeof a.tier === "string" && a.tier.trim().length > 0) entry.tier = a.tier.trim();
942
965
  if (typeof a.scope === "string" && GATE_ANGLE_SCOPES.includes(a.scope.trim())) entry.scope = a.scope.trim();
966
+ // Category/file-kind bindings for consumer angles. Enum membership is
967
+ // enforced by the schema; this hand-built path only keeps non-empty string
968
+ // entries (bad names simply never match at resolve time).
969
+ const cats = Array.isArray(a.categories)
970
+ ? a.categories.filter((c) => typeof c === "string" && c.trim().length > 0).map((c) => c.trim())
971
+ : [];
972
+ if (cats.length > 0) entry.categories = cats;
973
+ const kinds = Array.isArray(a.kinds)
974
+ ? a.kinds.filter((k) => typeof k === "string" && k.trim().length > 0).map((k) => k.trim())
975
+ : [];
976
+ if (kinds.length > 0) entry.kinds = kinds;
943
977
  return entry;
944
978
  }
945
979
  return null;
@@ -949,7 +983,7 @@ function normalizeAngleEntry(a) {
949
983
  * Normalize a raw `gates.<gate>.angles` array into full entry objects,
950
984
  * dropping malformed entries.
951
985
  * @param {unknown} raw
952
- * @returns {Array<{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string, scope?: string}>}
986
+ * @returns {Array<{name: string, mandatory?: boolean, enabled?: boolean, persona?: string, prompt?: string, model?: string, tier?: string, scope?: string, categories?: string[], kinds?: string[]}>}
953
987
  */
954
988
  function normalizeAngleEntries(raw) {
955
989
  if (!Array.isArray(raw)) return [];
@@ -1771,7 +1805,7 @@ function resolveBlockingSeverities(config, gate) {
1771
1805
  *
1772
1806
  * @param {DevLoopConfig} config
1773
1807
  * @param {"draft"|"preApproval"|"spike"} gate
1774
- * @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean, additiveAngles: boolean, mediumFixWindow: number, tiers: Array<{name: string, match: object, angles: string[]}> }}
1808
+ * @returns {{ angles: string[]|null, excludeAngles: string[], mandatoryAngles: string[], required: boolean, requireCi: boolean, blockCleanOnFindingSeverities: string[], dynamicAngles: boolean, additiveAngles: boolean, mediumFixWindow: number, inlineSeverityFloor: string, tiers: Array<{name: string, match: object, angles: string[]}>, angleCategoryBindings: Record<string, {categories: string[], kinds: string[]}> }}
1775
1809
  * @throws {Error} when ANY gate's (not only the requested one's) PRESENT
1776
1810
  * `blockCleanOnFindingSeverities` is schema-invalid (non-array, empty, or an
1777
1811
  * out-of-vocabulary entry). Validated EAGERLY across all three gates on every
@@ -1808,7 +1842,17 @@ export function resolveGateConfig(config, gate) {
1808
1842
  // mediumFixWindow wins; worthFixingNowFixWindow is the deprecated alias,
1809
1843
  // still honored so an unmigrated config keeps its window.
1810
1844
  mediumFixWindow: gateConfig?.mediumFixWindow ?? gateConfig?.worthFixingNowFixWindow ?? 3,
1845
+ inlineSeverityFloor: gateConfig?.inlineSeverityFloor ?? "medium",
1811
1846
  tiers: gateConfig?.tiers ?? [],
1847
+ // Per-angle category/file-kind bindings for enabled entries that declare
1848
+ // them, so dynamic resolution can select a consumer angle by diff instead
1849
+ // of forcing it mandatory. Only entries WITH a declaration appear here;
1850
+ // everything else keeps today's behavior.
1851
+ angleCategoryBindings: Object.fromEntries(
1852
+ entries
1853
+ .filter((e) => e.enabled !== false && (e.categories || e.kinds))
1854
+ .map((e) => [e.name, { categories: e.categories ?? [], kinds: e.kinds ?? [] }]),
1855
+ ),
1812
1856
  };
1813
1857
  }
1814
1858
 
@@ -2668,6 +2712,10 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2668
2712
  });
2669
2713
 
2670
2714
  const categories = [...new Set(analysis.t1?.changeCategories ?? [])];
2715
+ // File kinds present in the diff, to honor a consumer angle's `kinds`
2716
+ // binding. classifyFile is the same classifier the categories above derive
2717
+ // from, so this adds no new classification surface.
2718
+ const fileKinds = [...new Set((analysis.t0?.files ?? []).map(classifyFile))];
2671
2719
 
2672
2720
  // excludeAngles is a hard ceiling: computed once and reused both to cap the
2673
2721
  // additive anglePool and to filter mandatoryAngles below.
@@ -2682,6 +2730,8 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2682
2730
  changeCategories: categories,
2683
2731
  ambiguous: analysis.ambiguous,
2684
2732
  anglePool,
2733
+ angleDeclarations: gateConfig.angleCategoryBindings,
2734
+ fileKinds,
2685
2735
  });
2686
2736
 
2687
2737
  // Merge: mandatory always included (filtered by excludeAngles) + dynamically-selected
@@ -133,6 +133,26 @@ gates:
133
133
  mandatory: true
134
134
  persona: review
135
135
  prompt: 'Review the PR description for completeness, contract fitness, and checkbox formatting before this PR is marked ready for review. The PR body is the implementation contract — it must have: - A Summary section explaining what changed and why - A Scope and context section defining the boundary of the change - An Acceptance criteria section with the linked issue acceptance criteria - A Definition of done section - A Non-goals section - A Validation command section describing exactly how to verify the change - The "Closes #N" line must match the linked issue; flag changes that alter or remove the operator-intended close target Checkboxes (`- [ ]` / `- [x]`, or `* [ ]` / `* [x]`) must appear inside genuine Markdown list items. Flag any checkbox marker used outside a list item (including table cells) as a medium finding. Flag any checkbox marker wrapped in backticks (e.g. `` `[x]` ``) as a medium finding. Flag PRs where the body is a single sentence or lacks any of these sections. Do not block on formatting preferences other than checkbox correctness.'
136
+ - name: holistic
137
+ mandatory: true
138
+ persona: review
139
+ prompt: >-
140
+ Review the WHOLE change holistically, on its merits, as a senior
141
+ engineer doing a final read of the entire diff. You are independent and
142
+ un-briefed: you receive only the spec (acceptance criteria, definition
143
+ of done, non-goals) and the diff — no author or developer brief and no
144
+ steering from the reviewed party. Your mandate is broad, not a single
145
+ named lens. Read the whole change end to end and judge whether, taken
146
+ together, it correctly and completely does what the spec asks, is
147
+ internally coherent, and is safe to ship. Concentrate on CROSS-CUTTING
148
+ problems that no narrow angle owns: mismatches between parts of the
149
+ change, gaps between the diff and the acceptance criteria, unintended
150
+ interactions across modules, missing pieces the spec implies, and
151
+ defects that would otherwise surface later in Copilot review or a
152
+ consumer repo. Cite concrete file:line evidence for each finding and
153
+ give a minimal fix. Respect declared non-goals — do not manufacture
154
+ scope. If the change is coherent and complete against the spec, return
155
+ clean.
136
156
  # #1442 (ADR 0041 prose half): required fail-closed deslop angle for prose
137
157
  # deliverables. Runs the A/B-contrast-removal deslop step (ab-contrast-
138
158
  # deslop-step.md) — flag surviving binary-contrast constructions so the gate
@@ -221,6 +241,14 @@ gates:
221
241
  angles: [srp, soc, ocp, lsp, isp, dip]
222
242
  - name: finalization
223
243
  angles: [correctness-final, ui-validation]
244
+ # The holistic reviewer reads the whole diff, so it gets its own
245
+ # reviewer rather than being auto-chunked with unrelated leftover
246
+ # angles — one reviewer reviews the whole change holistically.
247
+ # A consumer repo that overrides gates.fanout.groups replaces this
248
+ # table wholesale (shallow merge) and must restate this singleton to
249
+ # keep holistic un-batched.
250
+ - name: holistic
251
+ angles: [holistic]
224
252
  preApproval:
225
253
  angles:
226
254
  - name: dry
@@ -294,6 +322,26 @@ gates:
294
322
  require a matrix on the PR — the matrix lives on the issue; the PR carries the derived
295
323
  checklists. The boundary is explicit: the deterministic block enforces completeness (nothing
296
324
  left unchecked/forgotten); you verify each [x] is real and faithfully derived.
325
+ - name: holistic
326
+ mandatory: true
327
+ persona: review
328
+ prompt: >-
329
+ Review the WHOLE change holistically, on its merits, as a senior
330
+ engineer doing a final read of the entire diff. You are independent and
331
+ un-briefed: you receive only the spec (acceptance criteria, definition
332
+ of done, non-goals) and the diff — no author or developer brief and no
333
+ steering from the reviewed party. Your mandate is broad, not a single
334
+ named lens. Read the whole change end to end and judge whether, taken
335
+ together, it correctly and completely does what the spec asks, is
336
+ internally coherent, and is safe to ship. Concentrate on CROSS-CUTTING
337
+ problems that no narrow angle owns: mismatches between parts of the
338
+ change, gaps between the diff and the acceptance criteria, unintended
339
+ interactions across modules, missing pieces the spec implies, and
340
+ defects that would otherwise surface later in Copilot review or a
341
+ consumer repo. Cite concrete file:line evidence for each finding and
342
+ give a minimal fix. Respect declared non-goals — do not manufacture
343
+ scope. If the change is coherent and complete against the spec, return
344
+ clean.
297
345
  - contradiction-lens
298
346
  - correctness-final
299
347
  - ui-validation