@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
@@ -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";
@@ -0,0 +1,168 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * Enforces the commit-message contract AT COMMIT TIME (issue #1869): the
6
+ * attribution trailers, the no-bare-#N rule, and the conventional-commit
7
+ * subject form were previously prose-only — nothing checked them, so a
8
+ * non-compliant commit landed silently. Installed alongside the
9
+ * default-branch guard (see default-branch-guard.mjs), through the same
10
+ * ensure-worktree provisioning path, so it rides into every worktree too.
11
+ *
12
+ * The rendered hook is a single self-contained Node script (ESM: this repo's
13
+ * root package.json is `"type": "module"`, and Node resolves an
14
+ * extensionless direct-run script's module type by walking up for the
15
+ * nearest package.json — verified empirically, not merely assumed). Keeping
16
+ * the ENTIRE check inline in the rendered script (rather than requiring a
17
+ * sibling file back into the checkout) means the installed hook keeps
18
+ * working even if the checkout that installed it is later removed or moved
19
+ * — the same self-containment default-branch-guard's hooks rely on.
20
+ */
21
+ export const COMMIT_MSG_GUARD_MARKER = "dev-loops:commit-msg-guard";
22
+ export const COMMIT_MSG_WAIVER_MARKER = `${COMMIT_MSG_GUARD_MARKER}:allow`;
23
+
24
+ // Ownership check mirrors default-branch-guard's: the marker must be its own
25
+ // line (a `//` comment, since the rendered hook is JS), not merely mentioned,
26
+ // so a foreign hook that references us in prose is still left untouched.
27
+ const GUARD_MARKER_LINE = new RegExp(`^// ${COMMIT_MSG_GUARD_MARKER}$`, "mu");
28
+
29
+ /**
30
+ * Renders the commit-msg hook as a standalone, runnable Node script. The
31
+ * validation logic below is the ONLY copy of it — there is no separate JS
32
+ * implementation this must stay in sync with, exactly like renderGuardHook's
33
+ * shell body has none either. Tests exercise it by actually running it (see
34
+ * commit-msg-guard.test.mjs), the same way default-branch-guard.test.mjs
35
+ * drives real git rather than asserting on rendered text.
36
+ *
37
+ * String.raw, not a plain template literal: the generated script is full of
38
+ * regex backslash escapes (\s, \d, \b, \.) that a normal template literal
39
+ * would silently strip (an unrecognized string escape drops its backslash),
40
+ * corrupting every regex in the installed hook. String.raw keeps every
41
+ * backslash literal while still substituting the ${...} marker constants.
42
+ * The generated script deliberately uses NO template literals of its own
43
+ * (string concatenation instead) — a literal backtick would otherwise close
44
+ * THIS OUTER template early.
45
+ */
46
+ export function renderCommitMsgGuardHook() {
47
+ return String.raw`#!/usr/bin/env node
48
+ // ${COMMIT_MSG_GUARD_MARKER}
49
+ // Enforces the commit-message contract (issue #1869): attribution trailers,
50
+ // no bare non-issue #<digits>, and a conventional-commit subject. A
51
+ // per-commit waiver line (${COMMIT_MSG_WAIVER_MARKER}) skips every check
52
+ // below for a deliberate exception.
53
+ import { readFileSync } from "node:fs";
54
+
55
+ // git invokes commit-msg with ONLY the message-file path (unlike
56
+ // prepare-commit-msg, which also gets a source/sha) — no signal distinguishes
57
+ // an ordinary commit from a merge/squash at this hook. A default, unedited
58
+ // merge message ("Merge branch '...'", "Merge pull request #...", "Merge tag
59
+ // '...'"), a default git-revert message (Revert "..."), or a
60
+ // git commit --fixup/--squash autosquash subject (fixup! ... / squash! ...)
61
+ // is git/tooling-generated, not operator-authored prose, so each is exempt by
62
+ // its own recognizable shape rather than forced through a conventional-commit
63
+ // subject and trailers it was never meant to carry.
64
+ const [, , msgPath] = process.argv;
65
+ const message = readFileSync(msgPath, "utf8");
66
+ const subjectLine = message.split("\n", 1)[0] || "";
67
+ if (
68
+ /^Merge (branch|tag|remote-tracking branch|pull request) /u.test(subjectLine) ||
69
+ /^Revert "/u.test(subjectLine) ||
70
+ /^(fixup|squash)! /u.test(subjectLine)
71
+ ) process.exit(0);
72
+
73
+ if (/^${COMMIT_MSG_GUARD_MARKER}:allow\b/mu.test(message)) process.exit(0);
74
+
75
+ const errors = [];
76
+
77
+ // Trailers are required only for an AGENT-authored commit: Claude Code sets
78
+ // CLAUDECODE=1 in every shell it spawns (the same harness-detection signal
79
+ // packages/core/src/loop/run-context.mjs's isClaudeHarness checks) — a plain
80
+ // human commit (CLAUDECODE unset) is never "Claude", so requiring a Claude
81
+ // co-author trailer on it would misattribute the commit, not enforce honesty.
82
+ if (process.env.CLAUDECODE === "1") {
83
+ if (!/^Co-Authored-By:\s*Claude\s+.+\s+<noreply@anthropic\.com>\s*$/imu.test(message)) {
84
+ errors.push("missing required trailer: Co-Authored-By: Claude <model> <noreply@anthropic.com>");
85
+ }
86
+ if (!/^Claude-Session:\s*\S+/imu.test(message)) {
87
+ errors.push("missing required trailer: Claude-Session: <url>");
88
+ }
89
+ }
90
+
91
+ // A genuine "Closes #N" / "Fixes #N" / "Refs #N" reference (optionally a
92
+ // comma/and-joined list, and optionally the trailer colon form "Closes: #N")
93
+ // is allowed and stripped first; any #<digits> left over is a bare non-issue
94
+ // enumeration, which GitHub auto-links to an unrelated issue/PR when
95
+ // rendered.
96
+ const withoutAllowedRefs = message.replace(
97
+ /\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|refs?|references?):?\s+#\d+(?:\s*(?:,|and)\s*#\d+)*/giu,
98
+ "",
99
+ );
100
+ if (/#\d+/u.test(withoutAllowedRefs)) {
101
+ errors.push('bare #<digits> reference found; use "Closes #N" / "Fixes #N" / "Refs #N" for a genuine issue reference, or reword a non-issue enumeration (e.g. "defect N")');
102
+ }
103
+
104
+ if (!/^(feat|fix|chore|docs|test|refactor|revert|perf|style|ci|build)\([^()\n]+\): .+\S/u.test(subjectLine)) {
105
+ errors.push("subject must be conventional-commit form \"type(scope): summary\" (type one of feat/fix/chore/docs/test/refactor/revert/perf/style/ci/build)");
106
+ }
107
+
108
+ if (errors.length > 0) {
109
+ console.error("dev-loops: WORKTREE-COMMIT-MSG-GUARD refuses this commit — contract violation(s):");
110
+ for (const error of errors) console.error(" - " + error);
111
+ console.error(" Waiver: add a \"${COMMIT_MSG_WAIVER_MARKER}\" line to the commit message for a deliberate exception.");
112
+ process.exit(1);
113
+ }
114
+ process.exit(0);
115
+ `;
116
+ }
117
+
118
+ /**
119
+ * Install the commit-msg guard into a repository's hook directory. Mirrors
120
+ * default-branch-guard's install-refusal checks (a caller with an unsafe
121
+ * `core.hooksPath`, a non-absolute/non-git `gitDir`, or a linked worktree's
122
+ * OWN gitdir must never report success for a hook that can never fire) and
123
+ * its atomic write + foreign-hook preservation — duplicated rather than
124
+ * shared, since it is one hook, not a family; see default-branch-guard.mjs
125
+ * for the family version if a third hook installer ever needs the same
126
+ * shape factored out.
127
+ *
128
+ * @param {{ gitDir: string, hooksPathOverride?: string|null }} target
129
+ */
130
+ export function installCommitMsgGuard({ gitDir, hooksPathOverride = null }) {
131
+ const refuse = (reason) => ({ ok: false, installed: false, refreshed: false, skipped: true, reason });
132
+
133
+ if (typeof hooksPathOverride === "string") {
134
+ const configured = hooksPathOverride.trim();
135
+ return configured.length > 0
136
+ ? refuse(`core.hooksPath is set to ${JSON.stringify(configured)} — install the guard there, or unset it`)
137
+ : refuse("core.hooksPath is set to an empty string — git runs no hooks at all");
138
+ }
139
+ if (typeof gitDir !== "string" || !path.isAbsolute(gitDir)) {
140
+ return refuse(`gitDir must be an absolute path; got ${JSON.stringify(gitDir)}`);
141
+ }
142
+ if (!fs.existsSync(path.join(gitDir, "HEAD"))) {
143
+ return refuse(`gitDir ${JSON.stringify(gitDir)} does not look like a git directory (no HEAD file)`);
144
+ }
145
+ if (fs.existsSync(path.join(gitDir, "commondir"))) {
146
+ return refuse(`gitDir ${JSON.stringify(gitDir)} is a linked worktree's own git directory, not the common one — hooks installed there never run`);
147
+ }
148
+
149
+ const hooksDir = path.join(gitDir, "hooks");
150
+ fs.mkdirSync(hooksDir, { recursive: true });
151
+ const hookPath = path.join(hooksDir, "commit-msg");
152
+
153
+ const existing = fs.existsSync(hookPath) ? fs.readFileSync(hookPath, "utf8") : null;
154
+ const ours = existing === null || GUARD_MARKER_LINE.test(existing);
155
+ if (!ours) {
156
+ return { ok: true, installed: false, refreshed: false, skipped: true, reason: "a pre-existing hook is present and was left untouched" };
157
+ }
158
+
159
+ // Same atomic tmp-write + rename as default-branch-guard: the hooks dir is
160
+ // shared across worktrees, so a direct writeFileSync would be visible
161
+ // mid-write to a concurrent install or a real commit racing this one.
162
+ const tmpPath = path.join(hooksDir, `.commit-msg.tmp-${process.pid}-${Date.now()}`);
163
+ fs.writeFileSync(tmpPath, renderCommitMsgGuardHook(), { mode: 0o755 });
164
+ fs.chmodSync(tmpPath, 0o755);
165
+ fs.renameSync(tmpPath, hookPath);
166
+
167
+ return { ok: true, installed: existing === null, refreshed: existing !== null, skipped: false };
168
+ }
@@ -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" };