@dev-loops/core 1.0.0-rc.6 → 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 (41) hide show
  1. package/package.json +7 -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/copilot-loop-iterations.mjs +2 -1
  14. package/src/loop/default-branch-guard.mjs +34 -1
  15. package/src/loop/gate-carry-forward.mjs +19 -6
  16. package/src/loop/gate-fanin.mjs +190 -29
  17. package/src/loop/handoff-envelope.mjs +12 -19
  18. package/src/loop/lifecycle-state.mjs +21 -2
  19. package/src/loop/main-checkout-ff.mjs +34 -0
  20. package/src/loop/markdown-sections.mjs +40 -0
  21. package/src/loop/normalize.mjs +7 -0
  22. package/src/loop/plan-file-promote-contract.mjs +14 -1
  23. package/src/loop/plan-file-refine-contract.mjs +92 -8
  24. package/src/loop/policy-constants.mjs +9 -0
  25. package/src/loop/pr-gate-coordination.mjs +65 -12
  26. package/src/loop/public-dev-loop-routing.mjs +7 -15
  27. package/src/loop/queue-board-sync.mjs +1 -26
  28. package/src/loop/queue-driver.mjs +14 -1
  29. package/src/loop/refinement-grill-state.mjs +3 -5
  30. package/src/loop/review-dispatch-plan.mjs +448 -9
  31. package/src/loop/reviewer-loop-state.mjs +8 -13
  32. package/src/loop/run-post-merge-actions.mjs +148 -0
  33. package/src/loop/size-budget-merge-gate.mjs +121 -0
  34. package/src/loop/tracker-pr-state.mjs +5 -15
  35. package/src/loop/ui-designer-review-scoping.mjs +171 -0
  36. package/src/loop/ui-review-drive.mjs +3 -1
  37. package/src/loop/ui-review-report.mjs +2 -5
  38. package/src/loop/ui-review-teardown.mjs +3 -1
  39. package/src/projects/list-queue-items.mjs +1 -27
  40. package/src/projects/move-queue-item.mjs +1 -27
  41. package/src/security/secret-scan.mjs +330 -0
@@ -6,14 +6,32 @@
6
6
  * scripts and other packages/core modules.
7
7
  */
8
8
 
9
+ import { GATE_REVIEW_VERDICT_SET } from "../loop/policy-constants.mjs";
10
+ import { trimmedOrNull } from "../loop/normalize.mjs";
11
+
9
12
  // Exported so anything deciding "is there a real prior review" uses the same
10
13
  // whitelist as the loop-state reader — two copies could drift, and a guard
11
14
  // acting on the gate's behalf must agree with the gate about what a submitted
12
15
  // review is.
13
16
  export const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
14
17
  const GATE_REVIEW_NAMES = new Set(["draft_gate", "pre_approval_gate"]);
15
- const GATE_REVIEW_VERDICTS = new Set(["clean", "findings_present", "blocked"]);
18
+ // `review` (the standalone review entrypoint, upsert-checkpoint-verdict.mjs's
19
+ // `--gate review`) is a RECOGNIZED gate header — it is identified, not
20
+ // absent — but carries no draft/pre-approval evidence by design (#1808 AC3).
21
+ // Recognizing it (rather than leaving it unrecognized) is what lets
22
+ // parseGateReviewCommentFields below short-circuit to null the instant a
23
+ // `review` header is seen, instead of falling through to the lenient
24
+ // draft_gate/pre_approval_gate token scan — the fallthrough that previously
25
+ // let a `review` verdict whose findings text merely MENTIONED "draft_gate"
26
+ // get recorded as real draft-gate evidence (a draft-gate bypass).
27
+ const NON_EVIDENCE_GATE_NAMES = new Set(["review"]);
28
+ const RECOGNIZED_GATE_NAMES = new Set([...GATE_REVIEW_NAMES, ...NON_EVIDENCE_GATE_NAMES]);
16
29
  const GATE_EXECUTION_MODES = new Set(["fanout_fanin", "inline_single_agent"]);
