@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 +2 -1
- package/src/config/config.mjs +11 -97
- package/src/config/extension-defaults.yaml +11 -5
- package/src/loop/commit-msg-guard.mjs +168 -0
- package/src/loop/gate-fanin.mjs +13 -7
- package/src/loop/issue-refinement-artifact.mjs +495 -77
- package/src/loop/pr-gate-coordination.mjs +27 -2
- package/src/loop/public-dev-loop-routing.mjs +4 -0
- package/src/loop/queue-board-sync.mjs +6 -10
- package/src/loop/retrospective-checkpoint.mjs +59 -1
- package/src/projects/move-queue-item.mjs +1 -1
- package/src/projects/resolve-project.mjs +6 -6
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev-loops/core",
|
|
3
|
-
"version": "1.0.
|
|
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",
|
package/src/config/config.mjs
CHANGED
|
@@ -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`
|
|
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
|
|
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
|
|
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 {"
|
|
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
|
|
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"|"
|
|
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
|
-
*
|
|
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, "
|
|
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
|
|
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 (.
|
|
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
|
-
|
|
267
|
-
|
|
268
|
-
-
|
|
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
|
|
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
|
+
}
|
package/src/loop/gate-fanin.mjs
CHANGED
|
@@ -63,13 +63,19 @@ export function scheduleFanoutWaves(dispatchGroups, maxConcurrent = 4) {
|
|
|
63
63
|
}
|
|
64
64
|
|
|
65
65
|
/**
|
|
66
|
-
* Adaptive
|
|
67
|
-
* escalating to foreground one-at-a-time fallback.
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
*/
|