@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.
- package/package.json +2 -2
- package/src/bin/shim.d.ts +2 -0
- package/src/bin/shim.js +46 -19
- package/src/bin/shim.js.map +1 -1
- package/src/core/build-context.d.ts +2 -1
- package/src/core/build-context.js +5 -2
- package/src/core/build-context.js.map +1 -1
- package/src/core/effective-tree.d.ts +75 -0
- package/src/core/effective-tree.js +136 -0
- package/src/core/effective-tree.js.map +1 -0
- 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/src/core/rules/content-read-scan.d.ts +29 -4
- package/src/core/rules/content-read-scan.js +54 -9
- package/src/core/rules/content-read-scan.js.map +1 -1
- package/src/core/rules/feature-branch-guard.js +1 -1
- package/src/core/rules/feature-branch-guard.js.map +1 -1
- package/src/core/rules/merged-branch-bash-guard.js +8 -2
- package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
- package/src/core/rules/merged-branch-message.d.ts +8 -0
- package/src/core/rules/merged-branch-message.js +17 -1
- package/src/core/rules/merged-branch-message.js.map +1 -1
- package/src/core/rules/read-stale-guard.js +2 -2
- package/src/core/rules/read-stale-guard.js.map +1 -1
- package/src/core/rules/stale-main-bash-guard.js +2 -2
- package/src/core/rules/stale-main-bash-guard.js.map +1 -1
- package/src/core/rules/stale-main-message.d.ts +12 -0
- package/src/core/rules/stale-main-message.js +17 -1
- package/src/core/rules/stale-main-message.js.map +1 -1
- package/src/core/rules/tree-recovery.d.ts +16 -0
- package/src/core/rules/tree-recovery.js +28 -7
- package/src/core/rules/tree-recovery.js.map +1 -1
- package/src/core/runner.js +56 -77
- package/src/core/runner.js.map +1 -1
- package/src/core/types.d.ts +12 -1
- package/src/core/types.js +14 -1
- package/src/core/types.js.map +1 -1
- 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":"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","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
|
|
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;
|