@dev-loops/core 1.0.0-rc.7 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dev-loops/core",
3
- "version": "1.0.0-rc.7",
3
+ "version": "1.0.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -53,6 +53,7 @@
53
53
  "./loop/refinement-grill-state": "./src/loop/refinement-grill-state.mjs",
54
54
  "./loop/queue-board-ordering": "./src/loop/queue-board-ordering.mjs",
55
55
  "./loop/default-branch-guard": "./src/loop/default-branch-guard.mjs",
56
+ "./loop/commit-msg-guard": "./src/loop/commit-msg-guard.mjs",
56
57
  "./loop/queue-board-sync": "./src/loop/queue-board-sync.mjs",
57
58
  "./loop/queue-driver": "./src/loop/queue-driver.mjs",
58
59
  "./loop/queue-membership": "./src/loop/queue-membership.mjs",
@@ -563,17 +563,11 @@ function boardRefConfig(ownerKey) {
563
563
  });
564
564
  }
565
565
 
566
- const QueueBoardConfig = boardRefConfig("queue.board");
567
-
568
566
  /** Queue mode config */
569
567
  const QueueConfig = z.strictObject({
570
568
  maxParallel: z.number().int().min(1).max(10).default(3).describe("Maximum queue items worked in parallel."),
571
569
  maxAutoFiledIssues: z.number().int().min(0).max(100).default(10).describe("Cap on auto-filed issues per run."),
572
570
  reDispatchMaxRetries: z.number().int().min(0).max(10).default(1).describe("Retries when re-dispatching a failed queue item."),
573
- // Deprecated: superseded by `tracker.board` (issue #1408, the tracker-agnostic
574
- // seam). Kept accepted for back-compat — see resolveTrackerBoard, which reads
575
- // `tracker.board` first and falls back to this field with a load-time warning.
576
- board: QueueBoardConfig.describe("Deprecated: use tracker.board instead. GitHub Projects board identifier.").optional(),
577
571
  archiveOlderThanDays: z.number().int().positive().describe("Archive done board items older than this many days.").optional(),
578
572
  });
579
573
 
