@dev-loops/core 1.0.0-rc.5 → 1.0.0-rc.7
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 +12 -1
- package/src/analysis/change-classifier.mjs +10 -0
- package/src/analysis/diff-analyzer.mjs +68 -1
- package/src/claude/hook-decisions.mjs +204 -5
- package/src/cli/primitives.mjs +51 -1
- package/src/config/config.mjs +307 -14
- package/src/config/extension-defaults.yaml +39 -1
- package/src/github/comment-id-guard.mjs +158 -0
- package/src/github/copilot-helpers.mjs +145 -5
- package/src/github/gh.mjs +94 -0
- package/src/github/issue-ops.mjs +13 -0
- package/src/loop/agent-stall.mjs +196 -0
- package/src/loop/bash-command-classify.mjs +277 -0
- package/src/loop/cache-telemetry-evidence.mjs +437 -0
- package/src/loop/copilot-loop-iterations.mjs +2 -1
- package/src/loop/default-branch-guard.mjs +35 -2
- package/src/loop/gate-carry-forward.mjs +19 -6
- package/src/loop/gate-fanin.mjs +190 -29
- package/src/loop/handoff-envelope.mjs +40 -20
- package/src/loop/issue-refinement-artifact.mjs +94 -0
- package/src/loop/lifecycle-state.mjs +21 -2
- package/src/loop/main-checkout-ff.mjs +73 -0
- package/src/loop/markdown-sections.mjs +40 -0
- package/src/loop/normalize.mjs +7 -0
- package/src/loop/plan-file-promote-contract.mjs +14 -1
- package/src/loop/plan-file-refine-contract.mjs +92 -8
- package/src/loop/policy-constants.mjs +9 -0
- package/src/loop/pr-gate-coordination.mjs +65 -12
- package/src/loop/primer-evidence.mjs +375 -0
- package/src/loop/public-dev-loop-routing.mjs +7 -15
- package/src/loop/queue-board-sync.mjs +1 -26
- package/src/loop/queue-driver.mjs +14 -1
- package/src/loop/refinement-grill-state.mjs +3 -5
- package/src/loop/review-dispatch-plan.mjs +1034 -0
- package/src/loop/review-lineage.mjs +588 -0
- package/src/loop/reviewer-loop-state.mjs +8 -13
- package/src/loop/run-post-merge-actions.mjs +148 -0
- package/src/loop/size-budget-merge-gate.mjs +121 -0
- package/src/loop/tracker-pr-state.mjs +5 -15
- package/src/loop/ui-designer-review-scoping.mjs +171 -0
- package/src/loop/ui-review-drive.mjs +3 -1
- package/src/loop/ui-review-report.mjs +2 -5
- package/src/loop/ui-review-teardown.mjs +3 -1
- package/src/loop/worktree-guard.mjs +80 -0
- package/src/projects/list-queue-items.mjs +1 -27
- package/src/projects/move-queue-item.mjs +38 -28
- package/src/security/secret-scan.mjs +330 -0
package/src/config/config.mjs
CHANGED
|
@@ -7,6 +7,7 @@ import { fileURLToPath } from "node:url";
|
|
|
7
7
|
import { z } from "zod";
|
|
8
8
|
import { classifyFile } from "../analysis/diff-analyzer.mjs";
|
|
9
9
|
import { isDevLoopConfigSourcePath } from "../loop/gate-carry-forward.mjs";
|
|
10
|
+
import { trimmedOrNull } from "../loop/normalize.mjs";
|
|
10
11
|
|
|
11
12
|
// ============================================================================
|
|
12
13
|
// Sub-schemas
|
|
@@ -219,6 +220,47 @@ const GateDynamicConfig = z.strictObject({
|
|
|
219
220
|
// accepted but INERT for spike (a findings-doc deliverable has no "clean
|
|
220
221
|
// verdict" escalation path and no additive dynamic pool) rather than being
|
|
221
222
|
// split into a second schema.
|
|
223
|
+
// Single source for the blockCleanOnFindingSeverities vocabulary: the schema
|
|
224
|
+
// enum below consumes these spellings verbatim, and resolveGateConfig's
|
|
225
|
+
// fail-closed guard exact-matches raw entries against the same list, so the
|
|
226
|
+
// guard's accept set is byte-identical to the schema's (no trim/normalize
|
|
227
|
+
// superset) and widening the enum can never leave the runtime guard behind.
|
|
228
|
+
// Exported (not just module-internal) so the vocabulary contract test
|
|
229
|
+
// (test/contracts/gate-severity-vocabulary-contract.test.mjs) can pin this
|
|
230
|
+
// list against SEVERITY_ORDER + LEGACY_SEVERITY_ALIASES
|
|
231
|
+
// (@dev-loops/core/loop/gate-fanin) — a DEFECT severity added to
|
|
232
|
+
// SEVERITY_ORDER (one not also added to NON_DEFECT_SEVERITIES) without
|
|
233
|
+
// updating this canonical defect trio plus its legacy alias spellings (or a
|
|
234
|
+
// new defect-targeting legacy alias added without a matching entry here)
|
|
235
|
+
// must fail that test rather than leaving this enum silently stale.
|
|
236
|
+
export const BLOCKING_SEVERITY_SPELLINGS = Object.freeze(["high", "medium", "low", "must-fix", "worth-fixing-now", "nice-to-have", "defer"]);
|
|
237
|
+
const BLOCKING_SEVERITY_SPELLING_SET = new Set(BLOCKING_SEVERITY_SPELLINGS);
|
|
238
|
+
|
|
239
|
+
// Render an offending config value for a refusal message without letting the
|
|
240
|
+
// renderer itself throw: JSON.stringify raises on BigInt and circular
|
|
241
|
+
// structures and returns undefined for undefined/symbol/function (those fall
|
|
242
|
+
// back to String()), and String() itself can throw for exotic values (a
|
|
243
|
+
// null-prototype cycle, a throwing Symbol.toPrimitive) — those get a literal
|
|
244
|
+
// placeholder so the refusal always surfaces as the refusal.
|
|
245
|
+
function formatConfigValue(value) {
|
|
246
|
+
try {
|
|
247
|
+
const rendered = JSON.stringify(value);
|
|
248
|
+
return rendered === undefined ? String(value) : rendered;
|
|
249
|
+
} catch {
|
|
250
|
+
try {
|
|
251
|
+
return String(value);
|
|
252
|
+
} catch {
|
|
253
|
+
return "<unrenderable value>";
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// The three GatesConfig keys whose value is a GateConfig (i.e. carries its
|
|
259
|
+
// own blockCleanOnFindingSeverities) — kept in sync with the `gates:
|
|
260
|
+
// { draft, preApproval, spike }` keys below by construction (both list the
|
|
261
|
+
// same three names; a fourth GateConfig-typed gate would need both updated).
|
|
262
|
+
const GATE_KEYS_WITH_BLOCKING_SEVERITIES = /** @type {const} */ (["draft", "preApproval", "spike"]);
|
|
263
|
+
|
|
222
264
|
const GateConfig = z.strictObject({
|
|
223
265
|
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."),
|
|
224
266
|
dynamic: GateDynamicConfig.optional().describe("Diff-driven dynamic angle selection policy for this gate."),
|
|
@@ -231,7 +273,7 @@ const GateConfig = z.strictObject({
|
|
|
231
273
|
// admitting either here would let a config block on a severity the
|
|
232
274
|
// disposition pass simultaneously auto-resolves.
|
|
233
275
|
blockCleanOnFindingSeverities: z
|
|
234
|
-
.array(z.enum(
|
|
276
|
+
.array(z.enum(/** @type {[string, ...string[]]} */ (BLOCKING_SEVERITY_SPELLINGS)))
|
|
235
277
|
.min(1)
|
|
236
278
|
.default(["high"])
|
|
237
279
|
.describe("Defect finding severities that block a clean gate verdict (high/medium/low only — \"question\"/\"nit\" are non-defect categories and never block by severity). \"must-fix\" is the deprecated legacy spelling of \"high\", \"worth-fixing-now\" of \"medium\", and \"nice-to-have\"/\"defer\" of \"low\"; consumers normalize them."),
|
|
@@ -286,7 +328,8 @@ const FanoutConfig = z.strictObject({
|
|
|
286
328
|
mode: z.enum(["grouped", "per-angle"]).default("grouped").describe("Angle dispatch mode: grouped batches related angles onto one reviewer each (default); per-angle bypasses the configured-groups table and emits one singleton unit per angle (the original full-scrutiny shape). per-angle is equivalent to maxAnglesPerGroup: 1 in dispatch unit size ONLY when no configured multi-angle group matches a resolved angle; otherwise per-angle bypasses configured groups while maxAnglesPerGroup: 1 honors them (matched first, never split)."),
|
|
287
329
|
groups: z.array(FanoutGroup).optional().describe("Static named angle groups consulted in grouped mode. An angle absent from every group joins the auto-chunked leftover pool (chunked into units of ≤maxAnglesPerGroup)."),
|
|
288
330
|
maxAnglesPerGroup: z.number().int().min(1).default(3).describe("Max angles per auto-chunked dispatch unit for leftover ungrouped angles (default 3, min 1). Configured groups are matched first and never split by this knob; mode: per-angle bypasses the table entirely (one singleton per angle)."),
|
|
289
|
-
maxConcurrent: z.number().int().min(1).default(4).describe("Max dispatch units (groups) the conductor dispatches concurrently per wave (default 4, min 1). The wave plan is emitted by write-gate-context.mjs via scheduleFanoutWaves (scheduleParallelWaves)."),
|
|
331
|
+
maxConcurrent: z.number().int().min(1).default(4).describe("Max dispatch units (groups) the conductor dispatches concurrently per wave (default 4, min 1). The wave plan is emitted by write-gate-context.mjs via scheduleFanoutWaves (scheduleParallelWaves). Ignored when sequential is true (which forces one unit per wave)."),
|
|
332
|
+
sequential: z.boolean().default(false).describe("Dispatch heavy reviewers one at a time (serial) instead of wave-by-wave parallel (issue #1726). When true, effective fan-out concurrency is one dispatch unit per wave regardless of maxConcurrent, so each heavy reviewer completes and writes its evidence artifact before the next starts. Distinct reviewers, real fan-in/ledger, and provenance are unchanged — this only bounds dispatch concurrency. Default false keeps shipped behaviour unchanged for other harnesses/repos (cross-harness non-regression #1086); a repo sets it in .devloops to bound concurrency for all its PRs."),
|
|
290
333
|
});
|
|
291
334
|
|
|
292
335
|
/**
|
|
@@ -314,8 +357,45 @@ function rejectDuplicateFanoutGroupNames(val, ctx) {
|
|
|
314
357
|
}
|
|
315
358
|
}
|
|
316
359
|
|
|
360
|
+
// Fail-closed PR size budget (Phase 1 of the escalate-don't-chop size gate:
|
|
361
|
+
// this schema plus check-size-budget.mjs's pure computation only — no
|
|
362
|
+
// enforcement wiring yet). `patterns` classifies a changed file into t1/t3
|
|
363
|
+
// by path glob; the default tier is implicit for every file matching
|
|
364
|
+
// neither, so it carries no `patterns` field of its own. `sliceHardLoc`
|
|
365
|
+
// (t1 only) caps the T1-slice LOC, not the whole-PR LOC.
|
|
366
|
+
//
|
|
367
|
+
// Per-tier `softLoc`/`waiverLoc` on t1/t3, and `sliceHardLoc` on t3, are a
|
|
368
|
+
// later phase's escalation surface (e.g. a t3 "relaxed" tier with its own
|
|
369
|
+
// softLoc, or a t1 slice with its own waiver ceiling) — not honored by
|
|
370
|
+
// computeSizeBudget yet, so they are parked out of the schema for Phase 1
|
|
371
|
+
// rather than shipped as inert accepted-but-ignored knobs. Only the
|
|
372
|
+
// default tier's softLoc/waiverLoc and t1's sliceHardLoc drive Phase 1's
|
|
373
|
+
// outcome; see check-size-budget.mjs.
|
|
374
|
+
const SizeTierConfig = z.strictObject({
|
|
375
|
+
patterns: z.array(z.string().trim().min(1)).optional().describe("Glob-style path patterns; a changed file matching one resolves to this tier."),
|
|
376
|
+
softLoc: z.number().int().positive().nullable().optional().describe("Escalate above this many logic LOC; null disables the soft threshold for this tier."),
|
|
377
|
+
waiverLoc: z.number().int().positive().nullable().optional().describe("Block (waiver required) above this many logic LOC, up to absoluteHardLoc; null disables the waiver threshold for this tier."),
|
|
378
|
+
sliceHardLoc: z.number().int().positive().optional().describe("T1 only: block above this many T1-slice logic LOC unless waived by a named human approver."),
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const SizeTiersConfig = z.strictObject({
|
|
382
|
+
default: SizeTierConfig.omit({ patterns: true, sliceHardLoc: true }).default({ softLoc: 400, waiverLoc: 1500 }).describe("Fallback tier applied to every changed file that matches no t1/t3 pattern."),
|
|
383
|
+
t1: SizeTierConfig.omit({ softLoc: true, waiverLoc: true }).optional().describe("Risk-slice tier (money/auth/shared/ungated paths). Empty by default — a repo defines its own patterns; the T1 slice is computed separately from whole-PR LOC."),
|
|
384
|
+
t3: SizeTierConfig.pick({ patterns: true }).optional().describe("Relaxed tier (e.g. scaffold/template clones). Empty by default — a repo defines its own patterns; the absolute ceiling still applies. Per-tier soft/waiver thresholds are not yet honored (Phase 1)."),
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
const SizeConfig = z.strictObject({
|
|
388
|
+
testDiscount: z.number().min(0).max(1).default(0.25).describe("Weight applied to test LOC when computing logicLoc = code + testDiscount * test."),
|
|
389
|
+
absoluteHardLoc: z.number().int().positive().default(2000).describe("Whole-PR logic-LOC ceiling; blocks with no waiver possible above this, for any tier."),
|
|
390
|
+
tiers: SizeTiersConfig.default({}).describe("Per-tier soft/waiver/slice thresholds and path patterns."),
|
|
391
|
+
});
|
|
392
|
+
|
|
317
393
|
const GatesConfig = z.strictObject({
|
|
318
394
|
draft: GateConfig.optional(),
|
|
395
|
+
// Fail-closed PR size/tier budget (active by default). Computation lives in
|
|
396
|
+
// scripts/loop/check-size-budget.mjs; this config carries only the
|
|
397
|
+
// thresholds and tier patterns it reads.
|
|
398
|
+
size: SizeConfig.optional(),
|
|
319
399
|
// `requireCi` is honored on both gates: default true keeps CI a precondition,
|
|
320
400
|
// false is an opt-out escape hatch so a repo with no CI is not held at the
|
|
321
401
|
// gate. The pre-approval gate mirrors the draft gate's `requireCi` semantics —
|
|
@@ -427,6 +507,17 @@ const WorkflowConfig = z.strictObject({
|
|
|
427
507
|
requireRetrospective: z.boolean().describe("Require a retrospective checkpoint for the previous qualifying async completion before the next dev-loop start/resume."),
|
|
428
508
|
requireDraftFirst: z.boolean().describe("Open pull requests as drafts and promote via the draft gate."),
|
|
429
509
|
devModeDefault: z.boolean().describe("Default new loops to dev mode."),
|
|
510
|
+
// Agent-level stall detection (#1669): when a dev-loop child shows no turn
|
|
511
|
+
// progress for `thresholdMinutes` with no pending request, the parent bails
|
|
512
|
+
// to a fresh-context recovery dispatch instead of waiting through a manual
|
|
513
|
+
// interrupt+resume. `enabled: false` disables the auto-bail and restores
|
|
514
|
+
// the old wait behavior.
|
|
515
|
+
stallDetection: z
|
|
516
|
+
.strictObject({
|
|
517
|
+
enabled: z.boolean().default(true).describe("Enable agent-level stall -> auto-fresh-dispatch."),
|
|
518
|
+
thresholdMinutes: z.number().int().min(1).default(5).describe("No-turn-progress window in minutes before a child is treated as stalled."),
|
|
519
|
+
})
|
|
520
|
+
.optional(),
|
|
430
521
|
// No default here and absent from BUILT_IN_DEFAULTS — unset means "keep
|
|
431
522
|
// auto-detecting the default branch" (see resolveBaseBranch), never a static
|
|
432
523
|
// "main". Bare branch name; consumers add the `origin/` remote-ref prefix
|
|
@@ -719,6 +810,48 @@ const UiReviewConfig = z.strictObject({
|
|
|
719
810
|
.optional(),
|
|
720
811
|
});
|
|
721
812
|
|
|
813
|
+
// Default/ceiling bounds for a post-merge action's run/verify timing (#1457).
|
|
814
|
+
// The default keeps a config-declared action from hanging a harness hook
|
|
815
|
+
// forever when the author leaves timeoutMs unset; the ceiling caps how far a
|
|
816
|
+
// config CAN push it — a config can only tighten these, never loosen past the
|
|
817
|
+
// ceiling.
|
|
818
|
+
export const POST_MERGE_ACTION_DEFAULT_TIMEOUT_MS = 120000;
|
|
819
|
+
export const POST_MERGE_ACTION_TIMEOUT_CEILING_MS = 600000;
|
|
820
|
+
export const POST_MERGE_VERIFY_DEFAULT_TIMEOUT_MS = 60000;
|
|
821
|
+
export const POST_MERGE_VERIFY_TIMEOUT_CEILING_MS = 600000;
|
|
822
|
+
export const POST_MERGE_VERIFY_DEFAULT_INTERVAL_MS = 2000;
|
|
823
|
+
export const POST_MERGE_VERIFY_INTERVAL_CEILING_MS = 60000;
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* One post-merge action (Stage: local consumer hook). `run` and `verify` are
|
|
827
|
+
* executed VERBATIM as the operator wrote them — same trust level as
|
|
828
|
+
* `uiReview.run.command` (this repo's own committed `.devloops`) — so callers
|
|
829
|
+
* must never build these strings by interpolating untrusted runtime data
|
|
830
|
+
* (PR titles, branch names, verify output) into them. `onlyIfChanged` is
|
|
831
|
+
* matched as DATA (plain substrings against changed file paths), never
|
|
832
|
+
* shelled out.
|
|
833
|
+
*/
|
|
834
|
+
const PostMergeActionConfig = z.strictObject({
|
|
835
|
+
name: z.string().trim().min(1),
|
|
836
|
+
run: z.string().trim().min(1),
|
|
837
|
+
onlyIfChanged: z.array(z.string().trim().min(1)).optional(),
|
|
838
|
+
verify: z.string().trim().min(1).optional(),
|
|
839
|
+
timeoutMs: z.number().int().min(1).max(POST_MERGE_ACTION_TIMEOUT_CEILING_MS).default(POST_MERGE_ACTION_DEFAULT_TIMEOUT_MS),
|
|
840
|
+
verifyTimeoutMs: z.number().int().min(1).max(POST_MERGE_VERIFY_TIMEOUT_CEILING_MS).default(POST_MERGE_VERIFY_DEFAULT_TIMEOUT_MS),
|
|
841
|
+
verifyIntervalMs: z.number().int().min(1).max(POST_MERGE_VERIFY_INTERVAL_CEILING_MS).default(POST_MERGE_VERIFY_DEFAULT_INTERVAL_MS),
|
|
842
|
+
});
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* `postMerge.actions`: consumer-declared local actions (sync checkout, restart
|
|
846
|
+
* a local service, smoke check) run sequentially, in declared order, after the
|
|
847
|
+
* dev-loop's merge succeeds. Mirrors `uiReview.run` as the config-shape,
|
|
848
|
+
* validation, and command-execution precedent. Absent (the default) means no
|
|
849
|
+
* action is declared — a repo without this family gets zero new hook commands.
|
|
850
|
+
*/
|
|
851
|
+
const PostMergeConfig = z.strictObject({
|
|
852
|
+
actions: z.array(PostMergeActionConfig).optional(),
|
|
853
|
+
});
|
|
854
|
+
|
|
722
855
|
/** Internal path whitelist for internal-only PR detection — flat array of regex strings */
|
|
723
856
|
const InternalPatternsConfig = z.array(z.string().trim().min(1)).min(1);
|
|
724
857
|
|
|
@@ -731,6 +864,7 @@ const FileGatesConfig = z.strictObject({
|
|
|
731
864
|
draft: GateConfig.partial().describe("Draft gate config (runs before a PR leaves draft).").optional(),
|
|
732
865
|
preApproval: GateConfig.partial().describe("Pre-approval gate config (final re-review before the merge handoff).").optional(),
|
|
733
866
|
spike: GateConfig.partial().describe("Relaxed spike gate profile; applies only to spike-mode work.").optional(),
|
|
867
|
+
size: SizeConfig.partial().describe("Fail-closed PR size/tier budget: testDiscount, absoluteHardLoc, and per-tier soft/waiver/slice thresholds + patterns.").optional(),
|
|
734
868
|
requireFanoutEvidence: z.boolean().describe("Require fan-out/fan-in review evidence on gate verdicts; inline single-agent verdicts are rejected except under the strict light-mode exception (under-threshold scope, no gate:full label, recorded inline reason).").optional(),
|
|
735
869
|
requireFanoutProvenance: z.boolean().describe("Additionally require recorded, internally-consistent fan-out provenance (distinct reviewer count + per-angle dispatch).").optional(),
|
|
736
870
|
maxFanoutReviewers: z.number().int().min(1).max(64).describe("SUPERSEDED by gates.fanout.maxConcurrent (#1601, ADR 0048): no longer governs fan-out dispatch — the conductor dispatches wave-by-wave at most gates.fanout.maxConcurrent (M) dispatch units per wave via scheduleFanoutWaves (the wave plan emitted by write-gate-context.mjs). Kept for back-compat; setting it has no dispatch effect.").optional(),
|
|
@@ -771,6 +905,7 @@ export const DevLoopConfigSchema = z.strictObject({
|
|
|
771
905
|
internalPathPatterns: InternalPatternsConfig.optional(),
|
|
772
906
|
worktree: WorktreeConfig.optional(),
|
|
773
907
|
uiReview: UiReviewConfig.optional(),
|
|
908
|
+
postMerge: PostMergeConfig.optional(),
|
|
774
909
|
});
|
|
775
910
|
|
|
776
911
|
// ============================================================================
|
|
@@ -795,6 +930,7 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
|
|
|
795
930
|
requireRetrospective: false,
|
|
796
931
|
requireDraftFirst: false,
|
|
797
932
|
devModeDefault: false,
|
|
933
|
+
stallDetection: Object.freeze({ enabled: true, thresholdMinutes: 5 }),
|
|
798
934
|
}),
|
|
799
935
|
localImplementation: Object.freeze({
|
|
800
936
|
lightMode: Object.freeze({ enabled: false, maxFiles: 3, maxLines: 200, maxCopilotRounds: 1 }),
|
|
@@ -845,6 +981,7 @@ export const FileConfigSchema = z.strictObject({
|
|
|
845
981
|
internalPathPatterns: InternalPatternsConfig.describe("Regex whitelist for internal-only PR detection.").optional(),
|
|
846
982
|
worktree: WorktreeConfig.partial().describe("Worktree provisioning: gitignored files/dirs copied or symlinked into fresh worktrees.").optional(),
|
|
847
983
|
uiReview: UiReviewConfig.partial().describe("UI-review route recipes: per-project run/boot, dev-login, driven flows, and caps.").optional(),
|
|
984
|
+
postMerge: PostMergeConfig.partial().describe("Post-merge local hook actions (postMerge.actions): consumer-declared commands run sequentially, in order, after a merge succeeds — optionally scoped to changed-file substrings (onlyIfChanged) and polled for readiness (verify).").optional(),
|
|
848
985
|
// 1.0 hard break (no dual-form): the deprecated `localPlanning` key (removed
|
|
849
986
|
// behavior in #1088, tolerated-but-unread since) is dropped from the 1.0
|
|
850
987
|
// schema entirely — an unknown key now fails closed like any other typo,
|
|
@@ -888,6 +1025,7 @@ const BUILTIN_PERSONAS = Object.freeze({
|
|
|
888
1025
|
determinism: { persona: "review", defaultModel: null },
|
|
889
1026
|
"acceptance-criteria": { persona: "review", defaultModel: null },
|
|
890
1027
|
"ac-dod": { persona: "review", defaultModel: null },
|
|
1028
|
+
deslop: { persona: "review", defaultModel: null },
|
|
891
1029
|
});
|
|
892
1030
|
|
|
893
1031
|
const DEFAULT_REVIEWER_PERSONA = "default-reviewer";
|
|
@@ -1024,7 +1162,7 @@ function resolveTierMapping(config, tierAlias, harness) {
|
|
|
1024
1162
|
if (!builtinMapping && !configMapping) return null;
|
|
1025
1163
|
const mapping = { ...builtinMapping, ...configMapping };
|
|
1026
1164
|
const model = mapping[harness];
|
|
1027
|
-
return
|
|
1165
|
+
return trimmedOrNull(model);
|
|
1028
1166
|
}
|
|
1029
1167
|
|
|
1030
1168
|
/**
|
|
@@ -1805,6 +1943,51 @@ export function resolveRefinement(config) {
|
|
|
1805
1943
|
return { fanOut, mode, roles, maxCopilotRounds, stopOnLowSignal, lowSignalRoundThreshold, lowSignalMaxComments, preApprovalRequireCi };
|
|
1806
1944
|
}
|
|
1807
1945
|
|
|
1946
|
+
/**
|
|
1947
|
+
* Resolve and validate ONE gate's raw `blockCleanOnFindingSeverities` value
|
|
1948
|
+
* against the schema's severity vocabulary, returning the normalized/deduped
|
|
1949
|
+
* list (or the ["high"] default when the key is absent). Shared by
|
|
1950
|
+
* resolveGateConfig, which calls this for every GateConfig-typed gate on
|
|
1951
|
+
* every invocation (see that function's @throws doc) so an invalid list on
|
|
1952
|
+
* any gate refuses eagerly, not only when that specific gate is requested.
|
|
1953
|
+
*
|
|
1954
|
+
* @param {DevLoopConfig} config
|
|
1955
|
+
* @param {"draft"|"preApproval"|"spike"} gate
|
|
1956
|
+
* @returns {string[]}
|
|
1957
|
+
* @throws {Error} when `gate`'s PRESENT `blockCleanOnFindingSeverities` key
|
|
1958
|
+
* is schema-invalid (non-array, empty, or containing an out-of-vocabulary
|
|
1959
|
+
* entry).
|
|
1960
|
+
*/
|
|
1961
|
+
function resolveBlockingSeverities(config, gate) {
|
|
1962
|
+
const rawBlocking = config?.gates?.[gate]?.blockCleanOnFindingSeverities;
|
|
1963
|
+
if (rawBlocking === undefined) return ["high"];
|
|
1964
|
+
if (!Array.isArray(rawBlocking)) {
|
|
1965
|
+
throw new Error(
|
|
1966
|
+
`Config validation failed: gates.${gate}.blockCleanOnFindingSeverities must be an array ` +
|
|
1967
|
+
`of defect severities, got ${formatConfigValue(rawBlocking)}. ` +
|
|
1968
|
+
`Fix the config before gate operations can proceed.`
|
|
1969
|
+
);
|
|
1970
|
+
}
|
|
1971
|
+
if (rawBlocking.length === 0) {
|
|
1972
|
+
throw new Error(
|
|
1973
|
+
`Config validation failed: gates.${gate}.blockCleanOnFindingSeverities is empty; ` +
|
|
1974
|
+
`the schema requires at least one blocking severity, and an empty list would make the gate block on nothing. ` +
|
|
1975
|
+
`Fix the config before gate operations can proceed.`
|
|
1976
|
+
);
|
|
1977
|
+
}
|
|
1978
|
+
const invalid = rawBlocking.filter((s) => typeof s !== "string" || !BLOCKING_SEVERITY_SPELLING_SET.has(s));
|
|
1979
|
+
if (invalid.length > 0) {
|
|
1980
|
+
throw new Error(
|
|
1981
|
+
`Config validation failed: gates.${gate}.blockCleanOnFindingSeverities contains ` +
|
|
1982
|
+
`value(s) outside the schema's severity vocabulary: ${invalid.map((s) => formatConfigValue(s)).join(", ")}. ` +
|
|
1983
|
+
`Allowed (exact spellings): ${BLOCKING_SEVERITY_SPELLINGS.join(", ")} ` +
|
|
1984
|
+
`(the legacy spellings normalize to high/medium/low). ` +
|
|
1985
|
+
`Fix the config before gate operations can proceed.`
|
|
1986
|
+
);
|
|
1987
|
+
}
|
|
1988
|
+
return [...new Set(rawBlocking.map((s) => normalizeSeverity(s)))];
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1808
1991
|
/**
|
|
1809
1992
|
* Resolve one gate configuration object from the merged dev-loop config.
|
|
1810
1993
|
*
|
|
@@ -1825,9 +2008,42 @@ export function resolveRefinement(config) {
|
|
|
1825
2008
|
* @param {DevLoopConfig} config
|
|
1826
2009
|
* @param {"draft"|"preApproval"|"spike"} gate
|
|
1827
2010
|
* @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[]}> }}
|
|
2011
|
+
* @throws {Error} when ANY gate's (draft, preApproval, or spike — not only
|
|
2012
|
+
* the requested `gate`'s) PRESENT `blockCleanOnFindingSeverities` key is
|
|
2013
|
+
* any schema-invalid shape: a non-array value, an empty array, or an entry
|
|
2014
|
+
* that is not one of the schema enum's exact spellings. Every such shape
|
|
2015
|
+
* can only arrive through a config that failed schema validation (the
|
|
2016
|
+
* schema requires a min-1 array of the enum spellings; the guard
|
|
2017
|
+
* exact-matches raw entries against the same spelling list, so its accept
|
|
2018
|
+
* set is byte-identical to the schema's), and passing it through would make
|
|
2019
|
+
* the affected gate block on the wrong severities or on nothing at all.
|
|
2020
|
+
* Validated EAGERLY across all three gates on every call — not lazily,
|
|
2021
|
+
* only for the requested `gate` — so that a single-gate consumer (e.g. a
|
|
2022
|
+
* draft-only fan-in consolidation) can never proceed and produce a
|
|
2023
|
+
* side-effect (write a ledger artifact, flip ready-for-review) while a
|
|
2024
|
+
* DIFFERENT gate's severity list is invalid; that invalid gate would only
|
|
2025
|
+
* have surfaced later, lazily, at a dual-gate call site (e.g. verdict
|
|
2026
|
+
* posting), after the single-gate side effect already happened. This is
|
|
2027
|
+
* the stated boundary with the module's degrade-quietly convention for
|
|
2028
|
+
* dispatch-ergonomics keys (resolveMaxAnglesPerGroup substitutes its
|
|
2029
|
+
* default, resolveFanoutGroups drops malformed entries): those keys only
|
|
2030
|
+
* shape dispatch, so they degrade; the key that decides what blocks a
|
|
2031
|
+
* clean verdict refuses, fail-closed, before any gate proceeds. An ABSENT
|
|
2032
|
+
* key still falls back to the default unchanged.
|
|
1828
2033
|
*/
|
|
1829
2034
|
export function resolveGateConfig(config, gate) {
|
|
1830
2035
|
const gateConfig = config?.gates?.[gate];
|
|
2036
|
+
// Eagerly validate every gate's blockCleanOnFindingSeverities together
|
|
2037
|
+
// (not just the requested `gate`'s) so an invalid list on ANY gate refuses
|
|
2038
|
+
// up front, before this call's single-gate result can be used for a
|
|
2039
|
+
// side effect that a later, different-gate call would otherwise still be
|
|
2040
|
+
// able to reach lazily. See the @throws doc above for the reachability
|
|
2041
|
+
// this closes.
|
|
2042
|
+
let blockCleanOnFindingSeverities = ["high"];
|
|
2043
|
+
for (const g of GATE_KEYS_WITH_BLOCKING_SEVERITIES) {
|
|
2044
|
+
const resolved = resolveBlockingSeverities(config, g);
|
|
2045
|
+
if (g === gate) blockCleanOnFindingSeverities = resolved;
|
|
2046
|
+
}
|
|
1831
2047
|
const entries = normalizeAngleEntries(gateConfig?.angles);
|
|
1832
2048
|
// An explicitly-empty (or all-garbage/malformed) array is a real configured
|
|
1833
2049
|
// "no angles" — distinct from the key being absent entirely, which callers
|
|
@@ -1841,12 +2057,11 @@ export function resolveGateConfig(config, gate) {
|
|
|
1841
2057
|
requireCi: gateConfig?.requireCi ?? true,
|
|
1842
2058
|
dynamicAngles: gateConfig?.dynamic?.subtractive ?? true,
|
|
1843
2059
|
additiveAngles: gateConfig?.dynamic?.additive ?? false,
|
|
1844
|
-
// Normalized + deduped at the resolve boundary so every consumer
|
|
1845
|
-
// verdict poster, fan-in, viewer) sees canonical spellings
|
|
1846
|
-
// half-migrated ["must-fix","low","defer"] collapses to two
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
: ["high"],
|
|
2060
|
+
// Normalized + deduped at the resolve boundary (above) so every consumer
|
|
2061
|
+
// (envelope, verdict poster, fan-in, viewer) sees canonical spellings
|
|
2062
|
+
// only; a half-migrated ["must-fix","low","defer"] collapses to two
|
|
2063
|
+
// entries, and anything outside the vocabulary has already thrown.
|
|
2064
|
+
blockCleanOnFindingSeverities,
|
|
1850
2065
|
// `mediumFixWindow` wins; `worthFixingNowFixWindow` is the deprecated
|
|
1851
2066
|
// pre-rename key, still honored so an unmigrated config keeps its
|
|
1852
2067
|
// configured window rather than silently reverting to the default.
|
|
@@ -2059,6 +2274,36 @@ export const DEFAULT_MAX_ANGLES_PER_GROUP = 3;
|
|
|
2059
2274
|
* `scheduleFanoutWaves` (@dev-loops/core/loop/gate-fanin).
|
|
2060
2275
|
*/
|
|
2061
2276
|
export const DEFAULT_FANOUT_MAX_CONCURRENT = 4;
|
|
2277
|
+
export const DEFAULT_FANOUT_SEQUENTIAL = false;
|
|
2278
|
+
|
|
2279
|
+
/**
|
|
2280
|
+
* Resolve `gates.fanout.sequential` (issue #1726, default false). Serial
|
|
2281
|
+
* (one-at-a-time) dispatch of heavy reviewers so each completes and writes its
|
|
2282
|
+
* evidence before the next starts — the concurrency bound that keeps genuine
|
|
2283
|
+
* fan-out from SIGTERMing under child-safe parallel overload. Separate from
|
|
2284
|
+
* `maxConcurrent` so a repo may choose either serial (sequential: true) or a
|
|
2285
|
+
* small parallel cap (maxConcurrent: 1-2, sequential: false); the shipped
|
|
2286
|
+
* default stays false for cross-harness non-regression (#1086).
|
|
2287
|
+
* @param {DevLoopConfig} config
|
|
2288
|
+
* @returns {boolean}
|
|
2289
|
+
*/
|
|
2290
|
+
export function resolveFanoutSequential(config) {
|
|
2291
|
+
const s = config?.gates?.fanout?.sequential;
|
|
2292
|
+
return s === true;
|
|
2293
|
+
}
|
|
2294
|
+
|
|
2295
|
+
/**
|
|
2296
|
+
* Resolve the effective fan-out concurrency (dispatch units per wave) for a
|
|
2297
|
+
* round: 1 when `gates.fanout.sequential` is set (serial dispatch forces one
|
|
2298
|
+
* unit per wave), else `resolveFanoutMaxConcurrent`. The conductor builds the
|
|
2299
|
+
* wave plan from this effective value (issue #1726).
|
|
2300
|
+
* @param {DevLoopConfig} config
|
|
2301
|
+
* @returns {number}
|
|
2302
|
+
*/
|
|
2303
|
+
export function resolveFanoutEffectiveConcurrency(config) {
|
|
2304
|
+
if (resolveFanoutSequential(config)) return 1;
|
|
2305
|
+
return resolveFanoutMaxConcurrent(config);
|
|
2306
|
+
}
|
|
2062
2307
|
|
|
2063
2308
|
/**
|
|
2064
2309
|
* Resolve `gates.fanout.maxAnglesPerGroup` (issue #1601, default 3, min 1).
|
|
@@ -2405,11 +2650,13 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
|
|
|
2405
2650
|
let changedFiles;
|
|
2406
2651
|
let filesChanged;
|
|
2407
2652
|
let linesChanged;
|
|
2653
|
+
let prosePresent = false;
|
|
2408
2654
|
if (diff) {
|
|
2409
2655
|
const { analyzeT0, analyzeT1 } = await import("../analysis/diff-analyzer.mjs");
|
|
2410
2656
|
const t0 = analyzeT0(diff.nameStatusOutput);
|
|
2411
2657
|
changedFiles = t0.files;
|
|
2412
2658
|
filesChanged = changedFiles.length;
|
|
2659
|
+
prosePresent = t0.prosePresent; // #1442: gate deslop on the prose surface
|
|
2413
2660
|
if (diff.diffOutput) {
|
|
2414
2661
|
const lineStats = analyzeT1(diff.diffOutput, t0).lineStats;
|
|
2415
2662
|
linesChanged = lineStats.added + lineStats.deleted;
|
|
@@ -2418,10 +2665,18 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
|
|
|
2418
2665
|
const tierResult = resolveGateTier(config, gate, { changedFiles, filesChanged, linesChanged, hasFullLabel });
|
|
2419
2666
|
if (tierResult.tier) {
|
|
2420
2667
|
const configuredAngles = resolveGateAngles(config, gate) ?? [];
|
|
2421
|
-
|
|
2422
|
-
|
|
2668
|
+
let recommendedAngles = tierResult.angles;
|
|
2669
|
+
// #1442 (ADR 0041 prose half): deslop is a prose-only angle. A docs-kind
|
|
2670
|
+
// tier (e.g. this repo's docs-only/small-non-code) names it so prose diffs
|
|
2671
|
+
// keep it, but that same kind matches exempt normative contracts
|
|
2672
|
+
// (skills/docs/**). Strip deslop when the diff touches no prose surface so
|
|
2673
|
+
// exemption holds even through the tier path.
|
|
2674
|
+
if (recommendedAngles.includes("deslop") && prosePresent === false) {
|
|
2675
|
+
recommendedAngles = recommendedAngles.filter((a) => a !== "deslop");
|
|
2676
|
+
}
|
|
2677
|
+
const skippedAngles = configuredAngles.filter((a) => !recommendedAngles.includes(a));
|
|
2423
2678
|
return {
|
|
2424
|
-
recommendedAngles
|
|
2679
|
+
recommendedAngles,
|
|
2425
2680
|
skippedAngles,
|
|
2426
2681
|
reasons: Object.fromEntries(skippedAngles.map((a) => [a, `tier:${tierResult.tier}`])),
|
|
2427
2682
|
fallbackToAll: false,
|
|
@@ -2531,6 +2786,15 @@ export function resolveWorkflowConfig(config, key) {
|
|
|
2531
2786
|
return config?.workflow?.devModeDefault ?? DEFAULT_WORKFLOW_CONFIG.devModeDefault;
|
|
2532
2787
|
}
|
|
2533
2788
|
|
|
2789
|
+
if (key === "stallDetection") {
|
|
2790
|
+
const configured = config?.workflow?.stallDetection;
|
|
2791
|
+
const def = DEFAULT_WORKFLOW_CONFIG.stallDetection;
|
|
2792
|
+
return {
|
|
2793
|
+
enabled: configured?.enabled ?? def.enabled,
|
|
2794
|
+
thresholdMinutes: configured?.thresholdMinutes ?? def.thresholdMinutes,
|
|
2795
|
+
};
|
|
2796
|
+
}
|
|
2797
|
+
|
|
2534
2798
|
throw new Error(`Unknown workflow config key: ${key}`);
|
|
2535
2799
|
}
|
|
2536
2800
|
|
|
@@ -2684,12 +2948,41 @@ export function resolveUiReviewRunRecipe(config) {
|
|
|
2684
2948
|
readyUrl: run.readyUrl.trim(),
|
|
2685
2949
|
readyTimeoutMs: Number.isInteger(run.readyTimeoutMs) ? run.readyTimeoutMs : 60000,
|
|
2686
2950
|
readyIntervalMs: Number.isInteger(run.readyIntervalMs) ? run.readyIntervalMs : 1000,
|
|
2687
|
-
cwd:
|
|
2951
|
+
cwd: trimmedOrNull(run.cwd),
|
|
2688
2952
|
migrate,
|
|
2689
2953
|
rowTeardown,
|
|
2690
2954
|
};
|
|
2691
2955
|
}
|
|
2692
2956
|
|
|
2957
|
+
/**
|
|
2958
|
+
* Resolve `postMerge.actions` from the merged config into normalized, runner-ready
|
|
2959
|
+
* action objects. `run`/`verify` are trimmed but otherwise passed through
|
|
2960
|
+
* VERBATIM (never rebuilt by concatenation) — the runner executes them exactly
|
|
2961
|
+
* as declared. Returns `[]` when `postMerge` is absent — a `.devloops` without
|
|
2962
|
+
* this family produces zero actions (and so zero hook commands).
|
|
2963
|
+
*
|
|
2964
|
+
* @param {DevLoopConfig} config
|
|
2965
|
+
* @returns {{ name: string, run: string, onlyIfChanged: string[]|null, verify: string|null,
|
|
2966
|
+
* timeoutMs: number, verifyTimeoutMs: number, verifyIntervalMs: number }[]}
|
|
2967
|
+
*/
|
|
2968
|
+
export function resolvePostMergeActions(config) {
|
|
2969
|
+
const actions = config?.postMerge?.actions;
|
|
2970
|
+
if (!Array.isArray(actions)) return [];
|
|
2971
|
+
return actions
|
|
2972
|
+
.filter((a) => a && typeof a.name === "string" && a.name.trim().length > 0 && typeof a.run === "string" && a.run.trim().length > 0)
|
|
2973
|
+
.map((a) => ({
|
|
2974
|
+
name: a.name.trim(),
|
|
2975
|
+
run: a.run.trim(),
|
|
2976
|
+
onlyIfChanged: Array.isArray(a.onlyIfChanged)
|
|
2977
|
+
? a.onlyIfChanged.filter((p) => typeof p === "string" && p.trim().length > 0).map((p) => p.trim())
|
|
2978
|
+
: null,
|
|
2979
|
+
verify: trimmedOrNull(a.verify),
|
|
2980
|
+
timeoutMs: Number.isInteger(a.timeoutMs) ? a.timeoutMs : POST_MERGE_ACTION_DEFAULT_TIMEOUT_MS,
|
|
2981
|
+
verifyTimeoutMs: Number.isInteger(a.verifyTimeoutMs) ? a.verifyTimeoutMs : POST_MERGE_VERIFY_DEFAULT_TIMEOUT_MS,
|
|
2982
|
+
verifyIntervalMs: Number.isInteger(a.verifyIntervalMs) ? a.verifyIntervalMs : POST_MERGE_VERIFY_DEFAULT_INTERVAL_MS,
|
|
2983
|
+
}));
|
|
2984
|
+
}
|
|
2985
|
+
|
|
2693
2986
|
/**
|
|
2694
2987
|
* Default server-log exception signal for the drive stage's log tail. Matched
|
|
2695
2988
|
* (case-insensitive, per line) against the tailed server-log text. This is a
|
|
@@ -2719,7 +3012,7 @@ export function resolveUiReviewDriveRecipe(config) {
|
|
|
2719
3012
|
if (!login || typeof login.loginUrl !== "string" || login.loginUrl.trim().length === 0) return null;
|
|
2720
3013
|
if (typeof login.submitSelector !== "string" || login.submitSelector.trim().length === 0) return null;
|
|
2721
3014
|
if (typeof login.successSelector !== "string" || login.successSelector.trim().length === 0) return null;
|
|
2722
|
-
const serverLogPath =
|
|
3015
|
+
const serverLogPath = trimmedOrNull(ui.serverLogPath);
|
|
2723
3016
|
return {
|
|
2724
3017
|
login: {
|
|
2725
3018
|
loginUrl: login.loginUrl.trim(),
|
|
@@ -106,7 +106,7 @@ gates:
|
|
|
106
106
|
prompt: Review this change for input-validation drift. Check repo slug, issue number, host, SHA, whitespace, and sentinel normalization. Prefer shared parsers/helpers over ad hoc validation. Flag malformed inputs that slip through, confusing errors, path traversal-like segments, and inconsistent trimming/normalization across CLI/API entrypoints. Recommend minimal tests for accepted and rejected forms.
|
|
107
107
|
- name: threat-model
|
|
108
108
|
persona: review
|
|
109
|
-
prompt: "Adversarially threat-model this change end to end — you are an attacker with control over every caller-/plan-influenced input (descriptors, paths, URLs, flags, env, fixture data). Do NOT spot-check; return a trust-boundary CHECKLIST and a verdict per item. Enumerate exhaustively for every seam the diff touches: (1) INPUT ALLOWLISTS — are actions/commands/schemes/hosts allowlisted (not denylisted), and enforced BEFORE any dangerous use (browser launch, exec, read)? (2) NAVIGATION/ORIGIN CONFINEMENT — same-origin/scheme enforced both pre-launch AND at runtime after every redirect / click / server-response (a pre-check the runtime can defeat is a hole). (3) RESOURCE/LOOP BOUNDS — step/size/time/recursion caps on attacker-influenced counts. (4) DATA-AT-REST + CLEANUP — sensitive intermediate artifacts minimized and removed on EVERY fail-closed path (not just the happy path); no off-origin/partial artifact left on disk on error. (5) EXPORTED/ENTRY-POINT TRUST — does every exported function / alternate entry self-validate, or can it bypass the parse-time validation the CLI does? (6) ERROR/TEARDOWN SAFETY — a throw in rm/close/teardown must not break the fail-closed envelope or leak state. (7) PATH TRAVERSAL / DESERIALIZATION — reject absolute/`..`/escape-base paths before read; no unsafe deserialization of untrusted data. (8) SHELL/PROCESS — no unescaped interpolation into a shell; prefer argv arrays; no `shell:true` with caller input. For each category that applies, state whether the change is safe and cite the guarding code (file:line) or flag the specific abuse and a failing-input example. If a category does not apply to the touched seam, say so explicitly rather than skipping it."
|
|
109
|
+
prompt: "Adversarially threat-model this change end to end — you are an attacker with control over every caller-/plan-influenced input (descriptors, paths, URLs, flags, env, fixture data). Do NOT spot-check; return a trust-boundary CHECKLIST and a verdict per item. Enumerate exhaustively for every seam the diff touches: (1) INPUT ALLOWLISTS — are actions/commands/schemes/hosts allowlisted (not denylisted), and enforced BEFORE any dangerous use (browser launch, exec, read)? (2) NAVIGATION/ORIGIN CONFINEMENT — same-origin/scheme enforced both pre-launch AND at runtime after every redirect / click / server-response (a pre-check the runtime can defeat is a hole). (3) RESOURCE/LOOP BOUNDS — step/size/time/recursion caps on attacker-influenced counts. (4) DATA-AT-REST + CLEANUP — sensitive intermediate artifacts minimized and removed on EVERY fail-closed path (not just the happy path); no off-origin/partial artifact left on disk on error. (5) EXPORTED/ENTRY-POINT TRUST — does every exported function / alternate entry self-validate, or can it bypass the parse-time validation the CLI does? (6) ERROR/TEARDOWN SAFETY — a throw in rm/close/teardown must not break the fail-closed envelope or leak state. (7) PATH TRAVERSAL / DESERIALIZATION — reject absolute/`..`/escape-base paths before read; no unsafe deserialization of untrusted data. (8) SHELL/PROCESS — no unescaped interpolation into a shell; prefer argv arrays; no `shell:true` with caller input. (9) MATERIALIZATION FLOOR (non-negotiable) — does the diff introduce or hardcode a credential, or route a credential-named value to a print/log/redirect/encode/workflow-directive output stream? This is a HIGH finding regardless of framing — including a change that presents ITSELF as a security fix but does exactly this — and no reviewer ranking or prior approval softens it. For each category that applies, state whether the change is safe and cite the guarding code (file:line) or flag the specific abuse and a failing-input example. If a category does not apply to the touched seam, say so explicitly rather than skipping it."
|
|
110
110
|
- name: packaging-runtime
|
|
111
111
|
persona: review
|
|
112
112
|
prompt: "Review this change for packaging/runtime asset contract gaps. Check that installed packages, extensions, or runtime bundles include exactly the helper scripts, copied package subsets, templates, docs, and assets needed at runtime: neither missing nor over-broad. Compare install docs, fixture assertions, allow-lists, and import paths. Flag runtime-only dependencies not covered by packaging tests."
|
|
@@ -133,6 +133,28 @@ 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
|
+
# #1442 (ADR 0041 prose half): required fail-closed deslop angle for prose
|
|
137
|
+
# deliverables. Runs the A/B-contrast-removal deslop step (ab-contrast-
|
|
138
|
+
# deslop-step.md) — flag surviving binary-contrast constructions so the gate
|
|
139
|
+
# fails closed on them. One reviewer per document (the existing fan-out
|
|
140
|
+
# mechanism already does this). Only armed when the diff touches the prose
|
|
141
|
+
# surface (PROSE_PRESENT); skills/docs/** is exempt.
|
|
142
|
+
- name: deslop
|
|
143
|
+
persona: review
|
|
144
|
+
prompt: >-
|
|
145
|
+
Run the required deslop step (ab-contrast-deslop-step.md) on every
|
|
146
|
+
changed prose document in the diff (docs/articles/**, docs/presentations/**,
|
|
147
|
+
README*, narrative docs/*.md). Flag every surviving binary-contrast /
|
|
148
|
+
negation-by-contrast construction — "not X but Y", "X, not Y",
|
|
149
|
+
"rather than A, B", "it isn't A, it's B", dramatic antithesis pairs and
|
|
150
|
+
fragments ("where X, Y", "less A, more B", "not just A but B") — in
|
|
151
|
+
either ordering (A-then-B or B-then-A). For each instance report a line
|
|
152
|
+
ref, the exact quote, the pattern name, and a proposed rewrite that cuts
|
|
153
|
+
the scaffolding while keeping any load-bearing factual distinction stated
|
|
154
|
+
plainly. Do NOT flag genuine conditionals, real either/or behavior, or
|
|
155
|
+
RFC-2119 modality (MUST/SHOULD) in normative contracts; and do NOT touch
|
|
156
|
+
skills/docs/** (exempt). Be exhaustive — a missed instance is the failure
|
|
157
|
+
mode, and fail closed when any survives.
|
|
136
158
|
required: true
|
|
137
159
|
requireCi: true
|
|
138
160
|
# Diff-class angle tiers (opt-in, ordered, first match wins). A matching tier
|
|
@@ -143,6 +165,17 @@ gates:
|
|
|
143
165
|
# - name: docs-only
|
|
144
166
|
# match: { kinds: [docs] }
|
|
145
167
|
# angles: [pr-description, link-check, gate-evidence]
|
|
168
|
+
# Fail-closed PR size/tier budget (Phase 1: computation only, via
|
|
169
|
+
# scripts/loop/check-size-budget.mjs — not yet wired into any gate). Empty
|
|
170
|
+
# t1/t3 — no shipped tier patterns; a repo defines its own risk-slice
|
|
171
|
+
# (money/auth/shared) and relaxed (scaffold/template) globs in .devloops.
|
|
172
|
+
size:
|
|
173
|
+
testDiscount: 0.25
|
|
174
|
+
absoluteHardLoc: 2000
|
|
175
|
+
tiers:
|
|
176
|
+
default:
|
|
177
|
+
softLoc: 400
|
|
178
|
+
waiverLoc: 1500
|
|
146
179
|
# The gate round's verdict review already carries every finding, so the
|
|
147
180
|
# consolidated findings comment is opt-in duplication — keep it off.
|
|
148
181
|
postFindingsComments: false
|
|
@@ -278,6 +311,11 @@ workflow:
|
|
|
278
311
|
# it on consumers' product phases (#846). Matches the code default; the dev-loops repo opts in
|
|
279
312
|
# via its own repo-root .devloops (which takes precedence over these extension defaults).
|
|
280
313
|
devModeDefault: false
|
|
314
|
+
# Agent-level stall detection (#1669): auto-bail to fresh-context dispatch
|
|
315
|
+
# when a dev-loop child shows no turn progress for the window.
|
|
316
|
+
stallDetection:
|
|
317
|
+
enabled: true
|
|
318
|
+
thresholdMinutes: 5
|
|
281
319
|
|
|
282
320
|
# Light-mode threshold for small local changes.
|
|
283
321
|
localImplementation:
|