@webpieces/ai-hook-rules 0.4.508 → 0.4.510

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/package.json +2 -2
  2. package/src/bin/shim.d.ts +2 -0
  3. package/src/bin/shim.js +46 -19
  4. package/src/bin/shim.js.map +1 -1
  5. package/src/core/build-context.d.ts +2 -1
  6. package/src/core/build-context.js +5 -2
  7. package/src/core/build-context.js.map +1 -1
  8. package/src/core/effective-tree.d.ts +75 -0
  9. package/src/core/effective-tree.js +136 -0
  10. package/src/core/effective-tree.js.map +1 -0
  11. package/src/core/rules/branch-creation-guard.d.ts +20 -47
  12. package/src/core/rules/branch-creation-guard.js +58 -150
  13. package/src/core/rules/branch-creation-guard.js.map +1 -1
  14. package/src/core/rules/cap-remedies.d.ts +79 -0
  15. package/src/core/rules/cap-remedies.js +175 -0
  16. package/src/core/rules/cap-remedies.js.map +1 -0
  17. package/src/core/rules/content-read-scan.d.ts +29 -4
  18. package/src/core/rules/content-read-scan.js +54 -9
  19. package/src/core/rules/content-read-scan.js.map +1 -1
  20. package/src/core/rules/feature-branch-guard.js +1 -1
  21. package/src/core/rules/feature-branch-guard.js.map +1 -1
  22. package/src/core/rules/merged-branch-bash-guard.js +8 -2
  23. package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
  24. package/src/core/rules/merged-branch-message.d.ts +8 -0
  25. package/src/core/rules/merged-branch-message.js +17 -1
  26. package/src/core/rules/merged-branch-message.js.map +1 -1
  27. package/src/core/rules/read-stale-guard.js +2 -2
  28. package/src/core/rules/read-stale-guard.js.map +1 -1
  29. package/src/core/rules/stale-main-bash-guard.js +2 -2
  30. package/src/core/rules/stale-main-bash-guard.js.map +1 -1
  31. package/src/core/rules/stale-main-message.d.ts +12 -0
  32. package/src/core/rules/stale-main-message.js +17 -1
  33. package/src/core/rules/stale-main-message.js.map +1 -1
  34. package/src/core/rules/tree-recovery.d.ts +16 -0
  35. package/src/core/rules/tree-recovery.js +28 -7
  36. package/src/core/rules/tree-recovery.js.map +1 -1
  37. package/src/core/runner.js +56 -77
  38. package/src/core/runner.js.map +1 -1
  39. package/src/core/types.d.ts +12 -1
  40. package/src/core/types.js +14 -1
  41. package/src/core/types.js.map +1 -1
  42. package/templates/ai-hook.sh +5 -5
