@webpieces/ai-hook-rules 0.4.508 → 0.4.509
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 -2
- package/src/core/rules/branch-creation-guard.d.ts +20 -47
- package/src/core/rules/branch-creation-guard.js +58 -150
- package/src/core/rules/branch-creation-guard.js.map +1 -1
- package/src/core/rules/cap-remedies.d.ts +79 -0
- package/src/core/rules/cap-remedies.js +175 -0
- package/src/core/rules/cap-remedies.js.map +1 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/ai-hook-rules",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.509",
|
|
4
4
|
"description": "Pluggable write-time validation framework for AI coding agents (@webpieces/ai-hook-rules). Claude Code PreToolUse + openclaw before_tool_call adapters share one rule engine.",
|
|
5
5
|
"type": "commonjs",
|
|
6
6
|
"main": "./src/index.js",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"directory": "packages/tooling/ai-hook-rules"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@webpieces/rules-config": "0.4.
|
|
35
|
+
"@webpieces/rules-config": "0.4.509"
|
|
36
36
|
},
|
|
37
37
|
"publishConfig": {
|
|
38
38
|
"access": "public"
|
|
@@ -15,6 +15,7 @@ export declare class BranchCreationGuardRule extends BashRuleBase<BranchCreation
|
|
|
15
15
|
private readonly mergedBranches;
|
|
16
16
|
private capCache;
|
|
17
17
|
private worktreeCapCache;
|
|
18
|
+
private capFreshness;
|
|
18
19
|
private worktreeAdd;
|
|
19
20
|
private get branchFormat();
|
|
20
21
|
private get subBranchNaming();
|
|
@@ -72,6 +73,25 @@ export declare class BranchCreationGuardRule extends BashRuleBase<BranchCreation
|
|
|
72
73
|
* so the cap starts enforcing on its own.
|
|
73
74
|
*/
|
|
74
75
|
private checkBranchCap;
|
|
76
|
+
/**
|
|
77
|
+
* The cache, with entries for branches/worktrees that no longer exist dropped, plus its age recorded.
|
|
78
|
+
*
|
|
79
|
+
* Reconciling is the fix for the phantom count: the file is written by a detached refresher and is
|
|
80
|
+
* DELIBERATELY allowed to go stale, so it keeps naming branches deleted minutes ago and worktrees
|
|
81
|
+
* already removed. Quoting it verbatim is how the guard announced "8 parked local branches … none of
|
|
82
|
+
* them are dead" over a repo that had ONE, and then blocked a legitimate `git worktree add` on that
|
|
83
|
+
* figure. `reconcile` re-checks existence with instant local git reads; it does NOT re-derive any
|
|
84
|
+
* verdict (that needs the network and belongs in the refresher).
|
|
85
|
+
*/
|
|
86
|
+
private loadReconciledCache;
|
|
87
|
+
/**
|
|
88
|
+
* The "N of them are dead" sentence — or an honest refusal to say a number.
|
|
89
|
+
*
|
|
90
|
+
* A count read off a stale file is a claim the guard cannot stand behind, and this guard's numbers
|
|
91
|
+
* are acted on: they decide whether an agent goes and deletes things. When the cache is too old,
|
|
92
|
+
* say so and point at the command that recomputes from scratch, rather than asserting a figure.
|
|
93
|
+
*/
|
|
94
|
+
private deadDetail;
|
|
75
95
|
/**
|
|
76
96
|
* The branch cap, yielding by ONE when the agent is standing on an already-merged branch.
|
|
77
97
|
*
|
|
@@ -96,51 +116,4 @@ export declare class BranchCreationGuardRule extends BashRuleBase<BranchCreation
|
|
|
96
116
|
* budget for it would just silently cost you one worktree.
|
|
97
117
|
*/
|
|
98
118
|
private checkWorktreeCap;
|
|
99
|
-
/**
|
|
100
|
-
* The reap instructions. `deletable` is PRECOMPUTED in the cache, and every entry earned its place
|
|
101
|
-
* by one of exactly two proofs: a MERGED PR (the work is in main), or zero commits of its own
|
|
102
|
-
* (there is no work). Deleting the list cannot lose anything — so just run the command.
|
|
103
|
-
*
|
|
104
|
-
* The command is `pnpm wp-cleanup`, NOT the `git branch -D a b c` this used to emit. Two reasons,
|
|
105
|
-
* both learned the hard way: agents read a bare `-D` as destructive and stop to ask (so nothing
|
|
106
|
-
* was ever cleaned, and this cap kept firing), and the multi-name form aborts wholesale on the
|
|
107
|
-
* first branch git refuses, stranding every branch after it in the list. wp-cleanup recomputes
|
|
108
|
-
* the verdicts, deletes one branch per command, and logs each pre-delete SHA.
|
|
109
|
-
*
|
|
110
|
-
* The wording must not overstate the safety: the list is NOT uniformly "merged PR" branches, and
|
|
111
|
-
* a message that tells an agent to delete has to be exactly true about why that's safe.
|
|
112
|
-
*/
|
|
113
|
-
private capFixHint;
|
|
114
|
-
/**
|
|
115
|
-
* The remedy that actually deletes something without loosening anything: SHOW the spared branches
|
|
116
|
-
* and ASK the human which may go.
|
|
117
|
-
*
|
|
118
|
-
* `keep` is the list the tooling refuses to touch on its own — no merged PR, so no proof the work
|
|
119
|
-
* is safe. That is exactly the list a human can adjudicate in five seconds and the tooling never
|
|
120
|
-
* can, and it was never printed: the cap said "N branches were SPARED, do not delete those" and
|
|
121
|
-
* then offered only config edits. So the agent edited the config.
|
|
122
|
-
*
|
|
123
|
-
* Every column is read straight off `.webpieces/merged-branches.json` (written by the detached
|
|
124
|
-
* refresher) — nothing is recomputed on this blocking path. The SHA is there so the human can see
|
|
125
|
-
* the delete is reversible; the commit count is there so "0 commits" branches are obvious yeses.
|
|
126
|
-
*/
|
|
127
|
-
private askHumanOption;
|
|
128
|
-
/**
|
|
129
|
-
* The worktree reap remedy.
|
|
130
|
-
*
|
|
131
|
-
* It is now ONE command — `pnpm wp-cleanup` — and that is the point of it. The hand-written
|
|
132
|
-
* sequence this used to print (prune → remove each path → one multi-name `git branch -D`) was
|
|
133
|
-
* correct git, and it was still never run: an AI agent reads a raw `worktree remove` / `-D` as
|
|
134
|
-
* destructive, asks permission, and stops. A guard whose only actionable remedies are "raise the
|
|
135
|
-
* cap" and "set turnOffRuleUntilEpoch" is a guard that teaches config edits, which is the exact
|
|
136
|
-
* failure the cap exists to prevent. wp-cleanup archives each branch as a tag, removes the
|
|
137
|
-
* directory, then deletes the branch — in that order, because git refuses to delete a branch a
|
|
138
|
-
* worktree still holds — and it will not touch the primary clone, the tree you are standing in, or
|
|
139
|
-
* anything with uncommitted work.
|
|
140
|
-
*
|
|
141
|
-
* The explicit git sequence is still printed BELOW the command, as reference rather than as the
|
|
142
|
-
* instruction: when wp-cleanup declines one of these (typically untracked files), the human needs
|
|
143
|
-
* to see which directory and what git would have run.
|
|
144
|
-
*/
|
|
145
|
-
private worktreeCapFixHint;
|
|
146
119
|
}
|
|
@@ -6,6 +6,7 @@ const rules_config_1 = require("@webpieces/rules-config");
|
|
|
6
6
|
const types_1 = require("../types");
|
|
7
7
|
const rule_base_1 = require("../rule-base");
|
|
8
8
|
const fix_hint_1 = require("../fix-hint");
|
|
9
|
+
const cap_remedies_1 = require("./cap-remedies");
|
|
9
10
|
const to_error_1 = require("../to-error");
|
|
10
11
|
// Defaults used when the rule has no explicit value in webpieces.config.json.
|
|
11
12
|
// branchFormat is a human sentence telling the AI how to name a branch created off main; it is
|
|
@@ -130,6 +131,9 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
|
|
|
130
131
|
// Two fields, because the two caps reap different things and their hints share no wording.
|
|
131
132
|
capCache = null;
|
|
132
133
|
worktreeCapCache = null;
|
|
134
|
+
// How old the cache backing whichever cap fired was, so the hint can SAY "these verdicts are stale"
|
|
135
|
+
// instead of quoting them as present-tense fact. Null until a cap fires.
|
|
136
|
+
capFreshness = null;
|
|
133
137
|
// True when the blocked command was a `git worktree add`, so the recovery command we hand back is
|
|
134
138
|
// a worktree command and not a `git checkout -b` the user cannot use here.
|
|
135
139
|
worktreeAdd = false;
|
|
@@ -155,10 +159,11 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
|
|
|
155
159
|
// convention. The sub-branch affordance only appears under mode 'ON'; 'ON_NO_SUBBRANCHES'
|
|
156
160
|
// hard-blocks it and points instead at the turnOffRuleUntilEpoch escape hatch.
|
|
157
161
|
get fixHint() {
|
|
162
|
+
const remedies = new cap_remedies_1.CapRemedies(this.capFreshness);
|
|
158
163
|
if (this.worktreeCapCache)
|
|
159
|
-
return
|
|
164
|
+
return remedies.worktreeCap(this.worktreeCapCache);
|
|
160
165
|
if (this.capCache)
|
|
161
|
-
return
|
|
166
|
+
return remedies.branchCap(this.capCache);
|
|
162
167
|
const create = this.worktreeAdd
|
|
163
168
|
? 'Create it off fresh main: git fetch origin main && git worktree add ../<dir> -b <name> origin/main'
|
|
164
169
|
: 'Create it off fresh main from anywhere (incl. a worktree): git fetch origin main && git checkout -b <name> origin/main';
|
|
@@ -193,6 +198,7 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
|
|
|
193
198
|
check(ctx) {
|
|
194
199
|
this.capCache = null;
|
|
195
200
|
this.worktreeCapCache = null;
|
|
201
|
+
this.capFreshness = null;
|
|
196
202
|
// Match against the command with heredoc bodies and prose-in-quotes removed (BashContext
|
|
197
203
|
// computes it for every guard now — this rule's private copy was the original).
|
|
198
204
|
const command = ctx.commandCode;
|
|
@@ -324,17 +330,57 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
|
|
|
324
330
|
const cap = this.effectiveBranchCap(ctx);
|
|
325
331
|
if (count < cap)
|
|
326
332
|
return null;
|
|
327
|
-
const cache = this.
|
|
333
|
+
const cache = this.loadReconciledCache(ctx);
|
|
328
334
|
if (!cache)
|
|
329
335
|
return null;
|
|
330
336
|
this.capCache = cache;
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
337
|
+
// Only branches that are BOTH still on disk (reconcile guaranteed that) and parked can be
|
|
338
|
+
// reaped to make room. A cached verdict about a branch a worktree now holds is not a figure
|
|
339
|
+
// this message may quote at someone.
|
|
340
|
+
const parkedSet = new Set(parked);
|
|
341
|
+
const reapable = cache.deletable
|
|
342
|
+
.filter((entry) => parkedSet.has(entry.branch)).length;
|
|
335
343
|
return new types_1.Violation(1, truncate(ctx.command), `You have ${String(count)} parked local branches (not counting any checked out in a worktree); ` +
|
|
336
344
|
`the cap (branch-creation-guard.maxLocalBranches) is ${String(this.maxLocalBranches)}. ` +
|
|
337
|
-
`${
|
|
345
|
+
`${this.deadDetail(reapable, 'dead (a MERGED PR backs them)')} Clean up before creating another.`);
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* The cache, with entries for branches/worktrees that no longer exist dropped, plus its age recorded.
|
|
349
|
+
*
|
|
350
|
+
* Reconciling is the fix for the phantom count: the file is written by a detached refresher and is
|
|
351
|
+
* DELIBERATELY allowed to go stale, so it keeps naming branches deleted minutes ago and worktrees
|
|
352
|
+
* already removed. Quoting it verbatim is how the guard announced "8 parked local branches … none of
|
|
353
|
+
* them are dead" over a repo that had ONE, and then blocked a legitimate `git worktree add` on that
|
|
354
|
+
* figure. `reconcile` re-checks existence with instant local git reads; it does NOT re-derive any
|
|
355
|
+
* verdict (that needs the network and belongs in the refresher).
|
|
356
|
+
*/
|
|
357
|
+
loadReconciledCache(ctx) {
|
|
358
|
+
const raw = this.mergedBranches.readMergedBranches(ctx.workspaceRoot);
|
|
359
|
+
if (!raw)
|
|
360
|
+
return null;
|
|
361
|
+
this.capFreshness = this.mergedBranches.freshness(raw);
|
|
362
|
+
return this.mergedBranches.reconcile(ctx.workspaceRoot, raw);
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* The "N of them are dead" sentence — or an honest refusal to say a number.
|
|
366
|
+
*
|
|
367
|
+
* A count read off a stale file is a claim the guard cannot stand behind, and this guard's numbers
|
|
368
|
+
* are acted on: they decide whether an agent goes and deletes things. When the cache is too old,
|
|
369
|
+
* say so and point at the command that recomputes from scratch, rather than asserting a figure.
|
|
370
|
+
*/
|
|
371
|
+
deadDetail(reapable, what) {
|
|
372
|
+
const freshness = this.capFreshness;
|
|
373
|
+
if (freshness !== null && freshness.stale) {
|
|
374
|
+
const age = freshness.ageMinutes >= 0
|
|
375
|
+
? `${String(freshness.ageMinutes)} minute(s) old`
|
|
376
|
+
: 'carrying no usable timestamp';
|
|
377
|
+
return `How many are dead is NOT known right now — the cached verdicts ` +
|
|
378
|
+
`(.webpieces/merged-branches.json, ${age}) are stale, so no count is asserted. ` +
|
|
379
|
+
'`pnpm wp-cleanup` recomputes them from scratch.';
|
|
380
|
+
}
|
|
381
|
+
if (reapable === 0)
|
|
382
|
+
return 'None of them are dead, so none can be auto-reaped — see the options below.';
|
|
383
|
+
return `${String(reapable)} of them are ${what} and wp-cleanup can reap them.`;
|
|
338
384
|
}
|
|
339
385
|
/**
|
|
340
386
|
* The branch cap, yielding by ONE when the agent is standing on an already-merged branch.
|
|
@@ -383,153 +429,15 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
|
|
|
383
429
|
const count = this.worktrees.linkedWorktrees(ctx.workspaceRoot).length;
|
|
384
430
|
if (count < this.maxWorktrees)
|
|
385
431
|
return null;
|
|
386
|
-
const cache = this.
|
|
432
|
+
const cache = this.loadReconciledCache(ctx);
|
|
387
433
|
if (!cache)
|
|
388
434
|
return null;
|
|
389
435
|
this.worktreeCapCache = cache;
|
|
390
436
|
const reapable = cache.worktrees.filter((tree) => tree.deletable).length;
|
|
391
|
-
const detail = reapable > 0
|
|
392
|
-
? `${String(reapable)} of them are dead (merged branch, no commits, or a missing directory) ` +
|
|
393
|
-
'and can be removed right now.'
|
|
394
|
-
: 'None of them are dead, so none can be auto-reaped — see the options below.';
|
|
395
437
|
return new types_1.Violation(1, truncate(ctx.command), `You have ${String(count)} linked worktrees; the cap (branch-creation-guard.maxWorktrees) ` +
|
|
396
|
-
`is ${String(this.maxWorktrees)}.
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
* The reap instructions. `deletable` is PRECOMPUTED in the cache, and every entry earned its place
|
|
400
|
-
* by one of exactly two proofs: a MERGED PR (the work is in main), or zero commits of its own
|
|
401
|
-
* (there is no work). Deleting the list cannot lose anything — so just run the command.
|
|
402
|
-
*
|
|
403
|
-
* The command is `pnpm wp-cleanup`, NOT the `git branch -D a b c` this used to emit. Two reasons,
|
|
404
|
-
* both learned the hard way: agents read a bare `-D` as destructive and stop to ask (so nothing
|
|
405
|
-
* was ever cleaned, and this cap kept firing), and the multi-name form aborts wholesale on the
|
|
406
|
-
* first branch git refuses, stranding every branch after it in the list. wp-cleanup recomputes
|
|
407
|
-
* the verdicts, deletes one branch per command, and logs each pre-delete SHA.
|
|
408
|
-
*
|
|
409
|
-
* The wording must not overstate the safety: the list is NOT uniformly "merged PR" branches, and
|
|
410
|
-
* a message that tells an agent to delete has to be exactly true about why that's safe.
|
|
411
|
-
*/
|
|
412
|
-
capFixHint(cache) {
|
|
413
|
-
const options = [];
|
|
414
|
-
// Nothing auto-reapable → the ASK is the preferred move, and it comes FIRST. This is the whole
|
|
415
|
-
// point of the option: with an empty `deletable` list the only advice left used to be "raise
|
|
416
|
-
// maxLocalBranches" / "set turnOffRuleUntilEpoch", and an agent with no human in the loop
|
|
417
|
-
// edited webpieces.config.json to escape — loosening the very rule that was working correctly.
|
|
418
|
-
const askFirst = cache.deletable.length === 0;
|
|
419
|
-
if (cache.deletable.length > 0) {
|
|
420
|
-
const names = cache.deletable.map((entry) => entry.branch);
|
|
421
|
-
options.push(new fix_hint_1.Option(`Run: pnpm wp-cleanup — it deletes these ${String(names.length)} dead branches. Each is either ` +
|
|
422
|
-
`backed by a MERGED PR or has no commits of its own, so no work can be lost, and every delete ` +
|
|
423
|
-
`is logged with a recover-by-SHA command (see merged-branches.json for the per-branch reason): ` +
|
|
424
|
-
names.join(' '), true));
|
|
425
|
-
}
|
|
426
|
-
const ask = this.askHumanOption(cache, askFirst);
|
|
427
|
-
if (ask)
|
|
428
|
-
options.push(ask);
|
|
429
|
-
options.push(new fix_hint_1.Option('ONLY IF A HUMAN SAYS SO: raise branch-creation-guard.maxLocalBranches in ' +
|
|
430
|
-
'webpieces.config.json. Editing this config to get past a guard is not a fix you may make on ' +
|
|
431
|
-
'your own — ask first (use the option above).'));
|
|
432
|
-
options.push(new fix_hint_1.Option('ONLY IF A HUMAN SAYS SO: set branch-creation-guard.turnOffRuleUntilEpoch (a future epoch) ' +
|
|
433
|
-
'in webpieces.config.json to bypass this once. Same rule — ask, do not self-approve.'));
|
|
434
|
-
const kept = cache.keep.length > 0
|
|
435
|
-
? ` ${String(cache.keep.length)} unmerged branch(es) with real commits were deliberately SPARED — ` +
|
|
436
|
-
'do not delete those; a human decides.'
|
|
437
|
-
: '';
|
|
438
|
-
return new fix_hint_1.FixHint('Too many local branches — reap the dead ones before creating another.', 'Full detail (deletable + spared, with per-branch reasons) is in .webpieces/merged-branches.json, ' +
|
|
439
|
-
`refreshed ${cache.timestamp || 'never'}.${kept} Pick one:`, options);
|
|
440
|
-
}
|
|
441
|
-
/**
|
|
442
|
-
* The remedy that actually deletes something without loosening anything: SHOW the spared branches
|
|
443
|
-
* and ASK the human which may go.
|
|
444
|
-
*
|
|
445
|
-
* `keep` is the list the tooling refuses to touch on its own — no merged PR, so no proof the work
|
|
446
|
-
* is safe. That is exactly the list a human can adjudicate in five seconds and the tooling never
|
|
447
|
-
* can, and it was never printed: the cap said "N branches were SPARED, do not delete those" and
|
|
448
|
-
* then offered only config edits. So the agent edited the config.
|
|
449
|
-
*
|
|
450
|
-
* Every column is read straight off `.webpieces/merged-branches.json` (written by the detached
|
|
451
|
-
* refresher) — nothing is recomputed on this blocking path. The SHA is there so the human can see
|
|
452
|
-
* the delete is reversible; the commit count is there so "0 commits" branches are obvious yeses.
|
|
453
|
-
*/
|
|
454
|
-
askHumanOption(cache, preferred) {
|
|
455
|
-
if (cache.keep.length === 0)
|
|
456
|
-
return null;
|
|
457
|
-
const rows = cache.keep.map((entry) => {
|
|
458
|
-
const sha = entry.sha !== '' ? entry.sha : '???????';
|
|
459
|
-
const pr = entry.pr > 0
|
|
460
|
-
? `PR #${String(entry.pr)} ${entry.prState || 'MERGED'}`
|
|
461
|
-
: (entry.prState !== '' ? `PR ${entry.prState}` : 'no PR');
|
|
462
|
-
const commits = entry.commits >= 0 ? `${String(entry.commits)} commit(s) of its own` : 'commit count unknown';
|
|
463
|
-
return ` ${entry.branch} [${sha}] ${pr} ${commits} — ${entry.reason}`;
|
|
464
|
-
});
|
|
465
|
-
return new fix_hint_1.Option(`ASK THE HUMAN which of these ${String(cache.keep.length)} branches may be deleted. They are ` +
|
|
466
|
-
'not provably dead, so the tooling will not reap them — but a human can decide in seconds, ' +
|
|
467
|
-
'and deleting one is the correct fix for "too many branches". Paste this list and ask:\n' +
|
|
468
|
-
rows.join('\n') + '\n' +
|
|
469
|
-
'Then delete ONLY the ones approved: git branch -D <approved-branch>\n' +
|
|
470
|
-
'(each is recoverable — `git branch <name> <sha>` restores it at the SHA shown above).\n' +
|
|
471
|
-
'Do NOT delete any of these without an explicit yes, and do NOT edit webpieces.config.json instead.', preferred);
|
|
472
|
-
}
|
|
473
|
-
/**
|
|
474
|
-
* The worktree reap remedy.
|
|
475
|
-
*
|
|
476
|
-
* It is now ONE command — `pnpm wp-cleanup` — and that is the point of it. The hand-written
|
|
477
|
-
* sequence this used to print (prune → remove each path → one multi-name `git branch -D`) was
|
|
478
|
-
* correct git, and it was still never run: an AI agent reads a raw `worktree remove` / `-D` as
|
|
479
|
-
* destructive, asks permission, and stops. A guard whose only actionable remedies are "raise the
|
|
480
|
-
* cap" and "set turnOffRuleUntilEpoch" is a guard that teaches config edits, which is the exact
|
|
481
|
-
* failure the cap exists to prevent. wp-cleanup archives each branch as a tag, removes the
|
|
482
|
-
* directory, then deletes the branch — in that order, because git refuses to delete a branch a
|
|
483
|
-
* worktree still holds — and it will not touch the primary clone, the tree you are standing in, or
|
|
484
|
-
* anything with uncommitted work.
|
|
485
|
-
*
|
|
486
|
-
* The explicit git sequence is still printed BELOW the command, as reference rather than as the
|
|
487
|
-
* instruction: when wp-cleanup declines one of these (typically untracked files), the human needs
|
|
488
|
-
* to see which directory and what git would have run.
|
|
489
|
-
*/
|
|
490
|
-
worktreeCapFixHint(cache) {
|
|
491
|
-
const options = [];
|
|
492
|
-
const dead = cache.worktrees.filter((tree) => tree.deletable);
|
|
493
|
-
if (dead.length > 0) {
|
|
494
|
-
const steps = ['git worktree prune'];
|
|
495
|
-
for (const tree of dead) {
|
|
496
|
-
// A prunable worktree has no directory left to remove — step 1 already handled it.
|
|
497
|
-
if (tree.path !== '')
|
|
498
|
-
steps.push(`git worktree remove ${tree.path}`);
|
|
499
|
-
}
|
|
500
|
-
const branches = dead
|
|
501
|
-
.map((tree) => tree.branch)
|
|
502
|
-
.filter((branch) => branch !== '');
|
|
503
|
-
if (branches.length > 0)
|
|
504
|
-
steps.push(`git branch -D ${branches.join(' ')}`);
|
|
505
|
-
options.push(new fix_hint_1.Option(`Run: pnpm wp-cleanup — it REMOVES these ${String(dead.length)} dead worktrees for you. Each ` +
|
|
506
|
-
'holds a branch backed by a MERGED PR, a branch with no commits of its own, or a directory ' +
|
|
507
|
-
'that is already gone, so no work can be lost (see merged-branches.json for the per-worktree ' +
|
|
508
|
-
'reason). It archives every branch as an `archive/<date>/<branch>` tag BEFORE removing ' +
|
|
509
|
-
'anything, logs each removal with a `recover=` command, and refuses to touch the primary ' +
|
|
510
|
-
'clone, the worktree you are standing in, or any worktree holding uncommitted work. ' +
|
|
511
|
-
`Equivalent by hand, in this order (prune first, branches last — git refuses to delete a ` +
|
|
512
|
-
`branch a worktree still holds): ${steps.join(' && ')}`, true));
|
|
513
|
-
}
|
|
514
|
-
else {
|
|
515
|
-
// Nothing is PROVABLY dead, but wp-cleanup also prompts about the probably-dead ones, so it
|
|
516
|
-
// is still the remedy that can delete something — and it is still better advice than a knob.
|
|
517
|
-
options.push(new fix_hint_1.Option('Run: pnpm wp-cleanup — none of these worktrees is provably dead, but it lists the ' +
|
|
518
|
-
'probably-dead ones (closed-unmerged PR, content already in main, never proposed) with ' +
|
|
519
|
-
'their branch and reason and asks which to remove. Answer that prompt instead of raising ' +
|
|
520
|
-
'the cap.', true));
|
|
521
|
-
}
|
|
522
|
-
options.push(new fix_hint_1.Option('If you genuinely need more worktrees in flight, raise branch-creation-guard.maxWorktrees ' +
|
|
523
|
-
'in webpieces.config.json.'));
|
|
524
|
-
options.push(new fix_hint_1.Option('To bypass this once, set branch-creation-guard.turnOffRuleUntilEpoch (a future epoch) ' +
|
|
525
|
-
'in webpieces.config.json.'));
|
|
526
|
-
const spared = cache.worktrees.length - dead.length;
|
|
527
|
-
const kept = spared > 0
|
|
528
|
-
? ` ${String(spared)} worktree(s) were deliberately SPARED (locked, holding unmerged work, or ` +
|
|
529
|
-
'the one you are standing in) — do not remove those; a human decides.'
|
|
530
|
-
: '';
|
|
531
|
-
return new fix_hint_1.FixHint('Too many worktrees — reap the dead ones before creating another.', 'Full detail (deletable + spared, with per-worktree reasons) is in .webpieces/merged-branches.json, ' +
|
|
532
|
-
`refreshed ${cache.timestamp || 'never'}.${kept} Pick one:`, options);
|
|
438
|
+
`is ${String(this.maxWorktrees)}. ` +
|
|
439
|
+
`${this.deadDetail(reapable, 'dead (a MERGED PR backs the branch, or the directory is already gone)')} ` +
|
|
440
|
+
'Clean up before creating another.');
|
|
533
441
|
}
|
|
534
442
|
}
|
|
535
443
|
exports.BranchCreationGuardRule = BranchCreationGuardRule;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"branch-creation-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/branch-creation-guard.ts"],"names":[],"mappings":";;;AAAA,iDAAyC;AAEzC,0DAQiC;AAGjC,oCAA0C;AAC1C,4CAA4C;AAC5C,0CAA8C;AAC9C,0CAAsC;AAEtC,8EAA8E;AAC9E,+FAA+F;AAC/F,kGAAkG;AAClG,MAAM,qBAAqB,GACvB,4HAA4H,CAAC;AACjI,MAAM,yBAAyB,GAAG,sCAAsC,CAAC;AAEzE,qGAAqG;AACrG,+EAA+E;AAC/E,EAAE;AACF,uGAAuG;AACvG,sGAAsG;AACtG,uGAAuG;AACvG,sCAAsC;AACtC,MAAM,0BAA0B,GAAG,CAAC,CAAC;AACrC,MAAM,qBAAqB,GAAG,CAAC,CAAC;AAEhC,oGAAoG;AACpG,kGAAkG;AAClG,mGAAmG;AACnG,MAAM,QAAQ,GAAG,MAAM,CAAC,GAAG,CAAA,6BAA6B,CAAC;AAEzD,MAAM,eAAe,GAAa;IAC9B,IAAI,MAAM,CAAC,MAAM,CAAC,GAAG,CAAA,6BAA6B,QAAQ,GAAG,CAAC;IAC9D,IAAI,MAAM,CAAC,MAAM,CAAC,GAAG,CAAA,2BAA2B,QAAQ,GAAG,CAAC;IAC5D,IAAI,MAAM,CAAC,MAAM,CAAC,GAAG,CAAA,wBAAwB,QAAQ,GAAG,CAAC;IACzD,iGAAiG;IACjG,+FAA+F;IAC/F,gGAAgG;IAChG,oGAAoG;IACpG,IAAI,MAAM,CAAC,MAAM,CAAC,GAAG,CAAA,+CAA+C,QAAQ,GAAG,CAAC;CACnF,CAAC;AAEF,oGAAoG;AACpG,oGAAoG;AACpG,qBAAqB;AACrB,MAAM,YAAY,GAAG,wBAAwB,CAAC;AAE9C,mGAAmG;AACnG,+FAA+F;AAC/F,oGAAoG;AACpG,6CAA6C;AAC7C,MAAM,qBAAqB,GAAG,IAAI,MAAM,CACpC,MAAM,CAAC,GAAG,CAAA,yDAAyD,QAAQ,GAAG,CACjF,CAAC;AAEF,6FAA6F;AAC7F,0FAA0F;AAC1F,EAAE;AACF,oGAAoG;AACpG,qGAAqG;AACrG,mGAAmG;AACnG,iGAAiG;AACjG,oGAAoG;AACpG,iFAAiF;AACjF,MAAM,cAAc,GAAG,IAAI,MAAM,CAAC,MAAM,CAAC,GAAG,CAAA,kBAAkB,QAAQ,2BAA2B,CAAC,CAAC;AAEnG,iGAAiG;AACjG,sGAAsG;AACtG,qGAAqG;AACrG,oGAAoG;AACpG,MAAM,0BAA0B,GAAG,QAAQ,CAAC;AAE5C,8FAA8F;AAC9F,oGAAoG;AACpG,oGAAoG;AACpG,0FAA0F;AAC1F,EAAE;AACF,oGAAoG;AACpG,sGAAsG;AACtG,iGAAiG;AACjG,MAAM,gBAAgB,GAAG,wEAAwE,CAAC;AAElG,uGAAuG;AACvG,mGAAmG;AACnG,oGAAoG;AACpG,yCAAyC;AACzC,MAAM,yBAAyB,GAAG,wDAAwD,CAAC;AAE3F,SAAS,iBAAiB,CAAC,OAAe;IACtC,KAAK,MAAM,OAAO,IAAI,eAAe,EAAE,CAAC;QACpC,MAAM,CAAC,GAAG,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAChC,IAAI,CAAC;YAAE,OAAO,CAAC,CAAC,CAAC,CAAC,CAAC;IACvB,CAAC;IACD,OAAO,IAAI,CAAC;AAChB,CAAC;AAED,SAAS,QAAQ,CAAC,CAAS;IACvB,MAAM,GAAG,GAAG,GAAG,CAAC;IAChB,OAAO,CAAC,CAAC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,EAAE,GAAG,CAAC,GAAG,GAAG,CAAC;AACvD,CAAC;AAED,SAAS,mBAAmB,CAAC,GAAgB,EAAE,aAAqB;IAChE,+EAA+E;IAC/E,sFAAsF;IACtF,kGAAkG;IAClG,kGAAkG;IAClG,mGAAmG;IACnG,8DAA8D;IAC9D,IAAI,CAAC;QACD,IAAA,wBAAQ,EAAC,qDAAqD,EAAE,EAAE,GAAG,EAAE,GAAG,CAAC,aAAa,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;IAClH,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,MAAM,IAAI,GAAG,KAAK,CAAC,OAAO,CAAC,WAAW,EAAE,CAAC;QACzC,MAAM,MAAM,GAAG,IAAI,CAAC,QAAQ,CAAC,gBAAgB,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,gBAAgB,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,qBAAqB,CAAC,CAAC;QAC1H,IAAI,CAAC,MAAM;YAAE,MAAM,KAAK,CAAC;QACzB,IAAA,wBAAQ,EAAC,+BAA+B,EAAE,EAAE,GAAG,EAAE,GAAG,CAAC,aAAa,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;IAC5F,CAAC;IACD,MAAM,QAAQ,GAAG,IAAA,wBAAQ,EAAC,wCAAwC,EAAE;QAChE,GAAG,EAAE,GAAG,CAAC,aAAa;QACtB,QAAQ,EAAE,MAAM;KACnB,CAAC,CAAC,IAAI,EAAE,CAAC;IACV,MAAM,KAAK,GAAG,QAAQ,CAAC,QAAQ,EAAE,EAAE,CAAC,CAAC;IACrC,IAAI,KAAK,GAAG,CAAC,EAAE,CAAC;QACZ,OAAO,CAAC,IAAI,iBAAC,CACT,CAAC,EACD,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,EACrB,iBAAiB,KAAK,gGAAgG,aAAa,IAAI,CAC1I,CAAC,CAAC;IACP,CAAC;IACD,OAAO,EAAE,CAAC;AACd,CAAC;AAED,MAAa,uBAAwB,SAAQ,wBAAuC;IAChF,YAAY,MAAiC,IAAI,KAAK,CAAC,MAAM,EAAE,uBAAuB,CAAC,CAAC,CAAC,CAAC;IAEjF,WAAW,GAChB,+FAA+F;QAC/F,wFAAwF,CAAC;IAC3E,cAAc,GAAG;QAC/B,eAAe,EAAE,yBAAyB;QAC1C,YAAY,EAAE,qBAAqB;QACnC,gBAAgB,EAAE,0BAA0B;QAC5C,YAAY,EAAE,qBAAqB;KACtC,CAAC;IAEe,SAAS,GAAG,IAAI,8BAAe,EAAE,CAAC;IAClC,cAAc,GAAG,IAAI,oCAAqB,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;IAE5E,4FAA4F;IAC5F,mGAAmG;IACnG,2FAA2F;IACnF,QAAQ,GAA+B,IAAI,CAAC;IAC5C,gBAAgB,GAA+B,IAAI,CAAC;IAE5D,kGAAkG;IAClG,2EAA2E;IACnE,WAAW,GAAG,KAAK,CAAC;IAE5B,IAAY,YAAY;QACpB,OAAO,IAAI,CAAC,MAAM,CAAC,YAAY,IAAI,qBAAqB,CAAC;IAC7D,CAAC;IAED,IAAY,eAAe;QACvB,OAAO,IAAI,CAAC,MAAM,CAAC,eAAe,IAAI,yBAAyB,CAAC;IACpE,CAAC;IAED,IAAY,gBAAgB;QACxB,OAAO,IAAI,CAAC,MAAM,CAAC,gBAAgB,IAAI,0BAA0B,CAAC;IACtE,CAAC;IAED,IAAY,YAAY;QACpB,OAAO,IAAI,CAAC,MAAM,CAAC,YAAY,IAAI,qBAAqB,CAAC;IAC7D,CAAC;IAED,+FAA+F;IACvF,gBAAgB,CAAC,IAAY;QACjC,OAAO,IAAI,CAAC,WAAW;YACnB,CAAC,CAAC,gDAAgD,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,OAAO,IAAI,cAAc;YACnG,CAAC,CAAC,4CAA4C,IAAI,cAAc,CAAC;IACzE,CAAC;IAED,qFAAqF;IACrF,0FAA0F;IAC1F,+EAA+E;IAC/E,IAAI,OAAO;QACP,IAAI,IAAI,CAAC,gBAAgB;YAAE,OAAO,IAAI,CAAC,kBAAkB,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC;QACjF,IAAI,IAAI,CAAC,QAAQ;YAAE,OAAO,IAAI,CAAC,UAAU,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAEzD,MAAM,MAAM,GAAG,IAAI,CAAC,WAAW;YAC3B,CAAC,CAAC,oGAAoG;YACtG,CAAC,CAAC,wHAAwH,CAAC;QAE/H,MAAM,OAAO,GAAG;YACZ,IAAI,iBAAM,CAAC,MAAM,EAAE,IAAI,CAAC;YACxB,IAAI,iBAAM,CAAC,kEAAkE,IAAI,CAAC,YAAY,EAAE,CAAC;SACpG,CAAC;QACF,IAAI,IAAI,CAAC,MAAM,CAAC,IAAI,KAAK,mBAAmB,EAAE,CAAC;YAC3C,OAAO,CAAC,IAAI,CAAC,IAAI,iBAAM,CACnB,kGAAkG;gBAClG,wFAAwF,CAC3F,CAAC,CAAC;QACP,CAAC;aAAM,CAAC;YACJ,OAAO,CAAC,IAAI,CAAC,IAAI,iBAAM,CACnB,wHAAwH,IAAI,CAAC,eAAe,EAAE,CACjJ,CAAC,CAAC;QACP,CAAC;QACD,OAAO,IAAI,kBAAO,CACd,gFAAgF,EAChF,uDAAuD,EACvD,OAAO,CACV,CAAC;IACN,CAAC;IAED;;;;;;;;;;;;;;OAcG;IACH,KAAK,CAAC,GAAgB;QAClB,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC;QACrB,IAAI,CAAC,gBAAgB,GAAG,IAAI,CAAC;QAC7B,yFAAyF;QACzF,gFAAgF;QAChF,MAAM,OAAO,GAAG,GAAG,CAAC,WAAW,CAAC;QAChC,MAAM,aAAa,GAAG,iBAAiB,CAAC,OAAO,CAAC,CAAC;QACjD,IAAI,CAAC,WAAW,GAAG,YAAY,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAE9C,iGAAiG;QACjG,2DAA2D;QAC3D,IAAI,CAAC,aAAa,IAAI,CAAC,IAAI,CAAC,WAAW;YAAE,OAAO,EAAE,CAAC;QAEnD,0FAA0F;QAC1F,yFAAyF;QACzF,IAAI,CAAC,IAAI,CAAC,WAAW,IAAI,cAAc,CAAC,IAAI,CAAC,OAAO,CAAC;YAAE,OAAO,EAAE,CAAC;QAEjE,MAAM,QAAQ,GAAG,IAAI,CAAC,mBAAmB,CAAC,GAAG,EAAE,aAAa,CAAC,CAAC;QAC9D,IAAI,QAAQ;YAAE,OAAO,CAAC,QAAQ,CAAC,CAAC;QAEhC,MAAM,YAAY,GAAG,IAAI,CAAC,SAAS,CAAC,GAAG,EAAE,aAAa,KAAK,IAAI,CAAC,CAAC;QACjE,IAAI,YAAY;YAAE,OAAO,CAAC,YAAY,CAAC,CAAC;QAExC,8FAA8F;QAC9F,0FAA0F;QAC1F,IAAI,CAAC,aAAa;YAAE,OAAO,IAAI,CAAC,2BAA2B,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;QAE1E,8FAA8F;QAC9F,iGAAiG;QACjG,+EAA+E;QAC/E,IAAI,gBAAgB,CAAC,IAAI,CAAC,OAAO,CAAC;YAAE,OAAO,EAAE,CAAC;QAC9C,IAAI,IAAI,CAAC,WAAW,IAAI,yBAAyB,CAAC,IAAI,CAAC,OAAO,CAAC;YAAE,OAAO,EAAE,CAAC;QAE3E,MAAM,aAAa,GAAG,IAAA,wBAAQ,EAAC,iCAAiC,EAAE;YAC9D,GAAG,EAAE,GAAG,CAAC,aAAa;YACtB,QAAQ,EAAE,MAAM;SACnB,CAAC,CAAC,IAAI,EAAE,CAAC;QAEV,IAAI,aAAa,KAAK,MAAM,EAAE,CAAC;YAC3B,OAAO,mBAAmB,CAAC,GAAG,EAAE,aAAa,CAAC,CAAC;QACnD,CAAC;QAED,uFAAuF;QACvF,IAAI,IAAI,CAAC,MAAM,CAAC,IAAI,KAAK,mBAAmB,EAAE,CAAC;YAC3C,OAAO,CAAC,IAAI,iBAAC,CACT,CAAC,EACD,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,EACrB,eAAe,aAAa,4DAA4D;oBACxF,+BAA+B,IAAI,CAAC,gBAAgB,CAAC,aAAa,CAAC,GAAG;oBACtE,uCAAuC,IAAI,CAAC,YAAY,IAAI;oBAC5D,8EAA8E;oBAC9E,wFAAwF,CAC3F,CAAC,CAAC;QACP,CAAC;QAED,OAAO,CAAC,IAAI,iBAAC,CACT,CAAC,EACD,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,EACrB,eAAe,aAAa,yDAAyD;gBACrF,GAAG,IAAI,CAAC,gBAAgB,CAAC,aAAa,CAAC,KAAK,IAAI,CAAC,YAAY,IAAI;gBACjE,gFAAgF;gBAChF,2CAA2C,IAAI,CAAC,eAAe,KAAK,CACvE,CAAC,CAAC;IACP,CAAC;IAED;;;;;;;;;;;;OAYG;IACK,2BAA2B,CAAC,GAAgB,EAAE,OAAe;QACjE,IAAI,CAAC,IAAI,CAAC,WAAW;YAAE,OAAO,EAAE,CAAC;QAEjC,MAAM,KAAK,GAAG,qBAAqB,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC;QAClD,IAAI,CAAC,KAAK;YAAE,OAAO,EAAE,CAAC;QACtB,MAAM,MAAM,GAAG,KAAK,CAAC,CAAC,CAAC,CAAC;QACxB,4FAA4F;QAC5F,IAAI,MAAM,CAAC,UAAU,CAAC,SAAS,CAAC;YAAE,OAAO,EAAE,CAAC;QAE5C,MAAM,KAAK,GAAG,IAAI,CAAC,cAAc,CAAC,kBAAkB,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;QACxE,IAAI,CAAC,KAAK;YAAE,OAAO,EAAE,CAAC;QAEtB,MAAM,IAAI,GAAG,KAAK,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,KAAsB,EAAW,EAAE,CAAC,KAAK,CAAC,MAAM,KAAK,MAAM,CAAC,CAAC;QAChG,IAAI,CAAC,IAAI;YAAE,OAAO,EAAE,CAAC;QAErB,MAAM,GAAG,GAAG,MAAM,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;QACvC,OAAO,CAAC,IAAI,iBAAC,CACT,CAAC,EACD,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,EACrB,WAAW,MAAM,eAAe,IAAI,CAAC,MAAM,kDAAkD;gBAC7F,+FAA+F;gBAC/F,+FAA+F;gBAC/F,uEAAuE,GAAG,8BAA8B,CAC3G,CAAC,CAAC;IACP,CAAC;IAED,kGAAkG;IAClG,iDAAiD;IACzC,mBAAmB,CAAC,GAAgB,EAAE,aAA4B;QACtE,IAAI,CAAC,aAAa,IAAI,CAAC,0BAA0B,CAAC,IAAI,CAAC,aAAa,CAAC;YAAE,OAAO,IAAI,CAAC;QACnF,OAAO,IAAI,iBAAC,CACR,CAAC,EACD,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,EACrB,gBAAgB,aAAa,oDAAoD;YACjF,oEAAoE;YACpE,wCAAwC,IAAI,CAAC,YAAY,GAAG,CAC/D,CAAC;IACN,CAAC;IAED;;;;;;;;OAQG;IACK,SAAS,CAAC,GAAgB,EAAE,aAAsB;QACtD,IAAI,IAAI,CAAC,WAAW,EAAE,CAAC;YACnB,MAAM,iBAAiB,GAAG,IAAI,CAAC,gBAAgB,CAAC,GAAG,CAAC,CAAC;YACrD,IAAI,iBAAiB;gBAAE,OAAO,iBAAiB,CAAC;QACpD,CAAC;QACD,IAAI,aAAa;YAAE,OAAO,IAAI,CAAC,cAAc,CAAC,GAAG,CAAC,CAAC;QACnD,OAAO,IAAI,CAAC;IAChB,CAAC;IAED;;;;;;;OAOG;IACK,cAAc,CAAC,GAAgB;QACnC,+FAA+F;QAC/F,qFAAqF;QACrF,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC,YAAY,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;QAC5D,MAAM,MAAM,GAAG,IAAI,CAAC,cAAc,CAAC,aAAa,CAAC,GAAG,CAAC,aAAa,CAAC;aAC9D,MAAM,CAAC,CAAC,MAAc,EAAW,EAAE,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC,CAAC;QAC5D,MAAM,KAAK,GAAG,MAAM,CAAC,MAAM,CAAC;QAC5B,MAAM,GAAG,GAAG,IAAI,CAAC,kBAAkB,CAAC,GAAG,CAAC,CAAC;QACzC,IAAI,KAAK,GAAG,GAAG;YAAE,OAAO,IAAI,CAAC;QAE7B,MAAM,KAAK,GAAG,IAAI,CAAC,cAAc,CAAC,kBAAkB,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;QACxE,IAAI,CAAC,KAAK;YAAE,OAAO,IAAI,CAAC;QAExB,IAAI,CAAC,QAAQ,GAAG,KAAK,CAAC;QACtB,MAAM,QAAQ,GAAG,KAAK,CAAC,SAAS,CAAC,MAAM,CAAC;QACxC,MAAM,MAAM,GAAG,QAAQ,GAAG,CAAC;YACvB,CAAC,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,iFAAiF;YACtG,CAAC,CAAC,4EAA4E,CAAC;QAEnF,OAAO,IAAI,iBAAC,CACR,CAAC,EACD,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,EACrB,YAAY,MAAM,CAAC,KAAK,CAAC,uEAAuE;YAChG,uDAAuD,MAAM,CAAC,IAAI,CAAC,gBAAgB,CAAC,IAAI;YACxF,GAAG,MAAM,oCAAoC,CAChD,CAAC;IACN,CAAC;IAED;;;;;;;;;;;;;OAaG;IACK,kBAAkB,CAAC,GAAgB;QACvC,MAAM,MAAM,GAAG,IAAA,iCAAkB,EAAC,GAAG,CAAC,aAAa,CAAC,CAAC;QACrD,IAAI,MAAM,KAAK,IAAI,IAAI,CAAC,MAAM,CAAC,mBAAmB;YAAE,OAAO,IAAI,CAAC,gBAAgB,CAAC;QACjF,MAAM,OAAO,GAAG,IAAI,CAAC,mBAAmB,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;QAC5D,IAAI,OAAO,KAAK,IAAI,IAAI,MAAM,CAAC,MAAM,KAAK,OAAO;YAAE,OAAO,IAAI,CAAC,gBAAgB,CAAC;QAChF,OAAO,IAAI,CAAC,gBAAgB,GAAG,CAAC,CAAC;IACrC,CAAC;IAEO,mBAAmB,CAAC,aAAqB;QAC7C,8DAA8D;QAC9D,IAAI,CAAC;YACD,OAAO,IAAA,wBAAQ,EAAC,iCAAiC,EAAE;gBAC/C,GAAG,EAAE,aAAa,EAAE,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC;aACxE,CAAC,CAAC,IAAI,EAAE,CAAC;QACd,CAAC;QAAC,OAAO,GAAY,EAAE,CAAC;YACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;YAC3B,KAAK,KAAK,CAAC;YACX,OAAO,IAAI,CAAC;QAChB,CAAC;IACL,CAAC;IAED;;;;;;OAMG;IACK,gBAAgB,CAAC,GAAgB;QACrC,MAAM,KAAK,GAAG,IAAI,CAAC,SAAS,CAAC,eAAe,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC,MAAM,CAAC;QACvE,IAAI,KAAK,GAAG,IAAI,CAAC,YAAY;YAAE,OAAO,IAAI,CAAC;QAE3C,MAAM,KAAK,GAAG,IAAI,CAAC,cAAc,CAAC,kBAAkB,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;QACxE,IAAI,CAAC,KAAK;YAAE,OAAO,IAAI,CAAC;QAExB,IAAI,CAAC,gBAAgB,GAAG,KAAK,CAAC;QAC9B,MAAM,QAAQ,GAAG,KAAK,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,IAAuB,EAAW,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,MAAM,CAAC;QACrG,MAAM,MAAM,GAAG,QAAQ,GAAG,CAAC;YACvB,CAAC,CAAC,GAAG,MAAM,CAAC,QAAQ,CAAC,wEAAwE;gBAC3F,+BAA+B;YACjC,CAAC,CAAC,4EAA4E,CAAC;QAEnF,OAAO,IAAI,iBAAC,CACR,CAAC,EACD,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,EACrB,YAAY,MAAM,CAAC,KAAK,CAAC,kEAAkE;YAC3F,MAAM,MAAM,CAAC,IAAI,CAAC,YAAY,CAAC,KAAK,MAAM,oCAAoC,CACjF,CAAC;IACN,CAAC;IAED;;;;;;;;;;;;;OAaG;IACK,UAAU,CAAC,KAA0B;QACzC,MAAM,OAAO,GAAa,EAAE,CAAC;QAC7B,+FAA+F;QAC/F,6FAA6F;QAC7F,0FAA0F;QAC1F,+FAA+F;QAC/F,MAAM,QAAQ,GAAG,KAAK,CAAC,SAAS,CAAC,MAAM,KAAK,CAAC,CAAC;QAE9C,IAAI,KAAK,CAAC,SAAS,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAC7B,MAAM,KAAK,GAAG,KAAK,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC,KAAsB,EAAU,EAAE,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC;YACpF,OAAO,CAAC,IAAI,CAAC,IAAI,iBAAM,CACnB,2CAA2C,MAAM,CAAC,KAAK,CAAC,MAAM,CAAC,iCAAiC;gBAChG,+FAA+F;gBAC/F,gGAAgG;gBAChG,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,EACf,IAAI,CACP,CAAC,CAAC;QACP,CAAC;QAED,MAAM,GAAG,GAAG,IAAI,CAAC,cAAc,CAAC,KAAK,EAAE,QAAQ,CAAC,CAAC;QACjD,IAAI,GAAG;YAAE,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QAE3B,OAAO,CAAC,IAAI,CAAC,IAAI,iBAAM,CACnB,2EAA2E;YAC3E,8FAA8F;YAC9F,8CAA8C,CACjD,CAAC,CAAC;QACH,OAAO,CAAC,IAAI,CAAC,IAAI,iBAAM,CACnB,4FAA4F;YAC5F,qFAAqF,CACxF,CAAC,CAAC;QAEH,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,MAAM,GAAG,CAAC;YAC9B,CAAC,CAAC,IAAI,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,oEAAoE;gBACjG,uCAAuC;YACzC,CAAC,CAAC,EAAE,CAAC;QAET,OAAO,IAAI,kBAAO,CACd,uEAAuE,EACvE,mGAAmG;YACnG,aAAa,KAAK,CAAC,SAAS,IAAI,OAAO,IAAI,IAAI,YAAY,EAC3D,OAAO,CACV,CAAC;IACN,CAAC;IAED;;;;;;;;;;;;OAYG;IACK,cAAc,CAAC,KAA0B,EAAE,SAAkB;QACjE,IAAI,KAAK,CAAC,IAAI,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO,IAAI,CAAC;QAEzC,MAAM,IAAI,GAAG,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,KAAsB,EAAU,EAAE;YAC3D,MAAM,GAAG,GAAG,KAAK,CAAC,GAAG,KAAK,EAAE,CAAC,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,CAAC;YACrD,MAAM,EAAE,GAAG,KAAK,CAAC,EAAE,GAAG,CAAC;gBACnB,CAAC,CAAC,OAAO,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,IAAI,KAAK,CAAC,OAAO,IAAI,QAAQ,EAAE;gBACxD,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,KAAK,EAAE,CAAC,CAAC,CAAC,MAAM,KAAK,CAAC,OAAO,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC;YAC/D,MAAM,OAAO,GAAG,KAAK,CAAC,OAAO,IAAI,CAAC,CAAC,CAAC,CAAC,GAAG,MAAM,CAAC,KAAK,CAAC,OAAO,CAAC,uBAAuB,CAAC,CAAC,CAAC,sBAAsB,CAAC;YAC9G,OAAO,OAAO,KAAK,CAAC,MAAM,MAAM,GAAG,MAAM,EAAE,KAAK,OAAO,OAAO,KAAK,CAAC,MAAM,EAAE,CAAC;QACjF,CAAC,CAAC,CAAC;QAEH,OAAO,IAAI,iBAAM,CACb,gCAAgC,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,qCAAqC;YAC9F,4FAA4F;YAC5F,yFAAyF;YACzF,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,IAAI;YACtB,uEAAuE;YACvE,yFAAyF;YACzF,oGAAoG,EACpG,SAAS,CACZ,CAAC;IACN,CAAC;IAED;;;;;;;;;;;;;;;;OAgBG;IACK,kBAAkB,CAAC,KAA0B;QACjD,MAAM,OAAO,GAAa,EAAE,CAAC;QAC7B,MAAM,IAAI,GAAG,KAAK,CAAC,SAAS,CAAC,MAAM,CAAC,CAAC,IAAuB,EAAW,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;QAE1F,IAAI,IAAI,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAClB,MAAM,KAAK,GAAG,CAAC,oBAAoB,CAAC,CAAC;YACrC,KAAK,MAAM,IAAI,IAAI,IAAI,EAAE,CAAC;gBACtB,mFAAmF;gBACnF,IAAI,IAAI,CAAC,IAAI,KAAK,EAAE;oBAAE,KAAK,CAAC,IAAI,CAAC,uBAAuB,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;YACzE,CAAC;YACD,MAAM,QAAQ,GAAG,IAAI;iBAChB,GAAG,CAAC,CAAC,IAAuB,EAAU,EAAE,CAAC,IAAI,CAAC,MAAM,CAAC;iBACrD,MAAM,CAAC,CAAC,MAAc,EAAW,EAAE,CAAC,MAAM,KAAK,EAAE,CAAC,CAAC;YACxD,IAAI,QAAQ,CAAC,MAAM,GAAG,CAAC;gBAAE,KAAK,CAAC,IAAI,CAAC,iBAAiB,QAAQ,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;YAE3E,OAAO,CAAC,IAAI,CAAC,IAAI,iBAAM,CACnB,2CAA2C,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,gCAAgC;gBAC9F,4FAA4F;gBAC5F,8FAA8F;gBAC9F,wFAAwF;gBACxF,0FAA0F;gBAC1F,qFAAqF;gBACrF,0FAA0F;gBAC1F,mCAAmC,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,EAAE,EACvD,IAAI,CACP,CAAC,CAAC;QACP,CAAC;aAAM,CAAC;YACJ,4FAA4F;YAC5F,6FAA6F;YAC7F,OAAO,CAAC,IAAI,CAAC,IAAI,iBAAM,CACnB,oFAAoF;gBACpF,wFAAwF;gBACxF,0FAA0F;gBAC1F,UAAU,EACV,IAAI,CACP,CAAC,CAAC;QACP,CAAC;QAED,OAAO,CAAC,IAAI,CAAC,IAAI,iBAAM,CACnB,2FAA2F;YAC3F,2BAA2B,CAC9B,CAAC,CAAC;QACH,OAAO,CAAC,IAAI,CAAC,IAAI,iBAAM,CACnB,wFAAwF;YACxF,2BAA2B,CAC9B,CAAC,CAAC;QAEH,MAAM,MAAM,GAAG,KAAK,CAAC,SAAS,CAAC,MAAM,GAAG,IAAI,CAAC,MAAM,CAAC;QACpD,MAAM,IAAI,GAAG,MAAM,GAAG,CAAC;YACnB,CAAC,CAAC,IAAI,MAAM,CAAC,MAAM,CAAC,2EAA2E;gBAC7F,sEAAsE;YACxE,CAAC,CAAC,EAAE,CAAC;QAET,OAAO,IAAI,kBAAO,CACd,kEAAkE,EAClE,qGAAqG;YACrG,aAAa,KAAK,CAAC,SAAS,IAAI,OAAO,IAAI,IAAI,YAAY,EAC3D,OAAO,CACV,CAAC;IACN,CAAC;CACJ;AAxfD,0DAwfC","sourcesContent":["import { execSync } from 'child_process';\n\nimport {\n BranchCreationGuardConfig,\n DeletableBranch,\n DeletableWorktree,\n MergedBranchesCache,\n MergedBranchesService,\n WorktreeService,\n readMainSyncStatus,\n} from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint, Option } from '../fix-hint';\nimport { toError } from '../to-error';\n\n// Defaults used when the rule has no explicit value in webpieces.config.json.\n// branchFormat is a human sentence telling the AI how to name a branch created off main; it is\n// intentionally NOT the sub-branch convention (sub-branches are a separate, human-approved path).\nconst DEFAULT_BRANCH_FORMAT =\n 'Name it {whoami}/<short-feature-description> — lowercase, no version numbers, no sub/ prefix (e.g. dean/upgrade-webpieces)';\nconst DEFAULT_SUB_BRANCH_NAMING = 'feature/<ticket>/<short-description>';\n\n// Hard cap on local feature branches. Enforced at CREATION because that is the one moment cleanup is\n// both cheap and obviously worth it — reaping happens over time, never \"ASAP\".\n//\n// The branch cap counts PARKED branches only — branches not checked out in any worktree. Worktree-held\n// branches are counted by the worktree cap instead. Two budgets, because they are not substitutes: if\n// held branches also spent the branch budget, five worktrees would leave room for zero branches and no\n// branch could ever be created again.\nconst DEFAULT_MAX_LOCAL_BRANCHES = 5;\nconst DEFAULT_MAX_WORKTREES = 5;\n\n// A plausible git ref name. Deliberately NOT `[^\\s-]` — that class matches shell metacharacters, so\n// `git branch | wc -l` (a read-only LISTING, piped) was parsed as \"create a branch named `|`\" and\n// blocked. Cleanup work necessarily reads and deletes branches, so a listing must never trip this.\nconst REF_NAME = String.raw`[A-Za-z0-9][A-Za-z0-9_./-]*`;\n\nconst BRANCH_PATTERNS: RegExp[] = [\n new RegExp(String.raw`git\\s+checkout\\s+-[bB]\\s+(${REF_NAME})`),\n new RegExp(String.raw`git\\s+switch\\s+-[cC]\\s+(${REF_NAME})`),\n new RegExp(String.raw`git\\s+branch\\s+(?!-)(${REF_NAME})`),\n // `git worktree add ../dir -b <name> origin/main` — the form docs/git-workflow.md recommends for\n // starting a feature. It creates a branch just as surely as `checkout -b` does, and until this\n // pattern existed it walked straight past the cap, the reserved-suffix check and the sub-branch\n // check. `(?:\\S+\\s+)*?` absorbs the path and any other flags, so the -b may precede or follow them.\n new RegExp(String.raw`git\\s+worktree\\s+add\\s+(?:\\S+\\s+)*?-[bB]\\s+(${REF_NAME})`),\n];\n\n// ANY worktree creation, with or without -b. The no-`-b` forms (`git worktree add ../dir existing`,\n// `--detach`) create no branch but DO create a worktree, so they spend the worktree budget and must\n// still hit the cap.\nconst WORKTREE_ADD = /git\\s+worktree\\s+add\\b/;\n\n// `git worktree add <path> <existing-branch>` — the checkout-an-existing-branch form. Captures the\n// LAST bare (non-flag) argument, which is the committish; the first bare argument is the path.\n// Flags that take a value (`--reason <s>`, `-b <name>`) are excluded by the caller, which only uses\n// this on commands with no `-b`/`-B` at all.\nconst WORKTREE_ADD_EXISTING = new RegExp(\n String.raw`git\\s+worktree\\s+add\\s+(?:-{1,2}[A-Za-z-]+\\s+)*\\S+\\s+(${REF_NAME})`,\n);\n\n// `git branch <name> <sha>` — RESTORING a branch at an explicit commit, which is exactly the\n// `recover=` command wp-cleanup writes to branch-mutations.log for every branch it reaps.\n//\n// This must be allowed UNCONDITIONALLY, ahead of even the caps. The entire argument for letting the\n// tooling delete branches unattended is that any delete is one logged command away from being undone\n// — so a guard that blocks that command turns a real guarantee into a decorative one. (It did: the\n// generic `git branch <name>` creation pattern matched the restore and refused it, demanding the\n// branch be recreated off origin/main, which is precisely the content the restore is meant to bring\n// back.) A restore also cannot grow the branch list beyond what already existed.\nconst RESTORE_AT_SHA = new RegExp(String.raw`git\\s+branch\\s+${REF_NAME}\\s+[0-9a-f]{7,40}(?:\\W|$)`);\n\n// A trailing `wp<number>` was the old squash-merge generation marker (base → basewp2 → basewp3).\n// The tooling NO LONGER produces it — a sync now lands back on the same feature name — but the suffix\n// stays RESERVED so a human branch can't collide with a leftover `…wpN` still floating in a consumer\n// repo mid-transition. Block it at creation time and steer the name back to the plain feature form.\nconst RESERVED_GENERATION_SUFFIX = /wp\\d+$/;\n\n// A branch-creation command that explicitly bases off origin/main (e.g. `git checkout -b feat\n// origin/main`). This is exactly the fresh-main base the guard wants, and it works from ANY current\n// branch or linked worktree — main need not (and in a worktree cannot) be checked out here. Allowed\n// unconditionally so the recovery messages can safely tell you to run it from a worktree.\n//\n// The trailing check is `\\W|$`, not `\\s|$`: the ALLOW pattern must not be stricter about delimiters\n// than the BLOCK pattern above, or a `git checkout -b x origin/main` that ends at a quote or backtick\n// is seen as a branch creation but NOT as an origin/main one — recognised, then wrongly blocked.\nconst ORIGIN_MAIN_BASE = /git\\s+(?:checkout\\s+-[bB]|switch\\s+-[cC])\\s+\\S+\\s+origin\\/main(?:\\W|$)/;\n\n// The worktree arm of the same allow: `git worktree add ../dir -b <name> origin/main`. Same fresh-main\n// base, same reasoning — and in a worktree it is the ONLY workable base, since `git checkout main`\n// fatals there. Kept separate from ORIGIN_MAIN_BASE because the argument shape differs (a path sits\n// between the subcommand and the flags).\nconst WORKTREE_ORIGIN_MAIN_BASE = /git\\s+worktree\\s+add\\s+(?:\\S+\\s+)*origin\\/main(?:\\W|$)/;\n\nfunction extractBranchName(command: string): string | null {\n for (const pattern of BRANCH_PATTERNS) {\n const m = pattern.exec(command);\n if (m) return m[1];\n }\n return null;\n}\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\nfunction checkMainIsUpToDate(ctx: BashContext, requestedName: string): readonly Violation[] {\n // `--no-write-fetch-head` for the same reason as the background refresher (see\n // MainSyncStatusService.fetchOriginMain): this refresh can run while the agent is mid\n // `git fetch`/`git pull`, and two overlapping writers of the unlocked `.git/FETCH_HEAD` can leave\n // a duplicate for-merge line that makes the agent's `git pull` fatal with \"multiple branches\". We\n // read `origin/main` below, never FETCH_HEAD. On a git too old for the flag, retry the plain form.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n execSync('git fetch --no-write-fetch-head origin main --quiet', { cwd: ctx.workspaceRoot, encoding: 'utf8' });\n } catch (err: unknown) {\n const error = toError(err);\n const text = error.message.toLowerCase();\n const tooOld = text.includes('unknown option') || text.includes('unknown switch') || text.includes('unrecognized option');\n if (!tooOld) throw error;\n execSync('git fetch origin main --quiet', { cwd: ctx.workspaceRoot, encoding: 'utf8' });\n }\n const countStr = execSync('git rev-list HEAD..origin/main --count', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n const count = parseInt(countStr, 10);\n if (count > 0) {\n return [new V(\n 1,\n truncate(ctx.command),\n `Local main is ${count} commit(s) behind origin/main. Run 'git pull origin main' first, then retry creating branch '${requestedName}'.`,\n )];\n }\n return [];\n}\n\nexport class BranchCreationGuardRule extends BashRuleBase<BranchCreationGuardConfig> {\n constructor(config: BranchCreationGuardConfig) { super(config, 'branch-creation-guard'); }\n\n readonly description =\n 'Block new-branch and new-worktree creation when main is stale, when branching off a non-main ' +\n 'branch, or when the branch/worktree count is at its cap (forces cleanup of dead ones).';\n override readonly defaultOptions = {\n subBranchNaming: DEFAULT_SUB_BRANCH_NAMING,\n branchFormat: DEFAULT_BRANCH_FORMAT,\n maxLocalBranches: DEFAULT_MAX_LOCAL_BRANCHES,\n maxWorktrees: DEFAULT_MAX_WORKTREES,\n };\n\n private readonly worktrees = new WorktreeService();\n private readonly mergedBranches = new MergedBranchesService(this.worktrees);\n\n // Set by check() when (and only when) a cap is what blocked, so fixHint can render the reap\n // instructions instead of the branch-naming ones. Same instance-field handoff pr-merge-guard uses.\n // Two fields, because the two caps reap different things and their hints share no wording.\n private capCache: MergedBranchesCache | null = null;\n private worktreeCapCache: MergedBranchesCache | null = null;\n\n // True when the blocked command was a `git worktree add`, so the recovery command we hand back is\n // a worktree command and not a `git checkout -b` the user cannot use here.\n private worktreeAdd = false;\n\n private get branchFormat(): string {\n return this.config.branchFormat ?? DEFAULT_BRANCH_FORMAT;\n }\n\n private get subBranchNaming(): string {\n return this.config.subBranchNaming ?? DEFAULT_SUB_BRANCH_NAMING;\n }\n\n private get maxLocalBranches(): number {\n return this.config.maxLocalBranches ?? DEFAULT_MAX_LOCAL_BRANCHES;\n }\n\n private get maxWorktrees(): number {\n return this.config.maxWorktrees ?? DEFAULT_MAX_WORKTREES;\n }\n\n // The recovery command for \"base this off fresh main\", in the flavour of whatever was blocked.\n private freshMainCommand(name: string): string {\n return this.worktreeAdd\n ? `git fetch origin main && git worktree add ../${name.replace(/\\//g, '-')} -b ${name} origin/main`\n : `git fetch origin main && git checkout -b ${name} origin/main`;\n }\n\n // Mode-aware fix hints. Branches off main follow branchFormat — never the sub-branch\n // convention. The sub-branch affordance only appears under mode 'ON'; 'ON_NO_SUBBRANCHES'\n // hard-blocks it and points instead at the turnOffRuleUntilEpoch escape hatch.\n get fixHint(): FixHint {\n if (this.worktreeCapCache) return this.worktreeCapFixHint(this.worktreeCapCache);\n if (this.capCache) return this.capFixHint(this.capCache);\n\n const create = this.worktreeAdd\n ? 'Create it off fresh main: git fetch origin main && git worktree add ../<dir> -b <name> origin/main'\n : 'Create it off fresh main from anywhere (incl. a worktree): git fetch origin main && git checkout -b <name> origin/main';\n\n const options = [\n new Option(create, true),\n new Option(`Name a branch off main per branch-creation-guard.branchFormat: ${this.branchFormat}`),\n ];\n if (this.config.mode === 'ON_NO_SUBBRANCHES') {\n options.push(new Option(\n 'Sub-branches (branching off another feature branch) are disabled. To temporarily allow one, set ' +\n \"branch-creation-guard.turnOffRuleUntilEpoch to a future epoch in webpieces.config.json\",\n ));\n } else {\n options.push(new Option(\n `If you truly need a stacked sub-branch (requires human approval), name it per branch-creation-guard.subBranchNaming: ${this.subBranchNaming}`,\n ));\n }\n return new FixHint(\n 'Cannot create this branch (main is stale, or branching off a non-main branch).',\n 'Create your branch from an up-to-date main. Pick one:',\n options,\n );\n }\n\n /**\n * Strip the parts of a shell command that are DATA rather than executable commands, so the guard\n * stops reading prose as instructions.\n *\n * This guard regex-scans the raw command string and has no notion of quoting, so\n * `git commit -m \"... git checkout -b foo ...\"` — or any heredoc commit message that mentions a\n * branch command — was parsed as an actual branch creation and blocked. That bit three separate\n * times while building the branch cap, including on the cap's own commit. It matters far more now\n * that the cap check runs BEFORE the origin/main allow: at the cap, a merely-MENTIONED branch\n * command would block your commit.\n *\n * A quoted span whose content has no whitespace is kept verbatim (it is a single token — the name\n * in `git checkout -b \"dean/foo\"`), so quoting a branch name cannot smuggle a creation past the\n * guard. Anything with whitespace inside quotes is prose, and collapses to a space.\n */\n check(ctx: BashContext): readonly Violation[] {\n this.capCache = null;\n this.worktreeCapCache = null;\n // Match against the command with heredoc bodies and prose-in-quotes removed (BashContext\n // computes it for every guard now — this rule's private copy was the original).\n const command = ctx.commandCode;\n const requestedName = extractBranchName(command);\n this.worktreeAdd = WORKTREE_ADD.test(command);\n\n // A worktree add with no -b creates no branch, but it DOES spend the worktree budget, so it must\n // survive this early-out and reach the worktree cap below.\n if (!requestedName && !this.worktreeAdd) return [];\n\n // Restoring a reaped branch at its logged SHA is undo, not creation — always allowed, and\n // checked before the caps so a full branch list can never trap you on the recovery path.\n if (!this.worktreeAdd && RESTORE_AT_SHA.test(command)) return [];\n\n const reserved = this.checkReservedSuffix(ctx, requestedName);\n if (reserved) return [reserved];\n\n const capViolation = this.checkCaps(ctx, requestedName !== null);\n if (capViolation) return [capViolation];\n\n // `git worktree add` of an EXISTING branch (or --detach) creates no branch, so the naming and\n // fresh-main rules below do not apply — but one thing still does: the branch may be DEAD.\n if (!requestedName) return this.checkWorktreeOntoDeadBranch(ctx, command);\n\n // Explicitly basing off origin/main is always allowed — it creates the branch from fresh main\n // regardless of the current branch, and is the ONLY way that also works inside a linked worktree\n // (where `git checkout main` fatals). Reserved-name check above still applies.\n if (ORIGIN_MAIN_BASE.test(command)) return [];\n if (this.worktreeAdd && WORKTREE_ORIGIN_MAIN_BASE.test(command)) return [];\n\n const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n\n if (currentBranch === 'main') {\n return checkMainIsUpToDate(ctx, requestedName);\n }\n\n // Not on main: creating this branch would stack it on a feature branch (a sub-branch).\n if (this.config.mode === 'ON_NO_SUBBRANCHES') {\n return [new V(\n 1,\n truncate(ctx.command),\n `You are on '${currentBranch}', not main. Create the branch OFF origin/main instead of ` +\n `stacking it on this branch: ${this.freshMainCommand(requestedName)} ` +\n `(works here and inside a worktree). ${this.branchFormat}. ` +\n `You can temporarily turn this off if you truly need a sub-branch by setting ` +\n `branch-creation-guard.turnOffRuleUntilEpoch (a future epoch) in webpieces.config.json.`,\n )];\n }\n\n return [new V(\n 1,\n truncate(ctx.command),\n `You are on '${currentBranch}', not main. Branches must be created from fresh main: ` +\n `${this.freshMainCommand(requestedName)}. ${this.branchFormat}. ` +\n `If you truly need a stacked sub-branch (requires human approval), name it per ` +\n `branch-creation-guard.subBranchNaming ('${this.subBranchNaming}').`,\n )];\n }\n\n /**\n * `git worktree add ../dir <existing-branch>` onto a branch whose PR is ALREADY MERGED.\n *\n * The count caps never catch this: the command creates no branch, and if you are under the\n * worktree cap it sails straight through — materialising a fresh directory full of PRE-MERGE\n * code that the AI will then read, plan from and edit. read-stale-guard blocks the reads and\n * feature-branch-guard blocks the edits once you are in there, but that is a turn wasted per\n * tool call. Refuse at the moment of creation instead, using the SAME merged-PR proof the caps\n * already have precomputed on disk.\n *\n * Fails OPEN exactly like both caps: no cache (fresh clone, no `gh`, refresher hasn't run) or an\n * unparseable command → no opinion.\n */\n private checkWorktreeOntoDeadBranch(ctx: BashContext, command: string): readonly Violation[] {\n if (!this.worktreeAdd) return [];\n\n const match = WORKTREE_ADD_EXISTING.exec(command);\n if (!match) return [];\n const branch = match[1];\n // `origin/main` (and any remote-tracking ref) is the RECOMMENDED base, never a dead branch.\n if (branch.startsWith('origin/')) return [];\n\n const cache = this.mergedBranches.readMergedBranches(ctx.workspaceRoot);\n if (!cache) return [];\n\n const dead = cache.deletable.find((entry: DeletableBranch): boolean => entry.branch === branch);\n if (!dead) return [];\n\n const dir = branch.replace(/\\//g, '-');\n return [new V(\n 1,\n truncate(ctx.command),\n `Branch '${branch}' is dead — ${dead.reason}. A worktree on it would be a directory full of ` +\n `PRE-MERGE code: everything you read there is stale relative to origin/main (read-stale-guard ` +\n `blocks those reads) and every edit is blocked by feature-branch-guard. Base the new worktree ` +\n `on fresh main instead: git fetch origin main && git worktree add ../${dir} -b <new-branch> origin/main`,\n )];\n }\n\n // The reserved `…wpN` generation suffix — see RESERVED_GENERATION_SUFFIX for why it stays blocked\n // even though the tooling no longer produces it.\n private checkReservedSuffix(ctx: BashContext, requestedName: string | null): Violation | null {\n if (!requestedName || !RESERVED_GENERATION_SUFFIX.test(requestedName)) return null;\n return new V(\n 1,\n truncate(ctx.command),\n `Branch name '${requestedName}' ends in 'wp<number>', which is reserved for the ` +\n `squash-merge tool's generation marker (base → basewp2 → basewp3). ` +\n `Rename it to a plain feature branch. ${this.branchFormat}.`,\n );\n }\n\n /**\n * Both budgets, in the order that produces the most useful complaint.\n *\n * Called BEFORE the origin/main allow in check() — `... -b <name> origin/main` is the normal,\n * always-permitted path, so a cap checked after it would never once fire.\n *\n * Worktree cap first: a `git worktree add -b` spends BOTH budgets, and when both are full the\n * worktree is the thing the command was actually trying to make, so it is the thing to talk about.\n */\n private checkCaps(ctx: BashContext, createsBranch: boolean): Violation | null {\n if (this.worktreeAdd) {\n const worktreeViolation = this.checkWorktreeCap(ctx);\n if (worktreeViolation) return worktreeViolation;\n }\n if (createsBranch) return this.checkBranchCap(ctx);\n return null;\n }\n\n /**\n * The cap. Blocks branch #N+1 until already-merged branches are reaped, which is the ONLY thing\n * keeping the local branch list bounded.\n *\n * Fails OPEN when the cache is absent (fresh clone, `gh` unavailable, refresher hasn't run yet):\n * never block on data we don't have. The detached refresher regenerates it within one hook call,\n * so the cap starts enforcing on its own.\n */\n private checkBranchCap(ctx: BashContext): Violation | null {\n // PARKED branches only — a branch checked out in a worktree is the worktree cap's problem, and\n // counting it twice would let five worktrees exhaust the branch budget on their own.\n const held = this.worktrees.heldBranches(ctx.workspaceRoot);\n const parked = this.mergedBranches.localBranches(ctx.workspaceRoot)\n .filter((branch: string): boolean => !held.has(branch));\n const count = parked.length;\n const cap = this.effectiveBranchCap(ctx);\n if (count < cap) return null;\n\n const cache = this.mergedBranches.readMergedBranches(ctx.workspaceRoot);\n if (!cache) return null;\n\n this.capCache = cache;\n const reapable = cache.deletable.length;\n const detail = reapable > 0\n ? `${String(reapable)} of them are dead (merged, or holding no commits) and can be deleted right now.`\n : 'None of them are dead, so none can be auto-reaped — see the options below.';\n\n return new V(\n 1,\n truncate(ctx.command),\n `You have ${String(count)} parked local branches (not counting any checked out in a worktree); ` +\n `the cap (branch-creation-guard.maxLocalBranches) is ${String(this.maxLocalBranches)}. ` +\n `${detail} Clean up before creating another.`,\n );\n }\n\n /**\n * The branch cap, yielding by ONE when the agent is standing on an already-merged branch.\n *\n * Two individually-correct guards were composing into a trap: merged-branch-bash-guard blocks\n * almost all Bash until you get off a merged branch, and the ONLY way off it that guard advertises\n * is creating a fresh branch — which this cap then refused. Every printed exit led to editing\n * webpieces.config.json, and that is what an unsupervised agent did.\n *\n * One over cap, and only while a merged branch is what is pushing you: the branch about to be\n * created replaces a branch that is already dead, so the steady-state count does not grow. The cap\n * still fires on the NEXT creation, so this defers the cleanup by exactly one branch, never skips it.\n *\n * Fails toward the strict cap: no cache, a cache for another branch, or a clean branch → no yield.\n */\n private effectiveBranchCap(ctx: BashContext): number {\n const status = readMainSyncStatus(ctx.workspaceRoot);\n if (status === null || !status.branchAlreadyMerged) return this.maxLocalBranches;\n const current = this.currentBranchOrNull(ctx.workspaceRoot);\n if (current === null || status.branch !== current) return this.maxLocalBranches;\n return this.maxLocalBranches + 1;\n }\n\n private currentBranchOrNull(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n /**\n * The worktree cap — the second budget. Same gate, same fail-open rule as the branch cap: a\n * worktree list we cannot classify (no cache on disk) blocks nothing.\n *\n * Counts LINKED worktrees only. The primary clone is not a thing anyone can remove, so charging the\n * budget for it would just silently cost you one worktree.\n */\n private checkWorktreeCap(ctx: BashContext): Violation | null {\n const count = this.worktrees.linkedWorktrees(ctx.workspaceRoot).length;\n if (count < this.maxWorktrees) return null;\n\n const cache = this.mergedBranches.readMergedBranches(ctx.workspaceRoot);\n if (!cache) return null;\n\n this.worktreeCapCache = cache;\n const reapable = cache.worktrees.filter((tree: DeletableWorktree): boolean => tree.deletable).length;\n const detail = reapable > 0\n ? `${String(reapable)} of them are dead (merged branch, no commits, or a missing directory) ` +\n 'and can be removed right now.'\n : 'None of them are dead, so none can be auto-reaped — see the options below.';\n\n return new V(\n 1,\n truncate(ctx.command),\n `You have ${String(count)} linked worktrees; the cap (branch-creation-guard.maxWorktrees) ` +\n `is ${String(this.maxWorktrees)}. ${detail} Clean up before creating another.`,\n );\n }\n\n /**\n * The reap instructions. `deletable` is PRECOMPUTED in the cache, and every entry earned its place\n * by one of exactly two proofs: a MERGED PR (the work is in main), or zero commits of its own\n * (there is no work). Deleting the list cannot lose anything — so just run the command.\n *\n * The command is `pnpm wp-cleanup`, NOT the `git branch -D a b c` this used to emit. Two reasons,\n * both learned the hard way: agents read a bare `-D` as destructive and stop to ask (so nothing\n * was ever cleaned, and this cap kept firing), and the multi-name form aborts wholesale on the\n * first branch git refuses, stranding every branch after it in the list. wp-cleanup recomputes\n * the verdicts, deletes one branch per command, and logs each pre-delete SHA.\n *\n * The wording must not overstate the safety: the list is NOT uniformly \"merged PR\" branches, and\n * a message that tells an agent to delete has to be exactly true about why that's safe.\n */\n private capFixHint(cache: MergedBranchesCache): FixHint {\n const options: Option[] = [];\n // Nothing auto-reapable → the ASK is the preferred move, and it comes FIRST. This is the whole\n // point of the option: with an empty `deletable` list the only advice left used to be \"raise\n // maxLocalBranches\" / \"set turnOffRuleUntilEpoch\", and an agent with no human in the loop\n // edited webpieces.config.json to escape — loosening the very rule that was working correctly.\n const askFirst = cache.deletable.length === 0;\n\n if (cache.deletable.length > 0) {\n const names = cache.deletable.map((entry: DeletableBranch): string => entry.branch);\n options.push(new Option(\n `Run: pnpm wp-cleanup — it deletes these ${String(names.length)} dead branches. Each is either ` +\n `backed by a MERGED PR or has no commits of its own, so no work can be lost, and every delete ` +\n `is logged with a recover-by-SHA command (see merged-branches.json for the per-branch reason): ` +\n names.join(' '),\n true,\n ));\n }\n\n const ask = this.askHumanOption(cache, askFirst);\n if (ask) options.push(ask);\n\n options.push(new Option(\n 'ONLY IF A HUMAN SAYS SO: raise branch-creation-guard.maxLocalBranches in ' +\n 'webpieces.config.json. Editing this config to get past a guard is not a fix you may make on ' +\n 'your own — ask first (use the option above).',\n ));\n options.push(new Option(\n 'ONLY IF A HUMAN SAYS SO: set branch-creation-guard.turnOffRuleUntilEpoch (a future epoch) ' +\n 'in webpieces.config.json to bypass this once. Same rule — ask, do not self-approve.',\n ));\n\n const kept = cache.keep.length > 0\n ? ` ${String(cache.keep.length)} unmerged branch(es) with real commits were deliberately SPARED — ` +\n 'do not delete those; a human decides.'\n : '';\n\n return new FixHint(\n 'Too many local branches — reap the dead ones before creating another.',\n 'Full detail (deletable + spared, with per-branch reasons) is in .webpieces/merged-branches.json, ' +\n `refreshed ${cache.timestamp || 'never'}.${kept} Pick one:`,\n options,\n );\n }\n\n /**\n * The remedy that actually deletes something without loosening anything: SHOW the spared branches\n * and ASK the human which may go.\n *\n * `keep` is the list the tooling refuses to touch on its own — no merged PR, so no proof the work\n * is safe. That is exactly the list a human can adjudicate in five seconds and the tooling never\n * can, and it was never printed: the cap said \"N branches were SPARED, do not delete those\" and\n * then offered only config edits. So the agent edited the config.\n *\n * Every column is read straight off `.webpieces/merged-branches.json` (written by the detached\n * refresher) — nothing is recomputed on this blocking path. The SHA is there so the human can see\n * the delete is reversible; the commit count is there so \"0 commits\" branches are obvious yeses.\n */\n private askHumanOption(cache: MergedBranchesCache, preferred: boolean): Option | null {\n if (cache.keep.length === 0) return null;\n\n const rows = cache.keep.map((entry: DeletableBranch): string => {\n const sha = entry.sha !== '' ? entry.sha : '???????';\n const pr = entry.pr > 0\n ? `PR #${String(entry.pr)} ${entry.prState || 'MERGED'}`\n : (entry.prState !== '' ? `PR ${entry.prState}` : 'no PR');\n const commits = entry.commits >= 0 ? `${String(entry.commits)} commit(s) of its own` : 'commit count unknown';\n return ` ${entry.branch} [${sha}] ${pr} ${commits} — ${entry.reason}`;\n });\n\n return new Option(\n `ASK THE HUMAN which of these ${String(cache.keep.length)} branches may be deleted. They are ` +\n 'not provably dead, so the tooling will not reap them — but a human can decide in seconds, ' +\n 'and deleting one is the correct fix for \"too many branches\". Paste this list and ask:\\n' +\n rows.join('\\n') + '\\n' +\n 'Then delete ONLY the ones approved: git branch -D <approved-branch>\\n' +\n '(each is recoverable — `git branch <name> <sha>` restores it at the SHA shown above).\\n' +\n 'Do NOT delete any of these without an explicit yes, and do NOT edit webpieces.config.json instead.',\n preferred,\n );\n }\n\n /**\n * The worktree reap remedy.\n *\n * It is now ONE command — `pnpm wp-cleanup` — and that is the point of it. The hand-written\n * sequence this used to print (prune → remove each path → one multi-name `git branch -D`) was\n * correct git, and it was still never run: an AI agent reads a raw `worktree remove` / `-D` as\n * destructive, asks permission, and stops. A guard whose only actionable remedies are \"raise the\n * cap\" and \"set turnOffRuleUntilEpoch\" is a guard that teaches config edits, which is the exact\n * failure the cap exists to prevent. wp-cleanup archives each branch as a tag, removes the\n * directory, then deletes the branch — in that order, because git refuses to delete a branch a\n * worktree still holds — and it will not touch the primary clone, the tree you are standing in, or\n * anything with uncommitted work.\n *\n * The explicit git sequence is still printed BELOW the command, as reference rather than as the\n * instruction: when wp-cleanup declines one of these (typically untracked files), the human needs\n * to see which directory and what git would have run.\n */\n private worktreeCapFixHint(cache: MergedBranchesCache): FixHint {\n const options: Option[] = [];\n const dead = cache.worktrees.filter((tree: DeletableWorktree): boolean => tree.deletable);\n\n if (dead.length > 0) {\n const steps = ['git worktree prune'];\n for (const tree of dead) {\n // A prunable worktree has no directory left to remove — step 1 already handled it.\n if (tree.path !== '') steps.push(`git worktree remove ${tree.path}`);\n }\n const branches = dead\n .map((tree: DeletableWorktree): string => tree.branch)\n .filter((branch: string): boolean => branch !== '');\n if (branches.length > 0) steps.push(`git branch -D ${branches.join(' ')}`);\n\n options.push(new Option(\n `Run: pnpm wp-cleanup — it REMOVES these ${String(dead.length)} dead worktrees for you. Each ` +\n 'holds a branch backed by a MERGED PR, a branch with no commits of its own, or a directory ' +\n 'that is already gone, so no work can be lost (see merged-branches.json for the per-worktree ' +\n 'reason). It archives every branch as an `archive/<date>/<branch>` tag BEFORE removing ' +\n 'anything, logs each removal with a `recover=` command, and refuses to touch the primary ' +\n 'clone, the worktree you are standing in, or any worktree holding uncommitted work. ' +\n `Equivalent by hand, in this order (prune first, branches last — git refuses to delete a ` +\n `branch a worktree still holds): ${steps.join(' && ')}`,\n true,\n ));\n } else {\n // Nothing is PROVABLY dead, but wp-cleanup also prompts about the probably-dead ones, so it\n // is still the remedy that can delete something — and it is still better advice than a knob.\n options.push(new Option(\n 'Run: pnpm wp-cleanup — none of these worktrees is provably dead, but it lists the ' +\n 'probably-dead ones (closed-unmerged PR, content already in main, never proposed) with ' +\n 'their branch and reason and asks which to remove. Answer that prompt instead of raising ' +\n 'the cap.',\n true,\n ));\n }\n\n options.push(new Option(\n 'If you genuinely need more worktrees in flight, raise branch-creation-guard.maxWorktrees ' +\n 'in webpieces.config.json.',\n ));\n options.push(new Option(\n 'To bypass this once, set branch-creation-guard.turnOffRuleUntilEpoch (a future epoch) ' +\n 'in webpieces.config.json.',\n ));\n\n const spared = cache.worktrees.length - dead.length;\n const kept = spared > 0\n ? ` ${String(spared)} worktree(s) were deliberately SPARED (locked, holding unmerged work, or ` +\n 'the one you are standing in) — do not remove those; a human decides.'\n : '';\n\n return new FixHint(\n 'Too many worktrees — reap the dead ones before creating another.',\n 'Full detail (deletable + spared, with per-worktree reasons) is in .webpieces/merged-branches.json, ' +\n `refreshed ${cache.timestamp || 'never'}.${kept} Pick one:`,\n options,\n );\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"branch-creation-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/branch-creation-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport {\n BranchCreationGuardConfig,\n CacheFreshness,\n DeletableBranch,\n DeletableWorktree,\n MergedBranchesCache,\n MergedBranchesService,\n WorktreeService,\n readMainSyncStatus,\n} from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint, Option } from '../fix-hint';\nimport { CapRemedies } from './cap-remedies';\nimport { toError } from '../to-error';\n\n// Defaults used when the rule has no explicit value in webpieces.config.json.\n// branchFormat is a human sentence telling the AI how to name a branch created off main; it is\n// intentionally NOT the sub-branch convention (sub-branches are a separate, human-approved path).\nconst DEFAULT_BRANCH_FORMAT =\n 'Name it {whoami}/<short-feature-description> — lowercase, no version numbers, no sub/ prefix (e.g. dean/upgrade-webpieces)';\nconst DEFAULT_SUB_BRANCH_NAMING = 'feature/<ticket>/<short-description>';\n\n// Hard cap on local feature branches. Enforced at CREATION because that is the one moment cleanup is\n// both cheap and obviously worth it — reaping happens over time, never \"ASAP\".\n//\n// The branch cap counts PARKED branches only — branches not checked out in any worktree. Worktree-held\n// branches are counted by the worktree cap instead. Two budgets, because they are not substitutes: if\n// held branches also spent the branch budget, five worktrees would leave room for zero branches and no\n// branch could ever be created again.\nconst DEFAULT_MAX_LOCAL_BRANCHES = 5;\nconst DEFAULT_MAX_WORKTREES = 5;\n\n// A plausible git ref name. Deliberately NOT `[^\\s-]` — that class matches shell metacharacters, so\n// `git branch | wc -l` (a read-only LISTING, piped) was parsed as \"create a branch named `|`\" and\n// blocked. Cleanup work necessarily reads and deletes branches, so a listing must never trip this.\nconst REF_NAME = String.raw`[A-Za-z0-9][A-Za-z0-9_./-]*`;\n\nconst BRANCH_PATTERNS: RegExp[] = [\n new RegExp(String.raw`git\\s+checkout\\s+-[bB]\\s+(${REF_NAME})`),\n new RegExp(String.raw`git\\s+switch\\s+-[cC]\\s+(${REF_NAME})`),\n new RegExp(String.raw`git\\s+branch\\s+(?!-)(${REF_NAME})`),\n // `git worktree add ../dir -b <name> origin/main` — the form docs/git-workflow.md recommends for\n // starting a feature. It creates a branch just as surely as `checkout -b` does, and until this\n // pattern existed it walked straight past the cap, the reserved-suffix check and the sub-branch\n // check. `(?:\\S+\\s+)*?` absorbs the path and any other flags, so the -b may precede or follow them.\n new RegExp(String.raw`git\\s+worktree\\s+add\\s+(?:\\S+\\s+)*?-[bB]\\s+(${REF_NAME})`),\n];\n\n// ANY worktree creation, with or without -b. The no-`-b` forms (`git worktree add ../dir existing`,\n// `--detach`) create no branch but DO create a worktree, so they spend the worktree budget and must\n// still hit the cap.\nconst WORKTREE_ADD = /git\\s+worktree\\s+add\\b/;\n\n// `git worktree add <path> <existing-branch>` — the checkout-an-existing-branch form. Captures the\n// LAST bare (non-flag) argument, which is the committish; the first bare argument is the path.\n// Flags that take a value (`--reason <s>`, `-b <name>`) are excluded by the caller, which only uses\n// this on commands with no `-b`/`-B` at all.\nconst WORKTREE_ADD_EXISTING = new RegExp(\n String.raw`git\\s+worktree\\s+add\\s+(?:-{1,2}[A-Za-z-]+\\s+)*\\S+\\s+(${REF_NAME})`,\n);\n\n// `git branch <name> <sha>` — RESTORING a branch at an explicit commit, which is exactly the\n// `recover=` command wp-cleanup writes to branch-mutations.log for every branch it reaps.\n//\n// This must be allowed UNCONDITIONALLY, ahead of even the caps. The entire argument for letting the\n// tooling delete branches unattended is that any delete is one logged command away from being undone\n// — so a guard that blocks that command turns a real guarantee into a decorative one. (It did: the\n// generic `git branch <name>` creation pattern matched the restore and refused it, demanding the\n// branch be recreated off origin/main, which is precisely the content the restore is meant to bring\n// back.) A restore also cannot grow the branch list beyond what already existed.\nconst RESTORE_AT_SHA = new RegExp(String.raw`git\\s+branch\\s+${REF_NAME}\\s+[0-9a-f]{7,40}(?:\\W|$)`);\n\n// A trailing `wp<number>` was the old squash-merge generation marker (base → basewp2 → basewp3).\n// The tooling NO LONGER produces it — a sync now lands back on the same feature name — but the suffix\n// stays RESERVED so a human branch can't collide with a leftover `…wpN` still floating in a consumer\n// repo mid-transition. Block it at creation time and steer the name back to the plain feature form.\nconst RESERVED_GENERATION_SUFFIX = /wp\\d+$/;\n\n// A branch-creation command that explicitly bases off origin/main (e.g. `git checkout -b feat\n// origin/main`). This is exactly the fresh-main base the guard wants, and it works from ANY current\n// branch or linked worktree — main need not (and in a worktree cannot) be checked out here. Allowed\n// unconditionally so the recovery messages can safely tell you to run it from a worktree.\n//\n// The trailing check is `\\W|$`, not `\\s|$`: the ALLOW pattern must not be stricter about delimiters\n// than the BLOCK pattern above, or a `git checkout -b x origin/main` that ends at a quote or backtick\n// is seen as a branch creation but NOT as an origin/main one — recognised, then wrongly blocked.\nconst ORIGIN_MAIN_BASE = /git\\s+(?:checkout\\s+-[bB]|switch\\s+-[cC])\\s+\\S+\\s+origin\\/main(?:\\W|$)/;\n\n// The worktree arm of the same allow: `git worktree add ../dir -b <name> origin/main`. Same fresh-main\n// base, same reasoning — and in a worktree it is the ONLY workable base, since `git checkout main`\n// fatals there. Kept separate from ORIGIN_MAIN_BASE because the argument shape differs (a path sits\n// between the subcommand and the flags).\nconst WORKTREE_ORIGIN_MAIN_BASE = /git\\s+worktree\\s+add\\s+(?:\\S+\\s+)*origin\\/main(?:\\W|$)/;\n\nfunction extractBranchName(command: string): string | null {\n for (const pattern of BRANCH_PATTERNS) {\n const m = pattern.exec(command);\n if (m) return m[1];\n }\n return null;\n}\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\nfunction checkMainIsUpToDate(ctx: BashContext, requestedName: string): readonly Violation[] {\n // `--no-write-fetch-head` for the same reason as the background refresher (see\n // MainSyncStatusService.fetchOriginMain): this refresh can run while the agent is mid\n // `git fetch`/`git pull`, and two overlapping writers of the unlocked `.git/FETCH_HEAD` can leave\n // a duplicate for-merge line that makes the agent's `git pull` fatal with \"multiple branches\". We\n // read `origin/main` below, never FETCH_HEAD. On a git too old for the flag, retry the plain form.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n execSync('git fetch --no-write-fetch-head origin main --quiet', { cwd: ctx.workspaceRoot, encoding: 'utf8' });\n } catch (err: unknown) {\n const error = toError(err);\n const text = error.message.toLowerCase();\n const tooOld = text.includes('unknown option') || text.includes('unknown switch') || text.includes('unrecognized option');\n if (!tooOld) throw error;\n execSync('git fetch origin main --quiet', { cwd: ctx.workspaceRoot, encoding: 'utf8' });\n }\n const countStr = execSync('git rev-list HEAD..origin/main --count', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n const count = parseInt(countStr, 10);\n if (count > 0) {\n return [new V(\n 1,\n truncate(ctx.command),\n `Local main is ${count} commit(s) behind origin/main. Run 'git pull origin main' first, then retry creating branch '${requestedName}'.`,\n )];\n }\n return [];\n}\n\nexport class BranchCreationGuardRule extends BashRuleBase<BranchCreationGuardConfig> {\n constructor(config: BranchCreationGuardConfig) { super(config, 'branch-creation-guard'); }\n\n readonly description =\n 'Block new-branch and new-worktree creation when main is stale, when branching off a non-main ' +\n 'branch, or when the branch/worktree count is at its cap (forces cleanup of dead ones).';\n override readonly defaultOptions = {\n subBranchNaming: DEFAULT_SUB_BRANCH_NAMING,\n branchFormat: DEFAULT_BRANCH_FORMAT,\n maxLocalBranches: DEFAULT_MAX_LOCAL_BRANCHES,\n maxWorktrees: DEFAULT_MAX_WORKTREES,\n };\n\n private readonly worktrees = new WorktreeService();\n private readonly mergedBranches = new MergedBranchesService(this.worktrees);\n\n // Set by check() when (and only when) a cap is what blocked, so fixHint can render the reap\n // instructions instead of the branch-naming ones. Same instance-field handoff pr-merge-guard uses.\n // Two fields, because the two caps reap different things and their hints share no wording.\n private capCache: MergedBranchesCache | null = null;\n private worktreeCapCache: MergedBranchesCache | null = null;\n\n // How old the cache backing whichever cap fired was, so the hint can SAY \"these verdicts are stale\"\n // instead of quoting them as present-tense fact. Null until a cap fires.\n private capFreshness: CacheFreshness | null = null;\n\n // True when the blocked command was a `git worktree add`, so the recovery command we hand back is\n // a worktree command and not a `git checkout -b` the user cannot use here.\n private worktreeAdd = false;\n\n private get branchFormat(): string {\n return this.config.branchFormat ?? DEFAULT_BRANCH_FORMAT;\n }\n\n private get subBranchNaming(): string {\n return this.config.subBranchNaming ?? DEFAULT_SUB_BRANCH_NAMING;\n }\n\n private get maxLocalBranches(): number {\n return this.config.maxLocalBranches ?? DEFAULT_MAX_LOCAL_BRANCHES;\n }\n\n private get maxWorktrees(): number {\n return this.config.maxWorktrees ?? DEFAULT_MAX_WORKTREES;\n }\n\n // The recovery command for \"base this off fresh main\", in the flavour of whatever was blocked.\n private freshMainCommand(name: string): string {\n return this.worktreeAdd\n ? `git fetch origin main && git worktree add ../${name.replace(/\\//g, '-')} -b ${name} origin/main`\n : `git fetch origin main && git checkout -b ${name} origin/main`;\n }\n\n // Mode-aware fix hints. Branches off main follow branchFormat — never the sub-branch\n // convention. The sub-branch affordance only appears under mode 'ON'; 'ON_NO_SUBBRANCHES'\n // hard-blocks it and points instead at the turnOffRuleUntilEpoch escape hatch.\n get fixHint(): FixHint {\n const remedies = new CapRemedies(this.capFreshness);\n if (this.worktreeCapCache) return remedies.worktreeCap(this.worktreeCapCache);\n if (this.capCache) return remedies.branchCap(this.capCache);\n\n const create = this.worktreeAdd\n ? 'Create it off fresh main: git fetch origin main && git worktree add ../<dir> -b <name> origin/main'\n : 'Create it off fresh main from anywhere (incl. a worktree): git fetch origin main && git checkout -b <name> origin/main';\n\n const options = [\n new Option(create, true),\n new Option(`Name a branch off main per branch-creation-guard.branchFormat: ${this.branchFormat}`),\n ];\n if (this.config.mode === 'ON_NO_SUBBRANCHES') {\n options.push(new Option(\n 'Sub-branches (branching off another feature branch) are disabled. To temporarily allow one, set ' +\n \"branch-creation-guard.turnOffRuleUntilEpoch to a future epoch in webpieces.config.json\",\n ));\n } else {\n options.push(new Option(\n `If you truly need a stacked sub-branch (requires human approval), name it per branch-creation-guard.subBranchNaming: ${this.subBranchNaming}`,\n ));\n }\n return new FixHint(\n 'Cannot create this branch (main is stale, or branching off a non-main branch).',\n 'Create your branch from an up-to-date main. Pick one:',\n options,\n );\n }\n\n /**\n * Strip the parts of a shell command that are DATA rather than executable commands, so the guard\n * stops reading prose as instructions.\n *\n * This guard regex-scans the raw command string and has no notion of quoting, so\n * `git commit -m \"... git checkout -b foo ...\"` — or any heredoc commit message that mentions a\n * branch command — was parsed as an actual branch creation and blocked. That bit three separate\n * times while building the branch cap, including on the cap's own commit. It matters far more now\n * that the cap check runs BEFORE the origin/main allow: at the cap, a merely-MENTIONED branch\n * command would block your commit.\n *\n * A quoted span whose content has no whitespace is kept verbatim (it is a single token — the name\n * in `git checkout -b \"dean/foo\"`), so quoting a branch name cannot smuggle a creation past the\n * guard. Anything with whitespace inside quotes is prose, and collapses to a space.\n */\n check(ctx: BashContext): readonly Violation[] {\n this.capCache = null;\n this.worktreeCapCache = null;\n this.capFreshness = null;\n // Match against the command with heredoc bodies and prose-in-quotes removed (BashContext\n // computes it for every guard now — this rule's private copy was the original).\n const command = ctx.commandCode;\n const requestedName = extractBranchName(command);\n this.worktreeAdd = WORKTREE_ADD.test(command);\n\n // A worktree add with no -b creates no branch, but it DOES spend the worktree budget, so it must\n // survive this early-out and reach the worktree cap below.\n if (!requestedName && !this.worktreeAdd) return [];\n\n // Restoring a reaped branch at its logged SHA is undo, not creation — always allowed, and\n // checked before the caps so a full branch list can never trap you on the recovery path.\n if (!this.worktreeAdd && RESTORE_AT_SHA.test(command)) return [];\n\n const reserved = this.checkReservedSuffix(ctx, requestedName);\n if (reserved) return [reserved];\n\n const capViolation = this.checkCaps(ctx, requestedName !== null);\n if (capViolation) return [capViolation];\n\n // `git worktree add` of an EXISTING branch (or --detach) creates no branch, so the naming and\n // fresh-main rules below do not apply — but one thing still does: the branch may be DEAD.\n if (!requestedName) return this.checkWorktreeOntoDeadBranch(ctx, command);\n\n // Explicitly basing off origin/main is always allowed — it creates the branch from fresh main\n // regardless of the current branch, and is the ONLY way that also works inside a linked worktree\n // (where `git checkout main` fatals). Reserved-name check above still applies.\n if (ORIGIN_MAIN_BASE.test(command)) return [];\n if (this.worktreeAdd && WORKTREE_ORIGIN_MAIN_BASE.test(command)) return [];\n\n const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n\n if (currentBranch === 'main') {\n return checkMainIsUpToDate(ctx, requestedName);\n }\n\n // Not on main: creating this branch would stack it on a feature branch (a sub-branch).\n if (this.config.mode === 'ON_NO_SUBBRANCHES') {\n return [new V(\n 1,\n truncate(ctx.command),\n `You are on '${currentBranch}', not main. Create the branch OFF origin/main instead of ` +\n `stacking it on this branch: ${this.freshMainCommand(requestedName)} ` +\n `(works here and inside a worktree). ${this.branchFormat}. ` +\n `You can temporarily turn this off if you truly need a sub-branch by setting ` +\n `branch-creation-guard.turnOffRuleUntilEpoch (a future epoch) in webpieces.config.json.`,\n )];\n }\n\n return [new V(\n 1,\n truncate(ctx.command),\n `You are on '${currentBranch}', not main. Branches must be created from fresh main: ` +\n `${this.freshMainCommand(requestedName)}. ${this.branchFormat}. ` +\n `If you truly need a stacked sub-branch (requires human approval), name it per ` +\n `branch-creation-guard.subBranchNaming ('${this.subBranchNaming}').`,\n )];\n }\n\n /**\n * `git worktree add ../dir <existing-branch>` onto a branch whose PR is ALREADY MERGED.\n *\n * The count caps never catch this: the command creates no branch, and if you are under the\n * worktree cap it sails straight through — materialising a fresh directory full of PRE-MERGE\n * code that the AI will then read, plan from and edit. read-stale-guard blocks the reads and\n * feature-branch-guard blocks the edits once you are in there, but that is a turn wasted per\n * tool call. Refuse at the moment of creation instead, using the SAME merged-PR proof the caps\n * already have precomputed on disk.\n *\n * Fails OPEN exactly like both caps: no cache (fresh clone, no `gh`, refresher hasn't run) or an\n * unparseable command → no opinion.\n */\n private checkWorktreeOntoDeadBranch(ctx: BashContext, command: string): readonly Violation[] {\n if (!this.worktreeAdd) return [];\n\n const match = WORKTREE_ADD_EXISTING.exec(command);\n if (!match) return [];\n const branch = match[1];\n // `origin/main` (and any remote-tracking ref) is the RECOMMENDED base, never a dead branch.\n if (branch.startsWith('origin/')) return [];\n\n const cache = this.mergedBranches.readMergedBranches(ctx.workspaceRoot);\n if (!cache) return [];\n\n const dead = cache.deletable.find((entry: DeletableBranch): boolean => entry.branch === branch);\n if (!dead) return [];\n\n const dir = branch.replace(/\\//g, '-');\n return [new V(\n 1,\n truncate(ctx.command),\n `Branch '${branch}' is dead — ${dead.reason}. A worktree on it would be a directory full of ` +\n `PRE-MERGE code: everything you read there is stale relative to origin/main (read-stale-guard ` +\n `blocks those reads) and every edit is blocked by feature-branch-guard. Base the new worktree ` +\n `on fresh main instead: git fetch origin main && git worktree add ../${dir} -b <new-branch> origin/main`,\n )];\n }\n\n // The reserved `…wpN` generation suffix — see RESERVED_GENERATION_SUFFIX for why it stays blocked\n // even though the tooling no longer produces it.\n private checkReservedSuffix(ctx: BashContext, requestedName: string | null): Violation | null {\n if (!requestedName || !RESERVED_GENERATION_SUFFIX.test(requestedName)) return null;\n return new V(\n 1,\n truncate(ctx.command),\n `Branch name '${requestedName}' ends in 'wp<number>', which is reserved for the ` +\n `squash-merge tool's generation marker (base → basewp2 → basewp3). ` +\n `Rename it to a plain feature branch. ${this.branchFormat}.`,\n );\n }\n\n /**\n * Both budgets, in the order that produces the most useful complaint.\n *\n * Called BEFORE the origin/main allow in check() — `... -b <name> origin/main` is the normal,\n * always-permitted path, so a cap checked after it would never once fire.\n *\n * Worktree cap first: a `git worktree add -b` spends BOTH budgets, and when both are full the\n * worktree is the thing the command was actually trying to make, so it is the thing to talk about.\n */\n private checkCaps(ctx: BashContext, createsBranch: boolean): Violation | null {\n if (this.worktreeAdd) {\n const worktreeViolation = this.checkWorktreeCap(ctx);\n if (worktreeViolation) return worktreeViolation;\n }\n if (createsBranch) return this.checkBranchCap(ctx);\n return null;\n }\n\n /**\n * The cap. Blocks branch #N+1 until already-merged branches are reaped, which is the ONLY thing\n * keeping the local branch list bounded.\n *\n * Fails OPEN when the cache is absent (fresh clone, `gh` unavailable, refresher hasn't run yet):\n * never block on data we don't have. The detached refresher regenerates it within one hook call,\n * so the cap starts enforcing on its own.\n */\n private checkBranchCap(ctx: BashContext): Violation | null {\n // PARKED branches only — a branch checked out in a worktree is the worktree cap's problem, and\n // counting it twice would let five worktrees exhaust the branch budget on their own.\n const held = this.worktrees.heldBranches(ctx.workspaceRoot);\n const parked = this.mergedBranches.localBranches(ctx.workspaceRoot)\n .filter((branch: string): boolean => !held.has(branch));\n const count = parked.length;\n const cap = this.effectiveBranchCap(ctx);\n if (count < cap) return null;\n\n const cache = this.loadReconciledCache(ctx);\n if (!cache) return null;\n\n this.capCache = cache;\n // Only branches that are BOTH still on disk (reconcile guaranteed that) and parked can be\n // reaped to make room. A cached verdict about a branch a worktree now holds is not a figure\n // this message may quote at someone.\n const parkedSet = new Set(parked);\n const reapable = cache.deletable\n .filter((entry: DeletableBranch): boolean => parkedSet.has(entry.branch)).length;\n\n return new V(\n 1,\n truncate(ctx.command),\n `You have ${String(count)} parked local branches (not counting any checked out in a worktree); ` +\n `the cap (branch-creation-guard.maxLocalBranches) is ${String(this.maxLocalBranches)}. ` +\n `${this.deadDetail(reapable, 'dead (a MERGED PR backs them)')} Clean up before creating another.`,\n );\n }\n\n /**\n * The cache, with entries for branches/worktrees that no longer exist dropped, plus its age recorded.\n *\n * Reconciling is the fix for the phantom count: the file is written by a detached refresher and is\n * DELIBERATELY allowed to go stale, so it keeps naming branches deleted minutes ago and worktrees\n * already removed. Quoting it verbatim is how the guard announced \"8 parked local branches … none of\n * them are dead\" over a repo that had ONE, and then blocked a legitimate `git worktree add` on that\n * figure. `reconcile` re-checks existence with instant local git reads; it does NOT re-derive any\n * verdict (that needs the network and belongs in the refresher).\n */\n private loadReconciledCache(ctx: BashContext): MergedBranchesCache | null {\n const raw = this.mergedBranches.readMergedBranches(ctx.workspaceRoot);\n if (!raw) return null;\n this.capFreshness = this.mergedBranches.freshness(raw);\n return this.mergedBranches.reconcile(ctx.workspaceRoot, raw);\n }\n\n /**\n * The \"N of them are dead\" sentence — or an honest refusal to say a number.\n *\n * A count read off a stale file is a claim the guard cannot stand behind, and this guard's numbers\n * are acted on: they decide whether an agent goes and deletes things. When the cache is too old,\n * say so and point at the command that recomputes from scratch, rather than asserting a figure.\n */\n private deadDetail(reapable: number, what: string): string {\n const freshness = this.capFreshness;\n if (freshness !== null && freshness.stale) {\n const age = freshness.ageMinutes >= 0\n ? `${String(freshness.ageMinutes)} minute(s) old`\n : 'carrying no usable timestamp';\n return `How many are dead is NOT known right now — the cached verdicts ` +\n `(.webpieces/merged-branches.json, ${age}) are stale, so no count is asserted. ` +\n '`pnpm wp-cleanup` recomputes them from scratch.';\n }\n if (reapable === 0) return 'None of them are dead, so none can be auto-reaped — see the options below.';\n return `${String(reapable)} of them are ${what} and wp-cleanup can reap them.`;\n }\n\n /**\n * The branch cap, yielding by ONE when the agent is standing on an already-merged branch.\n *\n * Two individually-correct guards were composing into a trap: merged-branch-bash-guard blocks\n * almost all Bash until you get off a merged branch, and the ONLY way off it that guard advertises\n * is creating a fresh branch — which this cap then refused. Every printed exit led to editing\n * webpieces.config.json, and that is what an unsupervised agent did.\n *\n * One over cap, and only while a merged branch is what is pushing you: the branch about to be\n * created replaces a branch that is already dead, so the steady-state count does not grow. The cap\n * still fires on the NEXT creation, so this defers the cleanup by exactly one branch, never skips it.\n *\n * Fails toward the strict cap: no cache, a cache for another branch, or a clean branch → no yield.\n */\n private effectiveBranchCap(ctx: BashContext): number {\n const status = readMainSyncStatus(ctx.workspaceRoot);\n if (status === null || !status.branchAlreadyMerged) return this.maxLocalBranches;\n const current = this.currentBranchOrNull(ctx.workspaceRoot);\n if (current === null || status.branch !== current) return this.maxLocalBranches;\n return this.maxLocalBranches + 1;\n }\n\n private currentBranchOrNull(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n\n /**\n * The worktree cap — the second budget. Same gate, same fail-open rule as the branch cap: a\n * worktree list we cannot classify (no cache on disk) blocks nothing.\n *\n * Counts LINKED worktrees only. The primary clone is not a thing anyone can remove, so charging the\n * budget for it would just silently cost you one worktree.\n */\n private checkWorktreeCap(ctx: BashContext): Violation | null {\n const count = this.worktrees.linkedWorktrees(ctx.workspaceRoot).length;\n if (count < this.maxWorktrees) return null;\n\n const cache = this.loadReconciledCache(ctx);\n if (!cache) return null;\n\n this.worktreeCapCache = cache;\n const reapable = cache.worktrees.filter((tree: DeletableWorktree): boolean => tree.deletable).length;\n\n return new V(\n 1,\n truncate(ctx.command),\n `You have ${String(count)} linked worktrees; the cap (branch-creation-guard.maxWorktrees) ` +\n `is ${String(this.maxWorktrees)}. ` +\n `${this.deadDetail(reapable, 'dead (a MERGED PR backs the branch, or the directory is already gone)')} ` +\n 'Clean up before creating another.',\n );\n }\n\n}\n"]}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { CacheFreshness, MergedBranchesCache } from '@webpieces/rules-config';
|
|
2
|
+
import { FixHint } from '../fix-hint';
|
|
3
|
+
/**
|
|
4
|
+
* What branch-creation-guard says when a CAP is what blocked — and, just as importantly, what it does
|
|
5
|
+
* NOT say.
|
|
6
|
+
*
|
|
7
|
+
* Split out of the guard itself because the guard's job is to decide, and this is a whole vocabulary
|
|
8
|
+
* about cleanup that it kept getting wrong. On 2026-07-30 the worktree remedy printed a chained
|
|
9
|
+
* `git worktree prune && git worktree remove … && git branch -D …` naming three worktrees with live
|
|
10
|
+
* agents in them, under the words "so no work can be lost". Nothing in this class may emit a
|
|
11
|
+
* destructive command; the one remedy is `pnpm wp-cleanup`, which archives, logs a recover command,
|
|
12
|
+
* refuses its own tree, and ASKS about anything a merged PR has not proved dead.
|
|
13
|
+
*
|
|
14
|
+
* `freshness` is the age of the cache these hints are built from. A stale cache may be pointed AT but
|
|
15
|
+
* never quoted FROM — see branchCap().
|
|
16
|
+
*/
|
|
17
|
+
export declare class CapRemedies {
|
|
18
|
+
private readonly freshness;
|
|
19
|
+
constructor(freshness: CacheFreshness | null);
|
|
20
|
+
/**
|
|
21
|
+
* The reap instructions. `deletable` is PRECOMPUTED in the cache, and every entry earned its place
|
|
22
|
+
* by exactly ONE proof: a MERGED PR — its own, or the PR of the branch it is a squash snapshot of.
|
|
23
|
+
* "Zero commits of its own" used to be a second proof and is not one any more; it is the normal
|
|
24
|
+
* state of a branch somebody is working on right now (see CLASSIFICATION_NO_COMMITS), so it is
|
|
25
|
+
* spared and merely PROMPTED about by wp-cleanup.
|
|
26
|
+
*
|
|
27
|
+
* The command is `pnpm wp-cleanup`, NOT the `git branch -D a b c` this used to emit. Two reasons,
|
|
28
|
+
* both learned the hard way: agents read a bare `-D` as destructive and stop to ask (so nothing
|
|
29
|
+
* was ever cleaned, and this cap kept firing), and the multi-name form aborts wholesale on the
|
|
30
|
+
* first branch git refuses, stranding every branch after it in the list. wp-cleanup recomputes
|
|
31
|
+
* the verdicts, deletes one branch per command, and logs each pre-delete SHA.
|
|
32
|
+
*
|
|
33
|
+
* The wording must not overstate the safety: the list is NOT uniformly "merged PR" branches, and
|
|
34
|
+
* a message that tells an agent to delete has to be exactly true about why that's safe.
|
|
35
|
+
*/
|
|
36
|
+
branchCap(cache: MergedBranchesCache): FixHint;
|
|
37
|
+
/**
|
|
38
|
+
* The remedy that actually deletes something without loosening anything: SHOW the spared branches
|
|
39
|
+
* and ASK the human which may go.
|
|
40
|
+
*
|
|
41
|
+
* `keep` is the list the tooling refuses to touch on its own — no merged PR, so no proof the work
|
|
42
|
+
* is safe. That is exactly the list a human can adjudicate in five seconds and the tooling never
|
|
43
|
+
* can, and it was never printed: the cap said "N branches were SPARED, do not delete those" and
|
|
44
|
+
* then offered only config edits. So the agent edited the config.
|
|
45
|
+
*
|
|
46
|
+
* Every column is read straight off `.webpieces/merged-branches.json` (written by the detached
|
|
47
|
+
* refresher) — nothing is recomputed on this blocking path. The SHA is there so the human can see
|
|
48
|
+
* the delete is reversible; the commit count is there so "0 commits" branches are obvious yeses.
|
|
49
|
+
*/
|
|
50
|
+
private askHumanOption;
|
|
51
|
+
/**
|
|
52
|
+
* The worktree remedy — ONE command, `pnpm wp-cleanup`, and NOTHING that deletes anything itself.
|
|
53
|
+
*
|
|
54
|
+
* This guard used to print the equivalent git by hand: `git worktree prune && git worktree remove
|
|
55
|
+
* <path> && ... && git branch -D <a> <b> <c>`, introduced as "so no work can be lost". On
|
|
56
|
+
* 2026-07-30 that line named three worktrees with LIVE AGENTS in them, because "a branch with no
|
|
57
|
+
* commits of its own" counted as dead — which is what every worktree looks like between
|
|
58
|
+
* `git worktree add -b ... origin/main` and its first commit, i.e. exactly while it is being worked
|
|
59
|
+
* in. That classification is fixed (see CLASSIFICATION_NO_COMMITS), and the one-liner is gone with
|
|
60
|
+
* it, permanently, for two independent reasons:
|
|
61
|
+
*
|
|
62
|
+
* - a single `&&` chain deleting seven things has no safe partial failure; and
|
|
63
|
+
* - this guard has no business owning reaping logic at all. wp-cleanup classifies from FRESH
|
|
64
|
+
* verdicts, archives every branch as an `archive/<date>/<branch>` tag before touching it, logs a
|
|
65
|
+
* `recover=` command per removal, refuses the primary clone and the tree it is standing in, never
|
|
66
|
+
* passes `--force`, and ASKS about anything not provably merged.
|
|
67
|
+
*
|
|
68
|
+
* So this method names WHAT is at the cap and hands over. It must never emit a destructive command.
|
|
69
|
+
*/
|
|
70
|
+
worktreeCap(cache: MergedBranchesCache): FixHint;
|
|
71
|
+
/**
|
|
72
|
+
* What was SPARED, and the standing instruction about it: leave it alone.
|
|
73
|
+
*
|
|
74
|
+
* Worded as "not provably merged", never as "probably dead". A spared worktree is LIVE until a
|
|
75
|
+
* merged PR says otherwise, and the previous wording ("holding unmerged work") quietly implied that
|
|
76
|
+
* everything NOT on that list was safe to remove.
|
|
77
|
+
*/
|
|
78
|
+
private sparedNote;
|
|
79
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.CapRemedies = void 0;
|
|
4
|
+
const fix_hint_1 = require("../fix-hint");
|
|
5
|
+
/**
|
|
6
|
+
* What branch-creation-guard says when a CAP is what blocked — and, just as importantly, what it does
|
|
7
|
+
* NOT say.
|
|
8
|
+
*
|
|
9
|
+
* Split out of the guard itself because the guard's job is to decide, and this is a whole vocabulary
|
|
10
|
+
* about cleanup that it kept getting wrong. On 2026-07-30 the worktree remedy printed a chained
|
|
11
|
+
* `git worktree prune && git worktree remove … && git branch -D …` naming three worktrees with live
|
|
12
|
+
* agents in them, under the words "so no work can be lost". Nothing in this class may emit a
|
|
13
|
+
* destructive command; the one remedy is `pnpm wp-cleanup`, which archives, logs a recover command,
|
|
14
|
+
* refuses its own tree, and ASKS about anything a merged PR has not proved dead.
|
|
15
|
+
*
|
|
16
|
+
* `freshness` is the age of the cache these hints are built from. A stale cache may be pointed AT but
|
|
17
|
+
* never quoted FROM — see branchCap().
|
|
18
|
+
*/
|
|
19
|
+
class CapRemedies {
|
|
20
|
+
freshness;
|
|
21
|
+
constructor(freshness) {
|
|
22
|
+
this.freshness = freshness;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The reap instructions. `deletable` is PRECOMPUTED in the cache, and every entry earned its place
|
|
26
|
+
* by exactly ONE proof: a MERGED PR — its own, or the PR of the branch it is a squash snapshot of.
|
|
27
|
+
* "Zero commits of its own" used to be a second proof and is not one any more; it is the normal
|
|
28
|
+
* state of a branch somebody is working on right now (see CLASSIFICATION_NO_COMMITS), so it is
|
|
29
|
+
* spared and merely PROMPTED about by wp-cleanup.
|
|
30
|
+
*
|
|
31
|
+
* The command is `pnpm wp-cleanup`, NOT the `git branch -D a b c` this used to emit. Two reasons,
|
|
32
|
+
* both learned the hard way: agents read a bare `-D` as destructive and stop to ask (so nothing
|
|
33
|
+
* was ever cleaned, and this cap kept firing), and the multi-name form aborts wholesale on the
|
|
34
|
+
* first branch git refuses, stranding every branch after it in the list. wp-cleanup recomputes
|
|
35
|
+
* the verdicts, deletes one branch per command, and logs each pre-delete SHA.
|
|
36
|
+
*
|
|
37
|
+
* The wording must not overstate the safety: the list is NOT uniformly "merged PR" branches, and
|
|
38
|
+
* a message that tells an agent to delete has to be exactly true about why that's safe.
|
|
39
|
+
*/
|
|
40
|
+
branchCap(cache) {
|
|
41
|
+
const options = [];
|
|
42
|
+
// Nothing auto-reapable → the ASK is the preferred move, and it comes FIRST. This is the whole
|
|
43
|
+
// point of the option: with an empty `deletable` list the only advice left used to be "raise
|
|
44
|
+
// maxLocalBranches" / "set turnOffRuleUntilEpoch", and an agent with no human in the loop
|
|
45
|
+
// edited webpieces.config.json to escape — loosening the very rule that was working correctly.
|
|
46
|
+
// A stale cache may not be quoted as fact — same rule the violation message follows. Naming
|
|
47
|
+
// branches out of it while the message above says "no count is asserted" would be the guard
|
|
48
|
+
// contradicting itself, and the contradiction resolves in the direction of deleting things.
|
|
49
|
+
const stale = this.freshness !== null && this.freshness.stale;
|
|
50
|
+
const askFirst = cache.deletable.length === 0 || stale;
|
|
51
|
+
if (stale) {
|
|
52
|
+
options.push(new fix_hint_1.Option('Run: pnpm wp-cleanup — the cached verdicts are stale, so it recomputes them from scratch, ' +
|
|
53
|
+
'deletes only what a MERGED PR proves is dead, and asks you about the rest.', cache.keep.length === 0));
|
|
54
|
+
}
|
|
55
|
+
else if (cache.deletable.length > 0) {
|
|
56
|
+
const names = cache.deletable.map((entry) => entry.branch);
|
|
57
|
+
options.push(new fix_hint_1.Option(`Run: pnpm wp-cleanup — it deletes these ${String(names.length)} dead branches. Every one is ` +
|
|
58
|
+
'backed by a MERGED PR (its own, or the PR of the branch it snapshots) — that is now the ONLY ' +
|
|
59
|
+
'proof that reaps anything unattended — and every delete is logged with a recover-by-SHA ' +
|
|
60
|
+
`command (see merged-branches.json for the per-branch reason): ${names.join(' ')}`, true));
|
|
61
|
+
}
|
|
62
|
+
const ask = this.askHumanOption(cache, askFirst);
|
|
63
|
+
if (ask)
|
|
64
|
+
options.push(ask);
|
|
65
|
+
options.push(new fix_hint_1.Option('ONLY IF A HUMAN SAYS SO: raise branch-creation-guard.maxLocalBranches in ' +
|
|
66
|
+
'webpieces.config.json. Editing this config to get past a guard is not a fix you may make on ' +
|
|
67
|
+
'your own — ask first (use the option above).'));
|
|
68
|
+
options.push(new fix_hint_1.Option('ONLY IF A HUMAN SAYS SO: set branch-creation-guard.turnOffRuleUntilEpoch (a future epoch) ' +
|
|
69
|
+
'in webpieces.config.json to bypass this once. Same rule — ask, do not self-approve.'));
|
|
70
|
+
const kept = cache.keep.length > 0
|
|
71
|
+
? ` ${String(cache.keep.length)} branch(es) are NOT provably merged and were deliberately SPARED — ` +
|
|
72
|
+
'treat them as LIVE; a human decides, never the tooling and never you.'
|
|
73
|
+
: '';
|
|
74
|
+
return new fix_hint_1.FixHint('Too many local branches — reap the dead ones before creating another.', 'Full detail (deletable + spared, with per-branch reasons) is in .webpieces/merged-branches.json, ' +
|
|
75
|
+
`refreshed ${cache.timestamp || 'never'}.${kept} Pick one:`, options);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* The remedy that actually deletes something without loosening anything: SHOW the spared branches
|
|
79
|
+
* and ASK the human which may go.
|
|
80
|
+
*
|
|
81
|
+
* `keep` is the list the tooling refuses to touch on its own — no merged PR, so no proof the work
|
|
82
|
+
* is safe. That is exactly the list a human can adjudicate in five seconds and the tooling never
|
|
83
|
+
* can, and it was never printed: the cap said "N branches were SPARED, do not delete those" and
|
|
84
|
+
* then offered only config edits. So the agent edited the config.
|
|
85
|
+
*
|
|
86
|
+
* Every column is read straight off `.webpieces/merged-branches.json` (written by the detached
|
|
87
|
+
* refresher) — nothing is recomputed on this blocking path. The SHA is there so the human can see
|
|
88
|
+
* the delete is reversible; the commit count is there so "0 commits" branches are obvious yeses.
|
|
89
|
+
*/
|
|
90
|
+
askHumanOption(cache, preferred) {
|
|
91
|
+
if (cache.keep.length === 0)
|
|
92
|
+
return null;
|
|
93
|
+
const rows = cache.keep.map((entry) => {
|
|
94
|
+
const sha = entry.sha !== '' ? entry.sha : '???????';
|
|
95
|
+
const pr = entry.pr > 0
|
|
96
|
+
? `PR #${String(entry.pr)} ${entry.prState || 'MERGED'}`
|
|
97
|
+
: (entry.prState !== '' ? `PR ${entry.prState}` : 'no PR');
|
|
98
|
+
const commits = entry.commits >= 0 ? `${String(entry.commits)} commit(s) of its own` : 'commit count unknown';
|
|
99
|
+
return ` ${entry.branch} [${sha}] ${pr} ${commits} — ${entry.reason}`;
|
|
100
|
+
});
|
|
101
|
+
return new fix_hint_1.Option(`ASK THE HUMAN which of these ${String(cache.keep.length)} branches may be deleted. They are ` +
|
|
102
|
+
'not provably dead, so the tooling will not reap them — but a human can decide in seconds, ' +
|
|
103
|
+
'and deleting one is the correct fix for "too many branches". Paste this list and ask:\n' +
|
|
104
|
+
rows.join('\n') + '\n' +
|
|
105
|
+
'Then delete ONLY the ones approved: git branch -D <approved-branch>\n' +
|
|
106
|
+
'(each is recoverable — `git branch <name> <sha>` restores it at the SHA shown above).\n' +
|
|
107
|
+
'Do NOT delete any of these without an explicit yes, and do NOT edit webpieces.config.json instead.', preferred);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The worktree remedy — ONE command, `pnpm wp-cleanup`, and NOTHING that deletes anything itself.
|
|
111
|
+
*
|
|
112
|
+
* This guard used to print the equivalent git by hand: `git worktree prune && git worktree remove
|
|
113
|
+
* <path> && ... && git branch -D <a> <b> <c>`, introduced as "so no work can be lost". On
|
|
114
|
+
* 2026-07-30 that line named three worktrees with LIVE AGENTS in them, because "a branch with no
|
|
115
|
+
* commits of its own" counted as dead — which is what every worktree looks like between
|
|
116
|
+
* `git worktree add -b ... origin/main` and its first commit, i.e. exactly while it is being worked
|
|
117
|
+
* in. That classification is fixed (see CLASSIFICATION_NO_COMMITS), and the one-liner is gone with
|
|
118
|
+
* it, permanently, for two independent reasons:
|
|
119
|
+
*
|
|
120
|
+
* - a single `&&` chain deleting seven things has no safe partial failure; and
|
|
121
|
+
* - this guard has no business owning reaping logic at all. wp-cleanup classifies from FRESH
|
|
122
|
+
* verdicts, archives every branch as an `archive/<date>/<branch>` tag before touching it, logs a
|
|
123
|
+
* `recover=` command per removal, refuses the primary clone and the tree it is standing in, never
|
|
124
|
+
* passes `--force`, and ASKS about anything not provably merged.
|
|
125
|
+
*
|
|
126
|
+
* So this method names WHAT is at the cap and hands over. It must never emit a destructive command.
|
|
127
|
+
*/
|
|
128
|
+
worktreeCap(cache) {
|
|
129
|
+
const options = [];
|
|
130
|
+
const dead = cache.worktrees.filter((tree) => tree.deletable);
|
|
131
|
+
const stale = this.freshness !== null && this.freshness.stale;
|
|
132
|
+
if (dead.length > 0 && !stale) {
|
|
133
|
+
// Paths as a plain indented LIST — a description of what wp-cleanup will offer to remove,
|
|
134
|
+
// never a command. Nothing in this block is copy-pasteable into a shell, on purpose.
|
|
135
|
+
const paths = dead
|
|
136
|
+
.map((tree) => ` ${tree.path} [${tree.branch || 'detached'}] - ${tree.reason}`)
|
|
137
|
+
.join('\n');
|
|
138
|
+
options.push(new fix_hint_1.Option(`Run: pnpm wp-cleanup — ${String(dead.length)} of these worktrees is/are backed by a MERGED ` +
|
|
139
|
+
'PR or have a directory that is already gone, and it removes those (archiving each branch as ' +
|
|
140
|
+
'a tag first, logging a `recover=` command for each). Everything else it ASKS about before ' +
|
|
141
|
+
'touching. It will not remove the primary clone, the worktree you are standing in, or any ' +
|
|
142
|
+
`worktree with uncommitted work:\n${paths}`, true));
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
options.push(new fix_hint_1.Option('Run: pnpm wp-cleanup — it recomputes the verdicts from scratch, removes only what a MERGED ' +
|
|
146
|
+
'PR proves is dead, and ASKS you about anything that merely looks dead (closed-unmerged PR, ' +
|
|
147
|
+
'content already in main, never proposed, no commits yet) before removing anything. Answer ' +
|
|
148
|
+
'that prompt instead of raising the cap.', true));
|
|
149
|
+
}
|
|
150
|
+
options.push(new fix_hint_1.Option('ONLY IF A HUMAN SAYS SO: raise branch-creation-guard.maxWorktrees in webpieces.config.json. ' +
|
|
151
|
+
'Editing this config to get past a guard is not a fix you may make on your own — ask first.'));
|
|
152
|
+
options.push(new fix_hint_1.Option('ONLY IF A HUMAN SAYS SO: set branch-creation-guard.turnOffRuleUntilEpoch (a future epoch) ' +
|
|
153
|
+
'in webpieces.config.json to bypass this once. Same rule — ask, do not self-approve.'));
|
|
154
|
+
return new fix_hint_1.FixHint('Too many worktrees — run pnpm wp-cleanup before creating another. Do NOT remove any worktree ' +
|
|
155
|
+
'by hand: a worktree whose branch has no commits yet is almost always one an agent is working ' +
|
|
156
|
+
'in RIGHT NOW, and nothing here is proof that it is dead.', 'Full detail (with per-worktree reasons) is in .webpieces/merged-branches.json, ' +
|
|
157
|
+
`refreshed ${cache.timestamp || 'never'}.${this.sparedNote(cache)} Pick one:`, options);
|
|
158
|
+
}
|
|
159
|
+
/**
|
|
160
|
+
* What was SPARED, and the standing instruction about it: leave it alone.
|
|
161
|
+
*
|
|
162
|
+
* Worded as "not provably merged", never as "probably dead". A spared worktree is LIVE until a
|
|
163
|
+
* merged PR says otherwise, and the previous wording ("holding unmerged work") quietly implied that
|
|
164
|
+
* everything NOT on that list was safe to remove.
|
|
165
|
+
*/
|
|
166
|
+
sparedNote(cache) {
|
|
167
|
+
const spared = cache.worktrees.filter((tree) => !tree.deletable).length;
|
|
168
|
+
if (spared === 0)
|
|
169
|
+
return '';
|
|
170
|
+
return ` ${String(spared)} worktree(s) are NOT provably merged — treat every one of them as LIVE ` +
|
|
171
|
+
'(an agent may be working in it right now) and never remove one without an explicit human yes.';
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
exports.CapRemedies = CapRemedies;
|
|
175
|
+
//# sourceMappingURL=cap-remedies.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cap-remedies.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/cap-remedies.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n CacheFreshness,\n DeletableBranch,\n DeletableWorktree,\n MergedBranchesCache,\n} from '@webpieces/rules-config';\n\nimport { FixHint, Option } from '../fix-hint';\n\n/**\n * What branch-creation-guard says when a CAP is what blocked — and, just as importantly, what it does\n * NOT say.\n *\n * Split out of the guard itself because the guard's job is to decide, and this is a whole vocabulary\n * about cleanup that it kept getting wrong. On 2026-07-30 the worktree remedy printed a chained\n * `git worktree prune && git worktree remove … && git branch -D …` naming three worktrees with live\n * agents in them, under the words \"so no work can be lost\". Nothing in this class may emit a\n * destructive command; the one remedy is `pnpm wp-cleanup`, which archives, logs a recover command,\n * refuses its own tree, and ASKS about anything a merged PR has not proved dead.\n *\n * `freshness` is the age of the cache these hints are built from. A stale cache may be pointed AT but\n * never quoted FROM — see branchCap().\n */\nexport class CapRemedies {\n constructor(private readonly freshness: CacheFreshness | null) {}\n\n /**\n * The reap instructions. `deletable` is PRECOMPUTED in the cache, and every entry earned its place\n * by exactly ONE proof: a MERGED PR — its own, or the PR of the branch it is a squash snapshot of.\n * \"Zero commits of its own\" used to be a second proof and is not one any more; it is the normal\n * state of a branch somebody is working on right now (see CLASSIFICATION_NO_COMMITS), so it is\n * spared and merely PROMPTED about by wp-cleanup.\n *\n * The command is `pnpm wp-cleanup`, NOT the `git branch -D a b c` this used to emit. Two reasons,\n * both learned the hard way: agents read a bare `-D` as destructive and stop to ask (so nothing\n * was ever cleaned, and this cap kept firing), and the multi-name form aborts wholesale on the\n * first branch git refuses, stranding every branch after it in the list. wp-cleanup recomputes\n * the verdicts, deletes one branch per command, and logs each pre-delete SHA.\n *\n * The wording must not overstate the safety: the list is NOT uniformly \"merged PR\" branches, and\n * a message that tells an agent to delete has to be exactly true about why that's safe.\n */\n branchCap(cache: MergedBranchesCache): FixHint {\n const options: Option[] = [];\n // Nothing auto-reapable → the ASK is the preferred move, and it comes FIRST. This is the whole\n // point of the option: with an empty `deletable` list the only advice left used to be \"raise\n // maxLocalBranches\" / \"set turnOffRuleUntilEpoch\", and an agent with no human in the loop\n // edited webpieces.config.json to escape — loosening the very rule that was working correctly.\n // A stale cache may not be quoted as fact — same rule the violation message follows. Naming\n // branches out of it while the message above says \"no count is asserted\" would be the guard\n // contradicting itself, and the contradiction resolves in the direction of deleting things.\n const stale = this.freshness !== null && this.freshness.stale;\n const askFirst = cache.deletable.length === 0 || stale;\n\n if (stale) {\n options.push(new Option(\n 'Run: pnpm wp-cleanup — the cached verdicts are stale, so it recomputes them from scratch, ' +\n 'deletes only what a MERGED PR proves is dead, and asks you about the rest.',\n cache.keep.length === 0,\n ));\n } else if (cache.deletable.length > 0) {\n const names = cache.deletable.map((entry: DeletableBranch): string => entry.branch);\n options.push(new Option(\n `Run: pnpm wp-cleanup — it deletes these ${String(names.length)} dead branches. Every one is ` +\n 'backed by a MERGED PR (its own, or the PR of the branch it snapshots) — that is now the ONLY ' +\n 'proof that reaps anything unattended — and every delete is logged with a recover-by-SHA ' +\n `command (see merged-branches.json for the per-branch reason): ${names.join(' ')}`,\n true,\n ));\n }\n\n const ask = this.askHumanOption(cache, askFirst);\n if (ask) options.push(ask);\n\n options.push(new Option(\n 'ONLY IF A HUMAN SAYS SO: raise branch-creation-guard.maxLocalBranches in ' +\n 'webpieces.config.json. Editing this config to get past a guard is not a fix you may make on ' +\n 'your own — ask first (use the option above).',\n ));\n options.push(new Option(\n 'ONLY IF A HUMAN SAYS SO: set branch-creation-guard.turnOffRuleUntilEpoch (a future epoch) ' +\n 'in webpieces.config.json to bypass this once. Same rule — ask, do not self-approve.',\n ));\n\n const kept = cache.keep.length > 0\n ? ` ${String(cache.keep.length)} branch(es) are NOT provably merged and were deliberately SPARED — ` +\n 'treat them as LIVE; a human decides, never the tooling and never you.'\n : '';\n\n return new FixHint(\n 'Too many local branches — reap the dead ones before creating another.',\n 'Full detail (deletable + spared, with per-branch reasons) is in .webpieces/merged-branches.json, ' +\n `refreshed ${cache.timestamp || 'never'}.${kept} Pick one:`,\n options,\n );\n }\n\n /**\n * The remedy that actually deletes something without loosening anything: SHOW the spared branches\n * and ASK the human which may go.\n *\n * `keep` is the list the tooling refuses to touch on its own — no merged PR, so no proof the work\n * is safe. That is exactly the list a human can adjudicate in five seconds and the tooling never\n * can, and it was never printed: the cap said \"N branches were SPARED, do not delete those\" and\n * then offered only config edits. So the agent edited the config.\n *\n * Every column is read straight off `.webpieces/merged-branches.json` (written by the detached\n * refresher) — nothing is recomputed on this blocking path. The SHA is there so the human can see\n * the delete is reversible; the commit count is there so \"0 commits\" branches are obvious yeses.\n */\n private askHumanOption(cache: MergedBranchesCache, preferred: boolean): Option | null {\n if (cache.keep.length === 0) return null;\n\n const rows = cache.keep.map((entry: DeletableBranch): string => {\n const sha = entry.sha !== '' ? entry.sha : '???????';\n const pr = entry.pr > 0\n ? `PR #${String(entry.pr)} ${entry.prState || 'MERGED'}`\n : (entry.prState !== '' ? `PR ${entry.prState}` : 'no PR');\n const commits = entry.commits >= 0 ? `${String(entry.commits)} commit(s) of its own` : 'commit count unknown';\n return ` ${entry.branch} [${sha}] ${pr} ${commits} — ${entry.reason}`;\n });\n\n return new Option(\n `ASK THE HUMAN which of these ${String(cache.keep.length)} branches may be deleted. They are ` +\n 'not provably dead, so the tooling will not reap them — but a human can decide in seconds, ' +\n 'and deleting one is the correct fix for \"too many branches\". Paste this list and ask:\\n' +\n rows.join('\\n') + '\\n' +\n 'Then delete ONLY the ones approved: git branch -D <approved-branch>\\n' +\n '(each is recoverable — `git branch <name> <sha>` restores it at the SHA shown above).\\n' +\n 'Do NOT delete any of these without an explicit yes, and do NOT edit webpieces.config.json instead.',\n preferred,\n );\n }\n\n /**\n * The worktree remedy — ONE command, `pnpm wp-cleanup`, and NOTHING that deletes anything itself.\n *\n * This guard used to print the equivalent git by hand: `git worktree prune && git worktree remove\n * <path> && ... && git branch -D <a> <b> <c>`, introduced as \"so no work can be lost\". On\n * 2026-07-30 that line named three worktrees with LIVE AGENTS in them, because \"a branch with no\n * commits of its own\" counted as dead — which is what every worktree looks like between\n * `git worktree add -b ... origin/main` and its first commit, i.e. exactly while it is being worked\n * in. That classification is fixed (see CLASSIFICATION_NO_COMMITS), and the one-liner is gone with\n * it, permanently, for two independent reasons:\n *\n * - a single `&&` chain deleting seven things has no safe partial failure; and\n * - this guard has no business owning reaping logic at all. wp-cleanup classifies from FRESH\n * verdicts, archives every branch as an `archive/<date>/<branch>` tag before touching it, logs a\n * `recover=` command per removal, refuses the primary clone and the tree it is standing in, never\n * passes `--force`, and ASKS about anything not provably merged.\n *\n * So this method names WHAT is at the cap and hands over. It must never emit a destructive command.\n */\n worktreeCap(cache: MergedBranchesCache): FixHint {\n const options: Option[] = [];\n const dead = cache.worktrees.filter((tree: DeletableWorktree): boolean => tree.deletable);\n const stale = this.freshness !== null && this.freshness.stale;\n\n if (dead.length > 0 && !stale) {\n // Paths as a plain indented LIST — a description of what wp-cleanup will offer to remove,\n // never a command. Nothing in this block is copy-pasteable into a shell, on purpose.\n const paths = dead\n .map((tree: DeletableWorktree): string =>\n ` ${tree.path} [${tree.branch || 'detached'}] - ${tree.reason}`)\n .join('\\n');\n options.push(new Option(\n `Run: pnpm wp-cleanup — ${String(dead.length)} of these worktrees is/are backed by a MERGED ` +\n 'PR or have a directory that is already gone, and it removes those (archiving each branch as ' +\n 'a tag first, logging a `recover=` command for each). Everything else it ASKS about before ' +\n 'touching. It will not remove the primary clone, the worktree you are standing in, or any ' +\n `worktree with uncommitted work:\\n${paths}`,\n true,\n ));\n } else {\n options.push(new Option(\n 'Run: pnpm wp-cleanup — it recomputes the verdicts from scratch, removes only what a MERGED ' +\n 'PR proves is dead, and ASKS you about anything that merely looks dead (closed-unmerged PR, ' +\n 'content already in main, never proposed, no commits yet) before removing anything. Answer ' +\n 'that prompt instead of raising the cap.',\n true,\n ));\n }\n\n options.push(new Option(\n 'ONLY IF A HUMAN SAYS SO: raise branch-creation-guard.maxWorktrees in webpieces.config.json. ' +\n 'Editing this config to get past a guard is not a fix you may make on your own — ask first.',\n ));\n options.push(new Option(\n 'ONLY IF A HUMAN SAYS SO: set branch-creation-guard.turnOffRuleUntilEpoch (a future epoch) ' +\n 'in webpieces.config.json to bypass this once. Same rule — ask, do not self-approve.',\n ));\n\n return new FixHint(\n 'Too many worktrees — run pnpm wp-cleanup before creating another. Do NOT remove any worktree ' +\n 'by hand: a worktree whose branch has no commits yet is almost always one an agent is working ' +\n 'in RIGHT NOW, and nothing here is proof that it is dead.',\n 'Full detail (with per-worktree reasons) is in .webpieces/merged-branches.json, ' +\n `refreshed ${cache.timestamp || 'never'}.${this.sparedNote(cache)} Pick one:`,\n options,\n );\n }\n\n /**\n * What was SPARED, and the standing instruction about it: leave it alone.\n *\n * Worded as \"not provably merged\", never as \"probably dead\". A spared worktree is LIVE until a\n * merged PR says otherwise, and the previous wording (\"holding unmerged work\") quietly implied that\n * everything NOT on that list was safe to remove.\n */\n private sparedNote(cache: MergedBranchesCache): string {\n const spared = cache.worktrees.filter((tree: DeletableWorktree): boolean => !tree.deletable).length;\n if (spared === 0) return '';\n return ` ${String(spared)} worktree(s) are NOT provably merged — treat every one of them as LIVE ` +\n '(an agent may be working in it right now) and never remove one without an explicit human yes.';\n }\n}\n"]}
|