@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.
Files changed (47) hide show
  1. package/package.json +12 -1
  2. package/src/analysis/change-classifier.mjs +10 -0
  3. package/src/analysis/diff-analyzer.mjs +68 -1
  4. package/src/claude/hook-decisions.mjs +204 -5
  5. package/src/cli/primitives.mjs +51 -1
  6. package/src/config/config.mjs +307 -14
  7. package/src/config/extension-defaults.yaml +39 -1
  8. package/src/github/comment-id-guard.mjs +158 -0
  9. package/src/github/copilot-helpers.mjs +145 -5
  10. package/src/github/gh.mjs +94 -0
  11. package/src/github/issue-ops.mjs +13 -0
  12. package/src/loop/agent-stall.mjs +196 -0
  13. package/src/loop/bash-command-classify.mjs +277 -0
  14. package/src/loop/cache-telemetry-evidence.mjs +437 -0
  15. package/src/loop/copilot-loop-iterations.mjs +2 -1
  16. package/src/loop/default-branch-guard.mjs +35 -2
  17. package/src/loop/gate-carry-forward.mjs +19 -6
  18. package/src/loop/gate-fanin.mjs +190 -29
  19. package/src/loop/handoff-envelope.mjs +40 -20
  20. package/src/loop/issue-refinement-artifact.mjs +94 -0
  21. package/src/loop/lifecycle-state.mjs +21 -2
  22. package/src/loop/main-checkout-ff.mjs +73 -0
  23. package/src/loop/markdown-sections.mjs +40 -0
  24. package/src/loop/normalize.mjs +7 -0
  25. package/src/loop/plan-file-promote-contract.mjs +14 -1
  26. package/src/loop/plan-file-refine-contract.mjs +92 -8
  27. package/src/loop/policy-constants.mjs +9 -0
  28. package/src/loop/pr-gate-coordination.mjs +65 -12
  29. package/src/loop/primer-evidence.mjs +375 -0
  30. package/src/loop/public-dev-loop-routing.mjs +7 -15
  31. package/src/loop/queue-board-sync.mjs +1 -26
  32. package/src/loop/queue-driver.mjs +14 -1
  33. package/src/loop/refinement-grill-state.mjs +3 -5
  34. package/src/loop/review-dispatch-plan.mjs +1034 -0
  35. package/src/loop/review-lineage.mjs +588 -0
  36. package/src/loop/reviewer-loop-state.mjs +8 -13
  37. package/src/loop/run-post-merge-actions.mjs +148 -0
  38. package/src/loop/size-budget-merge-gate.mjs +121 -0
  39. package/src/loop/tracker-pr-state.mjs +5 -15
  40. package/src/loop/ui-designer-review-scoping.mjs +171 -0
  41. package/src/loop/ui-review-drive.mjs +3 -1
  42. package/src/loop/ui-review-report.mjs +2 -5
  43. package/src/loop/ui-review-teardown.mjs +3 -1
  44. package/src/loop/worktree-guard.mjs +80 -0
  45. package/src/projects/list-queue-items.mjs +1 -27
  46. package/src/projects/move-queue-item.mjs +38 -28
  47. package/src/security/secret-scan.mjs +330 -0
@@ -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(["high", "medium", "low", "must-fix", "worth-fixing-now", "nice-to-have", "defer"]))
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 typeof model === "string" && model.trim().length > 0 ? model.trim() : null;
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 (envelope,
1845
- // verdict poster, fan-in, viewer) sees canonical spellings only; a
1846
- // half-migrated ["must-fix","low","defer"] collapses to two entries.
1847
- blockCleanOnFindingSeverities: gateConfig?.blockCleanOnFindingSeverities && Array.isArray(gateConfig.blockCleanOnFindingSeverities)
1848
- ? [...new Set(gateConfig.blockCleanOnFindingSeverities.map((s) => normalizeSeverity(s)))]
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
- const tierAngleSet = new Set(tierResult.angles);
2422
- const skippedAngles = configuredAngles.filter((a) => !tierAngleSet.has(a));
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: tierResult.angles,
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: typeof run.cwd === "string" && run.cwd.trim().length > 0 ? run.cwd.trim() : null,
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 = typeof ui.serverLogPath === "string" && ui.serverLogPath.trim().length > 0 ? ui.serverLogPath.trim() : null;
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: