@webpieces/ai-hook-rules 0.4.569 → 0.4.571
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 +6 -7
- package/src/adapters/guards-hook.d.ts +1 -0
- package/src/adapters/guards-hook.js +1 -0
- package/src/adapters/guards-hook.js.map +1 -1
- package/src/adapters/rules-hook.d.ts +1 -0
- package/src/adapters/rules-hook.js +1 -0
- package/src/adapters/rules-hook.js.map +1 -1
- package/src/bin/install-entry.d.ts +1 -0
- package/src/bin/install-entry.js +1 -0
- package/src/bin/install-entry.js.map +1 -1
- package/src/bin/upgrade-shim.d.ts +1 -0
- package/src/bin/upgrade-shim.js +1 -0
- package/src/bin/upgrade-shim.js.map +1 -1
- package/src/core/rules/branch-creation-guard.d.ts +4 -10
- package/src/core/rules/branch-creation-guard.js +14 -43
- package/src/core/rules/branch-creation-guard.js.map +1 -1
- package/src/core/rules/cap-remedies.d.ts +24 -68
- package/src/core/rules/cap-remedies.js +36 -157
- package/src/core/rules/cap-remedies.js.map +1 -1
- package/bin/wp-ai-guards-hook.js +0 -16
- package/bin/wp-ai-rules-hook.js +0 -16
- package/bin/wp-install-ai-hooks.js +0 -23
- package/bin/wp-upgrade-shim.js +0 -21
package/package.json
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webpieces/ai-hook-rules",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.571",
|
|
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",
|
|
7
7
|
"bin": {
|
|
8
|
-
"wp-ai-rules-hook": "./
|
|
9
|
-
"wp-ai-guards-hook": "./
|
|
10
|
-
"wp-install-ai-hooks": "./bin/
|
|
11
|
-
"wp-upgrade-shim": "./bin/
|
|
8
|
+
"wp-ai-rules-hook": "./src/adapters/rules-hook.js",
|
|
9
|
+
"wp-ai-guards-hook": "./src/adapters/guards-hook.js",
|
|
10
|
+
"wp-install-ai-hooks": "./src/bin/install-entry.js",
|
|
11
|
+
"wp-upgrade-shim": "./src/bin/upgrade-shim.js"
|
|
12
12
|
},
|
|
13
13
|
"exports": {
|
|
14
14
|
".": "./src/index.js",
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
},
|
|
20
20
|
"files": [
|
|
21
21
|
"src/**/*",
|
|
22
|
-
"bin/**/*",
|
|
23
22
|
"templates/**/*",
|
|
24
23
|
"openclaw.plugin.json",
|
|
25
24
|
"README.md"
|
|
@@ -32,7 +31,7 @@
|
|
|
32
31
|
"directory": "packages/tooling/ai-hook-rules"
|
|
33
32
|
},
|
|
34
33
|
"dependencies": {
|
|
35
|
-
"@webpieces/rules-config": "0.4.
|
|
34
|
+
"@webpieces/rules-config": "0.4.571"
|
|
36
35
|
},
|
|
37
36
|
"publishConfig": {
|
|
38
37
|
"access": "public"
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"guards-hook.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/guards-hook.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"guards-hook.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/guards-hook.ts"],"names":[],"mappings":";;;AAKA,oBAEC;AAND,mFAAmF;AACnF,gGAAgG;AAChG,2CAAsC;AAEtC,SAAgB,IAAI;IAChB,OAAO,IAAA,mBAAO,EAAC,QAAQ,CAAC,CAAC;AAC7B,CAAC;AAED,IAAI,OAAO,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;IAC1B,KAAK,IAAI,EAAE,CAAC;AAChB,CAAC","sourcesContent":["#!/usr/bin/env node\n// Claude Code PreToolUse adapter for the GIT/PR/BRANCH GUARDS hook (matcher Bash).\n// File-edit payloads pass through untouched — code-style validation is the separate rules hook.\nimport { runMain } from './hook-core';\n\nexport function main(): Promise<void> {\n return runMain('guards');\n}\n\nif (require.main === module) {\n void main();\n}\n"]}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"rules-hook.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/rules-hook.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"rules-hook.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/adapters/rules-hook.ts"],"names":[],"mappings":";;;AAKA,oBAEC;AAND,+FAA+F;AAC/F,iGAAiG;AACjG,2CAAsC;AAEtC,SAAgB,IAAI;IAChB,OAAO,IAAA,mBAAO,EAAC,OAAO,CAAC,CAAC;AAC5B,CAAC;AAED,IAAI,OAAO,CAAC,IAAI,KAAK,MAAM,EAAE,CAAC;IAC1B,KAAK,IAAI,EAAE,CAAC;AAChB,CAAC","sourcesContent":["#!/usr/bin/env node\n// Claude Code PreToolUse adapter for the CODE-STYLE RULES hook (matcher Write|Edit|MultiEdit).\n// Bash payloads pass through untouched — branch/PR/merge protection is the separate guards hook.\nimport { runMain } from './hook-core';\n\nexport function main(): Promise<void> {\n return runMain('rules');\n}\n\nif (require.main === module) {\n void main();\n}\n"]}
|
package/src/bin/install-entry.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"install-entry.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/install-entry.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"install-entry.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/install-entry.ts"],"names":[],"mappings":";;;AAkCA,wCAuBC;AAiBD,8CAEC;AAKD,oCAsBC;AAtGD,iCAA8D;AAC9D,+CAA2C;AAE3C,8EAA8E;AAC9E,kFAAkF;AAClF,EAAE;AACF,yFAAyF;AACzF,oGAAoG;AACpG,sGAAsG;AACtG,qGAAqG;AACrG,oGAAoG;AACpG,sGAAsG;AACtG,kGAAkG;AAClG,2FAA2F;AAC3F,EAAE;AACF,mGAAmG;AACnG,4FAA4F;AAC5F,EAAE;AACF,kGAAkG;AAClG,mGAAmG;AACnG,oGAAoG;AACpG,qGAAqG;AACrG,8FAA8F;AAC9F,8EAA8E;AAE9E,iGAAiG;AACjG,8CAA8C;AAC9C,MAAM,GAAG,GAAG,SAAS,CAAC;AACtB,MAAM,KAAK,GAAG,MAAM,CAAC;AAErB,sGAAsG;AACtG,+EAA+E;AAC/E,iOAAiO;AACjO,SAAgB,cAAc,CAAC,MAAc,EAAE,aAAsB;IACjE,MAAM,KAAK,GAAG;QACV,GAAG,GAAG,0EAA0E,KAAK,EAAE;QACvF,EAAE;QACF,KAAK,MAAM,EAAE;QACb,EAAE;QACF,8FAA8F;QAC9F,6FAA6F;QAC7F,mEAAmE;QACnE,EAAE;QACF,GAAG,GAAG,kCAAkC,KAAK,EAAE;QAC/C,GAAG,GAAG,OAAO,mBAAY,GAAG,KAAK,EAAE;QACnC,EAAE;KACL,CAAC;IACF,IAAI,aAAa,EAAE,CAAC;QAChB,8FAA8F;QAC9F,sFAAsF;QACtF,KAAK,CAAC,IAAI,CAAC,uFAAuF,CAAC,CAAC;QACpG,KAAK,CAAC,IAAI,CAAC,+EAA+E,CAAC,CAAC;IAChG,CAAC;SAAM,CAAC;QACJ,KAAK,CAAC,IAAI,CAAC,mFAAmF,CAAC,CAAC;IACpG,CAAC;IACD,OAAO,KAAK,CAAC;AACjB,CAAC;AAED,0FAA0F;AAC1F,MAAM,gBAAiB,SAAQ,KAAK;IACvB,IAAI,CAAU;CAC1B;AAED,qGAAqG;AACrG,MAAM,WAAW;IACb,IAAI,CAAuB;CAC9B;AAED,uGAAuG;AACvG,qGAAqG;AACrG,uGAAuG;AACvG,oGAAoG;AACpG,iOAAiO;AACjO,SAAgB,iBAAiB,CAAC,KAAY;IAC1C,OAAQ,KAA0B,CAAC,IAAI,KAAK,kBAAkB,CAAC;AACnE,CAAC;AAED,iGAAiG;AACjG,kCAAkC;AAClC,iOAAiO;AAC1N,KAAK,UAAU,YAAY,CAAC,GAAW;IAC1C,6FAA6F;IAC7F,8FAA8F;IAC9F,MAAM,aAAa,GAAG,IAAA,mBAAY,EAAC,GAAG,CAAC,KAAK,IAAI,CAAC;IACjD,IAAA,eAAQ,EAAC,GAAG,CAAC,CAAC;IAEd,kFAAkF;IAClF,gGAAgG;IAChG,kGAAkG;IAClG,8NAA8N;IAC9N,8DAA8D;IAC9D,IAAI,CAAC;QACD,iEAAiE;QACjE,MAAM,KAAK,GAAG,OAAO,CAAC,SAAS,CAAgB,CAAC;QAChD,MAAM,KAAK,CAAC,IAAI,EAAE,CAAC;QACnB,OAAO,CAAC,CAAC;IACb,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,IAAI,CAAC,iBAAiB,CAAC,KAAK,CAAC;YAAE,MAAM,KAAK,CAAC,CAAG,mDAAmD;QACjG,KAAK,MAAM,IAAI,IAAI,cAAc,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,EAAE,aAAa,CAAC;YAAE,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;QACpG,OAAO,CAAC,CAAC;IACb,CAAC;AACL,CAAC","sourcesContent":["#!/usr/bin/env node\nimport { healShim, findShimRoot, RECOVERY_CMD } from './shim';\nimport { toError } from '../core/to-error';\n\n// ---------------------------------------------------------------------------\n// The `wp-install-ai-hooks` entry point, deliberately kept FREE of heavy imports.\n//\n// THE BUG THIS FIXES — the installer was a victim of the very corruption it must repair.\n// setup.ts top-level-imports @webpieces/rules-config, which imports minimatch. When node_modules is\n// corrupt — a package half-written by an install that was killed mid-copy — node dies at REQUIRE time\n// with MODULE_NOT_FOUND, before a single line of the installer runs. So `pnpm wp-install-ai-hooks` —\n// the one command that can rewrite the fail-closed shim — died with a raw 30-line node loader trace\n// and repaired nothing. Worse, `pnpm install` cannot heal that corruption either (pnpm sees the right\n// version on disk, considers the package installed, and skips it), so the repo was wedged: guards\n// down, installer unable to reinstall them, and the only signal an unreadable stack trace.\n//\n// The seam: ./shim imports NOTHING but fs and path. Writing the shim never needed the rule engine.\n// So do the repair FIRST with the dependency-free module, and only then reach for setup.ts:\n//\n// 1. healShim(cwd) — refresh the committed shim. Works on a corrupt tree. This re-arms the\n// fail-closed gate, so guards-being-down becomes LOUD instead of silent.\n// 2. require('./setup') — the full install (config seeding, settings.json wiring). Needs the rule\n// engine, so it loads LAZILY: a corrupt tree can no longer preempt step 1.\n// 3. MODULE_NOT_FOUND — print the one command that actually repairs it, not a node stack.\n// ---------------------------------------------------------------------------\n\n// ANSI red — the recovery command is the whole point of this message, so it must not scroll past\n// unnoticed the way the old loader trace did.\nconst RED = '\u001b[31;1m';\nconst RESET = '\u001b[0m';\n\n// The human-facing recovery notice, printed to stderr INSTEAD of a raw node loader trace. Mirrors the\n// shim's deny reason so the terminal and the AI tell the human the same story.\n// webpieces-disable no-function-outside-class -- bin entry point: this module MUST load with only fs+path (see header). A DI-managed class would pull the container in and reintroduce the exact require-time crash being fixed.\nexport function recoveryNotice(detail: string, shimRefreshed: boolean): string[] {\n const lines = [\n `${RED}🛑 @webpieces: cannot run the installer — your node_modules is corrupt.${RESET}`,\n '',\n ` ${detail}`,\n '',\n ' A package is only partially written on disk (usually an install that was killed mid-copy).',\n ' A plain `pnpm install` will NOT fix it: pnpm sees the correct version in the package.json',\n ' already on disk, considers the package installed, and skips it.',\n '',\n `${RED} Run exactly this, then retry:${RESET}`,\n `${RED} ${RECOVERY_CMD}${RESET}`,\n '',\n ];\n if (shimRefreshed) {\n // The important half of the job still got done: the fail-closed gate is current, so the AI is\n // BLOCKED (citing this same command) rather than silently editing behind dead guards.\n lines.push(' The AI guard shim WAS refreshed, so tool calls are now BLOCKED until you repair the');\n lines.push(' tree. That is intentional — it is what stops the AI from working unguarded.');\n } else {\n lines.push(' No committed shim was found to refresh. Re-run this installer after the repair.');\n }\n return lines;\n}\n\n// Node stamps a `code` onto require() failures, which the base Error type does not carry.\nclass NodeRequireError extends Error {\n readonly code?: string;\n}\n\n// The lazily-required ./setup module. Named (not an inline literal) so the require cast stays typed.\nclass SetupModule {\n main!: () => Promise<void>;\n}\n\n// A require() failure from a corrupt / partially-written node_modules. Node reports a missing relative\n// specifier ('./assert-valid-pattern.js' — a package's own file absent from disk) and a missing bare\n// specifier ('@webpieces/rules-config' — package not installed at all) with the SAME code. Both mean a\n// broken tree from the installer's point of view, and both have the same cure, so both map to true.\n// webpieces-disable no-function-outside-class -- bin entry point: this module MUST load with only fs+path (see header). A DI-managed class would pull the container in and reintroduce the exact require-time crash being fixed.\nexport function isBrokenTreeError(error: Error): boolean {\n return (error as NodeRequireError).code === 'MODULE_NOT_FOUND';\n}\n\n// Returns the process exit code (0 = ok). Kept as a function (not top-level code) so it is unit-\n// testable without spawning node.\n// webpieces-disable no-function-outside-class -- bin entry point: this module MUST load with only fs+path (see header). A DI-managed class would pull the container in and reintroduce the exact require-time crash being fixed.\nexport async function runInstaller(cwd: string): Promise<number> {\n // STEP 1 — re-arm the fail-closed gate FIRST, via the dependency-free module. healShim never\n // throws and only rewrites a shim that ALREADY exists, so a global install is left untouched.\n const shimRefreshed = findShimRoot(cwd) !== null;\n healShim(cwd);\n\n // STEP 2 — the real install, loaded LAZILY ON PURPOSE. A static import would drag\n // @webpieces/rules-config → minimatch in at module-load time and a corrupt tree would kill this\n // file before step 1 ever ran — which is precisely the failure being fixed here. Do not hoist it.\n // webpieces-disable no-unmanaged-exceptions -- this IS the top-level chokepoint: the bin entry. It exists to turn a raw MODULE_NOT_FOUND loader trace into an actionable recovery message; a real bug is re-thrown untouched.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n // eslint-disable-next-line @typescript-eslint/no-require-imports\n const setup = require('./setup') as SetupModule;\n await setup.main();\n return 0;\n } catch (err: unknown) {\n const error = toError(err);\n if (!isBrokenTreeError(error)) throw error; // a real bug — never hide it behind a nice message\n for (const line of recoveryNotice(error.message.split('\\n')[0], shimRefreshed)) console.error(line);\n return 1;\n }\n}\n"]}
|
package/src/bin/upgrade-shim.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"upgrade-shim.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/upgrade-shim.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"upgrade-shim.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/bin/upgrade-shim.ts"],"names":[],"mappings":";;;AA2BA,wCAsBC;;AAhDD,+CAAyB;AAEzB,iCAA4D;AAC5D,+CAA2C;AAE3C,8EAA8E;AAC9E,kFAAkF;AAClF,EAAE;AACF,mGAAmG;AACnG,wGAAwG;AACxG,qGAAqG;AACrG,uGAAuG;AACvG,8FAA8F;AAC9F,EAAE;AACF,uGAAuG;AACvG,sGAAsG;AACtG,yGAAyG;AACzG,yGAAyG;AACzG,uFAAuF;AACvF,8EAA8E;AAC9E,MAAM,GAAG,GAAG,QAAQ,CAAC;AACrB,MAAM,KAAK,GAAG,KAAK,CAAC;AAEpB,yGAAyG;AACzG,yBAAyB;AACzB,uRAAuR;AACvR,SAAgB,cAAc,CAAC,GAAW;IACtC,MAAM,IAAI,GAAG,IAAA,mBAAY,EAAC,GAAG,CAAC,CAAC;IAC/B,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;QAChB,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,gFAAgF,KAAK,EAAE,CAAC,CAAC;QAC7G,OAAO,CAAC,KAAK,CAAC,iHAAiH,CAAC,CAAC;QACjI,OAAO,CAAC,CAAC;IACb,CAAC;IACD,MAAM,MAAM,GAAG,IAAA,eAAQ,EAAC,IAAI,CAAC,CAAC;IAC9B,yMAAyM;IACzM,8DAA8D;IAC9D,IAAI,CAAC;QACD,EAAE,CAAC,aAAa,CAAC,MAAM,EAAE,IAAA,iBAAU,GAAE,EAAE,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC,CAAC;QACxD,8FAA8F;QAC9F,EAAE,CAAC,SAAS,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;QAC5B,OAAO,CAAC,GAAG,CAAC,iDAAiD,MAAM,6BAA6B,CAAC,CAAC;QAClG,OAAO,CAAC,GAAG,CAAC,mFAAmF,CAAC,CAAC;QACjG,OAAO,CAAC,CAAC;IACb,CAAC;IAAC,OAAO,GAAY,EAAE,CAAC;QACpB,MAAM,KAAK,GAAG,IAAA,kBAAO,EAAC,GAAG,CAAC,CAAC;QAC3B,OAAO,CAAC,KAAK,CAAC,GAAG,GAAG,kCAAkC,MAAM,KAAK,KAAK,CAAC,OAAO,GAAG,KAAK,EAAE,CAAC,CAAC;QAC1F,OAAO,CAAC,CAAC;IACb,CAAC;AACL,CAAC","sourcesContent":["#!/usr/bin/env node\nimport * as fs from 'fs';\n\nimport { renderShim, shimPath, findShimRoot } from './shim';\nimport { toError } from '../core/to-error';\n\n// ---------------------------------------------------------------------------\n// The `wp-upgrade-shim` entry point — the CURE for the committed-shim self-guard.\n//\n// The committed .claude/webpieces/ai-hook.sh is webpieces-MANAGED: generated from renderShim() and\n// checked in only so the hook has a stable entry point when node_modules is absent. When it is reverted\n// or hand-edited it no longer matches the installed template, and the shim's self-guard fails CLOSED\n// (blocking every tool call) because stale escape-hatch logic must not run silently. That guard allows\n// exactly ONE command through — this one — so the assistant can re-arm it without a deadlock.\n//\n// Deliberately imports only ./shim (fs + path) + toError, exactly like install-entry: the whole job is\n// to rewrite the committed shim, which never needed the rule engine, and must stay runnable on a tree\n// too broken to load it. We write renderShim() — the single source of truth — which the shipped template\n// (templates/ai-hook.sh, byte-identical to renderShim() by a unit test) equals, so the self-guard, which\n// compares the committed shim against that installed template, clears after this runs.\n// ---------------------------------------------------------------------------\nconst RED = '[31;1m';\nconst RESET = '[0m';\n\n// Returns the process exit code (0 = ok). Kept as a function (not top-level code) so it is unit-testable\n// without spawning node.\n// webpieces-disable no-function-outside-class -- bin entry point: this module MUST load with only fs+path (see header), mirroring install-entry.ts. A DI-managed class would pull the container in and reintroduce the require-time crash this dependency-free path exists to survive.\nexport function runUpgradeShim(cwd: string): number {\n const root = findShimRoot(cwd);\n if (root === null) {\n console.error(`${RED}🛑 @webpieces: no committed .claude/webpieces/ai-hook.sh found to regenerate.${RESET}`);\n console.error(' Run this from a repo that installs @webpieces/ai-hook-rules, or run the installer (pnpm wp-install-ai-hooks).');\n return 1;\n }\n const target = shimPath(root);\n // webpieces-disable no-unmanaged-exceptions -- bin entry chokepoint: turn an fs error into an actionable line + non-zero exit rather than a raw node trace; there is no caller above a bin to handle it.\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n fs.writeFileSync(target, renderShim(), { mode: 0o755 });\n // writeFileSync's mode only applies on create; force it on overwrite too (matches writeShim).\n fs.chmodSync(target, 0o755);\n console.log(`✅ @webpieces: regenerated the managed shim at ${target} — tool calls are re-armed.`);\n console.log(' This file is generated + committed by webpieces; do not revert or hand-edit it.');\n return 0;\n } catch (err: unknown) {\n const error = toError(err);\n console.error(`${RED}🛑 @webpieces: could not write ${target}: ${error.message}${RESET}`);\n return 1;\n }\n}\n"]}
|
|
@@ -15,7 +15,6 @@ export declare class BranchCreationGuardRule extends BashRuleBase<BranchCreation
|
|
|
15
15
|
private readonly mergedBranches;
|
|
16
16
|
private capCache;
|
|
17
17
|
private worktreeCapCache;
|
|
18
|
-
private capFreshness;
|
|
19
18
|
private worktreeAdd;
|
|
20
19
|
private get branchFormat();
|
|
21
20
|
private get subBranchNaming();
|
|
@@ -74,7 +73,7 @@ export declare class BranchCreationGuardRule extends BashRuleBase<BranchCreation
|
|
|
74
73
|
*/
|
|
75
74
|
private checkBranchCap;
|
|
76
75
|
/**
|
|
77
|
-
* The cache, with entries for branches/worktrees that no longer exist dropped
|
|
76
|
+
* The cache, with entries for branches/worktrees that no longer exist dropped.
|
|
78
77
|
*
|
|
79
78
|
* Reconciling is the fix for the phantom count: the file is written by a detached refresher and is
|
|
80
79
|
* DELIBERATELY allowed to go stale, so it keeps naming branches deleted minutes ago and worktrees
|
|
@@ -82,16 +81,11 @@ export declare class BranchCreationGuardRule extends BashRuleBase<BranchCreation
|
|
|
82
81
|
* them are dead" over a repo that had ONE, and then blocked a legitimate `git worktree add` on that
|
|
83
82
|
* figure. `reconcile` re-checks existence with instant local git reads; it does NOT re-derive any
|
|
84
83
|
* 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
84
|
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* say so and point at the command that recomputes from scratch, rather than asserting a figure.
|
|
85
|
+
* Its AGE is no longer read here. The cap messages stopped quoting verdict counts entirely, so
|
|
86
|
+
* there is nothing left for a freshness caveat to qualify — `wp-cleanup` recomputes from scratch.
|
|
93
87
|
*/
|
|
94
|
-
private
|
|
88
|
+
private loadReconciledCache;
|
|
95
89
|
/**
|
|
96
90
|
* The branch cap, yielding by ONE when the agent is standing on an already-merged branch.
|
|
97
91
|
*
|
|
@@ -131,9 +131,6 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
|
|
|
131
131
|
// Two fields, because the two caps reap different things and their hints share no wording.
|
|
132
132
|
capCache = null;
|
|
133
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;
|
|
137
134
|
// True when the blocked command was a `git worktree add`, so the recovery command we hand back is
|
|
138
135
|
// a worktree command and not a `git checkout -b` the user cannot use here.
|
|
139
136
|
worktreeAdd = false;
|
|
@@ -159,11 +156,11 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
|
|
|
159
156
|
// convention. The sub-branch affordance only appears under mode 'ON'; 'ON_NO_SUBBRANCHES'
|
|
160
157
|
// hard-blocks it and points instead at the turnOffRuleUntilEpoch escape hatch.
|
|
161
158
|
get fixHint() {
|
|
162
|
-
const remedies = new cap_remedies_1.CapRemedies(
|
|
159
|
+
const remedies = new cap_remedies_1.CapRemedies();
|
|
163
160
|
if (this.worktreeCapCache)
|
|
164
|
-
return remedies.worktreeCap(
|
|
161
|
+
return remedies.worktreeCap();
|
|
165
162
|
if (this.capCache)
|
|
166
|
-
return remedies.branchCap(
|
|
163
|
+
return remedies.branchCap();
|
|
167
164
|
const create = this.worktreeAdd
|
|
168
165
|
? 'Create it off fresh main: git fetch origin main && git worktree add ../<dir> -b <name> origin/main'
|
|
169
166
|
: 'Create it off fresh main from anywhere (incl. a worktree): git fetch origin main && git checkout -b <name> origin/main';
|
|
@@ -198,7 +195,6 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
|
|
|
198
195
|
check(ctx) {
|
|
199
196
|
this.capCache = null;
|
|
200
197
|
this.worktreeCapCache = null;
|
|
201
|
-
this.capFreshness = null;
|
|
202
198
|
// Match against the command with heredoc bodies and prose-in-quotes removed (BashContext
|
|
203
199
|
// computes it for every guard now — this rule's private copy was the original).
|
|
204
200
|
const command = ctx.commandCode;
|
|
@@ -334,18 +330,15 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
|
|
|
334
330
|
if (!cache)
|
|
335
331
|
return null;
|
|
336
332
|
this.capCache = cache;
|
|
337
|
-
//
|
|
338
|
-
//
|
|
339
|
-
//
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
.
|
|
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); ` +
|
|
344
|
-
`the cap (branch-creation-guard.maxLocalBranches) is ${String(this.maxLocalBranches)}. ` +
|
|
345
|
-
`${this.deadDetail(reapable, 'dead (a MERGED PR backs them)')} Clean up before creating another.`);
|
|
333
|
+
// No count of "how many are dead" any more. It was read off a cache that is stale BY DESIGN,
|
|
334
|
+
// so every branch of that sentence had to hedge ("None of them are dead", "NOT known right
|
|
335
|
+
// now — the cached verdicts are stale"), and none of the three variants changed the next
|
|
336
|
+
// move: run pnpm wp-cleanup, which recomputes from scratch. Say the one thing that is true.
|
|
337
|
+
return new types_1.Violation(1, truncate(ctx.command), `You have ${String(count)} parked local branches (worktree-held ones not counted); the cap ` +
|
|
338
|
+
`(branch-creation-guard.maxLocalBranches) is ${String(this.maxLocalBranches)}. Run pnpm wp-cleanup.`);
|
|
346
339
|
}
|
|
347
340
|
/**
|
|
348
|
-
* The cache, with entries for branches/worktrees that no longer exist dropped
|
|
341
|
+
* The cache, with entries for branches/worktrees that no longer exist dropped.
|
|
349
342
|
*
|
|
350
343
|
* Reconciling is the fix for the phantom count: the file is written by a detached refresher and is
|
|
351
344
|
* DELIBERATELY allowed to go stale, so it keeps naming branches deleted minutes ago and worktrees
|
|
@@ -353,35 +346,16 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
|
|
|
353
346
|
* them are dead" over a repo that had ONE, and then blocked a legitimate `git worktree add` on that
|
|
354
347
|
* figure. `reconcile` re-checks existence with instant local git reads; it does NOT re-derive any
|
|
355
348
|
* verdict (that needs the network and belongs in the refresher).
|
|
349
|
+
*
|
|
350
|
+
* Its AGE is no longer read here. The cap messages stopped quoting verdict counts entirely, so
|
|
351
|
+
* there is nothing left for a freshness caveat to qualify — `wp-cleanup` recomputes from scratch.
|
|
356
352
|
*/
|
|
357
353
|
loadReconciledCache(ctx) {
|
|
358
354
|
const raw = this.mergedBranches.readMergedBranches(ctx.workspaceRoot);
|
|
359
355
|
if (!raw)
|
|
360
356
|
return null;
|
|
361
|
-
this.capFreshness = this.mergedBranches.freshness(raw);
|
|
362
357
|
return this.mergedBranches.reconcile(ctx.workspaceRoot, raw);
|
|
363
358
|
}
|
|
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.`;
|
|
384
|
-
}
|
|
385
359
|
/**
|
|
386
360
|
* The branch cap, yielding by ONE when the agent is standing on an already-merged branch.
|
|
387
361
|
*
|
|
@@ -437,11 +411,8 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
|
|
|
437
411
|
if (!cache)
|
|
438
412
|
return null;
|
|
439
413
|
this.worktreeCapCache = cache;
|
|
440
|
-
const reapable = cache.worktrees.filter((tree) => tree.deletable).length;
|
|
441
414
|
return new types_1.Violation(1, truncate(ctx.command), `You have ${String(count)} linked worktrees; the cap (branch-creation-guard.maxWorktrees) ` +
|
|
442
|
-
`is ${String(this.maxWorktrees)}.
|
|
443
|
-
`${this.deadDetail(reapable, 'dead (a MERGED PR backs the branch, or the directory is already gone)')} ` +
|
|
444
|
-
'Clean up before creating another.');
|
|
415
|
+
`is ${String(this.maxWorktrees)}. Run pnpm wp-cleanup.`);
|
|
445
416
|
}
|
|
446
417
|
}
|
|
447
418
|
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,0DASiC;AAGjC,oCAA0C;AAC1C,4CAA4C;AAC5C,0CAA8C;AAC9C,iDAA6C;AAC7C,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,oGAAoG;IACpG,yEAAyE;IACjE,YAAY,GAA0B,IAAI,CAAC;IAEnD,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,MAAM,QAAQ,GAAG,IAAI,0BAAW,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;QACpD,IAAI,IAAI,CAAC,gBAAgB;YAAE,OAAO,QAAQ,CAAC,WAAW,CAAC,IAAI,CAAC,gBAAgB,CAAC,CAAC;QAC9E,IAAI,IAAI,CAAC,QAAQ;YAAE,OAAO,QAAQ,CAAC,SAAS,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;QAE5D,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,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC;QACzB,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,mBAAmB,CAAC,GAAG,CAAC,CAAC;QAC5C,IAAI,CAAC,KAAK;YAAE,OAAO,IAAI,CAAC;QAExB,IAAI,CAAC,QAAQ,GAAG,KAAK,CAAC;QACtB,0FAA0F;QAC1F,4FAA4F;QAC5F,qCAAqC;QACrC,MAAM,SAAS,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC;QAClC,MAAM,QAAQ,GAAG,KAAK,CAAC,SAAS;aAC3B,MAAM,CAAC,CAAC,KAAsB,EAAW,EAAE,CAAC,SAAS,CAAC,GAAG,CAAC,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM,CAAC;QAErF,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,IAAI,CAAC,UAAU,CAAC,QAAQ,EAAE,+BAA+B,CAAC,oCAAoC,CACpG,CAAC;IACN,CAAC;IAED;;;;;;;;;OASG;IACK,mBAAmB,CAAC,GAAgB;QACxC,MAAM,GAAG,GAAG,IAAI,CAAC,cAAc,CAAC,kBAAkB,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;QACtE,IAAI,CAAC,GAAG;YAAE,OAAO,IAAI,CAAC;QACtB,IAAI,CAAC,YAAY,GAAG,IAAI,CAAC,cAAc,CAAC,SAAS,CAAC,GAAG,CAAC,CAAC;QACvD,OAAO,IAAI,CAAC,cAAc,CAAC,SAAS,CAAC,GAAG,CAAC,aAAa,EAAE,GAAG,CAAC,CAAC;IACjE,CAAC;IAED;;;;;;OAMG;IACK,UAAU,CAAC,QAAgB,EAAE,IAAY;QAC7C,MAAM,SAAS,GAAG,IAAI,CAAC,YAAY,CAAC;QACpC,IAAI,SAAS,KAAK,IAAI,IAAI,SAAS,CAAC,KAAK,EAAE,CAAC;YACxC,MAAM,GAAG,GAAG,SAAS,CAAC,UAAU,IAAI,CAAC;gBACjC,CAAC,CAAC,GAAG,MAAM,CAAC,SAAS,CAAC,UAAU,CAAC,gBAAgB;gBACjD,CAAC,CAAC,8BAA8B,CAAC;YACrC,OAAO,iEAAiE;gBACpE,qCAAqC,GAAG,wCAAwC;gBAChF,iDAAiD,CAAC;QAC1D,CAAC;QACD,IAAI,QAAQ,KAAK,CAAC;YAAE,OAAO,4EAA4E,CAAC;QACxG,OAAO,GAAG,MAAM,CAAC,QAAQ,CAAC,gBAAgB,IAAI,gCAAgC,CAAC;IACnF,CAAC;IAED;;;;;;;;;;;;;OAaG;IACK,kBAAkB,CAAC,GAAgB;QACvC,+FAA+F;QAC/F,iFAAiF;QACjF,MAAM,OAAO,GAAG,IAAI,CAAC,mBAAmB,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;QAC5D,IAAI,OAAO,KAAK,IAAI;YAAE,OAAO,IAAI,CAAC,gBAAgB,CAAC;QACnD,MAAM,MAAM,GAAG,IAAA,iCAAkB,EAAC,GAAG,CAAC,aAAa,EAAE,OAAO,CAAC,CAAC;QAC9D,IAAI,MAAM,KAAK,IAAI,IAAI,CAAC,MAAM,CAAC,mBAAmB;YAAE,OAAO,IAAI,CAAC,gBAAgB,CAAC;QACjF,IAAI,MAAM,CAAC,MAAM,KAAK,OAAO;YAAE,OAAO,IAAI,CAAC,gBAAgB,CAAC;QAC5D,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,mBAAmB,CAAC,GAAG,CAAC,CAAC;QAC5C,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;QAErG,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,IAAI;YACnC,GAAG,IAAI,CAAC,UAAU,CAAC,QAAQ,EAAE,uEAAuE,CAAC,GAAG;YACxG,mCAAmC,CACtC,CAAC;IACN,CAAC;CAEJ;AA1XD,0DA0XC","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 // The cache is branch-keyed, so the branch we are standing on is what we look the entry up by.\n // A branch the last refresh never saw misses (null) → the strict cap, as before.\n const current = this.currentBranchOrNull(ctx.workspaceRoot);\n if (current === null) return this.maxLocalBranches;\n const status = readMainSyncStatus(ctx.workspaceRoot, current);\n if (status === null || !status.branchAlreadyMerged) return this.maxLocalBranches;\n if (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"]}
|
|
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 DeletableBranch,\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 // 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();\n if (this.worktreeCapCache) return remedies.worktreeCap();\n if (this.capCache) return remedies.branchCap();\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.loadReconciledCache(ctx);\n if (!cache) return null;\n\n this.capCache = cache;\n\n // No count of \"how many are dead\" any more. It was read off a cache that is stale BY DESIGN,\n // so every branch of that sentence had to hedge (\"None of them are dead\", \"NOT known right\n // now — the cached verdicts are stale\"), and none of the three variants changed the next\n // move: run pnpm wp-cleanup, which recomputes from scratch. Say the one thing that is true.\n return new V(\n 1,\n truncate(ctx.command),\n `You have ${String(count)} parked local branches (worktree-held ones not counted); the cap ` +\n `(branch-creation-guard.maxLocalBranches) is ${String(this.maxLocalBranches)}. Run pnpm wp-cleanup.`,\n );\n }\n\n /**\n * The cache, with entries for branches/worktrees that no longer exist dropped.\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 * Its AGE is no longer read here. The cap messages stopped quoting verdict counts entirely, so\n * there is nothing left for a freshness caveat to qualify — `wp-cleanup` recomputes from scratch.\n */\n private loadReconciledCache(ctx: BashContext): MergedBranchesCache | null {\n const raw = this.mergedBranches.readMergedBranches(ctx.workspaceRoot);\n if (!raw) return null;\n return this.mergedBranches.reconcile(ctx.workspaceRoot, raw);\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 // The cache is branch-keyed, so the branch we are standing on is what we look the entry up by.\n // A branch the last refresh never saw misses (null) → the strict cap, as before.\n const current = this.currentBranchOrNull(ctx.workspaceRoot);\n if (current === null) return this.maxLocalBranches;\n const status = readMainSyncStatus(ctx.workspaceRoot, current);\n if (status === null || !status.branchAlreadyMerged) return this.maxLocalBranches;\n if (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\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)}. Run pnpm wp-cleanup.`,\n );\n }\n\n}\n"]}
|
|
@@ -1,79 +1,35 @@
|
|
|
1
|
-
import { CacheFreshness, MergedBranchesCache } from '@webpieces/rules-config';
|
|
2
1
|
import { FixHint } from '../fix-hint';
|
|
3
2
|
/**
|
|
4
|
-
* What branch-creation-guard says when a CAP is what blocked
|
|
5
|
-
*
|
|
3
|
+
* What branch-creation-guard says when a CAP is what blocked. TWO SHORT LINES, and never a
|
|
4
|
+
* destructive command.
|
|
6
5
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
6
|
+
* It used to be forty. The branch remedy printed every spared branch with its SHA, PR state, unique
|
|
7
|
+
* commit count and prose reason, told the agent to paste that table at the human and ask which may
|
|
8
|
+
* go, and then listed the config knobs it must not touch. The human's verdict on that, verbatim:
|
|
9
|
+
* "I am tired of AI asking me to cleanup things it can do by itself". They were right twice over —
|
|
10
|
+
* the wall of text buried the one actionable line, and the biggest spared group (pre-merge snapshots
|
|
11
|
+
* of a branch that still exists) was never a judgement call at all. That group is auto-reaped now
|
|
12
|
+
* (see CLASSIFICATION_BACKUP_OF_LIVE), so the remedy is one command.
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
14
|
+
* Everything this class used to say still exists — in `pnpm wp-cleanup`, which recomputes the
|
|
15
|
+
* verdicts fresh, archives each branch as an `archive/<date>/<branch>` tag before deleting, logs a
|
|
16
|
+
* `recover=` command per removal, refuses the primary clone and the tree it is standing in, never
|
|
17
|
+
* passes `--force`, and PROMPTS about anything not provably dead. A guard that duplicates that
|
|
18
|
+
* inventory is a second copy to keep in sync and a wall to read before running the command anyway.
|
|
19
|
+
* So: name the command, stop talking.
|
|
16
20
|
*/
|
|
17
21
|
export declare class CapRemedies {
|
|
18
|
-
private readonly freshness;
|
|
19
|
-
constructor(freshness: CacheFreshness | null);
|
|
20
22
|
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
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.
|
|
23
|
+
* Deliberately takes no cache and quotes no figures. The cache is allowed to be stale by design,
|
|
24
|
+
* and a stale table is worse than no table when the reader's next move is `wp-cleanup` — which
|
|
25
|
+
* recomputes from scratch — either way.
|
|
35
26
|
*/
|
|
36
|
-
branchCap(
|
|
27
|
+
branchCap(): FixHint;
|
|
37
28
|
/**
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
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.
|
|
29
|
+
* Same shape for worktrees, with one extra sentence that is not boilerplate: a worktree whose
|
|
30
|
+
* branch has no commits yet is almost always one an agent is working in RIGHT NOW. That is why
|
|
31
|
+
* this never emits `git worktree remove` — it once printed a `prune && remove && branch -D` chain
|
|
32
|
+
* naming three live worktrees under the words "so no work can be lost".
|
|
49
33
|
*/
|
|
50
|
-
|
|
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;
|
|
34
|
+
worktreeCap(): FixHint;
|
|
79
35
|
}
|
|
@@ -3,172 +3,51 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
3
3
|
exports.CapRemedies = void 0;
|
|
4
4
|
const fix_hint_1 = require("../fix-hint");
|
|
5
5
|
/**
|
|
6
|
-
* What branch-creation-guard says when a CAP is what blocked
|
|
7
|
-
*
|
|
6
|
+
* What branch-creation-guard says when a CAP is what blocked. TWO SHORT LINES, and never a
|
|
7
|
+
* destructive command.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
9
|
+
* It used to be forty. The branch remedy printed every spared branch with its SHA, PR state, unique
|
|
10
|
+
* commit count and prose reason, told the agent to paste that table at the human and ask which may
|
|
11
|
+
* go, and then listed the config knobs it must not touch. The human's verdict on that, verbatim:
|
|
12
|
+
* "I am tired of AI asking me to cleanup things it can do by itself". They were right twice over —
|
|
13
|
+
* the wall of text buried the one actionable line, and the biggest spared group (pre-merge snapshots
|
|
14
|
+
* of a branch that still exists) was never a judgement call at all. That group is auto-reaped now
|
|
15
|
+
* (see CLASSIFICATION_BACKUP_OF_LIVE), so the remedy is one command.
|
|
15
16
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
17
|
+
* Everything this class used to say still exists — in `pnpm wp-cleanup`, which recomputes the
|
|
18
|
+
* verdicts fresh, archives each branch as an `archive/<date>/<branch>` tag before deleting, logs a
|
|
19
|
+
* `recover=` command per removal, refuses the primary clone and the tree it is standing in, never
|
|
20
|
+
* passes `--force`, and PROMPTS about anything not provably dead. A guard that duplicates that
|
|
21
|
+
* inventory is a second copy to keep in sync and a wall to read before running the command anyway.
|
|
22
|
+
* So: name the command, stop talking.
|
|
18
23
|
*/
|
|
19
24
|
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
25
|
/**
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
*
|
|
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.
|
|
26
|
+
* Deliberately takes no cache and quotes no figures. The cache is allowed to be stale by design,
|
|
27
|
+
* and a stale table is worse than no table when the reader's next move is `wp-cleanup` — which
|
|
28
|
+
* recomputes from scratch — either way.
|
|
127
29
|
*/
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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);
|
|
30
|
+
branchCap() {
|
|
31
|
+
return new fix_hint_1.FixHint('Too many local branches.', '', [
|
|
32
|
+
new fix_hint_1.Option('Run: pnpm wp-cleanup — reaps the dead branches, prompts about the rest.', true),
|
|
33
|
+
new fix_hint_1.Option('Only if that leaves you at the cap: ask which of the branches it spared may go. ' +
|
|
34
|
+
'Never delete a spared branch or edit webpieces.config.json ' +
|
|
35
|
+
'(maxLocalBranches / turnOffRuleUntilEpoch) without an explicit yes.'),
|
|
36
|
+
]);
|
|
158
37
|
}
|
|
159
38
|
/**
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
-
*
|
|
164
|
-
* everything NOT on that list was safe to remove.
|
|
39
|
+
* Same shape for worktrees, with one extra sentence that is not boilerplate: a worktree whose
|
|
40
|
+
* branch has no commits yet is almost always one an agent is working in RIGHT NOW. That is why
|
|
41
|
+
* this never emits `git worktree remove` — it once printed a `prune && remove && branch -D` chain
|
|
42
|
+
* naming three live worktrees under the words "so no work can be lost".
|
|
165
43
|
*/
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
44
|
+
worktreeCap() {
|
|
45
|
+
return new fix_hint_1.FixHint('Too many worktrees. Do NOT remove one by hand — an agent may be working in it right now.', '', [
|
|
46
|
+
new fix_hint_1.Option('Run: pnpm wp-cleanup — removes the dead worktrees, prompts about the rest.', true),
|
|
47
|
+
new fix_hint_1.Option('Only if that leaves you at the cap: ask which of the worktrees it spared may go. ' +
|
|
48
|
+
'Never edit webpieces.config.json (maxWorktrees / turnOffRuleUntilEpoch) without ' +
|
|
49
|
+
'an explicit yes.'),
|
|
50
|
+
]);
|
|
172
51
|
}
|
|
173
52
|
}
|
|
174
53
|
exports.CapRemedies = CapRemedies;
|
|
@@ -1 +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"]}
|
|
1
|
+
{"version":3,"file":"cap-remedies.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/cap-remedies.ts"],"names":[],"mappings":";;;AAAA,0CAA8C;AAE9C;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAa,WAAW;IACpB;;;;OAIG;IACH,SAAS;QACL,OAAO,IAAI,kBAAO,CACd,0BAA0B,EAC1B,EAAE,EACF;YACI,IAAI,iBAAM,CAAC,yEAAyE,EAAE,IAAI,CAAC;YAC3F,IAAI,iBAAM,CACN,kFAAkF;gBAClF,6DAA6D;gBAC7D,qEAAqE,CACxE;SACJ,CACJ,CAAC;IACN,CAAC;IAED;;;;;OAKG;IACH,WAAW;QACP,OAAO,IAAI,kBAAO,CACd,0FAA0F,EAC1F,EAAE,EACF;YACI,IAAI,iBAAM,CAAC,4EAA4E,EAAE,IAAI,CAAC;YAC9F,IAAI,iBAAM,CACN,mFAAmF;gBACnF,kFAAkF;gBAClF,kBAAkB,CACrB;SACJ,CACJ,CAAC;IACN,CAAC;CACJ;AAzCD,kCAyCC","sourcesContent":["import { FixHint, Option } from '../fix-hint';\n\n/**\n * What branch-creation-guard says when a CAP is what blocked. TWO SHORT LINES, and never a\n * destructive command.\n *\n * It used to be forty. The branch remedy printed every spared branch with its SHA, PR state, unique\n * commit count and prose reason, told the agent to paste that table at the human and ask which may\n * go, and then listed the config knobs it must not touch. The human's verdict on that, verbatim:\n * \"I am tired of AI asking me to cleanup things it can do by itself\". They were right twice over —\n * the wall of text buried the one actionable line, and the biggest spared group (pre-merge snapshots\n * of a branch that still exists) was never a judgement call at all. That group is auto-reaped now\n * (see CLASSIFICATION_BACKUP_OF_LIVE), so the remedy is one command.\n *\n * Everything this class used to say still exists — in `pnpm wp-cleanup`, which recomputes the\n * verdicts fresh, archives each branch as an `archive/<date>/<branch>` tag before deleting, logs a\n * `recover=` command per removal, refuses the primary clone and the tree it is standing in, never\n * passes `--force`, and PROMPTS about anything not provably dead. A guard that duplicates that\n * inventory is a second copy to keep in sync and a wall to read before running the command anyway.\n * So: name the command, stop talking.\n */\nexport class CapRemedies {\n /**\n * Deliberately takes no cache and quotes no figures. The cache is allowed to be stale by design,\n * and a stale table is worse than no table when the reader's next move is `wp-cleanup` — which\n * recomputes from scratch — either way.\n */\n branchCap(): FixHint {\n return new FixHint(\n 'Too many local branches.',\n '',\n [\n new Option('Run: pnpm wp-cleanup — reaps the dead branches, prompts about the rest.', true),\n new Option(\n 'Only if that leaves you at the cap: ask which of the branches it spared may go. ' +\n 'Never delete a spared branch or edit webpieces.config.json ' +\n '(maxLocalBranches / turnOffRuleUntilEpoch) without an explicit yes.',\n ),\n ],\n );\n }\n\n /**\n * Same shape for worktrees, with one extra sentence that is not boilerplate: a worktree whose\n * branch has no commits yet is almost always one an agent is working in RIGHT NOW. That is why\n * this never emits `git worktree remove` — it once printed a `prune && remove && branch -D` chain\n * naming three live worktrees under the words \"so no work can be lost\".\n */\n worktreeCap(): FixHint {\n return new FixHint(\n 'Too many worktrees. Do NOT remove one by hand — an agent may be working in it right now.',\n '',\n [\n new Option('Run: pnpm wp-cleanup — removes the dead worktrees, prompts about the rest.', true),\n new Option(\n 'Only if that leaves you at the cap: ask which of the worktrees it spared may go. ' +\n 'Never edit webpieces.config.json (maxWorktrees / turnOffRuleUntilEpoch) without ' +\n 'an explicit yes.',\n ),\n ],\n );\n }\n}\n"]}
|
package/bin/wp-ai-guards-hook.js
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Plain JS shim — delegates to compiled TypeScript.
|
|
3
|
-
// Must NOT be converted to TypeScript (needs to exist pre-build for pnpm bin symlinks).
|
|
4
|
-
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
5
|
-
'use strict';
|
|
6
|
-
|
|
7
|
-
const path = require('path');
|
|
8
|
-
const fs = require('fs');
|
|
9
|
-
const compiled = path.join(__dirname, '..', 'src', 'adapters', 'guards-hook.js');
|
|
10
|
-
|
|
11
|
-
if (fs.existsSync(compiled)) {
|
|
12
|
-
require(compiled).main();
|
|
13
|
-
} else {
|
|
14
|
-
console.error(' [ai-hook-rules] Package not built yet. Run the build first, or install from npm.');
|
|
15
|
-
process.exit(1);
|
|
16
|
-
}
|
package/bin/wp-ai-rules-hook.js
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Plain JS shim — delegates to compiled TypeScript.
|
|
3
|
-
// Must NOT be converted to TypeScript (needs to exist pre-build for pnpm bin symlinks).
|
|
4
|
-
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
5
|
-
'use strict';
|
|
6
|
-
|
|
7
|
-
const path = require('path');
|
|
8
|
-
const fs = require('fs');
|
|
9
|
-
const compiled = path.join(__dirname, '..', 'src', 'adapters', 'rules-hook.js');
|
|
10
|
-
|
|
11
|
-
if (fs.existsSync(compiled)) {
|
|
12
|
-
require(compiled).main();
|
|
13
|
-
} else {
|
|
14
|
-
console.error(' [ai-hook-rules] Package not built yet. Run the build first, or install from npm.');
|
|
15
|
-
process.exit(1);
|
|
16
|
-
}
|
|
@@ -1,23 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Plain JS shim — delegates to compiled TypeScript.
|
|
3
|
-
// Must NOT be converted to TypeScript (needs to exist pre-build for pnpm bin symlinks).
|
|
4
|
-
//
|
|
5
|
-
// Points at install-entry.js, NOT setup.js, on purpose. setup.js top-level-imports
|
|
6
|
-
// @webpieces/rules-config -> minimatch, so on a CORRUPT node_modules (a package half-written by an
|
|
7
|
-
// install that was killed mid-copy) node died at require() time with a raw MODULE_NOT_FOUND loader
|
|
8
|
-
// trace — before the installer could rewrite the fail-closed shim, which is the one thing that would
|
|
9
|
-
// have made the breakage visible. install-entry.js imports only ./shim (fs + path), re-arms the shim
|
|
10
|
-
// first, and only then loads setup.js lazily. See install-entry.ts for the full story.
|
|
11
|
-
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
12
|
-
'use strict';
|
|
13
|
-
|
|
14
|
-
const path = require('path');
|
|
15
|
-
const fs = require('fs');
|
|
16
|
-
const compiled = path.join(__dirname, '..', 'src', 'bin', 'install-entry.js');
|
|
17
|
-
|
|
18
|
-
if (fs.existsSync(compiled)) {
|
|
19
|
-
require(compiled).runInstaller(process.cwd()).then((code) => process.exit(code));
|
|
20
|
-
} else {
|
|
21
|
-
console.error(' [ai-hook-rules] Package not built yet. Run the build first, or install from npm.');
|
|
22
|
-
process.exit(1);
|
|
23
|
-
}
|
package/bin/wp-upgrade-shim.js
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// Plain JS shim — delegates to compiled TypeScript.
|
|
3
|
-
// Must NOT be converted to TypeScript (needs to exist pre-build for pnpm bin symlinks).
|
|
4
|
-
//
|
|
5
|
-
// Points at upgrade-shim.js, which imports only ./shim (fs + path) + toError — no rule engine — so it
|
|
6
|
-
// stays runnable on a tree too broken to load setup.js. It is the CURE allowed through the committed
|
|
7
|
-
// shim's self-guard: it rewrites .claude/webpieces/ai-hook.sh from renderShim() (the single source of
|
|
8
|
-
// truth) when that committed file was reverted or hand-edited. See upgrade-shim.ts for the full story.
|
|
9
|
-
// eslint-disable-next-line @webpieces/no-unmanaged-exceptions
|
|
10
|
-
'use strict';
|
|
11
|
-
|
|
12
|
-
const path = require('path');
|
|
13
|
-
const fs = require('fs');
|
|
14
|
-
const compiled = path.join(__dirname, '..', 'src', 'bin', 'upgrade-shim.js');
|
|
15
|
-
|
|
16
|
-
if (fs.existsSync(compiled)) {
|
|
17
|
-
process.exit(require(compiled).runUpgradeShim(process.cwd()));
|
|
18
|
-
} else {
|
|
19
|
-
console.error(' [ai-hook-rules] Package not built yet. Run the build first, or install from npm.');
|
|
20
|
-
process.exit(1);
|
|
21
|
-
}
|