@dev-loops/core 1.0.3 → 1.0.4-pre.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,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "1.0.3",
3
+ "version": "1.0.4-pre.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -99,7 +99,7 @@ export const ALWAYS_INCLUDE = new Set(["gate-evidence", "renderer-security", "pr
99
99
  * merges addedAngles on top to form the full effective run set
100
100
  * @property {string[]} skippedAngles — angles skipped with reasons
101
101
  * @property {Record<string, string>} reasons — why each angle was skipped
102
- * @property {boolean} fallbackToAll — true when ambiguous → all angles recommended
102
+ * @property {boolean} fallbackToAll — retained for compatibility; always false
103
103
  * @property {string[]} addedAngles — catalog angles added (additive mode only, see #1048)
104
104
  * @property {Record<string, string>} addedReasons — why each added angle was added
105
105
  */
@@ -108,8 +108,14 @@ export const ALWAYS_INCLUDE = new Set(["gate-evidence", "renderer-security", "pr
108
108
  * Resolve which gate angles to run based on detected change categories.
109
109
  *
110
110
  * When the diff is ambiguous (no detected categories / analysis failure),
111
- * all configured angles are recommended (fallback-to-all). A LOGIC_CHANGE
112
- * diff resolves to its core review subset, not fallback-to-all.
111
+ * uncertainty still resolves through the same best-effort selection as a
112
+ * classified diff; it never expands to every configured angle. A LOGIC_CHANGE
113
+ * diff likewise resolves to its core review subset.
114
+ *
115
+ * `configuredAngles` is the caller's candidate pool (mandatory angles have
116
+ * already been removed), so an uncertain diff may legitimately resolve to an
117
+ * empty candidate set. The caller combines this with its mandatory floor and
118
+ * falls back to the static pool if that combined selection would be empty.
113
119
  *
114
120
  * When `anglePool` is provided (additive mode, see #1048), catalog angles in
115
121
  * the pool that the change categories recommend but that are not already in
@@ -117,6 +123,13 @@ export const ALWAYS_INCLUDE = new Set(["gate-evidence", "renderer-security", "pr
117
123
  * When `anglePool` is omitted, additive mode is off and `addedAngles` is
118
124
  * always empty.
119
125
  *
126
+ * A configured angle that is NOT in CATEGORY_ANGLE_MAP (a consumer-defined
127
+ * angle) can still be recommended BY change category or file kind when it
128
+ * declares `categories`/`kinds` via `angleDeclarations`. This is purely
129
+ * additive: it can only SELECT such an angle when the diff intersects its
130
+ * declaration; it never drops any angle the catalog map or ALWAYS_INCLUDE
131
+ * would otherwise recommend.
132
+ *
120
133
  * @param {object} options
121
134
  * @param {string[]} options.configuredAngles — all angles configured for this gate
122
135
  * @param {string[]} options.changeCategories — from diff analysis
@@ -124,6 +137,11 @@ export const ALWAYS_INCLUDE = new Set(["gate-evidence", "renderer-security", "pr
124
137
  * @param {string[]} [options.anglePool] — catalog of angles eligible for additive
125
138
  * selection (caller pre-filters this against excludeAngles); when undefined,
126
139
  * additive selection is disabled
140
+ * @param {Record<string, {categories?: string[], kinds?: string[]}>} [options.angleDeclarations]
141
+ * — per-angle category/file-kind bindings for consumer angles absent from
142
+ * CATEGORY_ANGLE_MAP; a match recommends the angle deterministically
143
+ * @param {string[]} [options.fileKinds] — file kinds present in the diff
144
+ * (classifyFile output), used to honor an angle's `kinds` declaration
127
145
  * @returns {DynamicAngleResult}
128
146
  */
129
147
  export function resolveDynamicAngles({
@@ -131,31 +149,9 @@ export function resolveDynamicAngles({
131
149
  changeCategories,
132
150
  ambiguous = false,
133
151
  anglePool,
152
+ angleDeclarations = {},
153
+ fileKinds = [],
134
154
  }) {
135
- // Fallback: ambiguous diff → all angles
136
- if (ambiguous) {
137
- return {
138
- recommendedAngles: [...configuredAngles],
139
- skippedAngles: [],
140
- reasons: {},
141
- fallbackToAll: true,
142
- addedAngles: [],
143
- addedReasons: {},
144
- };
145
- }
146
-
147
- // No change categories → all angles (defensive)
148
- if (changeCategories.length === 0) {
149
- return {
150
- recommendedAngles: [...configuredAngles],
151
- skippedAngles: [],
152
- reasons: {},
153
- fallbackToAll: true,
154
- addedAngles: [],
155
- addedReasons: {},
156
- };
157
- }
158
-
159
155
  // Build recommended set from category union, tracking the first trigger per angle
160
156
  const recommended = new Set();
161
157
  const triggers = new Map();
@@ -177,6 +173,27 @@ export function resolveDynamicAngles({
177
173
  }
178
174
  }
179
175
 
176
+ // Consumer angles bound by declaration: a configured angle absent from
177
+ // CATEGORY_ANGLE_MAP can name the change-categories / file-kinds that select
178
+ // it. Recommend it when the diff intersects that declaration, so it need not
179
+ // be forced `mandatory` to survive dynamic pruning. Additive only: never
180
+ // removes an angle already recommended above.
181
+ const changeCatSet = new Set(changeCategories);
182
+ const fileKindSet = new Set(fileKinds);
183
+ for (const angle of configuredAngles) {
184
+ if (recommended.has(angle)) continue;
185
+ const decl = angleDeclarations[angle];
186
+ if (!decl) continue;
187
+ const catHit = (decl.categories ?? []).some((c) => changeCatSet.has(c));
188
+ const kindHit = (decl.kinds ?? []).some((k) => fileKindSet.has(k));
189
+ if (catHit || kindHit) {
190
+ recommended.add(angle);
191
+ if (!triggers.has(angle)) {
192
+ triggers.set(angle, catHit ? "declared-category" : "declared-kind");
193
+ }
194
+ }
195
+ }
196
+
180
197
  // Filter to only angles that are configured
181
198
  const recommendedAngles = configuredAngles.filter((a) => recommended.has(a));
182
199
  const skippedAngles = configuredAngles.filter((a) => !recommended.has(a));
@@ -184,7 +201,11 @@ export function resolveDynamicAngles({
184
201
  // Build reasons
185
202
  const reasons = {};
186
203
  for (const angle of skippedAngles) {
187
- reasons[angle] = `Skipped: detected categories (${changeCategories.join(", ") || "none"}) do not trigger this angle`;
204
+ reasons[angle] = changeCategories.length === 0
205
+ ? "Skipped: no change category could be established (uncertain classification)"
206
+ : ambiguous
207
+ ? `Skipped: analysis remained ambiguous despite detected categories (${changeCategories.join(", ")})`
208
+ : `Skipped: detected categories (${changeCategories.join(", ")}) do not trigger this angle`;
188
209
  }
189
210
 
190
211
  // Additive: pull in recommended catalog angles not already configured (#1048)
@@ -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";
@@ -370,6 +443,8 @@ export function analyzeT1(diffOutput, t0) {
370
443
  * @property {T0Result} t0
371
444
  * @property {T1Result | null} t1
372
445
  * @property {boolean} ambiguous — true when heuristics cannot confidently classify
446
+ * @property {boolean} fullDiffMissing — true when a mixed diff needed hunk-level
447
+ * analysis but the full-diff capture was absent/empty (see analyzeDiff)
373
448
  */
374
449
 
375
450
  /**
@@ -423,9 +498,8 @@ function t0PresentSurfaceCategories(t0) {
423
498
  function inferCategoriesFromT0(t0) {
424
499
  const categories = t0FileCategories(t0);
425
500
  // Pure code-only change (all files classify as code, not a rename) is a
426
- // LOGIC_CHANGE. Without this an all-code diff yields no category, which
427
- // resolveDynamicAngles treats as unclassifiable → fallback-to-all, regressing
428
- // the primary case: a code-only PR must resolve to the LOGIC_CHANGE subset.
501
+ // LOGIC_CHANGE. Without this an all-code diff yields no category and loses
502
+ // the justified code-review core from best-effort selection.
429
503
  if (!t0.renameOnly && t0.files.length > 0 && t0.files.every((f) => classifyFile(f) === "code")) {
430
504
  categories.push("LOGIC_CHANGE");
431
505
  }
@@ -450,19 +524,31 @@ export function analyzeDiff({ nameStatusOutput, diffOutput }) {
450
524
  const t0Ambiguous = !t0.renameOnly && !t0.allDocs && t0.files.length > 1 &&
451
525
  new Set(t0.files.map(classifyFile)).size > 1;
452
526
 
453
- if (t0Ambiguous && diffOutput) {
527
+ // A full-diff capture is usable evidence only when it carries non-whitespace
528
+ // content. A whitespace-only capture (e.g. " \n") must take the SAME
529
+ // fail-closed path as an absent/empty one: T1 must not run and
530
+ // `fullDiffMissing` must fire, so a gate needing the full diff cannot read a
531
+ // hunk-less whitespace capture as complete evidence.
532
+ const hasDiffText = typeof diffOutput === "string" && diffOutput.trim().length > 0;
533
+
534
+ if (t0Ambiguous && hasDiffText) {
454
535
  t1 = analyzeT1(diffOutput, t0);
455
536
  }
456
537
 
457
538
  // When t1 is null (unambiguous diff), infer categories from t0
458
539
  // so dynamic angle resolution can narrow for config-only / test-only etc.
459
540
  if (!t1) {
460
- // A genuinely MIXED diff whose T1 never ran (no diffOutput) must NOT get a
461
- // T0-only category: non-empty categories set ambiguous=false, so it would
462
- // under-select and drop the code-review core. T0-only inference is safe only
463
- // for unambiguous diffs; a mixed diff without hunk content is unclassifiable,
464
- // so return empty categories and fall back to the full angle set (fail closed).
465
- const changeCategories = t0Ambiguous ? [] : inferCategoriesFromT0(t0);
541
+ // A genuinely MIXED diff whose T1 never ran (no diffOutput) still has
542
+ // honest T0 surface evidence. Reuse the same surface-presence categories
543
+ // as the hunk path, and add LOGIC_CHANGE when code is present, so
544
+ // best-effort selection retains the code-review core without widening to
545
+ // the full pool.
546
+ const changeCategories = t0Ambiguous
547
+ ? [
548
+ ...t0PresentSurfaceCategories(t0),
549
+ ...(t0.files.some((f) => classifyFile(f) === "code") ? ["LOGIC_CHANGE"] : []),
550
+ ]
551
+ : inferCategoriesFromT0(t0);
466
552
  t1 = {
467
553
  changeCategories,
468
554
  hunkCount: 0,
@@ -478,11 +564,21 @@ export function analyzeDiff({ nameStatusOutput, diffOutput }) {
478
564
  }
479
565
 
480
566
  // `ambiguous` flags one case: a diff T0 could not classify (mixed categories)
481
- // AND whose hunk analysis produced no category. It is NOT the only fallback
482
- // trigger — resolveDynamicAngles also falls back whenever changeCategories is
483
- // empty. A mixed diff that yields a category (e.g. LOGIC_CHANGE) is classified
484
- // and not ambiguous, so LOGIC_CHANGE never forces fallback-to-all via this flag.
567
+ // AND its available analysis produced no category. Empty categories resolve
568
+ // through mandatory-floor best-effort selection, while a hunk-less mixed diff
569
+ // with T0 surface evidence stays classified and keeps its justified core.
485
570
  const ambiguous = t0Ambiguous && t1.changeCategories.length === 0;
486
571
 
487
- return { t0, t1, ambiguous };
572
+ // Evidence-availability signal, SEPARATE from `ambiguous` on purpose. A mixed
573
+ // diff whose hunk-level analysis never ran (no full-diff capture) legitimately
574
+ // classifies through its honest T0 surfaces for ANGLE SELECTION — that is what
575
+ // keeps the code-review core in the best-effort subset without widening to the
576
+ // whole pool. But a fail-closed gate that needs the FULL diff (the size
577
+ // budget's unwaivable block) must not read that angle-selection fallback as
578
+ // complete evidence: `ambiguous` is now false for this case, so such a gate
579
+ // would silently downgrade. Consumers that need the diff itself key off THIS
580
+ // flag instead of piggybacking on the angle classifier's ambiguity flag.
581
+ const fullDiffMissing = t0Ambiguous && !hasDiffText;
582
+
583
+ return { t0, t1, ambiguous, fullDiffMissing };
488
584
  }
@@ -21,7 +21,10 @@
21
21
  * async-dispatch concerns. The user entrypoint under Claude is the dev-loop *skill*.)
22
22
  * - Skills keep name/description/allowed-tools (space-separated) and preserve `user-invocable`
23
23
  * (Claude honors it 1:1 — `user-invocable: false` hides the skill from the `/` menu). The
24
- * Pi-specific `compatibility` text is dropped (no Claude field).
24
+ * Pi-specific `compatibility` text is dropped (no Claude field). Whole-file skill exclusion is
25
+ * applied by `collectGeneratedAssets` in scripts/claude/generate-claude-assets.mjs before these
26
+ * transforms run; this is distinct from `<!-- pi-only -->` blocks, which remove only marked body
27
+ * sections.
25
28
  */
26
29
 
27
30
  import { parse as parseYaml } from "yaml";
@@ -295,6 +298,38 @@ export function transformCommand({ source, raw, version = "latest" }) {
295
298
  return `${lines.join("\n")}\n${body}`;
296
299
  }
297
300
 
301
+ /**
302
+ * Check whether a skill should be excluded from Claude asset generation.
303
+ * Supports:
304
+ * - `claude-sync: false`
305
+ * - `harness: pi` (or `harness: ["pi"]`)
306
+ * - `pi-only: true`
307
+ *
308
+ * @param {Record<string, unknown> | undefined} frontmatter
309
+ * @returns {boolean}
310
+ */
311
+ export function isSkillExcludedFromClaude(frontmatter) {
312
+ if (!frontmatter || typeof frontmatter !== "object") {
313
+ return false;
314
+ }
315
+ const claudeSync = frontmatter["claude-sync"];
316
+ if (claudeSync === false || (typeof claudeSync === "string" && claudeSync.trim().toLowerCase() === "false")) {
317
+ return true;
318
+ }
319
+ const piOnly = frontmatter["pi-only"];
320
+ if (piOnly === true || (typeof piOnly === "string" && piOnly.trim().toLowerCase() === "true")) {
321
+ return true;
322
+ }
323
+ const harness = frontmatter.harness;
324
+ if (typeof harness === "string" && harness.trim().toLowerCase() === "pi") {
325
+ return true;
326
+ }
327
+ if (Array.isArray(harness) && harness.length === 1 && String(harness[0]).trim().toLowerCase() === "pi") {
328
+ return true;
329
+ }
330
+ return false;
331
+ }
332
+
298
333
  /**
299
334
  * Transform a canonical `skills/<name>/SKILL.md` into a Claude `.claude/skills/<name>/SKILL.md`.
300
335
  * @param {{ source: string, raw: string, version?: string }} input
@@ -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