@@ -0,0 +1,136 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.EffectiveTreeResolver = exports.EffectiveTree = void 0;
4
+ exports.atRoot = atRoot;
5
+ const tslib_1 = require("tslib");
6
+ const path = tslib_1.__importStar(require("path"));
7
+ const child_process_1 = require("child_process");
8
+ const rules_config_1 = require("@webpieces/rules-config");
9
+ const command_scan_1 = require("./command-scan");
10
+ const shell_segment_scan_1 = require("./rules/shell-segment-scan");
11
+ /** Data-only (per CLAUDE.md, classes for data). */
12
+ class EffectiveTree {
13
+ /** The pre-`cd` cwd the hook was handed. For an agent in a worktree this is the primary clone. */
14
+ shellCwd;
15
+ /** The directory the command really runs in, after its own leading `cd`/`pushd` run. */
16
+ effectiveCwd;
17
+ /** The tree root to JUDGE: the owning worktree root, the foreign repo root, or the governed root. */
18
+ root;
19
+ /** The root that owns webpieces.config.json — where config and excludePaths come from. */
20
+ governedRoot;
21
+ kind;
22
+ /** The command acts on a tree other than the shell's own — messages must steer with `cd <root> &&`. */
23
+ redirected;
24
+ constructor(shellCwd, effectiveCwd, root, governedRoot, kind) {
25
+ this.shellCwd = shellCwd;
26
+ this.effectiveCwd = effectiveCwd;
27
+ this.root = root;
28
+ this.governedRoot = governedRoot;
29
+ this.kind = kind;
30
+ this.redirected = path.resolve(root) !== path.resolve(shellCwd);
31
+ }
32
+ }
33
+ exports.EffectiveTree = EffectiveTree;
34
+ class EffectiveTreeResolver {
35
+ scanner;
36
+ worktrees = new rules_config_1.WorktreeService();
37
+ shell;
38
+ constructor(scanner = new command_scan_1.CommandScanner()) {
39
+ this.scanner = scanner;
40
+ this.shell = new shell_segment_scan_1.ShellSegmentScan(scanner);
41
+ }
42
+ resolve(command, shellCwd, governedRoot) {
43
+ const effectiveCwd = this.effectiveCwd(command, shellCwd);
44
+ const kindAndRoot = this.classify(effectiveCwd, governedRoot);
45
+ return new EffectiveTree(shellCwd, effectiveCwd, kindAndRoot.root, governedRoot, kindAndRoot.kind);
46
+ }
47
+ /**
48
+ * The cwd a command actually runs from, resolving a LEADING run of `cd`/`pushd` in the command.
49
+ *
50
+ * ONLY a leading run counts. Once a non-cd command appears it has ALREADY run in the current dir,
51
+ * so a later `cd` must not retroactively pull it out of scope — otherwise a trailing
52
+ * `… && cd <exempt-tree>` would exempt the WHOLE line, smuggling a root-level `git push` past the
53
+ * guards. `cd a && cd b && git …` resolves left to right, matching the shell.
54
+ *
55
+ * Segmentation is ShellSegmentScan's (over CommandScanner), so quoting is handled exactly as the
56
+ * guards handle it: `echo "cd sub && git push"` is ONE opaque segment whose first word is `echo`,
57
+ * so the quoted `cd` is never picked up and cannot be weaponised into a scope escape.
58
+ */
59
+ effectiveCwd(command, shellCwd) {
60
+ let effective = shellCwd;
61
+ for (const segment of this.scanner.commandSegments(command)) {
62
+ const words = this.shell.effectiveWords(segment);
63
+ if (words[0] !== 'cd' && words[0] !== 'pushd')
64
+ break;
65
+ if (words[1] !== undefined)
66
+ effective = path.resolve(effective, words[1]);
67
+ }
68
+ return effective;
69
+ }
70
+ classify(effectiveCwd, governedRoot) {
71
+ // Fast path — no `cd`, or a `cd` within the governed tree. No worktree enumeration needed, and
72
+ // the nested-clone check keeps its exact previous behaviour.
73
+ if (this.isInside(effectiveCwd, governedRoot)) {
74
+ const gitRoot = gitToplevel(effectiveCwd);
75
+ if (gitRoot === null)
76
+ return new TreeClassification('primary', governedRoot);
77
+ if (path.resolve(gitRoot) === path.resolve(governedRoot))
78
+ return new TreeClassification('primary', governedRoot);
79
+ return new TreeClassification('foreign', gitRoot);
80
+ }
81
+ // Outside the governed tree: is it a linked worktree of the SAME repo? Longest match wins, so a
82
+ // worktree nested under another resolves to the innermost one.
83
+ const owner = this.owningWorktree(effectiveCwd, governedRoot);
84
+ if (owner !== null) {
85
+ return new TreeClassification(owner.isMain ? 'primary' : 'worktree', owner.path);
86
+ }
87
+ const gitRoot = gitToplevel(effectiveCwd);
88
+ if (gitRoot === null)
89
+ return new TreeClassification('outside', governedRoot);
90
+ if (path.resolve(gitRoot) === path.resolve(governedRoot))
91
+ return new TreeClassification('primary', governedRoot);
92
+ return new TreeClassification('foreign', gitRoot);
93
+ }
94
+ owningWorktree(dir, governedRoot) {
95
+ let best = null;
96
+ for (const tree of this.worktrees.listWorktrees(governedRoot)) {
97
+ if (!this.isInside(dir, tree.path))
98
+ continue;
99
+ if (best === null || tree.path.length > best.path.length)
100
+ best = tree;
101
+ }
102
+ return best;
103
+ }
104
+ /** Is `dir` the directory `root` itself, or somewhere beneath it? Pure path math, no filesystem. */
105
+ isInside(dir, root) {
106
+ const relative = path.relative(path.resolve(root), path.resolve(dir));
107
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
108
+ }
109
+ }
110
+ exports.EffectiveTreeResolver = EffectiveTreeResolver;
111
+ /** Data-only carrier for the two values classify() decides together. */
112
+ class TreeClassification {
113
+ kind;
114
+ root;
115
+ constructor(kind, root) {
116
+ this.kind = kind;
117
+ this.root = root;
118
+ }
119
+ }
120
+ /** The steering prefix every remedy needs: `cd` does not persist between tool calls, so a bare
121
+ * remedy runs in whatever directory the NEXT call starts in — which is never the tree we judged. */
122
+ // webpieces-disable no-function-outside-class -- one-line path/string formatter shared by the guards' message builders; a class around it would be ceremony
123
+ function atRoot(root, command) {
124
+ return `cd ${root} && ${command}`;
125
+ }
126
+ // The git repo root of `dir`, or null when it is not in a git repo / git is unavailable. `status !== 0`
127
+ // IS the expected "not a repo" answer (spawnSync does not throw on a non-zero exit), so no try/catch.
128
+ // webpieces-disable no-function-outside-class -- sibling of atRoot(); this module is the resolver plus its two pure helpers
129
+ function gitToplevel(dir) {
130
+ const r = (0, child_process_1.spawnSync)('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });
131
+ if (r.status !== 0)
132
+ return null;
133
+ const root = (r.stdout ?? '').trim();
134
+ return root !== '' ? root : null;
135
+ }
136
+ //# sourceMappingURL=effective-tree.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"effective-tree.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/effective-tree.ts"],"names":[],"mappings":";;;AA0JA,wBAEC;;AA5JD,mDAA6B;AAC7B,iDAA0C;AAE1C,0DAAoE;AAEpE,iDAAgD;AAChD,mEAA8D;AAmC9D,mDAAmD;AACnD,MAAa,aAAa;IACtB,kGAAkG;IACzF,QAAQ,CAAS;IAC1B,wFAAwF;IAC/E,YAAY,CAAS;IAC9B,qGAAqG;IAC5F,IAAI,CAAS;IACtB,0FAA0F;IACjF,YAAY,CAAS;IACrB,IAAI,CAAW;IACxB,uGAAuG;IAC9F,UAAU,CAAU;IAE7B,YAAY,QAAgB,EAAE,YAAoB,EAAE,IAAY,EAAE,YAAoB,EAAE,IAAc;QAClG,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,CAAC,YAAY,GAAG,YAAY,CAAC;QACjC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,YAAY,GAAG,YAAY,CAAC;QACjC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,UAAU,GAAG,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,IAAI,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC;IACpE,CAAC;CACJ;AArBD,sCAqBC;AAED,MAAa,qBAAqB;IAID;IAHZ,SAAS,GAAG,IAAI,8BAAe,EAAE,CAAC;IAClC,KAAK,CAAmB;IAEzC,YAA6B,UAA0B,IAAI,6BAAc,EAAE;QAA9C,YAAO,GAAP,OAAO,CAAuC;QACvE,IAAI,CAAC,KAAK,GAAG,IAAI,qCAAgB,CAAC,OAAO,CAAC,CAAC;IAC/C,CAAC;IAED,OAAO,CAAC,OAAe,EAAE,QAAgB,EAAE,YAAoB;QAC3D,MAAM,YAAY,GAAG,IAAI,CAAC,YAAY,CAAC,OAAO,EAAE,QAAQ,CAAC,CAAC;QAC1D,MAAM,WAAW,GAAG,IAAI,CAAC,QAAQ,CAAC,YAAY,EAAE,YAAY,CAAC,CAAC;QAC9D,OAAO,IAAI,aAAa,CAAC,QAAQ,EAAE,YAAY,EAAE,WAAW,CAAC,IAAI,EAAE,YAAY,EAAE,WAAW,CAAC,IAAI,CAAC,CAAC;IACvG,CAAC;IAED;;;;;;;;;;;OAWG;IACH,YAAY,CAAC,OAAe,EAAE,QAAgB;QAC1C,IAAI,SAAS,GAAG,QAAQ,CAAC;QACzB,KAAK,MAAM,OAAO,IAAI,IAAI,CAAC,OAAO,CAAC,eAAe,CAAC,OAAO,CAAC,EAAE,CAAC;YAC1D,MAAM,KAAK,GAAG,IAAI,CAAC,KAAK,CAAC,cAAc,CAAC,OAAO,CAAC,CAAC;YACjD,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,IAAI,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,OAAO;gBAAE,MAAM;YACrD,IAAI,KAAK,CAAC,CAAC,CAAC,KAAK,SAAS;gBAAE,SAAS,GAAG,IAAI,CAAC,OAAO,CAAC,SAAS,EAAE,KAAK,CAAC,CAAC,CAAC,CAAC,CAAC;QAC9E,CAAC;QACD,OAAO,SAAS,CAAC;IACrB,CAAC;IAEO,QAAQ,CAAC,YAAoB,EAAE,YAAoB;QACvD,+FAA+F;QAC/F,6DAA6D;QAC7D,IAAI,IAAI,CAAC,QAAQ,CAAC,YAAY,EAAE,YAAY,CAAC,EAAE,CAAC;YAC5C,MAAM,OAAO,GAAG,WAAW,CAAC,YAAY,CAAC,CAAC;YAC1C,IAAI,OAAO,KAAK,IAAI;gBAAE,OAAO,IAAI,kBAAkB,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;YAC7E,IAAI,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,KAAK,IAAI,CAAC,OAAO,CAAC,YAAY,CAAC;gBAAE,OAAO,IAAI,kBAAkB,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;YACjH,OAAO,IAAI,kBAAkB,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;QACtD,CAAC;QAED,gGAAgG;QAChG,+DAA+D;QAC/D,MAAM,KAAK,GAAG,IAAI,CAAC,cAAc,CAAC,YAAY,EAAE,YAAY,CAAC,CAAC;QAC9D,IAAI,KAAK,KAAK,IAAI,EAAE,CAAC;YACjB,OAAO,IAAI,kBAAkB,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,UAAU,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;QACrF,CAAC;QAED,MAAM,OAAO,GAAG,WAAW,CAAC,YAAY,CAAC,CAAC;QAC1C,IAAI,OAAO,KAAK,IAAI;YAAE,OAAO,IAAI,kBAAkB,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;QAC7E,IAAI,IAAI,CAAC,OAAO,CAAC,OAAO,CAAC,KAAK,IAAI,CAAC,OAAO,CAAC,YAAY,CAAC;YAAE,OAAO,IAAI,kBAAkB,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;QACjH,OAAO,IAAI,kBAAkB,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;IACtD,CAAC;IAEO,cAAc,CAAC,GAAW,EAAE,YAAoB;QACpD,IAAI,IAAI,GAAoB,IAAI,CAAC;QACjC,KAAK,MAAM,IAAI,IAAI,IAAI,CAAC,SAAS,CAAC,aAAa,CAAC,YAAY,CAAC,EAAE,CAAC;YAC5D,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,GAAG,EAAE,IAAI,CAAC,IAAI,CAAC;gBAAE,SAAS;YAC7C,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,CAAC,IAAI,CAAC,MAAM,GAAG,IAAI,CAAC,IAAI,CAAC,MAAM;gBAAE,IAAI,GAAG,IAAI,CAAC;QAC1E,CAAC;QACD,OAAO,IAAI,CAAC;IAChB,CAAC;IAED,oGAAoG;IAC5F,QAAQ,CAAC,GAAW,EAAE,IAAY;QACtC,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,IAAI,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC;QACtE,OAAO,QAAQ,KAAK,EAAE,IAAI,CAAC,CAAC,QAAQ,CAAC,UAAU,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI,CAAC,UAAU,CAAC,QAAQ,CAAC,CAAC,CAAC;IACzF,CAAC;CACJ;AAzED,sDAyEC;AAED,wEAAwE;AACxE,MAAM,kBAAkB;IACX,IAAI,CAAW;IACf,IAAI,CAAS;IAEtB,YAAY,IAAc,EAAE,IAAY;QACpC,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;QACjB,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC;IACrB,CAAC;CACJ;AAED;qGACqG;AACrG,4JAA4J;AAC5J,SAAgB,MAAM,CAAC,IAAY,EAAE,OAAe;IAChD,OAAO,MAAM,IAAI,OAAO,OAAO,EAAE,CAAC;AACtC,CAAC;AAED,wGAAwG;AACxG,sGAAsG;AACtG,4HAA4H;AAC5H,SAAS,WAAW,CAAC,GAAW;IAC5B,MAAM,CAAC,GAAG,IAAA,yBAAS,EAAC,KAAK,EAAE,CAAC,IAAI,EAAE,GAAG,EAAE,WAAW,EAAE,iBAAiB,CAAC,EAAE,EAAE,QAAQ,EAAE,MAAM,EAAE,CAAC,CAAC;IAC9F,IAAI,CAAC,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAChC,MAAM,IAAI,GAAG,CAAC,CAAC,CAAC,MAAM,IAAI,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IACrC,OAAO,IAAI,KAAK,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,IAAI,CAAC;AACrC,CAAC","sourcesContent":["import * as path from 'path';\nimport { spawnSync } from 'child_process';\n\nimport { WorktreeService, Worktree } from '@webpieces/rules-config';\n\nimport { CommandScanner } from './command-scan';\nimport { ShellSegmentScan } from './rules/shell-segment-scan';\n\n/**\n * WHICH TREE does a Bash command actually act on? The ONE resolver every bash guard and the\n * force-to-root check share.\n *\n * WHY it has to exist at all: an agent's Bash tool does NOT persist `cd` between calls (verified —\n * a standalone `cd <worktree>` followed by `pwd` in the next call reports the primary clone again).\n * So an agent working in a linked worktree writes self-contained `cd <worktree> && …` commands, and\n * the shell cwd the PreToolUse hook is handed is ALWAYS the primary clone. Every guard that reasons\n * from that cwd judges the wrong tree on every single call. Three field sightings in one session:\n * an `ls` of a path outside every repo blocked as \"this branch is merged\"; a version-drift cure\n * (`pnpm install`) that could not be typed from the directory that needed it; and a command aimed at\n * `/private/tmp` blocked because the PRIMARY clone's main was behind — with a remedy (`git pull` in\n * the primary clone) the agent had been explicitly forbidden to run.\n *\n * Two separate copies of the cwd logic used to exist (the runner's foreign-repo/excludePaths check\n * and force-to-root). They are both this class now: two resolvers WILL disagree about which tree you\n * are in, and a guard that disagrees with the guard beside it is worse than either being wrong.\n *\n * Resolution, in order:\n * 1. `effectiveCwd` — the leading run of `cd`/`pushd` in the command itself, resolved left to right.\n * 2. If that sits under the governed root, the tree is the governed root unless git says the\n * directory belongs to a DIFFERENT repo (a nested clone under `repositories/**`) → foreign.\n * 3. Otherwise ask git for the worktree list. A LINKED WORKTREE of the governed repo is MANAGED —\n * it is the same project, just another checkout — so guards run, keyed on THAT tree's branch and\n * its own `.webpieces/` cache. Before this, a linked worktree read as a different git toplevel\n * and so as FOREIGN, which silently disabled every guard for `cd <worktree> && …` commands.\n * 4. Anything else that is a git repo → foreign (out of scope, hands-off, as before).\n * 5. Not a git repo at all (`cd /tmp && …`) → OUTSIDE. The guards still run — an absolute path back\n * into the repo must still be judged — but nothing the command names relative to `/tmp` is\n * workspace content, which is what ContentReadScan uses `effectiveCwd` for.\n */\nexport type TreeKind = 'primary' | 'worktree' | 'foreign' | 'outside';\n\n/** Data-only (per CLAUDE.md, classes for data). */\nexport class EffectiveTree {\n /** The pre-`cd` cwd the hook was handed. For an agent in a worktree this is the primary clone. */\n readonly shellCwd: string;\n /** The directory the command really runs in, after its own leading `cd`/`pushd` run. */\n readonly effectiveCwd: string;\n /** The tree root to JUDGE: the owning worktree root, the foreign repo root, or the governed root. */\n readonly root: string;\n /** The root that owns webpieces.config.json — where config and excludePaths come from. */\n readonly governedRoot: string;\n readonly kind: TreeKind;\n /** The command acts on a tree other than the shell's own — messages must steer with `cd <root> &&`. */\n readonly redirected: boolean;\n\n constructor(shellCwd: string, effectiveCwd: string, root: string, governedRoot: string, kind: TreeKind) {\n this.shellCwd = shellCwd;\n this.effectiveCwd = effectiveCwd;\n this.root = root;\n this.governedRoot = governedRoot;\n this.kind = kind;\n this.redirected = path.resolve(root) !== path.resolve(shellCwd);\n }\n}\n\nexport class EffectiveTreeResolver {\n private readonly worktrees = new WorktreeService();\n private readonly shell: ShellSegmentScan;\n\n constructor(private readonly scanner: CommandScanner = new CommandScanner()) {\n this.shell = new ShellSegmentScan(scanner);\n }\n\n resolve(command: string, shellCwd: string, governedRoot: string): EffectiveTree {\n const effectiveCwd = this.effectiveCwd(command, shellCwd);\n const kindAndRoot = this.classify(effectiveCwd, governedRoot);\n return new EffectiveTree(shellCwd, effectiveCwd, kindAndRoot.root, governedRoot, kindAndRoot.kind);\n }\n\n /**\n * The cwd a command actually runs from, resolving a LEADING run of `cd`/`pushd` in the command.\n *\n * ONLY a leading run counts. Once a non-cd command appears it has ALREADY run in the current dir,\n * so a later `cd` must not retroactively pull it out of scope — otherwise a trailing\n * `… && cd <exempt-tree>` would exempt the WHOLE line, smuggling a root-level `git push` past the\n * guards. `cd a && cd b && git …` resolves left to right, matching the shell.\n *\n * Segmentation is ShellSegmentScan's (over CommandScanner), so quoting is handled exactly as the\n * guards handle it: `echo \"cd sub && git push\"` is ONE opaque segment whose first word is `echo`,\n * so the quoted `cd` is never picked up and cannot be weaponised into a scope escape.\n */\n effectiveCwd(command: string, shellCwd: string): string {\n let effective = shellCwd;\n for (const segment of this.scanner.commandSegments(command)) {\n const words = this.shell.effectiveWords(segment);\n if (words[0] !== 'cd' && words[0] !== 'pushd') break;\n if (words[1] !== undefined) effective = path.resolve(effective, words[1]);\n }\n return effective;\n }\n\n private classify(effectiveCwd: string, governedRoot: string): TreeClassification {\n // Fast path — no `cd`, or a `cd` within the governed tree. No worktree enumeration needed, and\n // the nested-clone check keeps its exact previous behaviour.\n if (this.isInside(effectiveCwd, governedRoot)) {\n const gitRoot = gitToplevel(effectiveCwd);\n if (gitRoot === null) return new TreeClassification('primary', governedRoot);\n if (path.resolve(gitRoot) === path.resolve(governedRoot)) return new TreeClassification('primary', governedRoot);\n return new TreeClassification('foreign', gitRoot);\n }\n\n // Outside the governed tree: is it a linked worktree of the SAME repo? Longest match wins, so a\n // worktree nested under another resolves to the innermost one.\n const owner = this.owningWorktree(effectiveCwd, governedRoot);\n if (owner !== null) {\n return new TreeClassification(owner.isMain ? 'primary' : 'worktree', owner.path);\n }\n\n const gitRoot = gitToplevel(effectiveCwd);\n if (gitRoot === null) return new TreeClassification('outside', governedRoot);\n if (path.resolve(gitRoot) === path.resolve(governedRoot)) return new TreeClassification('primary', governedRoot);\n return new TreeClassification('foreign', gitRoot);\n }\n\n private owningWorktree(dir: string, governedRoot: string): Worktree | null {\n let best: Worktree | null = null;\n for (const tree of this.worktrees.listWorktrees(governedRoot)) {\n if (!this.isInside(dir, tree.path)) continue;\n if (best === null || tree.path.length > best.path.length) best = tree;\n }\n return best;\n }\n\n /** Is `dir` the directory `root` itself, or somewhere beneath it? Pure path math, no filesystem. */\n private isInside(dir: string, root: string): boolean {\n const relative = path.relative(path.resolve(root), path.resolve(dir));\n return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));\n }\n}\n\n/** Data-only carrier for the two values classify() decides together. */\nclass TreeClassification {\n readonly kind: TreeKind;\n readonly root: string;\n\n constructor(kind: TreeKind, root: string) {\n this.kind = kind;\n this.root = root;\n }\n}\n\n/** The steering prefix every remedy needs: `cd` does not persist between tool calls, so a bare\n * remedy runs in whatever directory the NEXT call starts in — which is never the tree we judged. */\n// webpieces-disable no-function-outside-class -- one-line path/string formatter shared by the guards' message builders; a class around it would be ceremony\nexport function atRoot(root: string, command: string): string {\n return `cd ${root} && ${command}`;\n}\n\n// The git repo root of `dir`, or null when it is not in a git repo / git is unavailable. `status !== 0`\n// IS the expected \"not a repo\" answer (spawnSync does not throw on a non-zero exit), so no try/catch.\n// webpieces-disable no-function-outside-class -- sibling of atRoot(); this module is the resolver plus its two pure helpers\nfunction gitToplevel(dir: string): string | null {\n const r = spawnSync('git', ['-C', dir, 'rev-parse', '--show-toplevel'], { encoding: 'utf8' });\n if (r.status !== 0) return null;\n const root = (r.stdout ?? '').trim();\n return root !== '' ? root : null;\n}\n"]}
@@ -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 this.worktreeCapFixHint(this.worktreeCapCache);
164
+ return remedies.worktreeCap(this.worktreeCapCache);
160
165
  if (this.capCache)
161
- return this.capFixHint(this.capCache);
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.mergedBranches.readMergedBranches(ctx.workspaceRoot);
333
+ const cache = this.loadReconciledCache(ctx);
328
334
  if (!cache)
329
335
  return null;
330
336
  this.capCache = cache;
331
- const reapable = cache.deletable.length;
332
- const detail = reapable > 0
333
- ? `${String(reapable)} of them are dead (merged, or holding no commits) and can be deleted right now.`
334
- : 'None of them are dead, so none can be auto-reaped — see the options below.';
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
- `${detail} Clean up before creating another.`);
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.mergedBranches.readMergedBranches(ctx.workspaceRoot);
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)}. ${detail} Clean up before creating another.`);
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;