@@ -583,7 +577,7 @@ const QueueConfig = z.strictObject({
583
577
  * at `resolveTrackerAdapter` call time, not at config-parse time — the
584
578
  * seam/resolver must not preclude a consumer registering an external
585
579
  * provider post-1.0 (`plugin`, reserved, not implemented in this pass).
586
- * `board` supersedes the deprecated `queue.board` (see resolveTrackerBoard).
580
+ * `board` is the canonical GitHub Projects board identifier (see resolveTrackerBoard).
587
581
  *
588
582
  * No generic `fieldMappings` (logical-column -> provider-status) key here:
589
583
  * the github provider's logical-column -> Status mapping IS the existing,
@@ -598,7 +592,7 @@ const QueueConfig = z.strictObject({
598
592
  const TrackerConfig = z.strictObject({
599
593
  provider: z.string().trim().min(1).describe("Tracker provider registry key. Built-in: \"github\" (default).").optional(),
600
594
  plugin: z.string().trim().min(1).describe("Reserved: module specifier for an external tracker provider plugin (post-1.0, not implemented in this pass).").optional(),
601
- board: boardRefConfig("tracker.board").describe("Tracker board identifier; supersedes the deprecated queue.board.").optional(),
595
+ board: boardRefConfig("tracker.board").describe("Tracker board identifier.").optional(),
602
596
  });
603
597
 
604
598
  /**
@@ -940,13 +934,11 @@ export const BUILT_IN_DEFAULTS = Object.freeze({
940
934
  maxParallel: 3,
941
935
  maxAutoFiledIssues: 10,
942
936
  reDispatchMaxRetries: 1,
943
- // queue.board is intentionally absent from defaults — setting it is an
944
- // explicit operator opt-in for Projects-based queue ordering.
945
937
  }),
946
938
  tracker: Object.freeze({
947
939
  provider: "github",
948
940
  // tracker.board is intentionally absent from defaults — setting it is an
949
- // explicit operator opt-in (mirrors queue.board). The logical-column ->
941
+ // explicit operator opt-in for Projects-based queue ordering. The logical-column ->
950
942
  // Status mapping is queue.statusColumns (see TrackerConfig above), not a
951
943
  // tracker-owned default.
952
944
  }),
@@ -1285,7 +1277,7 @@ export function resolveRoleModel(config, { role, harness, kind } = {}) {
1285
1277
  * @typedef {object} ConfigLoadError
1286
1278
  * @property {string} path - Human-readable file path or layer name
1287
1279
  * @property {string} message - Error description
1288
- * @property {"defaults"|"settings"|"extensionDefaults"|"merged"} layer - Which config layer failed
1280
+ * @property {"extensionDefaults"|"defaults"|"devloops"|"merged"} layer - Which config layer failed
1289
1281
  */
1290
1282
 
1291
1283
  // ============================================================================
@@ -1499,10 +1491,11 @@ function configError(message, code, filePath) {
1499
1491
  }
1500
1492
 
1501
1493
  /**
1502
- * Try to load and merge one config layer (defaults or settings).
1494
+ * Try to load and merge one config layer (extensionDefaults, defaults, or
1495
+ * devloops).
1503
1496
  * @param {Record<string, unknown>} merged - Current merged config
1504
1497
  * @param {string|string[]} basePaths - Config file base path(s) without extension
1505
- * @param {"defaults"|"settings"} layer - Layer name
1498
+ * @param {"extensionDefaults"|"defaults"|"devloops"} layer - Layer name
1506
1499
  * @param {string[]} warnings
1507
1500
  * @param {ConfigLoadError[]} errors
1508
1501
  * @param {{ warnOnMissing?: boolean }} [options]
@@ -1628,7 +1621,7 @@ async function applyLayer(merged, basePaths, layer, warnings, errors, options =
1628
1621
 
1629
1622
  /**
1630
1623
  * Load the dev-loop configuration with full precedence:
1631
- * settings.(yaml|yml|json) > legacy overrides.(yaml|yml|json) > repo .pi/dev-loop/defaults.(yaml|yml|json) > extension defaults > built-in defaults
1624
+ * repo .devloops > repo .pi/dev-loop/defaults.(yaml|yml|json) > extension defaults > built-in defaults
1632
1625
  *
1633
1626
  * Never throws for config-related problems.
1634
1627
  * Returns extension defaults (with built-in defaults as the final fallback) even when all repo-local config files are missing or broken.
@@ -1641,7 +1634,6 @@ export async function loadDevLoopConfig(options = {}) {
1641
1634
  const configDir = path.join(repoRoot, ".pi", "dev-loop");
1642
1635
  const defaultsPath = path.join(configDir, "defaults");
1643
1636
  const devloopsPath = path.join(repoRoot, ".devloops");
1644
- const settingsPaths = [path.join(configDir, "settings"), path.join(configDir, "overrides")];
1645
1637
 
1646
1638
  /** @type {string[]} */
1647
1639
  const warnings = [];
@@ -1678,79 +1670,7 @@ export async function loadDevLoopConfig(options = {}) {
1678
1670
 
1679
1671
  if (primaryExists) {
1680
1672
  // .devloops is the primary override — apply it
1681
- merged = await applyLayer(merged, devloopsPath, "settings", warnings, errors);
1682
-
1683
- // Warn if legacy files still exist alongside .devloops (but don't load them —
1684
- // .devloops is authoritative; legacy must not override it)
1685
- let legacyAlongside = false;
1686
- for (const legacyPath of settingsPaths) {
1687
- for (const ext of [".yaml", ".yml", ".json"]) {
1688
- try {
1689
- await readFile(legacyPath + ext, "utf8");
1690
- legacyAlongside = true;
1691
- break;
1692
- } catch (err) {
1693
- if (err?.code !== "ENOENT") {
1694
- // File exists but is unreadable — treat as "found" so the
1695
- // deprecation warning fires (applyLayer is not called for legacy
1696
- // paths when .devloops is present, so the flag only controls the warning).
1697
- legacyAlongside = true;
1698
- break;
1699
- }
1700
- }
1701
- }
1702
- if (legacyAlongside) break;
1703
- }
1704
- if (legacyAlongside) {
1705
- warnings.push(
1706
- `Deprecated config path(s) found under .pi/dev-loop/settings.* or .pi/dev-loop/overrides.*. ` +
1707
- `Migrate to .devloops (or .devloops.yaml/.devloops.yml/.devloops.json) at repo root. ` +
1708
- `Legacy paths will be removed in a future version.`
1709
- );
1710
- }
1711
- } else {
1712
- // No .devloops — fall back to legacy .pi/dev-loop/settings.* or overrides.* (deprecated)
1713
- let legacyFound = false;
1714
- for (const legacyPath of settingsPaths) {
1715
- for (const ext of [".yaml", ".yml", ".json"]) {
1716
- try {
1717
- await readFile(legacyPath + ext, "utf8");
1718
- legacyFound = true;
1719
- break;
1720
- } catch (err) {
1721
- if (err?.code !== "ENOENT") {
1722
- // File exists but is unreadable — treat as "found" so the
1723
- // deprecation warning fires and applyLayer can surface the error
1724
- // (legacy applyLayer runs in this branch).
1725
- legacyFound = true;
1726
- break;
1727
- }
1728
- }
1729
- }
1730
- if (legacyFound) break;
1731
- }
1732
- if (legacyFound) {
1733
- warnings.push(
1734
- `Deprecated config path(s) found under .pi/dev-loop/settings.* or .pi/dev-loop/overrides.*. ` +
1735
- `Migrate to .devloops (or .devloops.yaml/.devloops.yml/.devloops.json) at repo root. ` +
1736
- `Legacy paths will be removed in a future version.`
1737
- );
1738
- merged = await applyLayer(merged, settingsPaths, "settings", warnings, errors);
1739
- }
1740
- }
1741
-
1742
- // Deprecated `queue.board` -> `tracker.board` alias (issue #1408, the
1743
- // tracker-agnostic seam). Runs on the fully-merged object (unlike the
1744
- // `strategy: "github-first"` alias above, this only affects cross-layer
1745
- // MERGE PRECEDENCE, not per-layer schema validity — queue.board is still a
1746
- // valid FileConfigSchema shape on its own — so normalizing once here, after
1747
- // every layer has merged, is sufficient).
1748
- if (isPlainObject(merged.queue?.board) && !isPlainObject(merged.tracker?.board)) {
1749
- warnings.push(
1750
- `queue.board is a deprecated alias for tracker.board (issue #1408). ` +
1751
- `Update .devloops to set tracker.board instead; the alias will be removed in a future version.`
1752
- );
1753
- merged = { ...merged, tracker: { ...(merged.tracker ?? {}), board: merged.queue.board } };
1673
+ merged = await applyLayer(merged, devloopsPath, "devloops", warnings, errors);
1754
1674
  }
1755
1675
 
1756
1676
  // Validate final merged config
@@ -3085,19 +3005,13 @@ export function resolveTrackerProvider(config) {
3085
3005
  }
3086
3006
 
3087
3007
  /**
3088
- * Resolve the effective tracker board identifier. `tracker.board` is
3089
- * canonical; `queue.board` is a DEPRECATED alias, already normalized onto
3090
- * `tracker.board` by `loadDevLoopConfig` (with a load-time warning) for any
3091
- * config that went through the loader. This resolver also accepts a
3092
- * hand-built config object that sets `queue.board` directly (bypassing the
3093
- * loader, e.g. in a test) and falls back to it — with no warning, since only
3094
- * the loader surfaces warnings.
3008
+ * Resolve the effective tracker board identifier. `tracker.board` is the
3009
+ * canonical (and only) board config key.
3095
3010
  *
3096
3011
  * @param {DevLoopConfig} config
3097
3012
  * @returns {{ number?: number, title?: string } | null}
3098
3013
  */
3099
3014
  export function resolveTrackerBoard(config) {
3100
3015
  if (isPlainObject(config?.tracker?.board)) return config.tracker.board;
3101
- if (isPlainObject(config?.queue?.board)) return config.queue.board;
3102
3016
  return null;
3103
3017
  }
@@ -65,7 +65,7 @@ gates:
65
65
  - name: config-drift
66
66
  persona: review
67
67
  prompt: |-
68
- Cross-check config, schema, and documentation for contract drift: - Verify that configuration files (.pi/dev-loop/settings.yaml,
68
+ Cross-check config, schema, and documentation for contract drift: - Verify that configuration files (.devloops,
69
69
  package.json, CI workflows, skill manifests) agree on canonical
70
70
  status tokens, support floors, and required flags.
71
71
  - Flag any instance where two sources of truth disagree about the
@@ -263,9 +263,15 @@ gates:
263
263
  mandatory: true
264
264
  persona: review
265
265
  prompt: |-
266
- Verify before approval that the PR checklist and AC/DoD/non-goals matrix are complete. - Every PR checkbox (`- [ ]`) must be checked. If any box is unchecked, flag it as a blocking finding. - The PR body must contain an AC/DoD/non-goals matrix that maps each acceptance criterion
267
- to its definition-of-done item(s) and lists explicit non-goals.
268
- - The matrix must have a markdown table with at least a header row and one content row. - Flag the matrix as incomplete if any acceptance criterion, definition-of-done item, or non-goal is missing.
266
+ Completeness is enforced deterministically (#1877): the pre_approval_gate fails closed on any
267
+ unchecked `- [ ]` in the PR body's AC/DoD checklist, so this angle's completeness duty is
268
+ machine-backed. Your remaining duty is TRUTHFULNESS, which the machine cannot check: verify
269
+ that every checked `- [x]` box in the PR body's Acceptance criteria / Definition of done
270
+ checklists is actually satisfied by the implementation — cite concrete code/test/behavior
271
+ evidence; flag a dishonestly-ticked box as a blocking finding. Also verify the checked
272
+ content mirrors the linked issue's AC/DoD/Non-goals matrix and that declared non-goals are
273
+ respected (no scope creep). The boundary is explicit: the deterministic block enforces
274
+ completeness (nothing left unchecked/forgotten); you verify each [x] is real.
269
275
  - contradiction-lens
270
276
  - correctness-final
271
277
  - ui-validation
@@ -324,7 +330,7 @@ localImplementation:
324
330
  maxFiles: 2
325
331
  maxLines: 100
326
332
 
327
- # Queue defaults (repo-specific queue.board omitted by design).
333
+ # Queue defaults (repo-specific tracker.board omitted by design).
328
334
  queue:
329
335
  maxParallel: 3
330
336
  # Local-first is PR-first (issues are skipped, #952), so auto-filing issues is
@@ -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
+ }
@@ -63,13 +63,19 @@ export function scheduleFanoutWaves(dispatchGroups, maxConcurrent = 4) {
63
63
  }
64
64
 
65
65
  /**
66
- * Adaptive 429-backoff concurrency (issue #1601): halve the active batch before
67
- * escalating to foreground one-at-a-time fallback. On a 429, the conductor
68
- * recomputes the wave plan with `backoffMaxConcurrent(maxConcurrent)` and
69
- * retries the failed wave; if a single-unit wave still 429s, it falls back to
70
- * foreground (one-at-a-time) dispatch. The backoff is recorded in the round's
71
- * provenance (see skills/docs/gate-review-sub-loop-contract.md). Pure; never
72
- * returns 0 (a backoff from 1 stays 1 → foreground fallback owns that path).
66
+ * Adaptive concurrency backoff (issue #1601; retry discipline refined by #1907):
67
+ * halve the active batch before escalating to foreground one-at-a-time fallback.
68
+ * A transient dispatch failure (429/5xx) is first retried on the SAME unit with
69
+ * exponential backoff safe because a reviewer's findings artifact is an
70
+ * idempotent single-write at a deterministic path and the conductor reduces
71
+ * concurrency ONLY after that unit's retries are exhausted (~3 failed attempts),
72
+ * recomputing the wave plan with `backoffMaxConcurrent(maxConcurrent)` and
73
+ * retrying the reduced wave; if a single-unit wave still fails, it falls back to
74
+ * foreground (one-at-a-time) dispatch. This "retry the unit before reducing
75
+ * concurrency" ordering is owned by GATE-EXEC-DISPATCH-RETRY-BACKOFF in
76
+ * skills/docs/gate-review-sub-loop-contract.md; the backoff is recorded in the
77
+ * round's provenance. Pure; never returns 0 (a backoff from 1 stays 1 →
78
+ * foreground fallback owns that path).
73
79
  * @param {number} maxConcurrent
74
80
  * @returns {number}
75
81
  */