@dev-loops/core 1.0.0-rc.2 → 1.0.0-rc.4

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 (33) hide show
  1. package/package.json +7 -1
  2. package/src/analysis/diff-analyzer.mjs +31 -5
  3. package/src/claude/hook-decisions.mjs +14 -0
  4. package/src/cli/primitives.mjs +10 -2
  5. package/src/cli/retry-wrapper.mjs +14 -6
  6. package/src/config/config.mjs +1125 -240
  7. package/src/config/extension-defaults.yaml +217 -426
  8. package/src/github/copilot-helpers.mjs +139 -18
  9. package/src/github/issue-ops.mjs +556 -0
  10. package/src/github/ownership-helpers.mjs +79 -0
  11. package/src/github/review-threads.mjs +44 -3
  12. package/src/loop/bash-command-classify.mjs +35 -5
  13. package/src/loop/conductor-routing.mjs +1 -1
  14. package/src/loop/copilot-ci-status.mjs +76 -0
  15. package/src/loop/copilot-loop-iterations.mjs +1 -2
  16. package/src/loop/copilot-loop-state.mjs +9 -5
  17. package/src/loop/default-branch-guard.mjs +380 -0
  18. package/src/loop/gate-carry-forward.mjs +29 -2
  19. package/src/loop/gate-fanin.mjs +481 -31
  20. package/src/loop/handoff-envelope.mjs +43 -23
  21. package/src/loop/main-checkout-ff.mjs +58 -0
  22. package/src/loop/pr-gate-coordination.mjs +204 -47
  23. package/src/loop/pr-title-markers.mjs +76 -15
  24. package/src/loop/queue-board-sync.mjs +26 -9
  25. package/src/loop/reviewer-loop-state.mjs +2 -2
  26. package/src/loop/ui-e2e-scoping.mjs +2 -0
  27. package/src/loop/ui-review-drive.mjs +23 -0
  28. package/src/loop/ui-review-provision.mjs +36 -0
  29. package/src/projects/resolve-project.mjs +14 -7
  30. package/src/tracker/adapter.mjs +127 -0
  31. package/src/tracker/github-adapter.mjs +150 -0
  32. package/src/tracker/index.mjs +50 -0
  33. package/src/tracker/noop-adapter.mjs +35 -0
@@ -143,15 +143,16 @@ function shellSegments(command) {
143
143
  }
144
144
 
145
145
  /**
146
- * Leading prefix a `gh pr <verb>` segment may carry before the `gh` executable:
147
- * a run of `NAME=value` env assignments, optional `command`/`env`/`exec` wrapper
148
- * words, and an absolute/relative path on the gh binary (`/usr/bin/gh`).
146
+ * Leading prefix a command segment may carry before its real executable: a run of `NAME=value`
147
+ * env assignments, optional `command`/`env`/`exec` wrapper words, and an absolute/relative path on
148
+ * the binary (`/usr/bin/gh`, `/usr/bin/git`). Shared by every classifier in this file that must
149
+ * catch its verb behind these forms (`gh pr <verb>`, `git stash`, ...).
149
150
  *
150
151
  * Note: this is a pragmatic normalizer, not a full shell tokenizer. Subshell
151
152
  * `(gh pr create)`, `{ …; }` group, `-R=value` short-flag, and backslash-escaped
152
153
  * `\gh` forms are deliberately out of scope.
153
154
  */
154
- const GH_PR_VERB_PREFIX = "(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\s+)*(?:(?:command|env|exec)\\s+)*(?:\\S*/)?";
155
+ const SHELL_EXEC_PREFIX = "(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\s+)*(?:(?:command|env|exec)\\s+)*(?:\\S*/)?";
155
156
 
156
157
  /**
157
158
  * Build the `gh <subcmd> <verb>` prefix matcher (subcmd = "pr" | "issue").
@@ -163,7 +164,36 @@ const GH_PR_VERB_PREFIX = "(?:[A-Za-z_][A-Za-z0-9_]*=\\S*\\s+)*(?:(?:command|env
163
164
  * match — their first token is `node`, not `gh`.
164
165
  */
