@dev-loops/core 1.0.0-rc.6 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/package.json +8 -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 +36 -4
  5. package/src/cli/primitives.mjs +30 -1
  6. package/src/config/config.mjs +254 -13
  7. package/src/config/extension-defaults.yaml +34 -1
  8. package/src/github/comment-id-guard.mjs +97 -9
  9. package/src/github/copilot-helpers.mjs +114 -5
  10. package/src/github/gh.mjs +94 -0
  11. package/src/github/issue-ops.mjs +7 -0
  12. package/src/loop/agent-stall.mjs +4 -2
  13. package/src/loop/commit-msg-guard.mjs +168 -0
  14. package/src/loop/copilot-loop-iterations.mjs +2 -1
  15. package/src/loop/default-branch-guard.mjs +34 -1
  16. package/src/loop/gate-carry-forward.mjs +19 -6
  17. package/src/loop/gate-fanin.mjs +190 -29
  18. package/src/loop/handoff-envelope.mjs +12 -19
  19. package/src/loop/issue-refinement-artifact.mjs +186 -42
  20. package/src/loop/lifecycle-state.mjs +21 -2
  21. package/src/loop/main-checkout-ff.mjs +34 -0
  22. package/src/loop/markdown-sections.mjs +40 -0
  23. package/src/loop/normalize.mjs +7 -0
  24. package/src/loop/plan-file-promote-contract.mjs +14 -1
  25. package/src/loop/plan-file-refine-contract.mjs +92 -8
  26. package/src/loop/policy-constants.mjs +9 -0
  27. package/src/loop/pr-gate-coordination.mjs +65 -12
  28. package/src/loop/public-dev-loop-routing.mjs +11 -15
  29. package/src/loop/queue-board-sync.mjs +1 -26
  30. package/src/loop/queue-driver.mjs +14 -1
  31. package/src/loop/refinement-grill-state.mjs +3 -5
  32. package/src/loop/retrospective-checkpoint.mjs +59 -1
  33. package/src/loop/review-dispatch-plan.mjs +448 -9
  34. package/src/loop/reviewer-loop-state.mjs +8 -13
  35. package/src/loop/run-post-merge-actions.mjs +148 -0
  36. package/src/loop/size-budget-merge-gate.mjs +121 -0
  37. package/src/loop/tracker-pr-state.mjs +5 -15
  38. package/src/loop/ui-designer-review-scoping.mjs +171 -0
  39. package/src/loop/ui-review-drive.mjs +3 -1
  40. package/src/loop/ui-review-report.mjs +2 -5
  41. package/src/loop/ui-review-teardown.mjs +3 -1
  42. package/src/projects/list-queue-items.mjs +1 -27
  43. package/src/projects/move-queue-item.mjs +2 -28
  44. 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."),
@@ -315,8 +357,45 @@ function rejectDuplicateFanoutGroupNames(val, ctx) {
315
357
  }
316
358
  }