30
+ // Size-budget outcome vocabulary — mirrors
31
+ // check-size-budget.mjs's computeSizeBudget outcome enum exactly; this file
32
+ // never recomputes the outcome, only round-trips it through the verdict
33
+ // comment.
34
+ const GATE_SIZE_OUTCOMES = new Set(["pass", "escalate", "block"]);
17
35
 
18
36
  // The literal header line the gate review body always emits first
19
37
  // (upsert-checkpoint-verdict.mjs's renderGateReviewCommentBody, re-exported
@@ -288,14 +306,19 @@ function stripGateCommentMarkdown(rawLine) {
288
306
  return line.trim();
289
307
  }
290
308
 
309
+ // Recognizes BOTH evidence gates (draft_gate/pre_approval_gate) and the
310
+ // non-evidence `review` gate — parseGateReviewCommentFields below relies on
311
+ // `review` coming back as an identified value (not null) so it can
312
+ // short-circuit to non-evidence explicitly, rather than leaving the field
313
+ // null and falling through to the lenient token-scan fallback.
291
314
  function normalizeGateReviewName(value) {
292
315
  const normalized = stripOptionalCodeTicks(value).toLowerCase();
293
- return GATE_REVIEW_NAMES.has(normalized) ? normalized : null;
316
+ return RECOGNIZED_GATE_NAMES.has(normalized) ? normalized : null;
294
317
  }
295
318
 
296
319
  function normalizeGateReviewVerdict(value) {
297
320
  const normalized = stripOptionalCodeTicks(value).toLowerCase();
298
- return GATE_REVIEW_VERDICTS.has(normalized) ? normalized : null;
321
+ return GATE_REVIEW_VERDICT_SET.has(normalized) ? normalized : null;
299
322
  }
300
323
 