165
166
  function ghSubcmdVerbRegex(subcmd, verb) {
166
- return new RegExp(`^${GH_PR_VERB_PREFIX}gh\\s+${subcmd}\\s+${verb}(?:\\s|$)`, "i");
167
+ return new RegExp(`^${SHELL_EXEC_PREFIX}gh\\s+${subcmd}\\s+${verb}(?:\\s|$)`, "i");
168
+ }
169
+
170
+ /**
171
+ * A run of git global options that may appear between `git` and the subcommand: `-C <path>`,
172
+ * `-c <name>=<value>` (each consumes the following token as its value), `--git-dir=<path>`,
173
+ * `--work-tree=<path>`, or any other bare flag (`-x`, `--long-option`) that takes no value.
174
+ * Pragmatic normalizer, not a full git CLI parser.
175
+ */
176
+ const GIT_GLOBAL_OPTION_RUN =
177
+ "(?:(?:-C|-c)\\s+\\S+\\s+|--(?:git-dir|work-tree)=\\S+\\s+|--?[A-Za-z][\\w-]*\\s+)*";
178
+
179
+ /**
180
+ * Whether `command` contains a `git stash` invocation (any subcommand: bare, `push`, `pop`,
181
+ * `apply`, `save`, `list`, ...) in ANY shell segment — including behind the same env-assignment /
182
+ * `command`/`env`/`exec` wrapper / binary-path prefix (`GIT_DIR=.git git stash`, `command git
183
+ * stash`, `/usr/bin/git stash`) and git global options between `git` and `stash` (`git -C /tmp
184
+ * stash`, `git -c name=value stash pop`) that the sibling `gh` classifiers in this file already
185
+ * tolerate. Anchored per-segment, so `git stashed`, `git commit -m "git stash"`, or a path literal
186
+ * containing "git stash" never match. `refs/stash` is a single ref shared by every worktree over
187
+ * this repo's one `.git` directory, so a stash from one worktree can pop into another's — the
188
+ * PreToolUse gate blocks it outright on the target repo (see
189
+ * `skills/docs/worktree-guidance.md#never-git-stash-in-a-shared-git-layout`).
190
+ * @param {string} command @returns {boolean}
191
+ */
192
+ export function commandContainsGitStash(command) {
193
+ const re = new RegExp(`^${SHELL_EXEC_PREFIX}git\\s+${GIT_GLOBAL_OPTION_RUN}stash(?:\\s|$)`, "i");
194
+ return command
195
+ .split(SHELL_SEGMENT_SEPARATOR)
196
+ .some((segment) => re.test(segment.trim()));
167
197
  }
168
198
 
169
199
  /** Build the `gh pr <verb>` prefix matcher — delegates to the generic subcmd matcher (DRY). */
@@ -17,7 +17,7 @@
17
17
  * - The evaluator is purely functional; no I/O or side effects
18
18
  * - Callers use evaluateConductorRouting as the single routing authority
19
19
  *
20
- * Integration boundary (see docs/conductor-routing-contract.md):
20
+ * Integration boundary (see skills/docs/conductor-routing-contract.md):
21
21
  * - This module starts after active-run identity and ownership are already resolved
22
22
  * - It consumes already-detected family-local lifecycle states as inputs
23
23
  * - It derives the routing outcome directly from states; it does not take a
@@ -5,6 +5,58 @@ const STATUS_CONTEXT_FAILURE_STATES = new Set(["FAILURE", "ERROR"]);
5
5
  const STATUS_CONTEXT_PENDING_STATES = new Set(["PENDING", "EXPECTED"]);
6
6
  const STATUS_CONTEXT_SUCCESS_STATES = new Set(["SUCCESS"]);
7
7
 
8
+ /**
9
+ * Name of the explicit commit STATUS dev-loops posts on its own pull requests
10
+ * from `.github/workflows/gate-evidence.yml`. Its conclusion is DERIVED from
11
+ * the loop's own progress (a clean current-head pre_approval_gate verdict),
12
+ * not an independent build/test signal — so the loop must exclude it when
13
+ * deriving the CI status that gates its own pre_approval step. Otherwise the
14
+ * loop could never post the very verdict that would turn this check green
15
+ * (#1358). This constant names ONLY the status context; the workflow also
16
+ * surfaces as a check run, so anything partitioning check runs must use
17
+ * `LOOP_DERIVED_CI_CHECK_NAMES` below. It remains the label reported in
18
+ * `excludedFailureDetails` for either shape, since both name one workflow.
19
+ */
20
+ export const LOOP_DERIVED_CI_CHECK_NAME = "gate-evidence";
21
+
22
+ /**
23
+ * The same workflow ALSO surfaces as a check run under its job id
24
+ * (`gate-evidence-runner`) beside the commit status named above, and both are
25
+ * the loop's own derived signal. Excluding only the status context left the
26
+ * runner's conclusion gating the loop's own pre_approval step: once the
27
+ * workflow gained job-level concurrency, a superseded run is cancelled as
28
+ * normal operation, and a cancelled run is deliberately NOT treated as green
29
+ * (see normalizeStatusCheckRollupStatus) — so one routine cancellation made
30
+ * the whole head read "none" and the loop waited on CI forever.
31
+ */
32
+ export const LOOP_DERIVED_CI_CHECK_NAMES = Object.freeze([LOOP_DERIVED_CI_CHECK_NAME, "gate-evidence-runner"]);
33
+
34
+ function checkEntryName(entry) {
35
+ if (typeof entry?.name === "string" && entry.name.length > 0) return entry.name;
36
+ if (typeof entry?.context === "string" && entry.context.length > 0) return entry.context;
37
+ return "";
38
+ }
39
+
40
+ /**
41
+ * Split rollup/check-run entries into those matching `targetName` and the rest.
42
+ * Shared by both statusCheckRollup-shaped and check-runs-shaped payloads —
43
+ * both use `.name` (check-runs also use `.context` for legacy StatusContext).
44
+ *
45
+ * @param {Array<object>} entries
46
+ * @param {string|Array<string>} targetName One name, or several to match.
47
+ * @returns {{ matched: Array<object>, rest: Array<object> }}
48
+ */
49
+ export function partitionEntriesByCheckName(entries, targetName) {
50
+ const list = Array.isArray(entries) ? entries : [];
51
+ const targets = new Set(Array.isArray(targetName) ? targetName : [targetName]);
52
+ const matched = [];
53
+ const rest = [];
54
+ for (const entry of list) {
55
+ (targets.has(checkEntryName(entry)) ? matched : rest).push(entry);
56
+ }
57
+ return { matched, rest };
58
+ }
59
+
8
60
  function normalizeHeadScopedCiStatus(status) {
9
61
  return VALID_HEAD_SCOPED_CI_STATUSES.has(status) ? status : "none";
10
62
  }
