@webpieces/ai-hook-rules 0.4.421 → 0.4.422

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/ai-hook-rules",
3
- "version": "0.4.421",
3
+ "version": "0.4.422",
4
4
  "description": "Pluggable write-time validation framework for AI coding agents (@webpieces/ai-hook-rules). Claude Code PreToolUse + openclaw before_tool_call adapters share one rule engine.",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -32,7 +32,7 @@
32
32
  "directory": "packages/tooling/ai-hook-rules"
33
33
  },
34
34
  "dependencies": {
35
- "@webpieces/rules-config": "0.4.421"
35
+ "@webpieces/rules-config": "0.4.422"
36
36
  },
37
37
  "publishConfig": {
38
38
  "access": "public"
@@ -53,6 +53,7 @@ export declare class BranchCreationGuardRule extends BashRuleBase<BranchCreation
53
53
  * unparseable command → no opinion.
54
54
  */
55
55
  private checkWorktreeOntoDeadBranch;
56
+ private checkReservedSuffix;
56
57
  /**
57
58
  * Both budgets, in the order that produces the most useful complaint.
58
59
  *
@@ -85,8 +86,14 @@ export declare class BranchCreationGuardRule extends BashRuleBase<BranchCreation
85
86
  * by one of exactly two proofs: a MERGED PR (the work is in main), or zero commits of its own
86
87
  * (there is no work). Deleting the list cannot lose anything — so just run the command.
87
88
  *
88
- * The wording must not overstate that: the list is NOT uniformly "merged PR" branches, and a
89
- * message that tells an agent to run `git branch -D` has to be exactly true about why that's safe.
89
+ * The command is `pnpm wp-cleanup`, NOT the `git branch -D a b c` this used to emit. Two reasons,
90
+ * both learned the hard way: agents read a bare `-D` as destructive and stop to ask (so nothing
91
+ * was ever cleaned, and this cap kept firing), and the multi-name form aborts wholesale on the
92
+ * first branch git refuses, stranding every branch after it in the list. wp-cleanup recomputes
93
+ * the verdicts, deletes one branch per command, and logs each pre-delete SHA.
94
+ *
95
+ * The wording must not overstate the safety: the list is NOT uniformly "merged PR" branches, and
96
+ * a message that tells an agent to delete has to be exactly true about why that's safe.
90
97
  */
91
98
  private capFixHint;
92
99
  /**
@@ -43,6 +43,16 @@ const WORKTREE_ADD = /git\s+worktree\s+add\b/;
43
43
  // Flags that take a value (`--reason <s>`, `-b <name>`) are excluded by the caller, which only uses
44
44
  // this on commands with no `-b`/`-B` at all.
45
45
  const WORKTREE_ADD_EXISTING = new RegExp(String.raw `git\s+worktree\s+add\s+(?:-{1,2}[A-Za-z-]+\s+)*\S+\s+(${REF_NAME})`);
46
+ // `git branch <name> <sha>` — RESTORING a branch at an explicit commit, which is exactly the
47
+ // `recover=` command wp-cleanup writes to branch-mutations.log for every branch it reaps.
48
+ //
49
+ // This must be allowed UNCONDITIONALLY, ahead of even the caps. The entire argument for letting the
50
+ // tooling delete branches unattended is that any delete is one logged command away from being undone
51
+ // — so a guard that blocks that command turns a real guarantee into a decorative one. (It did: the
52
+ // generic `git branch <name>` creation pattern matched the restore and refused it, demanding the
53
+ // branch be recreated off origin/main, which is precisely the content the restore is meant to bring
54
+ // back.) A restore also cannot grow the branch list beyond what already existed.
55
+ const RESTORE_AT_SHA = new RegExp(String.raw `git\s+branch\s+${REF_NAME}\s+[0-9a-f]{7,40}(?:\W|$)`);
46
56
  // A trailing `wp<number>` was the old squash-merge generation marker (base → basewp2 → basewp3).
47
57
  // The tooling NO LONGER produces it — a sync now lands back on the same feature name — but the suffix
48
58
  // stays RESERVED so a human branch can't collide with a leftover `…wpN` still floating in a consumer
@@ -190,11 +200,13 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
190
200
  // survive this early-out and reach the worktree cap below.
191
201
  if (!requestedName && !this.worktreeAdd)
192
202
  return [];
193
- if (requestedName && RESERVED_GENERATION_SUFFIX.test(requestedName)) {
194
- return [new types_1.Violation(1, truncate(ctx.command), `Branch name '${requestedName}' ends in 'wp<number>', which is reserved for the ` +
195
- `squash-merge tool's generation marker (base → basewp2 → basewp3). ` +
196
- `Rename it to a plain feature branch. ${this.branchFormat}.`)];
197
- }
203
+ // Restoring a reaped branch at its logged SHA is undo, not creation — always allowed, and
204
+ // checked before the caps so a full branch list can never trap you on the recovery path.
205
+ if (!this.worktreeAdd && RESTORE_AT_SHA.test(command))
206
+ return [];
207
+ const reserved = this.checkReservedSuffix(ctx, requestedName);
208
+ if (reserved)
209
+ return [reserved];
198
210
  const capViolation = this.checkCaps(ctx, requestedName !== null);
199
211
  if (capViolation)
200
212
  return [capViolation];
@@ -264,6 +276,15 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
264
276
  `blocks those reads) and every edit is blocked by feature-branch-guard. Base the new worktree ` +
265
277
  `on fresh main instead: git fetch origin main && git worktree add ../${dir} -b <new-branch> origin/main`)];
266
278
  }
279
+ // The reserved `…wpN` generation suffix — see RESERVED_GENERATION_SUFFIX for why it stays blocked
280
+ // even though the tooling no longer produces it.
281
+ checkReservedSuffix(ctx, requestedName) {
282
+ if (!requestedName || !RESERVED_GENERATION_SUFFIX.test(requestedName))
283
+ return null;
284
+ return new types_1.Violation(1, truncate(ctx.command), `Branch name '${requestedName}' ends in 'wp<number>', which is reserved for the ` +
285
+ `squash-merge tool's generation marker (base → basewp2 → basewp3). ` +
286
+ `Rename it to a plain feature branch. ${this.branchFormat}.`);
287
+ }
267
288
  /**
268
289
  * Both budgets, in the order that produces the most useful complaint.
269
290
  *
@@ -340,16 +361,23 @@ class BranchCreationGuardRule extends rule_base_1.BashRuleBase {
340
361
  * by one of exactly two proofs: a MERGED PR (the work is in main), or zero commits of its own
341
362
  * (there is no work). Deleting the list cannot lose anything — so just run the command.
342
363
  *
343
- * The wording must not overstate that: the list is NOT uniformly "merged PR" branches, and a
344
- * message that tells an agent to run `git branch -D` has to be exactly true about why that's safe.
364
+ * The command is `pnpm wp-cleanup`, NOT the `git branch -D a b c` this used to emit. Two reasons,
365
+ * both learned the hard way: agents read a bare `-D` as destructive and stop to ask (so nothing
366
+ * was ever cleaned, and this cap kept firing), and the multi-name form aborts wholesale on the
367
+ * first branch git refuses, stranding every branch after it in the list. wp-cleanup recomputes
368
+ * the verdicts, deletes one branch per command, and logs each pre-delete SHA.
369
+ *
370
+ * The wording must not overstate the safety: the list is NOT uniformly "merged PR" branches, and
371
+ * a message that tells an agent to delete has to be exactly true about why that's safe.
345
372
  */
346
373
  capFixHint(cache) {
347
374
  const options = [];
348
375
  if (cache.deletable.length > 0) {
349
376
  const names = cache.deletable.map((entry) => entry.branch);
350
- options.push(new fix_hint_1.Option(`Delete these ${String(names.length)} dead branches each is either backed by a MERGED PR ` +
351
- `or has no commits of its own, so no work can be lost (see merged-branches.json for the ` +
352
- `per-branch reason): git branch -D ${names.join(' ')}`, true));
377
+ options.push(new fix_hint_1.Option(`Run: pnpm wp-cleanup — it deletes these ${String(names.length)} dead branches. Each is either ` +
378
+ `backed by a MERGED PR or has no commits of its own, so no work can be lost, and every delete ` +
379
+ `is logged with a recover-by-SHA command (see merged-branches.json for the per-branch reason): ` +
380
+ names.join(' '), true));
353
381
  }
354
382
  options.push(new fix_hint_1.Option('If you genuinely need more branches in flight, raise branch-creation-guard.maxLocalBranches ' +
355
383
  'in webpieces.config.json.'));
@@ -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":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport {\n BranchCreationGuardConfig,\n DeletableBranch,\n DeletableWorktree,\n MergedBranchesCache,\n MergedBranchesService,\n WorktreeService,\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';\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// 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\n// Heredoc bodies: `<<EOF … \\nEOF` / `<<-'EOF' … \\nEOF`. Their content is DATA (a commit message, a\n// file being written), never a command.\nconst HEREDOC_BODY = /<<-?\\s*(['\"]?)(\\w+)\\1[\\s\\S]*?^\\t*\\2\\s*$/gm;\n\n// A single- or double-quoted span.\nconst QUOTED_SPAN = /'([^']*)'|\"([^\"]*)\"/g;\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 execSync('git fetch origin main --quiet', {\n cwd: ctx.workspaceRoot,\n 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 ignoreModifiedUntilEpoch escape hatch.\n get fixHint(): FixHint {\n if (this.worktreeCapCache) return this.worktreeCapFixHint(this.worktreeCapCache);\n if (this.capCache) return this.capFixHint(this.capCache);\n\n const create = this.worktreeAdd\n ? 'Create it off fresh main: git fetch origin main && git worktree add ../<dir> -b <name> origin/main'\n : 'Create it off fresh main from anywhere (incl. a worktree): git fetch origin main && git checkout -b <name> origin/main';\n\n const options = [\n new Option(create, true),\n new Option(`Name a branch off main per branch-creation-guard.branchFormat: ${this.branchFormat}`),\n ];\n if (this.config.mode === 'ON_NO_SUBBRANCHES') {\n options.push(new Option(\n 'Sub-branches (branching off another feature branch) are disabled. To temporarily allow one, set ' +\n \"branch-creation-guard.ignoreModifiedUntilEpoch 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 private stripNonCommandText(command: string): string {\n const withoutHeredocs = command.replace(HEREDOC_BODY, ' ');\n return withoutHeredocs.replace(QUOTED_SPAN, (match: string, single?: string, double?: string): string => {\n const content = single ?? double ?? '';\n return /\\s/.test(content) ? ' ' : content;\n });\n }\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. A commit message\n // that merely MENTIONS a branch command is not a branch command.\n const command = this.stripNonCommandText(ctx.command);\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 if (requestedName && RESERVED_GENERATION_SUFFIX.test(requestedName)) {\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 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.ignoreModifiedUntilEpoch (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 /**\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 if (count < this.maxLocalBranches) return null;\n\n const cache = this.mergedBranches.readMergedBranches(ctx.workspaceRoot);\n if (!cache) return null;\n\n this.capCache = cache;\n const reapable = cache.deletable.length;\n const detail = reapable > 0\n ? `${String(reapable)} of them are dead (merged, or holding no commits) and can be deleted right now.`\n : 'None of them are dead, so none can be auto-reaped — see the options below.';\n\n return new V(\n 1,\n truncate(ctx.command),\n `You have ${String(count)} parked local branches (not counting any checked out in a worktree); ` +\n `the cap (branch-creation-guard.maxLocalBranches) is ${String(this.maxLocalBranches)}. ` +\n `${detail} Clean up before creating another.`,\n );\n }\n\n /**\n * The worktree cap — the second budget. Same gate, same fail-open rule as the branch cap: a\n * worktree list we cannot classify (no cache on disk) blocks nothing.\n *\n * Counts LINKED worktrees only. The primary clone is not a thing anyone can remove, so charging the\n * budget for it would just silently cost you one worktree.\n */\n private checkWorktreeCap(ctx: BashContext): Violation | null {\n const count = this.worktrees.linkedWorktrees(ctx.workspaceRoot).length;\n if (count < this.maxWorktrees) return null;\n\n const cache = this.mergedBranches.readMergedBranches(ctx.workspaceRoot);\n if (!cache) return null;\n\n this.worktreeCapCache = cache;\n const reapable = cache.worktrees.filter((tree: DeletableWorktree): boolean => tree.deletable).length;\n const detail = reapable > 0\n ? `${String(reapable)} of them are dead (merged branch, no commits, or a missing directory) ` +\n 'and can be removed right now.'\n : 'None of them are dead, so none can be auto-reaped — see the options below.';\n\n return new V(\n 1,\n truncate(ctx.command),\n `You have ${String(count)} linked worktrees; the cap (branch-creation-guard.maxWorktrees) ` +\n `is ${String(this.maxWorktrees)}. ${detail} Clean up before creating another.`,\n );\n }\n\n /**\n * The reap instructions. `deletable` is PRECOMPUTED in the cache, and every entry earned its place\n * by one of exactly two proofs: a MERGED PR (the work is in main), or zero commits of its own\n * (there is no work). Deleting the list cannot lose anything — so just run the command.\n *\n * The wording must not overstate that: the list is NOT uniformly \"merged PR\" branches, and a\n * message that tells an agent to run `git branch -D` has to be exactly true about why that's safe.\n */\n private capFixHint(cache: MergedBranchesCache): FixHint {\n const options: Option[] = [];\n\n if (cache.deletable.length > 0) {\n const names = cache.deletable.map((entry: DeletableBranch): string => entry.branch);\n options.push(new Option(\n `Delete these ${String(names.length)} dead branches — each is either backed by a MERGED PR ` +\n `or has no commits of its own, so no work can be lost (see merged-branches.json for the ` +\n `per-branch reason): git branch -D ${names.join(' ')}`,\n true,\n ));\n }\n\n options.push(new Option(\n 'If you genuinely need more branches in flight, raise branch-creation-guard.maxLocalBranches ' +\n 'in webpieces.config.json.',\n ));\n options.push(new Option(\n 'To bypass this once, set branch-creation-guard.ignoreModifiedUntilEpoch (a future epoch) ' +\n 'in webpieces.config.json.',\n ));\n\n const kept = cache.keep.length > 0\n ? ` ${String(cache.keep.length)} unmerged branch(es) with real commits were deliberately SPARED — ` +\n 'do not delete those; a human decides.'\n : '';\n\n return new FixHint(\n 'Too many local branches — reap the dead ones before creating another.',\n 'Full detail (deletable + spared, with per-branch reasons) is in .webpieces/merged-branches.json, ' +\n `refreshed ${cache.timestamp || 'never'}.${kept} Pick one:`,\n options,\n );\n }\n\n /**\n * The worktree reap instructions.\n *\n * The command ORDER is load-bearing and is the whole reason this is generated rather than described:\n * 1. `git worktree prune` — clears the admin data of worktrees whose directory is already gone.\n * `git worktree remove` FAILS on those, so it cannot be the first step.\n * 2. `git worktree remove <path>` — one path per invocation; git takes no path list here.\n * 3. `git branch -D <names>` — only now. git flatly refuses to delete a branch that is still\n * checked out in a worktree, so a branch delete placed before the removal fails, and because\n * it is one multi-name command it takes every other branch in the list down with it.\n */\n private worktreeCapFixHint(cache: MergedBranchesCache): FixHint {\n const options: Option[] = [];\n const dead = cache.worktrees.filter((tree: DeletableWorktree): boolean => tree.deletable);\n\n if (dead.length > 0) {\n const steps = ['git worktree prune'];\n for (const tree of dead) {\n // A prunable worktree has no directory left to remove — step 1 already handled it.\n if (tree.path !== '') steps.push(`git worktree remove ${tree.path}`);\n }\n const branches = dead\n .map((tree: DeletableWorktree): string => tree.branch)\n .filter((branch: string): boolean => branch !== '');\n if (branches.length > 0) steps.push(`git branch -D ${branches.join(' ')}`);\n\n options.push(new Option(\n `Remove these ${String(dead.length)} dead worktrees — each holds a branch backed by a MERGED ` +\n 'PR, a branch with no commits of its own, or a directory that is already gone, so no work ' +\n 'can be lost (see merged-branches.json for the per-worktree reason). Run it in this order ' +\n `(prune first, branches last — git refuses to delete a branch a worktree still holds): ${steps.join(' && ')}`,\n true,\n ));\n }\n\n options.push(new Option(\n 'If you genuinely need more worktrees in flight, raise branch-creation-guard.maxWorktrees ' +\n 'in webpieces.config.json.',\n ));\n options.push(new Option(\n 'To bypass this once, set branch-creation-guard.ignoreModifiedUntilEpoch (a future epoch) ' +\n 'in webpieces.config.json.',\n ));\n\n const spared = cache.worktrees.length - dead.length;\n const kept = spared > 0\n ? ` ${String(spared)} worktree(s) were deliberately SPARED (locked, holding unmerged work, or ` +\n 'the one you are standing in) — do not remove those; a human decides.'\n : '';\n\n return new FixHint(\n 'Too many worktrees — reap the dead ones before creating another.',\n 'Full detail (deletable + spared, with per-worktree reasons) is in .webpieces/merged-branches.json, ' +\n `refreshed ${cache.timestamp || 'never'}.${kept} Pick one:`,\n options,\n );\n }\n}\n"]}
1
+ {"version":3,"file":"branch-creation-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/branch-creation-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport {\n BranchCreationGuardConfig,\n DeletableBranch,\n DeletableWorktree,\n MergedBranchesCache,\n MergedBranchesService,\n WorktreeService,\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';\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\n// Heredoc bodies: `<<EOF … \\nEOF` / `<<-'EOF' … \\nEOF`. Their content is DATA (a commit message, a\n// file being written), never a command.\nconst HEREDOC_BODY = /<<-?\\s*(['\"]?)(\\w+)\\1[\\s\\S]*?^\\t*\\2\\s*$/gm;\n\n// A single- or double-quoted span.\nconst QUOTED_SPAN = /'([^']*)'|\"([^\"]*)\"/g;\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 execSync('git fetch origin main --quiet', {\n cwd: ctx.workspaceRoot,\n 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 ignoreModifiedUntilEpoch escape hatch.\n get fixHint(): FixHint {\n if (this.worktreeCapCache) return this.worktreeCapFixHint(this.worktreeCapCache);\n if (this.capCache) return this.capFixHint(this.capCache);\n\n const create = this.worktreeAdd\n ? 'Create it off fresh main: git fetch origin main && git worktree add ../<dir> -b <name> origin/main'\n : 'Create it off fresh main from anywhere (incl. a worktree): git fetch origin main && git checkout -b <name> origin/main';\n\n const options = [\n new Option(create, true),\n new Option(`Name a branch off main per branch-creation-guard.branchFormat: ${this.branchFormat}`),\n ];\n if (this.config.mode === 'ON_NO_SUBBRANCHES') {\n options.push(new Option(\n 'Sub-branches (branching off another feature branch) are disabled. To temporarily allow one, set ' +\n \"branch-creation-guard.ignoreModifiedUntilEpoch 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 private stripNonCommandText(command: string): string {\n const withoutHeredocs = command.replace(HEREDOC_BODY, ' ');\n return withoutHeredocs.replace(QUOTED_SPAN, (match: string, single?: string, double?: string): string => {\n const content = single ?? double ?? '';\n return /\\s/.test(content) ? ' ' : content;\n });\n }\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. A commit message\n // that merely MENTIONS a branch command is not a branch command.\n const command = this.stripNonCommandText(ctx.command);\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.ignoreModifiedUntilEpoch (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 if (count < this.maxLocalBranches) return null;\n\n const cache = this.mergedBranches.readMergedBranches(ctx.workspaceRoot);\n if (!cache) return null;\n\n this.capCache = cache;\n const reapable = cache.deletable.length;\n const detail = reapable > 0\n ? `${String(reapable)} of them are dead (merged, or holding no commits) and can be deleted right now.`\n : 'None of them are dead, so none can be auto-reaped — see the options below.';\n\n return new V(\n 1,\n truncate(ctx.command),\n `You have ${String(count)} parked local branches (not counting any checked out in a worktree); ` +\n `the cap (branch-creation-guard.maxLocalBranches) is ${String(this.maxLocalBranches)}. ` +\n `${detail} Clean up before creating another.`,\n );\n }\n\n /**\n * The worktree cap — the second budget. Same gate, same fail-open rule as the branch cap: a\n * worktree list we cannot classify (no cache on disk) blocks nothing.\n *\n * Counts LINKED worktrees only. The primary clone is not a thing anyone can remove, so charging the\n * budget for it would just silently cost you one worktree.\n */\n private checkWorktreeCap(ctx: BashContext): Violation | null {\n const count = this.worktrees.linkedWorktrees(ctx.workspaceRoot).length;\n if (count < this.maxWorktrees) return null;\n\n const cache = this.mergedBranches.readMergedBranches(ctx.workspaceRoot);\n if (!cache) return null;\n\n this.worktreeCapCache = cache;\n const reapable = cache.worktrees.filter((tree: DeletableWorktree): boolean => tree.deletable).length;\n const detail = reapable > 0\n ? `${String(reapable)} of them are dead (merged branch, no commits, or a missing directory) ` +\n 'and can be removed right now.'\n : 'None of them are dead, so none can be auto-reaped — see the options below.';\n\n return new V(\n 1,\n truncate(ctx.command),\n `You have ${String(count)} linked worktrees; the cap (branch-creation-guard.maxWorktrees) ` +\n `is ${String(this.maxWorktrees)}. ${detail} Clean up before creating another.`,\n );\n }\n\n /**\n * The reap instructions. `deletable` is PRECOMPUTED in the cache, and every entry earned its place\n * by one of exactly two proofs: a MERGED PR (the work is in main), or zero commits of its own\n * (there is no work). Deleting the list cannot lose anything — so just run the command.\n *\n * The command is `pnpm wp-cleanup`, NOT the `git branch -D a b c` this used to emit. Two reasons,\n * both learned the hard way: agents read a bare `-D` as destructive and stop to ask (so nothing\n * was ever cleaned, and this cap kept firing), and the multi-name form aborts wholesale on the\n * first branch git refuses, stranding every branch after it in the list. wp-cleanup recomputes\n * the verdicts, deletes one branch per command, and logs each pre-delete SHA.\n *\n * The wording must not overstate the safety: the list is NOT uniformly \"merged PR\" branches, and\n * a message that tells an agent to delete has to be exactly true about why that's safe.\n */\n private capFixHint(cache: MergedBranchesCache): FixHint {\n const options: Option[] = [];\n\n if (cache.deletable.length > 0) {\n const names = cache.deletable.map((entry: DeletableBranch): string => entry.branch);\n options.push(new Option(\n `Run: pnpm wp-cleanup — it deletes these ${String(names.length)} dead branches. Each is either ` +\n `backed by a MERGED PR or has no commits of its own, so no work can be lost, and every delete ` +\n `is logged with a recover-by-SHA command (see merged-branches.json for the per-branch reason): ` +\n names.join(' '),\n true,\n ));\n }\n\n options.push(new Option(\n 'If you genuinely need more branches in flight, raise branch-creation-guard.maxLocalBranches ' +\n 'in webpieces.config.json.',\n ));\n options.push(new Option(\n 'To bypass this once, set branch-creation-guard.ignoreModifiedUntilEpoch (a future epoch) ' +\n 'in webpieces.config.json.',\n ));\n\n const kept = cache.keep.length > 0\n ? ` ${String(cache.keep.length)} unmerged branch(es) with real commits were deliberately SPARED — ` +\n 'do not delete those; a human decides.'\n : '';\n\n return new FixHint(\n 'Too many local branches — reap the dead ones before creating another.',\n 'Full detail (deletable + spared, with per-branch reasons) is in .webpieces/merged-branches.json, ' +\n `refreshed ${cache.timestamp || 'never'}.${kept} Pick one:`,\n options,\n );\n }\n\n /**\n * The worktree reap instructions.\n *\n * The command ORDER is load-bearing and is the whole reason this is generated rather than described:\n * 1. `git worktree prune` — clears the admin data of worktrees whose directory is already gone.\n * `git worktree remove` FAILS on those, so it cannot be the first step.\n * 2. `git worktree remove <path>` — one path per invocation; git takes no path list here.\n * 3. `git branch -D <names>` — only now. git flatly refuses to delete a branch that is still\n * checked out in a worktree, so a branch delete placed before the removal fails, and because\n * it is one multi-name command it takes every other branch in the list down with it.\n */\n private worktreeCapFixHint(cache: MergedBranchesCache): FixHint {\n const options: Option[] = [];\n const dead = cache.worktrees.filter((tree: DeletableWorktree): boolean => tree.deletable);\n\n if (dead.length > 0) {\n const steps = ['git worktree prune'];\n for (const tree of dead) {\n // A prunable worktree has no directory left to remove — step 1 already handled it.\n if (tree.path !== '') steps.push(`git worktree remove ${tree.path}`);\n }\n const branches = dead\n .map((tree: DeletableWorktree): string => tree.branch)\n .filter((branch: string): boolean => branch !== '');\n if (branches.length > 0) steps.push(`git branch -D ${branches.join(' ')}`);\n\n options.push(new Option(\n `Remove these ${String(dead.length)} dead worktrees — each holds a branch backed by a MERGED ` +\n 'PR, a branch with no commits of its own, or a directory that is already gone, so no work ' +\n 'can be lost (see merged-branches.json for the per-worktree reason). Run it in this order ' +\n `(prune first, branches last — git refuses to delete a branch a worktree still holds): ${steps.join(' && ')}`,\n true,\n ));\n }\n\n options.push(new Option(\n 'If you genuinely need more worktrees in flight, raise branch-creation-guard.maxWorktrees ' +\n 'in webpieces.config.json.',\n ));\n options.push(new Option(\n 'To bypass this once, set branch-creation-guard.ignoreModifiedUntilEpoch (a future epoch) ' +\n 'in webpieces.config.json.',\n ));\n\n const spared = cache.worktrees.length - dead.length;\n const kept = spared > 0\n ? ` ${String(spared)} worktree(s) were deliberately SPARED (locked, holding unmerged work, or ` +\n 'the one you are standing in) — do not remove those; a human decides.'\n : '';\n\n return new FixHint(\n 'Too many worktrees — reap the dead ones before creating another.',\n 'Full detail (deletable + spared, with per-worktree reasons) is in .webpieces/merged-branches.json, ' +\n `refreshed ${cache.timestamp || 'never'}.${kept} Pick one:`,\n options,\n );\n }\n}\n"]}
@@ -22,6 +22,11 @@ class PrMergeGuardRule extends rule_base_1.BashRuleBase {
22
22
  recovery = new tree_recovery_1.TreeRecovery();
23
23
  // Single fix, no distinct options — the whole guidance lives in mainMessage so it renders as
24
24
  // one coherent block (never split into fake "Fix Option 1/2/3").
25
+ //
26
+ // `pnpm wp-cleanup` rather than `git branch -d <branch>`: a raw `-d` reads as destructive, so
27
+ // agents ask permission and stop, and the branch survives. wp-cleanup is one named command that
28
+ // deletes ONLY provably-dead branches — and it reaps every OTHER dead branch at the same time,
29
+ // which is the moment that actually keeps the local branch list bounded.
25
30
  get fixHint() {
26
31
  return new fix_hint_1.FixHint('After merging a PR you must clean up the branch (and the worktree, if it has one).', ['Run `gh pr merge --squash`, then:', '']
27
32
  .concat(this.recovery.cleanupSteps(this.treeKind, this.currentBranch, this.worktreePath))
@@ -36,7 +41,10 @@ class PrMergeGuardRule extends rule_base_1.BashRuleBase {
36
41
  check(ctx) {
37
42
  if (!/gh\s+pr\s+merge/.test(ctx.command))
38
43
  return [];
39
- const hasDelete = /git\s+branch\s+-[dD]/.test(ctx.command);
44
+ // `wp-cleanup` counts as a delete: it is the command the branch-flavoured cleanupSteps now
45
+ // hand out, and it deletes the just-merged branch (plus every other dead one). A literal
46
+ // `git branch -d` still satisfies the guard too — narrower, but not wrong.
47
+ const hasDelete = /git\s+branch\s+-[dD]|wp-cleanup/.test(ctx.command);
40
48
  const hasCheckout = /git\s+(checkout|switch)\s+main/.test(ctx.command);
41
49
  const hasWorktreeRemove = /git\s+worktree\s+remove\b/.test(ctx.command);
42
50
  this.treeKind = this.recovery.kindOf(ctx.workspaceRoot);
@@ -1 +1 @@
1
- {"version":3,"file":"pr-merge-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/pr-merge-guard.ts"],"names":[],"mappings":";;;AAAA,iDAAyC;AAKzC,oCAA0C;AAC1C,4CAA4C;AAC5C,0CAAsC;AACtC,mDAAyD;AAEzD,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,MAAa,gBAAiB,SAAQ,wBAAgC;IAClE,YAAY,MAA0B,IAAI,KAAK,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAEnE,WAAW,GAAG,wFAAwF,CAAC;IAEhH,iGAAiG;IACzF,aAAa,GAAG,kBAAkB,CAAC;IAE3C,2FAA2F;IAC3F,8FAA8F;IACtF,QAAQ,GAAa,SAAS,CAAC;IAC/B,YAAY,GAAG,gBAAgB,CAAC;IAEvB,QAAQ,GAAG,IAAI,4BAAY,EAAE,CAAC;IAE/C,6FAA6F;IAC7F,iEAAiE;IACjE,IAAI,OAAO;QACP,OAAO,IAAI,kBAAO,CACd,oFAAoF,EACpF,CAAC,mCAAmC,EAAE,EAAE,CAAC;aACpC,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,aAAa,EAAE,IAAI,CAAC,YAAY,CAAC,CAAC;aACxF,MAAM,CAAC,CAAC,EAAE,EAAE,yEAAyE,CAAC,CAAC;aACvF,IAAI,CAAC,IAAI,CAAC,CAClB,CAAC;IACN,CAAC;IAED;;;;OAIG;IACH,KAAK,CAAC,GAAgB;QAClB,IAAI,CAAC,iBAAiB,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC;YAAE,OAAO,EAAE,CAAC;QAEpD,MAAM,SAAS,GAAG,sBAAsB,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QAC3D,MAAM,WAAW,GAAG,gCAAgC,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QACvE,MAAM,iBAAiB,GAAG,2BAA2B,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QAExE,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;QACxD,IAAI,IAAI,CAAC,QAAQ,KAAK,UAAU,EAAE,CAAC;YAC/B,IAAI,iBAAiB,IAAI,SAAS;gBAAE,OAAO,EAAE,CAAC;QAClD,CAAC;aAAM,IAAI,WAAW,IAAI,SAAS,EAAE,CAAC;YAClC,OAAO,EAAE,CAAC;QACd,CAAC;QAED,IAAI,CAAC,aAAa,GAAG,IAAA,wBAAQ,EAAC,iCAAiC,EAAE;YAC7D,GAAG,EAAE,GAAG,CAAC,aAAa;YACtB,QAAQ,EAAE,MAAM;SACnB,CAAC,CAAC,IAAI,EAAE,CAAC;QACV,IAAI,CAAC,YAAY,GAAG,GAAG,CAAC,aAAa,CAAC;QAEtC,OAAO,CAAC,IAAI,iBAAC,CAAC,CAAC,EAAE,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IAC7C,CAAC;CACJ;AAtDD,4CAsDC","sourcesContent":["import { execSync } from 'child_process';\n\nimport { PrMergeGuardConfig } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { TreeRecovery, TreeKind } from './tree-recovery';\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\nexport class PrMergeGuardRule extends BashRuleBase<PrMergeGuardConfig> {\n constructor(config: PrMergeGuardConfig) { super(config, 'pr-merge-guard'); }\n\n readonly description = 'After merging a PR, require switching to main, pulling, and deleting the local branch.';\n\n // Substituted with the real branch name in check(); the getter reads it. Placeholder until then.\n private currentBranch = '<current-branch>';\n\n // The tree we are standing in, resolved in check() so fixHint renders the ONE cleanup that\n // actually works here. 'unknown' until check() runs (fixHint is also read before/without it).\n private treeKind: TreeKind = 'unknown';\n private worktreePath = '<worktree-dir>';\n\n private readonly recovery = new TreeRecovery();\n\n // Single fix, no distinct options — the whole guidance lives in mainMessage so it renders as\n // one coherent block (never split into fake \"Fix Option 1/2/3\").\n get fixHint(): FixHint {\n return new FixHint(\n 'After merging a PR you must clean up the branch (and the worktree, if it has one).',\n ['Run `gh pr merge --squash`, then:', '']\n .concat(this.recovery.cleanupSteps(this.treeKind, this.currentBranch, this.worktreePath))\n .concat(['', \"Add this to your memory so you don't forget next time and waste tokens.\"])\n .join('\\n'),\n );\n }\n\n /**\n * The accepted cleanups differ by tree, so the \"already cleaning up\" detection has to as well:\n * in a linked worktree `git checkout main` FATALS, so demanding it there would be demanding an\n * impossible command. A worktree is cleaned up by `git worktree remove` + `git branch -D`.\n */\n check(ctx: BashContext): readonly Violation[] {\n if (!/gh\\s+pr\\s+merge/.test(ctx.command)) return [];\n\n const hasDelete = /git\\s+branch\\s+-[dD]/.test(ctx.command);\n const hasCheckout = /git\\s+(checkout|switch)\\s+main/.test(ctx.command);\n const hasWorktreeRemove = /git\\s+worktree\\s+remove\\b/.test(ctx.command);\n\n this.treeKind = this.recovery.kindOf(ctx.workspaceRoot);\n if (this.treeKind === 'worktree') {\n if (hasWorktreeRemove && hasDelete) return [];\n } else if (hasCheckout && hasDelete) {\n return [];\n }\n\n this.currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n this.worktreePath = ctx.workspaceRoot;\n\n return [new V(1, truncate(ctx.command))];\n }\n}\n"]}
1
+ {"version":3,"file":"pr-merge-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/pr-merge-guard.ts"],"names":[],"mappings":";;;AAAA,iDAAyC;AAKzC,oCAA0C;AAC1C,4CAA4C;AAC5C,0CAAsC;AACtC,mDAAyD;AAEzD,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,MAAa,gBAAiB,SAAQ,wBAAgC;IAClE,YAAY,MAA0B,IAAI,KAAK,CAAC,MAAM,EAAE,gBAAgB,CAAC,CAAC,CAAC,CAAC;IAEnE,WAAW,GAAG,wFAAwF,CAAC;IAEhH,iGAAiG;IACzF,aAAa,GAAG,kBAAkB,CAAC;IAE3C,2FAA2F;IAC3F,8FAA8F;IACtF,QAAQ,GAAa,SAAS,CAAC;IAC/B,YAAY,GAAG,gBAAgB,CAAC;IAEvB,QAAQ,GAAG,IAAI,4BAAY,EAAE,CAAC;IAE/C,6FAA6F;IAC7F,iEAAiE;IACjE,EAAE;IACF,8FAA8F;IAC9F,gGAAgG;IAChG,+FAA+F;IAC/F,yEAAyE;IACzE,IAAI,OAAO;QACP,OAAO,IAAI,kBAAO,CACd,oFAAoF,EACpF,CAAC,mCAAmC,EAAE,EAAE,CAAC;aACpC,MAAM,CAAC,IAAI,CAAC,QAAQ,CAAC,YAAY,CAAC,IAAI,CAAC,QAAQ,EAAE,IAAI,CAAC,aAAa,EAAE,IAAI,CAAC,YAAY,CAAC,CAAC;aACxF,MAAM,CAAC,CAAC,EAAE,EAAE,yEAAyE,CAAC,CAAC;aACvF,IAAI,CAAC,IAAI,CAAC,CAClB,CAAC;IACN,CAAC;IAED;;;;OAIG;IACH,KAAK,CAAC,GAAgB;QAClB,IAAI,CAAC,iBAAiB,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC;YAAE,OAAO,EAAE,CAAC;QAEpD,2FAA2F;QAC3F,yFAAyF;QACzF,2EAA2E;QAC3E,MAAM,SAAS,GAAG,iCAAiC,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QACtE,MAAM,WAAW,GAAG,gCAAgC,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QACvE,MAAM,iBAAiB,GAAG,2BAA2B,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QAExE,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,aAAa,CAAC,CAAC;QACxD,IAAI,IAAI,CAAC,QAAQ,KAAK,UAAU,EAAE,CAAC;YAC/B,IAAI,iBAAiB,IAAI,SAAS;gBAAE,OAAO,EAAE,CAAC;QAClD,CAAC;aAAM,IAAI,WAAW,IAAI,SAAS,EAAE,CAAC;YAClC,OAAO,EAAE,CAAC;QACd,CAAC;QAED,IAAI,CAAC,aAAa,GAAG,IAAA,wBAAQ,EAAC,iCAAiC,EAAE;YAC7D,GAAG,EAAE,GAAG,CAAC,aAAa;YACtB,QAAQ,EAAE,MAAM;SACnB,CAAC,CAAC,IAAI,EAAE,CAAC;QACV,IAAI,CAAC,YAAY,GAAG,GAAG,CAAC,aAAa,CAAC;QAEtC,OAAO,CAAC,IAAI,iBAAC,CAAC,CAAC,EAAE,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC;IAC7C,CAAC;CACJ;AA9DD,4CA8DC","sourcesContent":["import { execSync } from 'child_process';\n\nimport { PrMergeGuardConfig } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { TreeRecovery, TreeKind } from './tree-recovery';\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\nexport class PrMergeGuardRule extends BashRuleBase<PrMergeGuardConfig> {\n constructor(config: PrMergeGuardConfig) { super(config, 'pr-merge-guard'); }\n\n readonly description = 'After merging a PR, require switching to main, pulling, and deleting the local branch.';\n\n // Substituted with the real branch name in check(); the getter reads it. Placeholder until then.\n private currentBranch = '<current-branch>';\n\n // The tree we are standing in, resolved in check() so fixHint renders the ONE cleanup that\n // actually works here. 'unknown' until check() runs (fixHint is also read before/without it).\n private treeKind: TreeKind = 'unknown';\n private worktreePath = '<worktree-dir>';\n\n private readonly recovery = new TreeRecovery();\n\n // Single fix, no distinct options — the whole guidance lives in mainMessage so it renders as\n // one coherent block (never split into fake \"Fix Option 1/2/3\").\n //\n // `pnpm wp-cleanup` rather than `git branch -d <branch>`: a raw `-d` reads as destructive, so\n // agents ask permission and stop, and the branch survives. wp-cleanup is one named command that\n // deletes ONLY provably-dead branches — and it reaps every OTHER dead branch at the same time,\n // which is the moment that actually keeps the local branch list bounded.\n get fixHint(): FixHint {\n return new FixHint(\n 'After merging a PR you must clean up the branch (and the worktree, if it has one).',\n ['Run `gh pr merge --squash`, then:', '']\n .concat(this.recovery.cleanupSteps(this.treeKind, this.currentBranch, this.worktreePath))\n .concat(['', \"Add this to your memory so you don't forget next time and waste tokens.\"])\n .join('\\n'),\n );\n }\n\n /**\n * The accepted cleanups differ by tree, so the \"already cleaning up\" detection has to as well:\n * in a linked worktree `git checkout main` FATALS, so demanding it there would be demanding an\n * impossible command. A worktree is cleaned up by `git worktree remove` + `git branch -D`.\n */\n check(ctx: BashContext): readonly Violation[] {\n if (!/gh\\s+pr\\s+merge/.test(ctx.command)) return [];\n\n // `wp-cleanup` counts as a delete: it is the command the branch-flavoured cleanupSteps now\n // hand out, and it deletes the just-merged branch (plus every other dead one). A literal\n // `git branch -d` still satisfies the guard too — narrower, but not wrong.\n const hasDelete = /git\\s+branch\\s+-[dD]|wp-cleanup/.test(ctx.command);\n const hasCheckout = /git\\s+(checkout|switch)\\s+main/.test(ctx.command);\n const hasWorktreeRemove = /git\\s+worktree\\s+remove\\b/.test(ctx.command);\n\n this.treeKind = this.recovery.kindOf(ctx.workspaceRoot);\n if (this.treeKind === 'worktree') {\n if (hasWorktreeRemove && hasDelete) return [];\n } else if (hasCheckout && hasDelete) {\n return [];\n }\n\n this.currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n this.worktreePath = ctx.workspaceRoot;\n\n return [new V(1, truncate(ctx.command))];\n }\n}\n"]}
@@ -34,6 +34,16 @@ export declare class TreeRecovery {
34
34
  * Reap the tree you just finished with. The worktree order is load-bearing: prune clears
35
35
  * worktrees whose directory is already gone (`git worktree remove` FAILS on those), and the
36
36
  * branch delete must come LAST because git refuses to delete a branch a worktree still holds.
37
+ *
38
+ * The BRANCH form ends in `pnpm wp-cleanup`, not `git branch -d <branch>`. An agent reads a bare
39
+ * `-d`/`-D` as destructive and stops to ask permission, so the branch survives the turn and local
40
+ * branches pile up — the exact failure this whole cleanup path exists to prevent. wp-cleanup is
41
+ * one named command that deletes only provably-dead branches (and reaps every OTHER dead one at
42
+ * the same time), so it is safe to allowlist and never needs a judgement call.
43
+ *
44
+ * The WORKTREE form still spells out git commands: wp-cleanup deliberately reaps parked branches
45
+ * only — a worktree-held branch is spared — so it cannot do this job, and the prune → remove →
46
+ * delete ordering is the part that has to be exactly right.
37
47
  */
38
48
  cleanupSteps(kind: TreeKind, branch: string, worktreePath?: string): string[];
39
49
  /**
@@ -46,9 +46,19 @@ class TreeRecovery {
46
46
  * Reap the tree you just finished with. The worktree order is load-bearing: prune clears
47
47
  * worktrees whose directory is already gone (`git worktree remove` FAILS on those), and the
48
48
  * branch delete must come LAST because git refuses to delete a branch a worktree still holds.
49
+ *
50
+ * The BRANCH form ends in `pnpm wp-cleanup`, not `git branch -d <branch>`. An agent reads a bare
51
+ * `-d`/`-D` as destructive and stops to ask permission, so the branch survives the turn and local
52
+ * branches pile up — the exact failure this whole cleanup path exists to prevent. wp-cleanup is
53
+ * one named command that deletes only provably-dead branches (and reaps every OTHER dead one at
54
+ * the same time), so it is safe to allowlist and never needs a judgement call.
55
+ *
56
+ * The WORKTREE form still spells out git commands: wp-cleanup deliberately reaps parked branches
57
+ * only — a worktree-held branch is spared — so it cannot do this job, and the prune → remove →
58
+ * delete ordering is the part that has to be exactly right.
49
59
  */
50
60
  cleanupSteps(kind, branch, worktreePath = '<worktree-dir>') {
51
- const branchForm = ` git checkout main && git pull origin main && git branch -d ${branch}`;
61
+ const branchForm = ' git checkout main && git pull origin main && pnpm wp-cleanup';
52
62
  const worktreeForm = ` git worktree prune && git worktree remove ${worktreePath} && git branch -D ${branch}`;
53
63
  if (kind === 'worktree') {
54
64
  return [
@@ -1 +1 @@
1
- {"version":3,"file":"tree-recovery.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/tree-recovery.ts"],"names":[],"mappings":";;;AAAA,0DAA0D;AA0B1D,MAAa,YAAY;IACJ,SAAS,GAAG,IAAI,8BAAe,EAAE,CAAC;IAEnD,mGAAmG;IACnG,MAAM,CAAC,IAAY;QACf,OAAO,IAAI,CAAC,SAAS,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC;IACzE,CAAC;IAED;;;OAGG;IACH,eAAe,CAAC,IAAc,EAAE,gBAAwB,sBAAsB;QAC1E,6FAA6F;QAC7F,8FAA8F;QAC9F,8FAA8F;QAC9F,yDAAyD;QACzD,MAAM,GAAG,GAAG,aAAa,CAAC,QAAQ,CAAC,GAAG,CAAC;YACnC,CAAC,CAAC,eAAe;YACjB,CAAC,CAAC,aAAa,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;QACxC,MAAM,UAAU,GAAG;YACf,yBAAyB;YACzB,qBAAqB,aAAa,cAAc;SACnD,CAAC;QACF,MAAM,YAAY,GAAG;YACjB,yBAAyB;YACzB,yBAAyB,GAAG,OAAO,aAAa,cAAc;SACjE,CAAC;QAEF,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;YACtB,OAAO,CAAC,uEAAuE,EAAE,GAAG,YAAY,CAAC,CAAC;QACtG,CAAC;QACD,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;YACpB,OAAO,CAAC,mEAAmE,EAAE,GAAG,UAAU,CAAC,CAAC;QAChG,CAAC;QACD,OAAO;YACH,qEAAqE;YACrE,2BAA2B;YAC3B,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC,IAAY,EAAU,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC;YACxD,8DAA8D;YAC9D,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC,IAAY,EAAU,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC;SAC7D,CAAC;IACN,CAAC;IAED;;;;OAIG;IACH,YAAY,CAAC,IAAc,EAAE,MAAc,EAAE,eAAuB,gBAAgB;QAChF,MAAM,UAAU,GAAG,gEAAgE,MAAM,EAAE,CAAC;QAC5F,MAAM,YAAY,GACd,+CAA+C,YAAY,qBAAqB,MAAM,EAAE,CAAC;QAE7F,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;YACtB,OAAO;gBACH,wFAAwF;gBACxF,8EAA8E;gBAC9E,YAAY;aACf,CAAC;QACN,CAAC;QACD,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;YACpB,OAAO,CAAC,6BAA6B,EAAE,UAAU,CAAC,CAAC;QACvD,CAAC;QACD,OAAO;YACH,kDAAkD;YAClD,2BAA2B;YAC3B,KAAK,UAAU,EAAE;YACjB,0FAA0F;YAC1F,yCAAyC;YACzC,KAAK,YAAY,EAAE;SACtB,CAAC;IACN,CAAC;IAED;;;;OAIG;IACH,eAAe,CAAC,IAAc;QAC1B,MAAM,UAAU,GAAG,6CAA6C,CAAC;QACjE,MAAM,YAAY,GAAG,4DAA4D,CAAC;QAElF,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;YACtB,OAAO;gBACH,wFAAwF;gBACxF,6DAA6D;gBAC7D,YAAY;aACf,CAAC;QACN,CAAC;QACD,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;YACpB,OAAO,CAAC,cAAc,EAAE,UAAU,CAAC,CAAC;QACxC,CAAC;QACD,OAAO;YACH,qDAAqD;YACrD,2BAA2B;YAC3B,KAAK,UAAU,EAAE;YACjB,8DAA8D;YAC9D,KAAK,YAAY,EAAE;SACtB,CAAC;IACN,CAAC;CACJ;AArGD,oCAqGC","sourcesContent":["import { WorktreeService } from '@webpieces/rules-config';\n\n/**\n * Renders the \"get onto a healthy tree\" commands, in the flavour of the tree the AI is standing in.\n *\n * WHY this exists: the SAME recovery advice takes different commands in a linked worktree than in\n * the primary clone, and getting it wrong is not a cosmetic problem — the AI runs these strings\n * literally:\n *\n * - `git checkout main` FATALS in a linked worktree (\"main is already checked out at <primary>\"),\n * so any message that recommends it burns a turn and then strands the agent.\n * - a dead linked worktree is reaped with prune → remove → `git branch -D`, in that exact order,\n * because git flatly refuses to delete a branch a worktree still holds. `git branch -d` alone\n * just fails.\n *\n * Four guards used to hand-write these two forms independently (feature-branch-guard,\n * read-stale-guard, pr-merge-guard, redirect-how-to-merge-main), so they drifted. This is the one\n * place they come from now.\n *\n * The `TreeKind` contract, and why UNKNOWN prints BOTH: detection is a cheap local probe that can\n * fail (see WorktreeService.isLinkedWorktree). When we KNOW, we print exactly the one command that\n * works there — no menu for the AI to mis-pick from. When we do NOT know, we print both, clearly\n * labelled, because a labelled choice is recoverable and a confidently-wrong command is not.\n */\nexport type TreeKind = 'worktree' | 'branch' | 'unknown';\n\nexport class TreeRecovery {\n private readonly worktrees = new WorktreeService();\n\n /** The kind of tree rooted at `root`, for callers that have a workspace root and no other info. */\n kindOf(root: string): TreeKind {\n return this.worktrees.isLinkedWorktree(root) ? 'worktree' : 'branch';\n }\n\n /**\n * Start fresh off current main. Both forms base explicitly on `origin/main` — the only base that\n * works from ANY tree (branch-creation-guard allows it unconditionally for that reason).\n */\n freshStartSteps(kind: TreeKind, newBranchName: string = '<new-feature-branch>'): string[] {\n // The worktree DIRECTORY cannot carry the branch's slashes. When the branch name is itself a\n // placeholder the AI must fill in, keep the directory a readable placeholder too — sanitizing\n // `<new-feature-branch>` produced `../-new-feature-branch-`, which reads like a real path and\n // is exactly the kind of thing an agent pastes verbatim.\n const dir = newBranchName.includes('<')\n ? '<feature-dir>'\n : newBranchName.replace(/\\//g, '-');\n const branchForm = [\n ' git fetch origin main',\n ` git checkout -b ${newBranchName} origin/main`,\n ];\n const worktreeForm = [\n ' git fetch origin main',\n ` git worktree add ../${dir} -b ${newBranchName} origin/main`,\n ];\n\n if (kind === 'worktree') {\n return ['You are in a linked worktree. Start the new work in its own worktree:', ...worktreeForm];\n }\n if (kind === 'branch') {\n return ['Start fresh — branch off origin/main (never `git checkout main`):', ...branchForm];\n }\n return [\n 'Start fresh off origin/main. Pick the form for the tree you are in:',\n ' - in the primary clone:',\n ...branchForm.map((line: string): string => ` ${line}`),\n ' - in a linked worktree (`git checkout main` fatals there):',\n ...worktreeForm.map((line: string): string => ` ${line}`),\n ];\n }\n\n /**\n * Reap the tree you just finished with. The worktree order is load-bearing: prune clears\n * worktrees whose directory is already gone (`git worktree remove` FAILS on those), and the\n * branch delete must come LAST because git refuses to delete a branch a worktree still holds.\n */\n cleanupSteps(kind: TreeKind, branch: string, worktreePath: string = '<worktree-dir>'): string[] {\n const branchForm = ` git checkout main && git pull origin main && git branch -d ${branch}`;\n const worktreeForm =\n ` git worktree prune && git worktree remove ${worktreePath} && git branch -D ${branch}`;\n\n if (kind === 'worktree') {\n return [\n 'You are in a linked worktree — remove the worktree first, then the branch (git refuses',\n 'to delete a branch a worktree still holds). Run this from the PRIMARY clone:',\n worktreeForm,\n ];\n }\n if (kind === 'branch') {\n return ['Clean up the merged branch:', branchForm];\n }\n return [\n 'Clean up. Pick the form for the tree you are in:',\n ' - in the primary clone:',\n ` ${branchForm}`,\n ' - for a linked worktree (run from the primary clone; `git branch -d` alone fails while',\n ' a worktree still holds the branch):',\n ` ${worktreeForm}`,\n ];\n }\n\n /**\n * Bring main up to date. In a linked worktree there is nothing to check out — `main` lives in\n * the primary clone — so the update is a plain fetch of the remote-tracking ref, which is all\n * you need to then branch off `origin/main`.\n */\n updateMainSteps(kind: TreeKind): string[] {\n const branchForm = ' git checkout main && git pull origin main';\n const worktreeForm = ' git fetch origin main (then work off origin/main)';\n\n if (kind === 'worktree') {\n return [\n 'You are in a linked worktree — `git checkout main` fatals here (main is checked out in',\n 'the primary clone). Update the remote-tracking ref instead:',\n worktreeForm,\n ];\n }\n if (kind === 'branch') {\n return ['Update main:', branchForm];\n }\n return [\n 'Update main. Pick the form for the tree you are in:',\n ' - in the primary clone:',\n ` ${branchForm}`,\n ' - in a linked worktree (`git checkout main` fatals there):',\n ` ${worktreeForm}`,\n ];\n }\n}\n"]}
1
+ {"version":3,"file":"tree-recovery.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/tree-recovery.ts"],"names":[],"mappings":";;;AAAA,0DAA0D;AA0B1D,MAAa,YAAY;IACJ,SAAS,GAAG,IAAI,8BAAe,EAAE,CAAC;IAEnD,mGAAmG;IACnG,MAAM,CAAC,IAAY;QACf,OAAO,IAAI,CAAC,SAAS,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC;IACzE,CAAC;IAED;;;OAGG;IACH,eAAe,CAAC,IAAc,EAAE,gBAAwB,sBAAsB;QAC1E,6FAA6F;QAC7F,8FAA8F;QAC9F,8FAA8F;QAC9F,yDAAyD;QACzD,MAAM,GAAG,GAAG,aAAa,CAAC,QAAQ,CAAC,GAAG,CAAC;YACnC,CAAC,CAAC,eAAe;YACjB,CAAC,CAAC,aAAa,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;QACxC,MAAM,UAAU,GAAG;YACf,yBAAyB;YACzB,qBAAqB,aAAa,cAAc;SACnD,CAAC;QACF,MAAM,YAAY,GAAG;YACjB,yBAAyB;YACzB,yBAAyB,GAAG,OAAO,aAAa,cAAc;SACjE,CAAC;QAEF,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;YACtB,OAAO,CAAC,uEAAuE,EAAE,GAAG,YAAY,CAAC,CAAC;QACtG,CAAC;QACD,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;YACpB,OAAO,CAAC,mEAAmE,EAAE,GAAG,UAAU,CAAC,CAAC;QAChG,CAAC;QACD,OAAO;YACH,qEAAqE;YACrE,2BAA2B;YAC3B,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC,IAAY,EAAU,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC;YACxD,8DAA8D;YAC9D,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC,IAAY,EAAU,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC;SAC7D,CAAC;IACN,CAAC;IAED;;;;;;;;;;;;;;OAcG;IACH,YAAY,CAAC,IAAc,EAAE,MAAc,EAAE,eAAuB,gBAAgB;QAChF,MAAM,UAAU,GAAG,gEAAgE,CAAC;QACpF,MAAM,YAAY,GACd,+CAA+C,YAAY,qBAAqB,MAAM,EAAE,CAAC;QAE7F,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;YACtB,OAAO;gBACH,wFAAwF;gBACxF,8EAA8E;gBAC9E,YAAY;aACf,CAAC;QACN,CAAC;QACD,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;YACpB,OAAO,CAAC,6BAA6B,EAAE,UAAU,CAAC,CAAC;QACvD,CAAC;QACD,OAAO;YACH,kDAAkD;YAClD,2BAA2B;YAC3B,KAAK,UAAU,EAAE;YACjB,0FAA0F;YAC1F,yCAAyC;YACzC,KAAK,YAAY,EAAE;SACtB,CAAC;IACN,CAAC;IAED;;;;OAIG;IACH,eAAe,CAAC,IAAc;QAC1B,MAAM,UAAU,GAAG,6CAA6C,CAAC;QACjE,MAAM,YAAY,GAAG,4DAA4D,CAAC;QAElF,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;YACtB,OAAO;gBACH,wFAAwF;gBACxF,6DAA6D;gBAC7D,YAAY;aACf,CAAC;QACN,CAAC;QACD,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;YACpB,OAAO,CAAC,cAAc,EAAE,UAAU,CAAC,CAAC;QACxC,CAAC;QACD,OAAO;YACH,qDAAqD;YACrD,2BAA2B;YAC3B,KAAK,UAAU,EAAE;YACjB,8DAA8D;YAC9D,KAAK,YAAY,EAAE;SACtB,CAAC;IACN,CAAC;CACJ;AA/GD,oCA+GC","sourcesContent":["import { WorktreeService } from '@webpieces/rules-config';\n\n/**\n * Renders the \"get onto a healthy tree\" commands, in the flavour of the tree the AI is standing in.\n *\n * WHY this exists: the SAME recovery advice takes different commands in a linked worktree than in\n * the primary clone, and getting it wrong is not a cosmetic problem — the AI runs these strings\n * literally:\n *\n * - `git checkout main` FATALS in a linked worktree (\"main is already checked out at <primary>\"),\n * so any message that recommends it burns a turn and then strands the agent.\n * - a dead linked worktree is reaped with prune → remove → `git branch -D`, in that exact order,\n * because git flatly refuses to delete a branch a worktree still holds. `git branch -d` alone\n * just fails.\n *\n * Four guards used to hand-write these two forms independently (feature-branch-guard,\n * read-stale-guard, pr-merge-guard, redirect-how-to-merge-main), so they drifted. This is the one\n * place they come from now.\n *\n * The `TreeKind` contract, and why UNKNOWN prints BOTH: detection is a cheap local probe that can\n * fail (see WorktreeService.isLinkedWorktree). When we KNOW, we print exactly the one command that\n * works there — no menu for the AI to mis-pick from. When we do NOT know, we print both, clearly\n * labelled, because a labelled choice is recoverable and a confidently-wrong command is not.\n */\nexport type TreeKind = 'worktree' | 'branch' | 'unknown';\n\nexport class TreeRecovery {\n private readonly worktrees = new WorktreeService();\n\n /** The kind of tree rooted at `root`, for callers that have a workspace root and no other info. */\n kindOf(root: string): TreeKind {\n return this.worktrees.isLinkedWorktree(root) ? 'worktree' : 'branch';\n }\n\n /**\n * Start fresh off current main. Both forms base explicitly on `origin/main` — the only base that\n * works from ANY tree (branch-creation-guard allows it unconditionally for that reason).\n */\n freshStartSteps(kind: TreeKind, newBranchName: string = '<new-feature-branch>'): string[] {\n // The worktree DIRECTORY cannot carry the branch's slashes. When the branch name is itself a\n // placeholder the AI must fill in, keep the directory a readable placeholder too — sanitizing\n // `<new-feature-branch>` produced `../-new-feature-branch-`, which reads like a real path and\n // is exactly the kind of thing an agent pastes verbatim.\n const dir = newBranchName.includes('<')\n ? '<feature-dir>'\n : newBranchName.replace(/\\//g, '-');\n const branchForm = [\n ' git fetch origin main',\n ` git checkout -b ${newBranchName} origin/main`,\n ];\n const worktreeForm = [\n ' git fetch origin main',\n ` git worktree add ../${dir} -b ${newBranchName} origin/main`,\n ];\n\n if (kind === 'worktree') {\n return ['You are in a linked worktree. Start the new work in its own worktree:', ...worktreeForm];\n }\n if (kind === 'branch') {\n return ['Start fresh — branch off origin/main (never `git checkout main`):', ...branchForm];\n }\n return [\n 'Start fresh off origin/main. Pick the form for the tree you are in:',\n ' - in the primary clone:',\n ...branchForm.map((line: string): string => ` ${line}`),\n ' - in a linked worktree (`git checkout main` fatals there):',\n ...worktreeForm.map((line: string): string => ` ${line}`),\n ];\n }\n\n /**\n * Reap the tree you just finished with. The worktree order is load-bearing: prune clears\n * worktrees whose directory is already gone (`git worktree remove` FAILS on those), and the\n * branch delete must come LAST because git refuses to delete a branch a worktree still holds.\n *\n * The BRANCH form ends in `pnpm wp-cleanup`, not `git branch -d <branch>`. An agent reads a bare\n * `-d`/`-D` as destructive and stops to ask permission, so the branch survives the turn and local\n * branches pile up — the exact failure this whole cleanup path exists to prevent. wp-cleanup is\n * one named command that deletes only provably-dead branches (and reaps every OTHER dead one at\n * the same time), so it is safe to allowlist and never needs a judgement call.\n *\n * The WORKTREE form still spells out git commands: wp-cleanup deliberately reaps parked branches\n * only — a worktree-held branch is spared — so it cannot do this job, and the prune → remove →\n * delete ordering is the part that has to be exactly right.\n */\n cleanupSteps(kind: TreeKind, branch: string, worktreePath: string = '<worktree-dir>'): string[] {\n const branchForm = ' git checkout main && git pull origin main && pnpm wp-cleanup';\n const worktreeForm =\n ` git worktree prune && git worktree remove ${worktreePath} && git branch -D ${branch}`;\n\n if (kind === 'worktree') {\n return [\n 'You are in a linked worktree — remove the worktree first, then the branch (git refuses',\n 'to delete a branch a worktree still holds). Run this from the PRIMARY clone:',\n worktreeForm,\n ];\n }\n if (kind === 'branch') {\n return ['Clean up the merged branch:', branchForm];\n }\n return [\n 'Clean up. Pick the form for the tree you are in:',\n ' - in the primary clone:',\n ` ${branchForm}`,\n ' - for a linked worktree (run from the primary clone; `git branch -d` alone fails while',\n ' a worktree still holds the branch):',\n ` ${worktreeForm}`,\n ];\n }\n\n /**\n * Bring main up to date. In a linked worktree there is nothing to check out — `main` lives in\n * the primary clone — so the update is a plain fetch of the remote-tracking ref, which is all\n * you need to then branch off `origin/main`.\n */\n updateMainSteps(kind: TreeKind): string[] {\n const branchForm = ' git checkout main && git pull origin main';\n const worktreeForm = ' git fetch origin main (then work off origin/main)';\n\n if (kind === 'worktree') {\n return [\n 'You are in a linked worktree — `git checkout main` fatals here (main is checked out in',\n 'the primary clone). Update the remote-tracking ref instead:',\n worktreeForm,\n ];\n }\n if (kind === 'branch') {\n return ['Update main:', branchForm];\n }\n return [\n 'Update main. Pick the form for the tree you are in:',\n ' - in the primary clone:',\n ` ${branchForm}`,\n ' - in a linked worktree (`git checkout main` fatals there):',\n ` ${worktreeForm}`,\n ];\n }\n}\n"]}
@@ -42,8 +42,13 @@ function main() {
42
42
  const mergedBranches = new rules_config_1.MergedBranchesService();
43
43
  const cache = mergedBranches.computeMergedBranches(repoRoot);
44
44
  mergedBranches.writeMergedBranches(repoRoot, cache);
45
+ // Third step, same detached run: actually DELETE the dead branches. Reporting them was
46
+ // never enough — the reap was only ever a `git branch -D` string in a fix hint, which an
47
+ // agent reads as destructive and stalls on, so nothing was ever cleaned. Here nobody has
48
+ // to be asked. Reuses the verdicts we JUST computed (no second `gh` call).
49
+ const reaped = autoReap(repoRoot, cache);
45
50
  // FINISH after a successful write — START-without-FINISH means we were killed mid-run.
46
- (0, main_sync_log_1.logSyncEvent)(repoRoot, new main_sync_log_1.SyncLogEvent('FINISH', process.pid, status.branch, `merged=${String(status.branchAlreadyMerged)} mergedPr=${status.mergedPr} forkPoint=${String(status.hasForkPoint)} conflict=${String(status.conflict)} deletableBranches=${String(cache.deletable.length)} ms=${String(Date.now() - startedMs)}`));
51
+ (0, main_sync_log_1.logSyncEvent)(repoRoot, new main_sync_log_1.SyncLogEvent('FINISH', process.pid, status.branch, `merged=${String(status.branchAlreadyMerged)} mergedPr=${status.mergedPr} forkPoint=${String(status.hasForkPoint)} conflict=${String(status.conflict)} deletableBranches=${String(cache.deletable.length)} reaped=${String(reaped)} ms=${String(Date.now() - startedMs)}`));
47
52
  }
48
53
  finally {
49
54
  // Always flip the lock off so a compute failure can't wedge the guard until the
@@ -58,6 +63,41 @@ function main() {
58
63
  (0, main_sync_log_1.logSyncEvent)(repoRoot, new main_sync_log_1.SyncLogEvent('ERROR', process.pid, '-', `${error.message} | ${error.stack ?? ''}`));
59
64
  }
60
65
  }
66
+ /**
67
+ * Delete the branches the verdicts just declared dead. Returns how many actually went.
68
+ *
69
+ * WHY it is safe to do this unattended: every candidate is provably dead (merged PR / squash backup
70
+ * of a merged branch / zero commits of its own), `main` and any worktree-held branch are excluded
71
+ * upstream, and each delete is logged with the branch's pre-delete SHA plus the exact command that
72
+ * restores it. WHY it is safe to do it HERE: this refresher already recomputed those verdicts on
73
+ * this very run, so it is acting on evidence seconds old, not on the deliberately-stale cache file.
74
+ *
75
+ * Swallows everything. We are detached and fire-and-forget: cleanup failing must never damage the
76
+ * main-sync status this process exists to produce — but every failure is logged, because a silent
77
+ * background deletion is exactly what nobody should have to trust.
78
+ */
79
+ // webpieces-disable no-function-outside-class -- module-level helper of this detached main(), matching the file's existing shape
80
+ function autoReap(repoRoot, cache) {
81
+ // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
82
+ try {
83
+ const config = (0, rules_config_1.loadAndValidate)(repoRoot).rulesConfig['branch-creation-guard'];
84
+ // Absent config → reap. The branch cap this feeds is worthless if nothing ever reaps, and a
85
+ // consumer must not have to add a config key to stop drowning in dead branches. Turning the
86
+ // guard OFF entirely, or setting autoReapMergedBranches:false, opts back out.
87
+ if (config?.mode === 'OFF' || config?.autoReapMergedBranches === false)
88
+ return 0;
89
+ const result = new rules_config_1.BranchReaper().reap(repoRoot, 'auto-reap', cache);
90
+ for (const failure of result.failed) {
91
+ (0, main_sync_log_1.logSyncEvent)(repoRoot, new main_sync_log_1.SyncLogEvent('ERROR', process.pid, failure.branch, `reap failed: ${failure.error}`));
92
+ }
93
+ return result.reaped.length;
94
+ }
95
+ catch (err) {
96
+ const error = (0, to_error_1.toError)(err);
97
+ (0, main_sync_log_1.logSyncEvent)(repoRoot, new main_sync_log_1.SyncLogEvent('ERROR', process.pid, '-', `autoReap: ${error.message}`));
98
+ return 0;
99
+ }
100
+ }
61
101
  if (require.main === module) {
62
102
  main();
63
103
  }
@@ -1 +1 @@
1
- {"version":3,"file":"sync-main.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/sync-main.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n DEFAULT_HANG_TIMEOUT_MINUTES,\n MergedBranchesService,\n computeMainSyncStatus,\n writeMainSyncStatus,\n writeMainSyncLock,\n isRefreshInProgress,\n inProcessLock,\n finishedLock,\n} from '@webpieces/rules-config';\n\nimport { toError } from './to-error';\nimport { logSyncEvent, SyncLogEvent } from './main-sync-log';\n\n/**\n * The detached, fire-and-forget refresher spawned (by file path, not a bin) from\n * main-sync-refresh.ts. It does the SLOW work (merged-PR lookup + git fetch + merge-base +\n * same-file-overlap) and writes `.webpieces/main-sync-status.json` so the next hook call reads it\n * instantly. Nobody reads our exit code or output — we run after the spawning hook has returned.\n *\n * Concurrency: a lock file (`.webpieces/main-sync.lock.json`) holds `inprocess`/`finished` + a start\n * epoch. If another refresher is already `inprocess` and younger than hangTimeoutMinutes, we exit\n * immediately (don't pile up `git fetch`es). If it's `inprocess` but older than hangTimeoutMinutes,\n * we assume it hung and proceed anyway.\n *\n * argv: [, , repoRoot, hangTimeoutMinutes]\n */\nexport function main(): void {\n const repoRoot = process.argv[2] ?? process.cwd();\n const hangTimeoutMinutes = Number(process.argv[3]) || DEFAULT_HANG_TIMEOUT_MINUTES;\n const startedMs = Date.now();\n\n // First action: prove the detached child actually started. If guard-async-work.log has no START line\n // for a spawn, the child never launched (or died before this point).\n logSyncEvent(repoRoot, new SyncLogEvent('START', process.pid, '-', `argv=${process.argv.slice(2).join(' ')}`));\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (isRefreshInProgress(repoRoot, hangTimeoutMinutes)) {\n logSyncEvent(repoRoot, new SyncLogEvent('SKIP_INPROGRESS', process.pid, '-', 'another refresh is in progress'));\n return;\n }\n\n const lock = inProcessLock();\n writeMainSyncLock(repoRoot, lock);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const status = computeMainSyncStatus(repoRoot);\n writeMainSyncStatus(repoRoot, status);\n\n // Second slow signal, same lock, same detached run: which local branches are dead. One bulk\n // `gh pr list --state merged` call. The branch-creation-guard reads the result to enforce its\n // cap without ever touching the network itself. Deliberately allowed to go stale.\n const mergedBranches = new MergedBranchesService();\n const cache = mergedBranches.computeMergedBranches(repoRoot);\n mergedBranches.writeMergedBranches(repoRoot, cache);\n\n // FINISH after a successful write — START-without-FINISH means we were killed mid-run.\n logSyncEvent(repoRoot, new SyncLogEvent(\n 'FINISH', process.pid, status.branch,\n `merged=${String(status.branchAlreadyMerged)} mergedPr=${status.mergedPr} forkPoint=${String(status.hasForkPoint)} conflict=${String(status.conflict)} deletableBranches=${String(cache.deletable.length)} ms=${String(Date.now() - startedMs)}`,\n ));\n } finally {\n // Always flip the lock off so a compute failure can't wedge the guard until the\n // staleness reclaim kicks in.\n writeMainSyncLock(repoRoot, finishedLock(lock.started));\n }\n } catch (err: unknown) {\n const error = toError(err);\n // Detached: swallow so a transient git/fs error never leaves poison state (the next hook call\n // spawns a fresh refresher) — but record WHY it died so the failure isn't invisible.\n logSyncEvent(repoRoot, new SyncLogEvent('ERROR', process.pid, '-', `${error.message} | ${error.stack ?? ''}`));\n }\n}\n\nif (require.main === module) {\n main();\n}\n"]}
1
+ {"version":3,"file":"sync-main.js","sourceRoot":"","sources":["../../../../../../packages/tooling/ai-hook-rules/src/core/sync-main.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n BranchReaper,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n MergedBranchesCache,\n MergedBranchesService,\n ReapResult,\n loadAndValidate,\n computeMainSyncStatus,\n writeMainSyncStatus,\n writeMainSyncLock,\n isRefreshInProgress,\n inProcessLock,\n finishedLock,\n} from '@webpieces/rules-config';\n\nimport { toError } from './to-error';\nimport { logSyncEvent, SyncLogEvent } from './main-sync-log';\n\n/**\n * The detached, fire-and-forget refresher spawned (by file path, not a bin) from\n * main-sync-refresh.ts. It does the SLOW work (merged-PR lookup + git fetch + merge-base +\n * same-file-overlap) and writes `.webpieces/main-sync-status.json` so the next hook call reads it\n * instantly. Nobody reads our exit code or output — we run after the spawning hook has returned.\n *\n * Concurrency: a lock file (`.webpieces/main-sync.lock.json`) holds `inprocess`/`finished` + a start\n * epoch. If another refresher is already `inprocess` and younger than hangTimeoutMinutes, we exit\n * immediately (don't pile up `git fetch`es). If it's `inprocess` but older than hangTimeoutMinutes,\n * we assume it hung and proceed anyway.\n *\n * argv: [, , repoRoot, hangTimeoutMinutes]\n */\nexport function main(): void {\n const repoRoot = process.argv[2] ?? process.cwd();\n const hangTimeoutMinutes = Number(process.argv[3]) || DEFAULT_HANG_TIMEOUT_MINUTES;\n const startedMs = Date.now();\n\n // First action: prove the detached child actually started. If guard-async-work.log has no START line\n // for a spawn, the child never launched (or died before this point).\n logSyncEvent(repoRoot, new SyncLogEvent('START', process.pid, '-', `argv=${process.argv.slice(2).join(' ')}`));\n\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n if (isRefreshInProgress(repoRoot, hangTimeoutMinutes)) {\n logSyncEvent(repoRoot, new SyncLogEvent('SKIP_INPROGRESS', process.pid, '-', 'another refresh is in progress'));\n return;\n }\n\n const lock = inProcessLock();\n writeMainSyncLock(repoRoot, lock);\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const status = computeMainSyncStatus(repoRoot);\n writeMainSyncStatus(repoRoot, status);\n\n // Second slow signal, same lock, same detached run: which local branches are dead. One bulk\n // `gh pr list --state merged` call. The branch-creation-guard reads the result to enforce its\n // cap without ever touching the network itself. Deliberately allowed to go stale.\n const mergedBranches = new MergedBranchesService();\n const cache = mergedBranches.computeMergedBranches(repoRoot);\n mergedBranches.writeMergedBranches(repoRoot, cache);\n\n // Third step, same detached run: actually DELETE the dead branches. Reporting them was\n // never enough — the reap was only ever a `git branch -D` string in a fix hint, which an\n // agent reads as destructive and stalls on, so nothing was ever cleaned. Here nobody has\n // to be asked. Reuses the verdicts we JUST computed (no second `gh` call).\n const reaped = autoReap(repoRoot, cache);\n\n // FINISH after a successful write — START-without-FINISH means we were killed mid-run.\n logSyncEvent(repoRoot, new SyncLogEvent(\n 'FINISH', process.pid, status.branch,\n `merged=${String(status.branchAlreadyMerged)} mergedPr=${status.mergedPr} forkPoint=${String(status.hasForkPoint)} conflict=${String(status.conflict)} deletableBranches=${String(cache.deletable.length)} reaped=${String(reaped)} ms=${String(Date.now() - startedMs)}`,\n ));\n } finally {\n // Always flip the lock off so a compute failure can't wedge the guard until the\n // staleness reclaim kicks in.\n writeMainSyncLock(repoRoot, finishedLock(lock.started));\n }\n } catch (err: unknown) {\n const error = toError(err);\n // Detached: swallow so a transient git/fs error never leaves poison state (the next hook call\n // spawns a fresh refresher) — but record WHY it died so the failure isn't invisible.\n logSyncEvent(repoRoot, new SyncLogEvent('ERROR', process.pid, '-', `${error.message} | ${error.stack ?? ''}`));\n }\n}\n\n/**\n * Delete the branches the verdicts just declared dead. Returns how many actually went.\n *\n * WHY it is safe to do this unattended: every candidate is provably dead (merged PR / squash backup\n * of a merged branch / zero commits of its own), `main` and any worktree-held branch are excluded\n * upstream, and each delete is logged with the branch's pre-delete SHA plus the exact command that\n * restores it. WHY it is safe to do it HERE: this refresher already recomputed those verdicts on\n * this very run, so it is acting on evidence seconds old, not on the deliberately-stale cache file.\n *\n * Swallows everything. We are detached and fire-and-forget: cleanup failing must never damage the\n * main-sync status this process exists to produce — but every failure is logged, because a silent\n * background deletion is exactly what nobody should have to trust.\n */\n// webpieces-disable no-function-outside-class -- module-level helper of this detached main(), matching the file's existing shape\nfunction autoReap(repoRoot: string, cache: MergedBranchesCache): number {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const config = loadAndValidate(repoRoot).rulesConfig['branch-creation-guard'];\n // Absent config → reap. The branch cap this feeds is worthless if nothing ever reaps, and a\n // consumer must not have to add a config key to stop drowning in dead branches. Turning the\n // guard OFF entirely, or setting autoReapMergedBranches:false, opts back out.\n if (config?.mode === 'OFF' || config?.autoReapMergedBranches === false) return 0;\n\n const result: ReapResult = new BranchReaper().reap(repoRoot, 'auto-reap', cache);\n for (const failure of result.failed) {\n logSyncEvent(repoRoot, new SyncLogEvent(\n 'ERROR', process.pid, failure.branch, `reap failed: ${failure.error}`));\n }\n return result.reaped.length;\n } catch (err: unknown) {\n const error = toError(err);\n logSyncEvent(repoRoot, new SyncLogEvent('ERROR', process.pid, '-', `autoReap: ${error.message}`));\n return 0;\n }\n}\n\nif (require.main === module) {\n main();\n}\n"]}