301
324
  function normalizeGateReviewHeadSha(value) {
@@ -308,6 +331,32 @@ function normalizeGateExecutionMode(value) {
308
331
  return GATE_EXECUTION_MODES.has(normalized) ? normalized : null;
309
332
  }
310
333
 
334
+ function normalizeGateSizeOutcome(value) {
335
+ const normalized = stripOptionalCodeTicks(value).toLowerCase();
336
+ return GATE_SIZE_OUTCOMES.has(normalized) ? normalized : null;
337
+ }
338
+
339
+ function normalizeGateSizeTouchesT1(value) {
340
+ const normalized = stripOptionalCodeTicks(value).toLowerCase();
341
+ if (normalized === "touched") return true;
342
+ if (normalized === "not touched") return false;
343
+ return null;
344
+ }
345
+
346
+ // "none" | "granted" | "granted by <name>" — the approver name is free text
347
+ // (already sanitized by the poster via sanitizeInline before rendering), so
348
+ // no further normalization beyond trimming is applied here.
349
+ function normalizeGateSizeWaiver(value) {
350
+ const normalized = stripOptionalCodeTicks(value).trim();
351
+ if (/^none$/iu.test(normalized)) return { granted: false, approvedBy: null };
352
+ const grantedMatch = normalized.match(/^granted(?:\s+by\s+(.+))?$/iu);
353
+ if (grantedMatch) {
354
+ const approvedBy = grantedMatch[1]?.trim();
355
+ return { granted: true, approvedBy: approvedBy && approvedBy.length > 0 ? approvedBy : null };
356
+ }
357
+ return null;
358
+ }
359
+
311
360
  function parseGateReviewCommentFields(body) {
312
361
  if (typeof body !== "string" || body.trim().length === 0) {
313
362
  return null;
@@ -321,6 +370,10 @@ function parseGateReviewCommentFields(body) {
321
370
  nextAction: null,
322
371
  executionMode: null,
323
372
  inlineReason: null,
373
+ sizeOutcome: null,
374
+ sizeTouchesT1: null,
375
+ sizeWaiverGranted: null,
376
+ sizeWaiverApprovedBy: null,
324
377
  };
325
378
 
326
379
  for (const rawLine of body.split(/\r?\n/u)) {
@@ -413,6 +466,50 @@ function parseGateReviewCommentFields(body) {
413
466
  }
414
467
  continue;
415
468
  }
469
+
470
+ match = line.match(/^(?:[-*]\s*)?size[\s-]budget\s+outcome\s*:\s*(.+)$/iu);
471
+ if (match) {
472
+ if (fields.sizeOutcome === null) {
473
+ fields.sizeOutcome = normalizeGateSizeOutcome(match[1]);
474
+ }
475
+ continue;
476
+ }
477
+
478
+ match = line.match(/^(?:[-*]\s*)?size[\s-]budget\s+t1\s+slice\s*:\s*(.+)$/iu);
479
+ if (match) {
480
+ if (fields.sizeTouchesT1 === null) {
481
+ fields.sizeTouchesT1 = normalizeGateSizeTouchesT1(match[1]);
482
+ }
483
+ continue;
484
+ }
485
+
486
+ match = line.match(/^(?:[-*]\s*)?size[\s-]budget\s+waiver\s*:\s*(.+)$/iu);
487
+ if (match) {
488
+ if (fields.sizeWaiverGranted === null) {
489
+ const parsedWaiver = normalizeGateSizeWaiver(match[1]);
490
+ if (parsedWaiver) {
491
+ fields.sizeWaiverGranted = parsedWaiver.granted;
492
+ fields.sizeWaiverApprovedBy = parsedWaiver.approvedBy;
493
+ }
494
+ }
495
+ continue;
496
+ }
497
+ }
498
+
499
+ // An explicit, RECOGNIZED `review` gate field is authoritative and returns
500
+ // null here — before the lenient token-scan fallback below ever runs. A
501
+ // `review` verdict comment carries no draft/pre-approval evidence by
502
+ // design (#1808 AC3); without this short circuit, `fields.gate` would stay
503
+ // "review" (not one of the two evidence gates) but the lenient fallback
504
+ // below only fires when `!fields.gate` — so a *recognized* `review` gate
505
+ // would otherwise skip the fallback yet still return non-null fields keyed
506
+ // to "review", which is harmless for the two summarizers here (they only
507
+ // read `.draft_gate`/`.pre_approval_gate`) but leaves the non-evidence
508
+ // contract implicit rather than explicit. Stated plainly: an identified
509
+ // non-evidence gate must never be treated as an unidentified body, and an
510
+ // unidentified body is the ONLY case the token-scan fallback exists for.
511
+ if (NON_EVIDENCE_GATE_NAMES.has(fields.gate)) {
512
+ return null;
416
513
  }
417
514
 
418
515
  // Lenient fallback: detect gate name and head SHA anywhere in body
@@ -481,6 +578,10 @@ export function parseGateReviewCommentMarkerBody(body) {
481
578
  nextAction: fields.nextAction,
482
579
  executionMode: fields.executionMode,
483
580
  inlineReason: fields.inlineReason,
581
+ sizeOutcome: fields.sizeOutcome,
582
+ sizeTouchesT1: fields.sizeTouchesT1,
583
+ sizeWaiverGranted: fields.sizeWaiverGranted,
584
+ sizeWaiverApprovedBy: fields.sizeWaiverApprovedBy,
484
585
  contractComplete: Boolean(fields.verdict && fields.findingsSummary && fields.nextAction),
485
586
  };
486
587
  }
@@ -525,9 +626,13 @@ export function summarizeGateReviewComments(comments) {
525
626
  nextAction: parsed.nextAction,
526
627
  executionMode: parsed.executionMode ?? null,
527
628
  inlineReason: parsed.inlineReason ?? null,
629
+ sizeOutcome: parsed.sizeOutcome ?? null,
630
+ sizeTouchesT1: parsed.sizeTouchesT1 ?? null,
631
+ sizeWaiverGranted: parsed.sizeWaiverGranted ?? null,
632
+ sizeWaiverApprovedBy: parsed.sizeWaiverApprovedBy ?? null,
528
633
  surface: normalizeVerdictSurface(comment?.surface),
529
634
  commentId: Number.isInteger(comment?.id) ? comment.id : null,
530
- commentUrl: typeof comment?.html_url === "string" && comment.html_url.trim().length > 0 ? comment.html_url.trim() : null,
635
+ commentUrl: trimmedOrNull(comment?.html_url),
531
636
  updatedAt: typeof (comment?.updated_at ?? comment?.updatedAt) === "string"
532
637
  ? (comment.updated_at ?? comment.updatedAt).trim()
533
638
  : typeof (comment?.created_at ?? comment?.createdAt) === "string"
@@ -579,10 +684,14 @@ export function summarizeGateReviewCommentMarkers(comments, { headSha } = {}) {
579
684
  nextAction: parsed.nextAction,
580
685
  executionMode: parsed.executionMode ?? null,
581
686
  inlineReason: parsed.inlineReason ?? null,
687
+ sizeOutcome: parsed.sizeOutcome ?? null,
688
+ sizeTouchesT1: parsed.sizeTouchesT1 ?? null,
689
+ sizeWaiverGranted: parsed.sizeWaiverGranted ?? null,
690
+ sizeWaiverApprovedBy: parsed.sizeWaiverApprovedBy ?? null,
582
691
  contractComplete: parsed.contractComplete,
583
692
  surface: normalizeVerdictSurface(comment?.surface),
584
693
  commentId: Number.isInteger(comment?.id) ? comment.id : null,
585
- commentUrl: typeof comment?.html_url === "string" && comment.html_url.trim().length > 0 ? comment.html_url.trim() : null,
694
+ commentUrl: trimmedOrNull(comment?.html_url),
586
695
  updatedAt: typeof (comment?.updated_at ?? comment?.updatedAt) === "string"
587
696
  ? (comment.updated_at ?? comment.updatedAt).trim()
588
697
  : typeof (comment?.created_at ?? comment?.createdAt) === "string"
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Shared `gh` CLI invoke-and-parse helpers (child 6 of the simplification
3
+ * epic #1689). This module introduces the shared implementation only; the
4
+ * ~19 call-site migrations are the follow-up children (projects: #1696;
5
+ * github/loop/refine: #1697).
6
+ *
7
+ * `ghJson` is the composed SUPERSET the callers share (per #1695's AC): a
8
+ * label-conditional non-zero-exit message — `gh command failed: <detail>` with
9
+ * no label (probe-ci-status shape) or `<label> failed: <detail>` with one
10
+ * (fetch-ci-logs shape) — always carrying `code: "GH_API_ERROR"`, plus the
11
+ * inline `Invalid JSON from gh: <stdout|<empty>>` malformed-stdout shape
12
+ * (probe-ci-status). `ghGraphql` reproduces scripts/projects/add-queue-item.mjs's
13
+ * superset (`gh api graphql failed` / `GraphQL errors:` with GH_API_ERROR /
14
+ * GRAPHQL_ERROR; `parseJsonText` → `Invalid JSON input`).
15
+ *
16
+ * Because `ghJson` is a composed superset, NO current caller matches it exactly
17
+ * — migrating each is a deliberate behavior harmonization, not a blind swap:
18
+ * - probe-ci-status.mjs: `gh command failed:` / `Invalid JSON from gh:` already
19
+ * match; it gains the `GH_API_ERROR` code on non-zero exit.
20
+ * - fetch-ci-logs.mjs: `<label> failed:` matches (pass its label); it gains the
21
+ * `GH_API_ERROR` code and its malformed-JSON message becomes
22
+ * `Invalid JSON from gh:` (was `parseJsonText` → `Invalid JSON input`).
23
+ * - upsert-checkpoint-verdict.mjs / post-gate-findings.mjs: gain the code and
24
+ * their malformed-JSON message becomes `Invalid JSON from gh:` (was
25
+ * `Invalid JSON input`).
26
+ * The follow-up children (projects: #1696; github/loop/refine: #1697) migrate
27
+ * each caller and update its pinned test messages accordingly.
28
+ */
29
+
30
+ import { runChild as defaultRunChild } from "../cli/primitives.mjs";
31
+ import { parseJsonText } from "./review-threads.mjs";
32
+
33
+ /**
34
+ * Run a `gh` subcommand and parse its stdout as JSON. Fails loudly on a
35
+ * non-zero exit (naming the command's stderr) and on malformed JSON stdout.
36
+ *
37
+ * @param {string[]} args - argv passed to `ghCommand`.
38
+ * @param {object} [opts]
39
+ * @param {NodeJS.ProcessEnv} [opts.env]
40
+ * @param {string} [opts.ghCommand] - defaults to `"gh"`.
41
+ * @param {typeof defaultRunChild} [opts.runChild] - injectable child-exec seam.
42
+ * @param {string} [opts.label] - controls the non-zero-exit message: when set,
43
+ * the thrown error reads `<label> failed: <detail>` (the fetch-ci-logs shape);
44
+ * when omitted it reads `gh command failed: <detail>` (the probe-ci-status
45
+ * shape). Either way the non-zero-exit error carries `code: "GH_API_ERROR"`.
46
+ */
47
+ export async function ghJson(args, { env, ghCommand = "gh", runChild = defaultRunChild, label } = {}) {
48
+ const result = await runChild(ghCommand, args, env);
49
+ if (result.code !== 0) {
50
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
51
+ const prefix = label ? `${label} failed` : "gh command failed";
52
+ throw Object.assign(new Error(`${prefix}: ${detail}`), { code: "GH_API_ERROR" });
53
+ }
54
+ try {
55
+ return JSON.parse(result.stdout);
56
+ } catch {
57
+ throw new Error(`Invalid JSON from gh: ${result.stdout.trim() || "<empty>"}`);
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Run a `gh api graphql` query and parse its response.
63
+ *
64
+ * @param {string} query - the GraphQL document.
65
+ * @param {Record<string, string>} vars - `--field key=value` variables.
66
+ * @param {NodeJS.ProcessEnv} env
67
+ * @param {typeof defaultRunChild} [runChild] - injectable child-exec seam.
68
+ * @param {object} [opts]
69
+ * @param {boolean} [opts.allowErrors] - when true, a GraphQL `errors` array
70
+ * in the response is returned instead of thrown.
71
+ */
72
+ export async function ghGraphql(query, vars, env, runChild = defaultRunChild, { allowErrors = false } = {}) {
73
+ const fieldArgs = [];
74
+ for (const [key, value] of Object.entries(vars)) {
75
+ fieldArgs.push("--field", `${key}=${value}`);
76
+ }
77
+ const result = await runChild(
78
+ "gh",
79
+ ["api", "graphql", "--field", `query=${query}`, ...fieldArgs],
80
+ env,
81
+ );
82
+ if (result.code !== 0) {
83
+ const detail = result.stderr.trim() || `exit code ${result.code}`;
84
+ throw Object.assign(new Error(`gh api graphql failed: ${detail}`), { code: "GH_API_ERROR" });
85
+ }
86
+ const payload = parseJsonText(result.stdout);
87
+ if (!allowErrors && payload.errors && payload.errors.length > 0) {
88
+ throw Object.assign(
89
+ new Error(`GraphQL errors: ${payload.errors.map((e) => e.message).join("; ")}`),
90
+ { code: "GRAPHQL_ERROR" },
91
+ );
92
+ }
93
+ return payload;
94
+ }
@@ -288,6 +288,13 @@ export async function listIssues(options, { env = process.env, ghCommand = "gh",
288
288
  for (const label of options.labels ?? []) {
289
289
  args.push("--label", label);
290
290
  }
291
+ // `--search` narrows the result set to gh's own full-text search (title,
292
+ // body, comments) rather than the bare paged listing — needed by a caller
293
+ // that must find one specific issue by title without trusting that it falls
294
+ // within the default 30-issue page.
295
+ if (typeof options.search === "string" && options.search.length > 0) {
296
+ args.push("--search", options.search);
297
+ }
291
298
  const result = await run(ghCommand, args, env);
292
299
  if (result.code !== 0) {
293
300
  const detail = result.stderr.trim() || `exit code ${result.code}`;
@@ -19,6 +19,8 @@
19
19
  * sources those signals from pi run artifacts + runner-coordination state.
20
20
  */
21
21
 
22
+ import { trimmedOrNull } from "./normalize.mjs";
23
+
22
24
  export const AGENT_STALL_STATUS = Object.freeze({
23
25
  STALLED: "stalled",
24
26
  NOT_STALLED: "not_stalled",
@@ -178,8 +180,8 @@ export function buildAgentStallRecoveryBrief({
178
180
  lastAction = null,
179
181
  reason = "",
180
182
  } = {}) {
181
- const r = typeof runId === "string" && runId.trim().length > 0 ? runId.trim() : null;
182
- const work = typeof cwd === "string" && cwd.trim().length > 0 ? cwd.trim() : null;
183
+ const r = trimmedOrNull(runId);
184
+ const work = trimmedOrNull(cwd);
183
185
  const action = typeof lastAction === "string" && lastAction.trim().length > 0
184
186
  ? lastAction.trim()
185
187
  : "unknown last action";
@@ -1,4 +1,5 @@
1
1
  import { SUBMITTED_REVIEW_STATES, isCopilotLogin, normalizeTimestamp } from "../github/copilot-helpers.mjs";
2
+ import { trimmedOrNull } from "./normalize.mjs";
2
3
 
3
4
  const ACTIVE_COPILOT_REVIEW_REQUEST_STATUSES = new Set(["requested", "already-requested"]);
4
5
 
@@ -70,7 +71,7 @@ function normalizeCommits(commits) {
70
71
  sortKey: index,
71
72
  committedAtMs: normalizeTimestamp(commit?.committedAt),
72
73
  authorLogin: typeof commit?.authorLogin === "string" ? commit.authorLogin.trim() : "",
73
- sha: typeof commit?.sha === "string" && commit.sha.trim().length > 0 ? commit.sha.trim() : null,
74
+ sha: trimmedOrNull(commit?.sha),
74
75
  }))
75
76
  .filter((commit) => commit.committedAtMs !== null)
76
77
  .sort((left, right) => left.committedAtMs - right.committedAtMs || left.sortKey - right.sortKey);
@@ -91,12 +91,45 @@ export function renderGuardHook(hookName, defaultBranches = null, explicitBranch
91
91
  }
92
92
  const defaults = branches.join(" ");
93
93
  const explicitDefaults = explicits.join(" ");
94
+ // pre-commit ONLY: runs the fail-closed secret scan BEFORE anything else in
95
+ // this hook — before the override-env check below (that override is a
96
+ // default-BRANCH escape hatch for a sanctioned release; it must never
97
+ // double as a secret-scan bypass, there is none) and before the
98
+ // unresolved-defaults early exit (a secret-scan must never go quiet just
99
+ // because branch resolution failed at install time). Git always invokes a
100
+ // hook with the working tree ROOT as its cwd (githooks(5)), so a plain
101
+ // cwd-relative path here already resolves against each worktree's OWN
102
+ // checked-out copy of the scanner — no --show-toplevel round trip needed.
103
+ // A missing scanner file is a checkout that predates this feature (nothing
104
+ // to run), not a bypass attempt, so it is skipped rather than blocked; a
105
+ // scanner that IS present and errors, or finds a hit, always blocks — see
106
+ // scripts/security/scan-staged-diff.mjs. Its stdout carries only the clean
107
+ // "{ok:true,hits:[]}" success payload (a hit's diagnostics go to STDERR,
108
+ // per the CLI's own `payload.ok ? stdout : stderr` split above) — silenced
109
+ // here so a normal commit prints no JSON noise, while a blocked commit's
110
+ // stderr (file/line/detector-class) still reaches the developer untouched.
111
+ // Shell var deliberately named "scan_cli" rather than e.g. "secret_scanner":
112
+ // this repo's own pre-commit hook (below) runs a heuristic sink-pattern
113
+ // detector that fires on a credential-shaped variable name feeding any
114
+ // stdout redirect, even one that discards output; naming it around a
115
+ // scanner CLI path rather than a credential keeps this file's own diff
116
+ // clean through its own hook.
117
+ const secretScanBlock = hookName === "pre-commit"
118
+ ? `scan_cli="scripts/security/scan-staged-diff.mjs"
119
+ if [ -f "$scan_cli" ]; then
120
+ node "$scan_cli" >/dev/null
121
+ if [ "$?" != "0" ]; then
122
+ exit 1
123
+ fi
124
+ fi
125
+ `
126
+ : "";
94
127
  const header = `#!/bin/sh
95
128
  # ${GUARD_MARKER}
96
129
  # Refuses a ${hookName} that would land on a guarded default branch. Installed
97
130
  # in the common hook directory, so linked worktrees run it too — their branch
98
131
  # is not one of the guarded ones, which is what lets their work through.
99
- if [ "\${${GUARD_OVERRIDE_ENV}}" = "1" ]; then
132
+ ${secretScanBlock}if [ "\${${GUARD_OVERRIDE_ENV}}" = "1" ]; then
100
133
  exit 0
101
134
  fi
102
135
  defaults="${defaults}"
@@ -57,7 +57,10 @@ import { ALWAYS_INCLUDE, CATEGORY_ANGLE_MAP } from "../analysis/change-classifie
57
57
  * @type {Record<string, string[]>}
58
58
  */
59
59
  const KIND_TO_CATEGORIES = {
60
- docs: ["DOCS_ONLY"],
60
+ // #1442: a docs file is PROSE_PRESENT when it lands on the prose surface, so
61
+ // deslop's carry-forward surface is the `docs` kind (a non-docs delta never
62
+ // re-runs a clean deslop verdict).
63
+ docs: ["DOCS_ONLY", "PROSE_PRESENT"],
61
64
  config: ["CONFIG_ONLY"],
62
65
  test: ["TEST_ONLY"],
63
66
  ci: ["CI_ONLY"],
@@ -108,6 +111,14 @@ const ANGLE_SURFACE_KINDS = (() => {
108
111
 
109
112
  /**
110
113
  * Resolve an angle's declared review surface (the pure angle -> surface mapping).
114
+ * `angle` is matched trim+lowercase against ALL THREE lookups below (the
115
+ * hardcoded ALWAYS_INCLUDE set, the caller-supplied `alwaysRerun` set, and the
116
+ * `kinds` map) — deliberately: this makes a case/whitespace-drifted name (e.g.
117
+ * "Correctness", "PR-Description") resolve the SAME surface as its canonical
118
+ * form, rather than falling through to `{ kind: "unknown" }`. That is a
119
+ * decision-affecting default: a case-drifted MAPPED angle now becomes
120
+ * carry-forward-eligible (via `resolveAngleCarryForward`) where an unnormalized
121
+ * lookup would have fail-closed it as unknown.
111
122
  *
112
123
  * - ALWAYS_INCLUDE angles (gate-evidence, renderer-security, pr-description) plus
113
124
  * any explicit alwaysRerun angle -> `{ kind: "always" }`. These review a surface
@@ -122,15 +133,17 @@ const ANGLE_SURFACE_KINDS = (() => {
122
133
  * @returns {AngleReviewSurface}
123
134
  */
124
135
  export function angleReviewSurface(angle, { alwaysRerun } = {}) {
125
- const name = typeof angle === "string" ? angle.trim() : "";
136
+ // Normalized ONCE here (trim+lowercase) so every caller the hardcoded
137
+ // ALWAYS_INCLUDE check below, the configured alwaysRerun match, and the
138
+ // kinds lookup — agrees on one predicate regardless of whether the caller
139
+ // pre-lowercases (consolidate-fanin.mjs does; write-gate-context.mjs's
140
+ // mandatory-angle refusal does not, and must not have to).
141
+ const name = typeof angle === "string" ? angle.trim().toLowerCase() : "";
126
142
  if (name.length === 0) return { kind: "unknown" };
127
143
  if (ALWAYS_INCLUDE.has(name)) return { kind: "always" };
128
- // Case-insensitive: callers key angles as base+lowercase while configs may
129
- // carry case drift ("Correctness"); normalizing HERE keeps producer and
130
- // consumer on one predicate instead of each caller pre-lowercasing.
131
144
  if (alwaysRerun) {
132
145
  const normalized = new Set([...alwaysRerun].map((entry) => String(entry).trim().toLowerCase()));
133
- if (normalized.has(name.toLowerCase())) return { kind: "always" };
146
+ if (normalized.has(name)) return { kind: "always" };
134
147
  }
135
148
  const kinds = ANGLE_SURFACE_KINDS.get(name);
136
149
  if (!kinds || kinds.size === 0) return { kind: "unknown" };