@@ -253,3 +305,27 @@ export function normalizeHeadScopedCiContract({
253
305
 
254
306
  return buildCiContract(overallStatus);
255
307
  }
308
+
309
+ /**
310
+ * Derive a loop-safe CI status from a PR `statusCheckRollup` snapshot: the
311
+ * `LOOP_DERIVED_CI_CHECK_NAMES` entries (the `gate-evidence` status and the
312
+ * workflow's own `gate-evidence-runner` check run) are excluded from the
313
+ * status computation before it can block, and surfaced separately so a
314
+ * genuinely failing check right beside it can never be masked. Every reason
315
+ * gate-evidence can be red (missing draft_gate/pre_approval evidence,
316
+ * unresolved threads, a stale runner) is independently tracked elsewhere in
317
+ * the loop snapshot, so excluding it here loses no real signal: `status`
318
+ * stays a plain "success" (not an "unconfirmed" crediblyGreen) when it is the
319
+ * only excluded failure and everything else is green.
320
+ *
321
+ * @param {Array<object>} rollup
322
+ * @returns {{ status: "success"|"failure"|"pending"|"none", excludedFailureDetails: Array<string> }}
323
+ */
324
+ export function deriveLoopCiStatusFromRollup(rollup) {
325
+ const { matched, rest } = partitionEntriesByCheckName(rollup, LOOP_DERIVED_CI_CHECK_NAMES);
326
+ const status = normalizeStatusCheckRollupStatus(rest);
327
+ const excludedFailureDetails = matched.length > 0 && normalizeStatusCheckRollupStatus(matched) === "failure"
328
+ ? [LOOP_DERIVED_CI_CHECK_NAME]
329
+ : [];
330
+ return { status, excludedFailureDetails };
331
+ }
@@ -1,7 +1,6 @@
1
- import { isCopilotLogin, normalizeTimestamp } from "../github/copilot-helpers.mjs";
1
+ import { SUBMITTED_REVIEW_STATES, isCopilotLogin, normalizeTimestamp } from "../github/copilot-helpers.mjs";
2
2
 
3
3
  const ACTIVE_COPILOT_REVIEW_REQUEST_STATUSES = new Set(["requested", "already-requested"]);
4
- const SUBMITTED_REVIEW_STATES = new Set(["APPROVED", "CHANGES_REQUESTED", "COMMENTED", "DISMISSED"]);
5
4
 
6
5
  function normalizeReviewRequestEvents(events) {
7
6
  if (!Array.isArray(events)) {
@@ -12,7 +12,7 @@
12
12
  * becomes an explicit bounded input (agentFixStatus) rather than hidden orchestration behavior.
13
13
  */
14
14
 
15
- import { normalizeStatusCheckRollupContract } from "./copilot-ci-status.mjs";
15
+ import { deriveLoopCiStatusFromRollup } from "./copilot-ci-status.mjs";
16
16
 
17
17
  /** Stable state name constants for the async Copilot review/fix loop. */
18
18
  export const STATE = Object.freeze({
@@ -191,7 +191,7 @@ export function isCopilotRoundCapReached({ copilotReviewRoundCount, maxCopilotRo
191
191
  }
192
192
 
193
193
  export function normalizeCiStatus(rollup) {
194
- return normalizeStatusCheckRollupContract(rollup).overallStatus;
194
+ return deriveLoopCiStatusFromRollup(rollup).status;
195
195
  }
196
196
 
197
197
  export function buildSnapshotFromPrFacts({
@@ -206,11 +206,15 @@ export function buildSnapshotFromPrFacts({
206
206
  ciStatus,
207
207
  lastCopilotRoundMaxSignal = null,
208
208
  failureDetails = [],
209
- excludedFailureDetails = [],
209
+ excludedFailureDetails,
210
210
  }) {
211
211
  const prState = typeof prData?.state === "string" ? prData.state.toUpperCase() : "OPEN";
212
212
  const prMerged = prState === "MERGED";
213
213
  const prClosed = prState === "CLOSED";
214
+ // Default derivation excludes the loop's own gate-evidence check (#1358) so a
215
+ // caller that never threads an explicit ciStatus (e.g. gate-coordination
216
+ // detection) still never treats it as a blocking CI failure.
217
+ const rollupDerivation = deriveLoopCiStatusFromRollup(prData?.statusCheckRollup);
214
218
 
215
219
  return normalizeSnapshot({
216
220
  prExists: true,
@@ -225,9 +229,9 @@ export function buildSnapshotFromPrFacts({
225
229
  actionableThreadCount,
226
230
  copilotReviewRoundCount,
227
231
  lastCopilotRoundMaxSignal,
228
- ciStatus: ciStatus ?? normalizeCiStatus(prData?.statusCheckRollup),
232
+ ciStatus: ciStatus ?? rollupDerivation.status,
229
233
  failureDetails,
230
- excludedFailureDetails,
234
+ excludedFailureDetails: excludedFailureDetails ?? rollupDerivation.excludedFailureDetails,
231
235
  });
232
236
  }
233
237
 
@@ -0,0 +1,380 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ /**
5
+ * A shell working directory can silently reset to the primary checkout — after a
6
+ * subprocess run, or when a `cd` inside a compound command does not persist. An
7
+ * agent that then runs a relative-path `git commit && git push` executes it in
8
+ * the PRIMARY checkout on the DEFAULT branch, so the change lands straight on
9
+ * the remote's default branch and skips the PR flow entirely. Prose in a
10
+ * contract cannot stop that; the shell never read it.
11
+ *
12
+ * These hooks make the dangerous operation itself fail. Linked worktrees DO run
13
+ * them — git resolves hooks from the common directory, which every worktree
14
+ * shares — so what spares the loop's own work is the branch/ref check, not the
15
+ * hook's absence. A sanctioned release or reconcile sets DEVLOOPS_ALLOW_MAIN=1
16
+ * to proceed deliberately.
17
+ */
18
+ export const GUARD_MARKER = "dev-loops:default-branch-guard";
19
+ export const GUARD_OVERRIDE_ENV = "DEVLOOPS_ALLOW_MAIN";
20
+ // pre-merge-commit, not pre-commit, is what git runs for `git merge` (a plain
21
+ // `git commit` never fires during a merge); omitting it let a merge onto a
22
+ // guarded branch land while a plain commit on the same branch was refused.
23
+ export const GUARDED_HOOKS = Object.freeze(["pre-commit", "pre-merge-commit", "pre-push"]);
24
+
25
+ const REFUSAL_BODY = (what, branchExpr) => ` echo "dev-loops: refusing to ${what} ($${branchExpr}) from this checkout." >&2
26
+ echo " The dev-loop works in a linked worktree; a cwd that silently reset to the" >&2
27
+ echo " primary checkout is the usual cause. Re-run from the worktree, addressing it" >&2
28
+ echo " explicitly (git -C <absolute-worktree-path> ...)." >&2
29
+ echo " For a sanctioned release or reconcile: ${GUARD_OVERRIDE_ENV}=1 <command>" >&2`;
30
+
31
+ /**
32
+ * Git's ref rules forbid spaces and control characters but permit `$`, backtick,
33
+ * quotes, `;`, `&`, `|` and parentheses — every one of which sh expands inside
34
+ * the double-quoted assignment below. A branch named `main$(id)` would execute
35
+ * on every commit, and `main$HOME` would expand to something that never matches
36
+ * the real branch, leaving the default silently unguarded. Only names matching
37
+ * this are baked in. An unsafe DEFAULT branch refuses the install; an unsafe EXPLICIT base is dropped (recorded in droppedExplicitBranches) while the default guard installs.
38
+ */
39
+ const SHELL_SAFE_BRANCH = /^[A-Za-z0-9._/-]+$/;
40
+
41
+ /**
42
+ * Normalize a `defaultBranches` argument (a single branch name, an array of
43
+ * them, or nothing) to a deduped array of trimmed, non-empty strings. More
44
+ * than one branch is guarded when a caller's own default (git's advertised
45
+ * `<remote>/HEAD`) and its resolved working base genuinely differ — e.g. a
46
+ * `.devloops` `workflow.baseBranch` of `develop` in a repo whose real default
47
+ * is still `main`: a stray commit on EITHER must be refused.
48
+ */
49
+ function normalizeBranchList(branches) {
50
+ const list = branches == null ? [] : Array.isArray(branches) ? branches : [branches];
51
+ const seen = new Set();
52
+ const out = [];
53
+ for (const raw of list) {
54
+ if (typeof raw !== "string") continue;
55
+ const trimmed = raw.trim();
56
+ if (trimmed.length === 0 || seen.has(trimmed)) continue;
57
+ seen.add(trimmed);
58
+ out.push(trimmed);
59
+ }
60
+ return out;
61
+ }
62
+
63
+ /**
64
+ * @param {(typeof GUARDED_HOOKS)[number]} hookName one of GUARDED_HOOKS —
65
+ * "pre-commit", "pre-merge-commit", or "pre-push".
66
+ * @param {string|string[]|null} defaultBranches the STICKY set, resolved at
67
+ * INSTALL time and unioned across installs by installDefaultBranchGuard.
68
+ * Baking them in beats re-deriving in shell: `origin/HEAD` is often absent,
69
+ * and a main-or-master guess picks a stale local `main` in a `master` repo,
70
+ * which would guard the wrong branch and leave the real default open.
71
+ * @param {string|string[]|null} explicitBranches an operator-scoped set
72
+ * (e.g. an explicit `--base`) that REPLACES rather than unions across
73
+ * installs — see installDefaultBranchGuard's explicitBaseBranches.
74
+ * @throws {Error} when hookName is not a guarded hook, or any branch is
75
+ * non-empty and not shell-safe. `hookName` and every branch name are
76
+ * interpolated straight into the generated script, so THIS function — not
77
+ * just its installDefaultBranchGuard caller — is the trust boundary and
78
+ * must refuse on its own rather than rely on every caller re-checking first.
79
+ */
80
+ export function renderGuardHook(hookName, defaultBranches = null, explicitBranches = null) {
81
+ if (!GUARDED_HOOKS.includes(hookName)) {
82
+ throw new Error(`renderGuardHook: unknown hook ${JSON.stringify(hookName)}; expected one of ${GUARDED_HOOKS.join(", ")}`);
83
+ }
84
+ const branches = normalizeBranchList(defaultBranches);
85
+ const explicits = normalizeBranchList(explicitBranches);
86
+ const unsafe = [...branches, ...explicits].find((branch) => !SHELL_SAFE_BRANCH.test(branch));
87
+ if (unsafe) {
88
+ throw new Error(
89
+ `default branch ${JSON.stringify(unsafe)} contains characters the generated hook's shell would expand; refusing to render a hook that could execute it`,
90
+ );
91
+ }
92
+ const defaults = branches.join(" ");
93
+ const explicitDefaults = explicits.join(" ");
94
+ const header = `#!/bin/sh
95
+ # ${GUARD_MARKER}
96
+ # Refuses a ${hookName} that would land on a guarded default branch. Installed
97
+ # in the common hook directory, so linked worktrees run it too — their branch
98
+ # is not one of the guarded ones, which is what lets their work through.
99
+ if [ "\${${GUARD_OVERRIDE_ENV}}" = "1" ]; then
100
+ exit 0
101
+ fi
102
+ defaults="${defaults}"
103
+ explicit_defaults="${explicitDefaults}"
104
+ if [ -z "$defaults" ] && [ -z "$explicit_defaults" ]; then
105
+ # Not resolvable at install time; fail OPEN rather than guess a branch and
106
+ # protect the wrong one. The install reports this so it is not silent.
107
+ exit 0
108
+ fi
109
+ `;
110
+
111
+ // pre-commit and pre-merge-commit both fire with HEAD already on the branch
112
+ // the commit would land on (a plain commit vs. finishing a merge) — same
113
+ // check, just under the name git actually invokes for each operation.
114
+ if (hookName !== "pre-push") {
115
+ return `${header}
116
+ # Full ref, not --short: git DISAMBIGUATES a --short symbolic-ref to
117
+ # "heads/main" when a tag also named "main" exists, so comparing the short
118
+ # form against a bare branch name would never match and let the commit land.
119
+ branch=$(git symbolic-ref --quiet HEAD 2>/dev/null) || exit 0
120
+ for default in $defaults $explicit_defaults; do
121
+ if [ "$branch" = "refs/heads/$default" ]; then
122
+ ${REFUSAL_BODY("commit on the default branch", "default")}
123
+ exit 1
124
+ fi
125
+ done
126
+ exit 0
127
+ `;
128
+ }
129
+
130
+ // pre-push receives "<local ref> <local sha> <remote ref> <remote sha>" per
131
+ // line on stdin. Checking the CURRENT branch instead would miss every
132
+ // explicit refspec — `git push origin HEAD:main` from a feature branch is the
133
+ // exact shape that moved a remote default in testing.
134
+ return `${header}
135
+ blocked=0
136
+ blocked_default=""
137
+ while read -r local_ref local_sha remote_ref remote_sha; do
138
+ [ -n "$remote_ref" ] || continue
139
+ for default in $defaults $explicit_defaults; do
140
+ if [ "$remote_ref" = "refs/heads/$default" ]; then
141
+ blocked=1
142
+ blocked_default="$default"
143
+ fi
144
+ done
145
+ done
146
+ if [ "$blocked" = "1" ]; then
147
+ ${REFUSAL_BODY("push to the default branch", "blocked_default")}
148
+ exit 1
149
+ fi
150
+ exit 0
151
+ `;
152
+ }
153
+
154
+ // Ownership is decided by the marker on a line of its OWN, exactly as the
155
+ // renderer emits it. A bare substring test would claim any file that merely
156
+ // mentions the sentinel — a user wrapper commented "# chains to
157
+ // dev-loops:default-branch-guard" reads as ours and gets overwritten, which is
158
+ // the one thing installDefaultBranchGuard promises never to do.
159
+ const GUARD_MARKER_LINE = new RegExp(`^# ${GUARD_MARKER}$`, "mu");
160
+
161
+ /**
162
+ * Pull a baked-in branch list back out of a line this guard itself wrote
163
+ * (`defaults="..."` or `explicit_defaults="..."`), re-validating every entry
164
+ * against SHELL_SAFE_BRANCH rather than trusting the file. A hand-edited (or
165
+ * otherwise corrupted) baked value would otherwise make renderGuardHook THROW
166
+ * on re-install — after earlier hook slots may already have been renamed into
167
+ * place — which breaks the documented "guard.ok: false means nothing was
168
+ * written" invariant. Dropping the unsafe entry here keeps the contract:
169
+ * install refuses on genuinely new bad input, and self-heals a tampered file.
170
+ */
171
+ function extractBakedBranches(contents, varName) {
172
+ const match = contents.match(new RegExp(`^${varName}="([^"]*)"$`, "mu"));
173
+ if (!match) return [];
174
+ return match[1].split(/\s+/u).filter((branch) => branch.length > 0 && SHELL_SAFE_BRANCH.test(branch));
175
+ }
176
+
177
+ function readHookState(hookPath) {
178
+ if (!fs.existsSync(hookPath)) return { ours: true, absent: true, existingBranches: [], existingExplicitBranches: [] };
179
+ const contents = fs.readFileSync(hookPath, "utf8");
180
+ const ours = GUARD_MARKER_LINE.test(contents);
181
+ return {
182
+ ours,
183
+ absent: false,
184
+ existingBranches: ours ? extractBakedBranches(contents, "defaults") : [],
185
+ existingExplicitBranches: ours ? extractBakedBranches(contents, "explicit_defaults") : [],
186
+ };
187
+ }
188
+
189
+ /**
190
+ * Install the guard hooks into a repository's hook directory.
191
+ *
192
+ * Idempotent: re-installing rewrites only hooks this guard authored. A hook the
193
+ * user (or another tool) wrote is NEVER clobbered — that file is left exactly as
194
+ * found and reported as `skipped`, because silently replacing someone's hook is
195
+ * a worse failure than not installing ours.
196
+ *
197
+ * `defaultBranches` and `explicitBaseBranches` are tracked as two SEPARATE
198
+ * slots baked into every hook, because they need opposite persistence rules:
199
+ * - `defaultBranches` (a repo's own default) is UNIONED across installs — a
200
+ * later call resolving fewer/none (a transient fetch hiccup) must never
201
+ * un-guard a branch an earlier install already protected.
202
+ * - `explicitBaseBranches` (an operator's `--base`, or a configured
203
+ * `workflow.baseBranch`) is REPLACED wholesale by whatever this call
204
+ * passes — a later call with a different (or no) explicit base must be
205
+ * able to replace or drop it, never stack it forever. Unioning this slot
206
+ * too would permanently guard every branch anyone ever stacked a worktree
207
+ * off, refusing that branch's OWN commits with no way to undo it.
208
+ *
209
+ * @param {{ gitDir: string, defaultBranches?: string|string[]|null, explicitBaseBranches?: string|string[]|null, hooksPathOverride?: string|null }} target
210
+ * `hooksPathOverride` is the repo's `core.hooksPath` when set. Installing into
211
+ * `$GIT_DIR/hooks` while git reads elsewhere would report success for a guard
212
+ * that can never fire, so that case refuses instead.
213
+ */
214
+ export function installDefaultBranchGuard({
215
+ gitDir,
216
+ defaultBranches = null,
217
+ explicitBaseBranches = null,
218
+ hooksPathOverride = null,
219
+ }) {
220
+ const refuse = (reason, skipReason) => ({
221
+ ok: false,
222
+ installed: [],
223
+ refreshed: [],
224
+ skipped: GUARDED_HOOKS.map((hook) => ({ hook, reason: skipReason })),
225
+ reason,
226
+ });
227
+
228
+ // Any STRING means core.hooksPath is set (`git config --get` exits 0), even
229
+ // to "" — which git treats as "run no hooks at all", not "unset". A caller
230
+ // that collapsed exit-0-empty and exit-1-unset to the same null would make
231
+ // this refusal unreachable for exactly the config value it exists to catch.
232
+ if (typeof hooksPathOverride === "string") {
233
+ const configured = hooksPathOverride.trim();
234
+ return configured.length > 0
235
+ ? refuse(
236
+ `core.hooksPath is set to ${JSON.stringify(configured)} — install the guard there, or unset it, or use ${GUARD_OVERRIDE_ENV} discipline instead`,
237
+ `core.hooksPath is set to ${JSON.stringify(configured)}, so hooks in $GIT_DIR/hooks would never run`,
238
+ )
239
+ : refuse(
240
+ `core.hooksPath is set to an empty string — git runs no hooks at all; unset it, or use ${GUARD_OVERRIDE_ENV} discipline instead`,
241
+ "core.hooksPath is set to an empty string, so git runs no hooks at all",
242
+ );
243
+ }
244
+
245
+ // A relative or empty gitDir resolves against the caller's cwd, which writes a
246
+ // stray hooks/ directory into the working tree and reports success for a guard
247
+ // git will never read.
248
+ if (typeof gitDir !== "string" || !path.isAbsolute(gitDir)) {
249
+ return refuse(
250
+ `gitDir must be an absolute path; got ${JSON.stringify(gitDir)}`,
251
+ "no absolute git directory to install into",
252
+ );
253
+ }
254
+
255
+ // Absoluteness alone does not make it a GIT dir: a caller slip (the worktree
256
+ // root instead of its .git) would otherwise pass, then mkdirSync a stray
257
+ // hooks/ tree there and report success for a guard git will never read.
258
+ // Every real git dir — bare, a main checkout, or a linked worktree's own
259
+ // gitdir — has a HEAD file; that is the cheapest reliable probe.
260
+ if (!fs.existsSync(path.join(gitDir, "HEAD"))) {
261
+ return refuse(
262
+ `gitDir ${JSON.stringify(gitDir)} does not look like a git directory (no HEAD file)`,
263
+ "gitDir does not look like a git directory",
264
+ );
265
+ }
266
+
267
+ // A linked worktree's OWN per-worktree gitdir (`.git/worktrees/<name>`) also
268
+ // has a HEAD file, so the probe above alone lets it through — hooks written
269
+ // there are never resolved by git (hooks always come from the COMMON dir),
270
+ // so this would report ok:true for a guard that can never fire. `commondir`
271
+ // exists only in a linked worktree's own gitdir, never in the common one:
272
+ // a one-line, same-cost discriminator against exactly that gitdir.
273
+ if (fs.existsSync(path.join(gitDir, "commondir"))) {
274
+ return refuse(
275
+ `gitDir ${JSON.stringify(gitDir)} is a linked worktree's own git directory, not the common one — hooks installed there never run`,
276
+ "gitDir is a linked worktree's own git directory (has a commondir file), not the common one hooks are resolved from",
277
+ );
278
+ }
279
+
280
+ const branches = normalizeBranchList(defaultBranches);
281
+ const allExplicitBranches = normalizeBranchList(explicitBaseBranches);
282
+ const unsafeBranch = branches.find((branch) => !SHELL_SAFE_BRANCH.test(branch));
283
+
284
+ if (unsafeBranch) {
285
+ return refuse(
286
+ `default branch ${JSON.stringify(unsafeBranch)} contains characters the generated hook's shell would expand; refusing rather than installing a hook that could execute it or silently guard the wrong branch`,
287
+ "the resolved default branch name is not shell-safe",
288
+ );
289
+ }
290
+
291
+ // A shell-unsafe EXPLICIT base (git-legal names like "feat(auth)" or
292
+ // "fix#123") must not kill the whole install — that would leave the real
293
+ // default branch unguarded while reporting the worktree ok. Drop only the
294
+ // unsafe explicit entries, install the default guard regardless, and record
295
+ // what was dropped so the caller can surface it.
296
+ const droppedExplicitBranches = allExplicitBranches.filter((branch) => !SHELL_SAFE_BRANCH.test(branch));
297
+ const explicitBranches = allExplicitBranches.filter((branch) => SHELL_SAFE_BRANCH.test(branch));
298
+
299
+ const hooksDir = path.join(gitDir, "hooks");
300
+ fs.mkdirSync(hooksDir, { recursive: true });
301
+
302
+ // Read every guarded slot's on-disk state ONCE, up front — not inside the
303
+ // write loop — so the union below is computed repo-wide. Computing it
304
+ // per-hook instead let a slot that just became free (a foreign hook
305
+ // removed) miss what its siblings already had baked in, e.g. a hook
306
+ // re-installed after its foreign occupant is gone could get written INERT
307
+ // while its siblings still enforced the real default.
308
+ const hookStates = GUARDED_HOOKS.map((hook) => ({ hook, hookPath: path.join(hooksDir, hook), ...readHookState(path.join(hooksDir, hook)) }));
309
+
310
+ // Sticky slot: union across whatever this call resolved (`branches`) with
311
+ // what ANY hook of ours already had baked in — never a straight overwrite.
312
+ // A caller's resolution can legitimately come back empty or narrower on a
313
+ // later call (a transient fetch/network hiccup, a remote HEAD that briefly
314
+ // stopped advertising); rewriting to that smaller set would silently
315
+ // un-guard a branch an earlier install already protected.
316
+ const stickyBranches = new Set(branches);
317
+ for (const state of hookStates) {
318
+ if (!state.ours) continue;
319
+ for (const branch of state.existingBranches) stickyBranches.add(branch);
320
+ }
321
+ const finalStickyBranches = [...stickyBranches];
322
+
323
+ // Explicit-base slot: REPLACED wholesale by whatever this call passed, never
324
+ // unioned with what a PRIOR call baked in — that is what makes the slot
325
+ // droppable (a later call with a different, or no, explicit base) instead
326
+ // of accumulating every branch anyone ever stacked a worktree off.
327
+ const finalExplicitBranches = explicitBranches;
328
+
329
+ const installed = [];
330
+ const refreshed = [];
331
+ const skipped = [];
332
+ let anyWritten = false;
333
+
334
+ for (const state of hookStates) {
335
+ const { hook, hookPath, ours, absent } = state;
336
+ if (!ours) {
337
+ skipped.push({ hook, reason: "a pre-existing hook is present and was left untouched" });
338
+ continue;
339
+ }
340
+ // Write + chmod a temp file in the SAME directory, then rename into place.
341
+ // A direct writeFileSync is visible to git mid-write: the common hooks dir
342
+ // is shared, so a concurrent ensureWorktree call (or a real commit racing
343
+ // an install) can exec the file while it is still header-only, falling
344
+ // through to `exit 0` and letting a default-branch commit land. A same-dir
345
+ // rename is atomic, so any reader sees either the old hook or the new one,
346
+ // never a partial one.
347
+ const tmpPath = path.join(hooksDir, `.${hook}.tmp-${process.pid}-${Date.now()}`);
348
+ fs.writeFileSync(tmpPath, renderGuardHook(hook, finalStickyBranches, finalExplicitBranches), { mode: 0o755 });
349
+ fs.chmodSync(tmpPath, 0o755); // mode above is umask-limited; force it
350
+ fs.renameSync(tmpPath, hookPath);
351
+ anyWritten = true;
352
+ (absent ? installed : refreshed).push(hook);
353
+ }
354
+
355
+ // Report only what a hook actually enforces: a branch reads as guarded when
356
+ // at least one slot was WRITTEN with it, never merely requested. Seeding
357
+ // this from `branches` before the loop (the prior bug) claimed enforcement
358
+ // — ok: true, defaultBranches: ["main"] — even when every slot was foreign
359
+ // and nothing was written.
360
+ const reportedBranches = anyWritten ? [...new Set([...finalStickyBranches, ...finalExplicitBranches])] : [];
361
+ let reason;
362
+ if (reportedBranches.length === 0) {
363
+ reason = anyWritten
364
+ ? "default branch could not be resolved at install time; the hooks are inert rather than guessing which branch to protect"
365
+ : "every guarded hook slot is already occupied by a foreign hook; nothing was written, so nothing is enforced";
366
+ }
367
+ if (droppedExplicitBranches.length > 0) {
368
+ const dropNote = `explicit base ${droppedExplicitBranches.map((branch) => JSON.stringify(branch)).join(", ")} contains characters the generated hook's shell would expand; the default guard was installed without it`;
369
+ reason = reason ? `${reason}; ${dropNote}` : dropNote;
370
+ }
371
+ return {
372
+ ok: true,
373
+ installed,
374
+ refreshed,
375
+ skipped,
376
+ defaultBranches: reportedBranches,
377
+ ...(droppedExplicitBranches.length > 0 ? { droppedExplicitBranches } : {}),
378
+ ...(reason ? { reason } : {}),
379
+ };
380
+ }
@@ -17,7 +17,7 @@
17
17
  * caller records the carried verdict with provenance pointing at the PRIOR head's
18
18
  * reviewer (that reviewer genuinely reviewed this angle's surface, which the delta
19
19
  * did not touch), clearly marked as carried — see
20
- * docs/gate-review-sub-loop-contract.md and write-gate-findings-log.mjs's
20
+ * skills/docs/gate-review-sub-loop-contract.md and write-gate-findings-log.mjs's
21
21
  * `carriedFromHead` provenance field.
22
22
  *
23
23
  * The angle -> review-surface mapping is DERIVED from the single source of truth
@@ -125,7 +125,13 @@ export function angleReviewSurface(angle, { alwaysRerun } = {}) {
125
125
  const name = typeof angle === "string" ? angle.trim() : "";
126
126
  if (name.length === 0) return { kind: "unknown" };
127
127
  if (ALWAYS_INCLUDE.has(name)) return { kind: "always" };
128
- if (alwaysRerun && new Set(alwaysRerun).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
+ if (alwaysRerun) {
132
+ const normalized = new Set([...alwaysRerun].map((entry) => String(entry).trim().toLowerCase()));
133
+ if (normalized.has(name.toLowerCase())) return { kind: "always" };
134
+ }
129
135
  const kinds = ANGLE_SURFACE_KINDS.get(name);
130
136
  if (!kinds || kinds.size === 0) return { kind: "unknown" };
131
137
  return { kind: "kinds", kinds: new Set(kinds) };
@@ -149,6 +155,24 @@ export function angleReviewSurface(angle, { alwaysRerun } = {}) {
149
155
  * is carry-forward-eligible.
150
156
  * @returns {{ carryForward: boolean, reason: string }}
151
157
  */
158
+
159
+ // A path whose change rewrites the dev-loop review system itself — the angle
160
+ // pool, mandatory floor, and reviewer personas/prompts — rather than a
161
+ // reviewed surface. A clean verdict produced under the OLD config cannot
162
+ // carry across such a delta, and a converged Copilot round cannot be treated
163
+ // as still-converged either. classifyFile correctly reports these as
164
+ // "config"; this predicate is the carry-forward-specific override. The
165
+ // shipped defaults file is in this class too: it is the layer that ships the
166
+ // angle pool and reviewer prompts, so a delta touching it must never be
167
+ // carried across or reviewed under a reduced diff-class tier.
168
+ const DEV_LOOP_CONFIG_SOURCE_RE = /(^|\/)(\.devloops(\.(ya?ml|json))?|\.pi\/dev-loop\/(settings|defaults)\.[^/]+|packages\/core\/src\/config\/extension-defaults\.yaml)$/;
169
+ export function isDevLoopConfigSourcePath(filePath) {
170
+ if (typeof filePath !== "string") return false;
171
+ // Normalize Windows separators like classifyFile does, so a
172
+ // ".pi\\dev-loop\\settings.yaml" delta is still detected on Windows.
173
+ return DEV_LOOP_CONFIG_SOURCE_RE.test(filePath.trim().replace(/\\/g, "/"));
174
+ }
175
+
152
176
  export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, prevVerdict }) {
153
177
  if (prevVerdict !== "clean") {
154
178
  return { carryForward: false, reason: `prior verdict is ${JSON.stringify(prevVerdict ?? null)}, not "clean"` };
@@ -164,6 +188,9 @@ export function resolveAngleCarryForward({ angle, angleSurface, changedFiles, pr
164
188
  return { carryForward: false, reason: "delta is empty or unavailable (fail-closed)" };
165
189
  }
166
190
  for (const file of changedFiles) {
191
+ if (isDevLoopConfigSourcePath(file)) {
192
+ return { carryForward: false, reason: `delta rewrites the dev-loop config source (reviewer pool/prompts): ${file}` };
193
+ }
167
194
  const kind = classifyFile(file);
168
195
  if (kind === "unknown") {
169
196
  return { carryForward: false, reason: `delta contains an unclassifiable file (fail-closed): ${file}` };