@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 +1 -1
- package/src/analysis/change-classifier.mjs +35 -0
- package/src/analysis/diff-analyzer.mjs +89 -16
- package/src/claude/hook-decisions.mjs +117 -18
- package/src/config/config.mjs +56 -6
- package/src/config/extension-defaults.yaml +48 -0
- package/src/github/copilot-helpers.mjs +63 -12
- package/src/loop/bash-command-classify.mjs +251 -14
- package/src/loop/copilot-ci-status.mjs +116 -6
- package/src/loop/finding-cluster.mjs +30 -11
- package/src/loop/gate-carry-forward.mjs +39 -6
- package/src/loop/gate-fanin.mjs +37 -3
- package/src/loop/issue-refinement-artifact.mjs +117 -9
- package/src/loop/merge-approval.mjs +121 -5
- package/src/loop/pr-gate-coordination.mjs +74 -12
- package/src/loop/run-inspection.mjs +6 -0
- package/src/loop/spec-authority.mjs +19 -6
- package/src/loop/ui-e2e-scoping.mjs +1 -0
package/package.json
CHANGED
|
@@ -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
|
-
//
|
|
142
|
-
//
|
|
143
|
-
// not prose.
|
|
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
|
-
|
|
146
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
|
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
|
|
241
|
-
//
|
|
242
|
-
//
|
|
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,
|
|
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
|
package/src/config/config.mjs
CHANGED
|
@@ -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(
|
|
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
|