317
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
+
318
393
  const GatesConfig = z.strictObject({
319
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(),
320
399
  // `requireCi` is honored on both gates: default true keeps CI a precondition,
321
400
  // false is an opt-out escape hatch so a repo with no CI is not held at the
322
401
  // gate. The pre-approval gate mirrors the draft gate's `requireCi` semantics —
@@ -731,6 +810,48 @@ const UiReviewConfig = z.strictObject({
731
810
  .optional(),
732
811
  });
733
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
+
734
855
  /** Internal path whitelist for internal-only PR detection — flat array of regex strings */
735
856
  const InternalPatternsConfig = z.array(z.string().trim().min(1)).min(1);
736
857
 
@@ -743,6 +864,7 @@ const FileGatesConfig = z.strictObject({
743
864
  draft: GateConfig.partial().describe("Draft gate config (runs before a PR leaves draft).").optional(),
744
865
  preApproval: GateConfig.partial().describe("Pre-approval gate config (final re-review before the merge handoff).").optional(),
745
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(),
746
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(),
747
869
  requireFanoutProvenance: z.boolean().describe("Additionally require recorded, internally-consistent fan-out provenance (distinct reviewer count + per-angle dispatch).").optional(),
748
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(),
@@ -783,6 +905,7 @@ export const DevLoopConfigSchema = z.strictObject({
783
905
  internalPathPatterns: InternalPatternsConfig.optional(),
784
906
  worktree: WorktreeConfig.optional(),
785
907
  uiReview: UiReviewConfig.optional(),
908
+ postMerge: PostMergeConfig.optional(),
786
909
  });
787
910
 
788
911
  // ============================================================================
@@ -858,6 +981,7 @@ export const FileConfigSchema = z.strictObject({
858
981
  internalPathPatterns: InternalPatternsConfig.describe("Regex whitelist for internal-only PR detection.").optional(),
859
982
  worktree: WorktreeConfig.partial().describe("Worktree provisioning: gitignored files/dirs copied or symlinked into fresh worktrees.").optional(),
860
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(),
861
985
  // 1.0 hard break (no dual-form): the deprecated `localPlanning` key (removed
862
986
  // behavior in #1088, tolerated-but-unread since) is dropped from the 1.0
863
987
  // schema entirely — an unknown key now fails closed like any other typo,
@@ -901,6 +1025,7 @@ const BUILTIN_PERSONAS = Object.freeze({
901
1025
  determinism: { persona: "review", defaultModel: null },
902
1026
  "acceptance-criteria": { persona: "review", defaultModel: null },
903
1027
  "ac-dod": { persona: "review", defaultModel: null },
1028
+ deslop: { persona: "review", defaultModel: null },
904
1029
  });
905
1030
 
906
1031
  const DEFAULT_REVIEWER_PERSONA = "default-reviewer";
@@ -1037,7 +1162,7 @@ function resolveTierMapping(config, tierAlias, harness) {
1037
1162
  if (!builtinMapping && !configMapping) return null;
1038
1163
  const mapping = { ...builtinMapping, ...configMapping };
1039
1164
  const model = mapping[harness];
1040
- return typeof model === "string" && model.trim().length > 0 ? model.trim() : null;
1165
+ return trimmedOrNull(model);
1041
1166
  }
1042
1167
 
1043
1168
  /**
@@ -1818,6 +1943,51 @@ export function resolveRefinement(config) {
1818
1943
  return { fanOut, mode, roles, maxCopilotRounds, stopOnLowSignal, lowSignalRoundThreshold, lowSignalMaxComments, preApprovalRequireCi };
1819
1944
  }
1820
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
+
1821
1991
  /**
1822
1992
  * Resolve one gate configuration object from the merged dev-loop config.
1823
1993
  *
@@ -1838,9 +2008,42 @@ export function resolveRefinement(config) {
1838
2008
  * @param {DevLoopConfig} config
1839
2009
  * @param {"draft"|"preApproval"|"spike"} gate
1840
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.
1841
2033
  */
1842
2034
  export function resolveGateConfig(config, gate) {
1843
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
+ }
1844
2047
  const entries = normalizeAngleEntries(gateConfig?.angles);
1845
2048
  // An explicitly-empty (or all-garbage/malformed) array is a real configured
1846
2049
  // "no angles" — distinct from the key being absent entirely, which callers
@@ -1854,12 +2057,11 @@ export function resolveGateConfig(config, gate) {
1854
2057
  requireCi: gateConfig?.requireCi ?? true,
1855
2058
  dynamicAngles: gateConfig?.dynamic?.subtractive ?? true,
1856
2059
  additiveAngles: gateConfig?.dynamic?.additive ?? false,
1857
- // Normalized + deduped at the resolve boundary so every consumer (envelope,
1858
- // verdict poster, fan-in, viewer) sees canonical spellings only; a
1859
- // half-migrated ["must-fix","low","defer"] collapses to two entries.
1860
- blockCleanOnFindingSeverities: gateConfig?.blockCleanOnFindingSeverities && Array.isArray(gateConfig.blockCleanOnFindingSeverities)
1861
- ? [...new Set(gateConfig.blockCleanOnFindingSeverities.map((s) => normalizeSeverity(s)))]
1862
- : ["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,
1863
2065
  // `mediumFixWindow` wins; `worthFixingNowFixWindow` is the deprecated
1864
2066
  // pre-rename key, still honored so an unmigrated config keeps its
1865
2067
  // configured window rather than silently reverting to the default.
@@ -2448,11 +2650,13 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2448
2650
  let changedFiles;
2449
2651
  let filesChanged;
2450
2652
  let linesChanged;
2653
+ let prosePresent = false;
2451
2654
  if (diff) {
2452
2655
  const { analyzeT0, analyzeT1 } = await import("../analysis/diff-analyzer.mjs");
2453
2656
  const t0 = analyzeT0(diff.nameStatusOutput);
2454
2657
  changedFiles = t0.files;
2455
2658
  filesChanged = changedFiles.length;
2659
+ prosePresent = t0.prosePresent; // #1442: gate deslop on the prose surface
2456
2660
  if (diff.diffOutput) {
2457
2661
  const lineStats = analyzeT1(diff.diffOutput, t0).lineStats;
2458
2662
  linesChanged = lineStats.added + lineStats.deleted;
@@ -2461,10 +2665,18 @@ export async function resolveGateAnglesDynamic(config, gate, { diff, hasFullLabe
2461
2665
  const tierResult = resolveGateTier(config, gate, { changedFiles, filesChanged, linesChanged, hasFullLabel });
2462
2666
  if (tierResult.tier) {
2463
2667
  const configuredAngles = resolveGateAngles(config, gate) ?? [];
2464
- const tierAngleSet = new Set(tierResult.angles);
2465
- 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));
2466
2678
  return {
2467
- recommendedAngles: tierResult.angles,
2679
+ recommendedAngles,
2468
2680
  skippedAngles,
2469
2681
  reasons: Object.fromEntries(skippedAngles.map((a) => [a, `tier:${tierResult.tier}`])),
2470
2682
  fallbackToAll: false,
@@ -2736,12 +2948,41 @@ export function resolveUiReviewRunRecipe(config) {
2736
2948
  readyUrl: run.readyUrl.trim(),
2737
2949
  readyTimeoutMs: Number.isInteger(run.readyTimeoutMs) ? run.readyTimeoutMs : 60000,
2738
2950
  readyIntervalMs: Number.isInteger(run.readyIntervalMs) ? run.readyIntervalMs : 1000,
2739
- cwd: typeof run.cwd === "string" && run.cwd.trim().length > 0 ? run.cwd.trim() : null,
2951
+ cwd: trimmedOrNull(run.cwd),
2740
2952
  migrate,
2741
2953
  rowTeardown,
2742
2954
  };
2743
2955
  }
2744
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
+
2745
2986
  /**
2746
2987
  * Default server-log exception signal for the drive stage's log tail. Matched
2747
2988
  * (case-insensitive, per line) against the tailed server-log text. This is a
@@ -2771,7 +3012,7 @@ export function resolveUiReviewDriveRecipe(config) {
2771
3012
  if (!login || typeof login.loginUrl !== "string" || login.loginUrl.trim().length === 0) return null;
2772
3013
  if (typeof login.submitSelector !== "string" || login.submitSelector.trim().length === 0) return null;
2773
3014
  if (typeof login.successSelector !== "string" || login.successSelector.trim().length === 0) return null;
2774
- const serverLogPath = typeof ui.serverLogPath === "string" && ui.serverLogPath.trim().length > 0 ? ui.serverLogPath.trim() : null;
3015
+ const serverLogPath = trimmedOrNull(ui.serverLogPath);
2775
3016
  return {
2776
3017
  login: {
2777
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
@@ -21,27 +21,113 @@
21
21
  * helpers cannot emit an issue/PR id without an explicit allowlist entry.
22
22
  *
23
23
  * Deliberate cross-reference mechanism: pass the id(s) to allow as
24
- * `allowedRefs: ["1670"]`. This is the ONLY sanctioned way a generated comment
25
- * body may reference an issue/PR id. Keep the allowlist small and deliberate.
24
+ * `allowedRefs: ["1670"]`. Aside from a genuine HTML numeric character
25
+ * reference (`&#<digits>;`, e.g. `&#91;` for `[`) — each such OCCURRENCE is
26
+ * skipped; the same digit run appearing elsewhere as a bare token still
27
+ * refuses — this is the ONLY sanctioned way a generated comment body may
28
+ * carry a `#<digits>` token. Extraction is decode-aware on BOTH sides of the
29
+ * token: the body is also scanned after a single left-to-right decode of the
30
+ * entity forms GitHub's renderer resolves (numeric character references,
31
+ * zero-padding and hex included at cmark-gfm's 8-digit bound, plus the named
32
+ * hash entity),
33
+ * so a hash or any digit of the id smuggled as an entity — `&#35;123`,
34
+ * `&num;123`, `#&#49;23`, any mix — still refuses. The decode is single-pass
35
+ * like the renderer's: a double-encoded form (`&amp;#35;123`) renders as
36
+ * inert literal text and the decode pass never manufactures a refusal of its
37
+ * INNER id — though the raw scan still refuses the outer digit run of the
38
+ * numeric form as a pre-existing fail-closed near-miss (the outer entity's
39
+ * semicolon sits before the hash, so the well-formed-entity exclusion does
40
+ * not apply). Case-variants of the named hash entity are decoded too even
41
+ * where GitHub would not (`&NUM;`): deliberate over-refusal, keeping the
42
+ * guard fail-closed. Keep the allowlist small and deliberate.
26
43
  */
27
44
 
28
45
  // Matches a bare GitHub auto-link issue/PR reference: `#<digits>`. Bound to
29
46
  // 1..9 digits to avoid absurd ids while covering the full GitHub id space.
47
+ // A match is excluded only when it forms a well-formed HTML numeric character
48
+ // reference — preceded by `&` AND immediately followed by `;` (e.g. `&#91;`,
49
+ // the entity-encoded form of `[`). Any other shape (a bare `#123`, an
50
+ // `&`-preceded run with no terminating `;`, or a `;`-followed run with no
51
+ // preceding `&`) is not a well-formed entity and still refuses as a genuine
52
+ // auto-link candidate.
30
53
  const ISSUE_PR_ID_RE = /#(\d{1,9})/g;
31
54
 
55
+ function isNumericCharacterReference(body, match) {
56
+ // The digit bound mirrors cmark-gfm's 8-digit entity parser: a 9-digit
57
+ // ampersand-wrapped run is NOT decoded by the renderer, so it renders
58
+ // literally and must not be excluded as a spent entity.
59
+ return match[1].length <= 8
60
+ && body[match.index - 1] === "&"
61
+ && body[match.index + match[0].length] === ";";
62
+ }
63
+
64
+ // Entity forms the renderer resolves that can participate in assembling a
65
+ // rendered `#<digits>` auto-link: numeric character references (any code
66
+ // point — the hash AND the digits themselves are smuggleable) plus the named
67
+ // hash entity. The digit bounds match cmark-gfm's numeric-entity parser
68
+ // (up to 8 digits, decimal or hex) so nothing GitHub decodes escapes the
69
+ // pass. Single non-rescanning replace = one decode, like the renderer, so a
70
+ // double-encoded form's output is never re-read as a fresh entity.
71
+ const DECODABLE_ENTITY_RE = /&(?:#(?:\d{1,8}|x[0-9a-f]{1,8})|num);/gi;
72
+
73
+ function decodeRenderedText(body) {
74
+ return body.replace(DECODABLE_ENTITY_RE, (entity) => {
75
+ const inner = entity.slice(1, -1).toLowerCase();
76
+ if (inner === "num") return "#";
77
+ const code = inner[1] === "x" ? Number.parseInt(inner.slice(2), 16) : Number.parseInt(inner.slice(1), 10);
78
+ try {
79
+ return String.fromCodePoint(code);
80
+ } catch {
81
+ // cmark substitutes the replacement character for a reference it cannot
82
+ // decode; mirroring that keeps decodable-shaped text from lingering in
83
+ // the decoded scan, where it could masquerade as a spent entity.
84
+ return "�";
85
+ }
86
+ });
87
+ }
88
+
89
+ function collectBareIds(text, found, { excludeEntities }) {
90
+ for (const m of text.matchAll(ISSUE_PR_ID_RE)) {
91
+ if (excludeEntities && isNumericCharacterReference(text, m)) continue;
92
+ found.add(m[1]);
93
+ }
94
+ }
95
+
32
96
  /**
33
97
  * Extract the raw issue/PR id tokens found in a body (as strings, deduped).
34
- * Returns [] for non-string input (and for a body with no `#<digits>`).
98
+ * Scans the body as written AND after a single renderer-like entity decode,
99
+ * so an id assembled from entity-encoded pieces is still found. The entity
100
+ * exclusion applies only to the RAW scan: in decoded text the renderer's one
101
+ * decode is already spent, so an ampersand-then-digits-then-semicolon shape
102
+ * there is plain text a wrapper cannot re-protect (an ampersand-wrapped
103
+ * encoded hash plus digits must refuse, not hide). Returns [] for non-string
104
+ * input (and for a body with no `#<digits>`).
35
105
  */
36
106
  export function extractIssuePrIds(body) {
37
107
  if (typeof body !== "string" || body.length === 0) return [];
38
108
  const found = new Set();
39
- for (const m of body.matchAll(ISSUE_PR_ID_RE)) {
40
- found.add(m[1]);
41
- }
109
+ collectBareIds(body, found, { excludeEntities: true });
110
+ const decoded = decodeRenderedText(body);
111
+ if (decoded !== body) collectBareIds(decoded, found, { excludeEntities: false });
42
112
  return [...found];
43
113
  }
44
114
 
115
+ // A caller-supplied allowlist is normally already an array (or other
116
+ // iterable) of ids. Guard the one mis-shaped input that would otherwise
117
+ // silently produce the wrong set: a plain CSV string. `Array.from` over a
118
+ // string character-splits it ("1670" -> ["1","6","7","0"]), which would
119
+ // spuriously allowlist single-digit refs while still refusing the id the
120
+ // caller meant to allow. Mirrors parseAllowedRefsCsv's comma-split (trim,
121
+ // drop empties) — deliberately without its numeric validation, since this is
122
+ // a permissive low-level guard, not the CLI arg parser.
123
+ function normalizeAllowedRefs(allowedRefs) {
124
+ if (allowedRefs == null) return [];
125
+ if (typeof allowedRefs === "string") {
126
+ return allowedRefs.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
127
+ }
128
+ return Array.from(allowedRefs, (id) => String(id));
129
+ }
130
+
45
131
  /**
46
132
  * Fail-closed guard: returns `body` unchanged when it contains no raw
47
133
  * issue/PR id (or every id it contains is explicitly allowlisted). Throws
@@ -50,13 +136,15 @@ export function extractIssuePrIds(body) {
50
136
  * @param {string} body - the generated comment body to guard.
51
137
  * @param {object} [opts]
52
138
  * @param {string} [opts.ref] - human label for the guarded surface (error context).
53
- * @param {Iterable<number|string>} [opts.allowedRefs] - explicit allowlist of
54
- * deliberate cross-reference ids permitted to appear in the body.
139
+ * @param {Iterable<number|string>|string} [opts.allowedRefs] - explicit
140
+ * allowlist of deliberate cross-reference ids permitted to appear in the
141
+ * body. A plain string is treated as a comma-separated list (like a CLI
142
+ * `--allowed-refs` value), never character-split.
55
143
  * @returns {string} the (unchanged, since no stripping) body.
56
144
  */
57
145
  export function guardCommentBodyNoIssuePrIds(body, { ref = "generated comment body", allowedRefs = [] } = {}) {
58
146
  if (typeof body !== "string") return body;
59
- const allow = new Set(Array.from(allowedRefs ?? [], (id) => String(id)));
147
+ const allow = new Set(normalizeAllowedRefs(allowedRefs));
60
148
  const offending = extractIssuePrIds(body).filter((id) => !allow.has(id));
61
149
  if (offending.length > 0) {
62
150
  throw new Error(