@webpieces/ai-hook-rules 0.4.420 → 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.
@@ -7,6 +7,7 @@ const types_1 = require("../types");
7
7
  const rule_base_1 = require("../rule-base");
8
8
  const fix_hint_1 = require("../fix-hint");
9
9
  const command_scan_1 = require("../command-scan");
10
+ const tree_recovery_1 = require("./tree-recovery");
10
11
  const INSTRUCT_FILE = 'webpieces.git-workflow.md';
11
12
  const UPDATE_COMMAND = 'pnpm wp-start-update';
12
13
  const FIX_HINT = new fix_hint_1.FixHint('`git merge` / `git rebase` are never run by AI — on any branch, in any form.', 'To bring main\'s changes into your feature branch:\n'
@@ -41,6 +42,7 @@ function truncate(s) {
41
42
  const SWITCHES_TO_NON_MAIN = /git\s+(?:checkout|switch)\s+(?!main\b|-\s|-$)\S+/;
42
43
  class RedirectHowToMergeMainRule extends rule_base_1.BashRuleBase {
43
44
  scanner = new command_scan_1.CommandScanner();
45
+ recovery = new tree_recovery_1.TreeRecovery();
44
46
  constructor(config) { super(config, 'redirect-how-to-merge-main'); }
45
47
  description = 'Block ALL `git merge`/`git rebase` (any branch, any form) and `git pull origin main` on a feature branch. Use the squash-update process instead.';
46
48
  fixHint = FIX_HINT;
@@ -78,9 +80,16 @@ class RedirectHowToMergeMainRule extends rule_base_1.BashRuleBase {
78
80
  if (SWITCHES_TO_NON_MAIN.test(ctx.command)) {
79
81
  return this.block(ctx, segment, 'Blocked: this command switches to a feature branch and then pulls main into it.');
80
82
  }
81
- // The recommended `git checkout main && git pull origin main`.
82
- if (/git\s+(?:checkout|switch)\s+main\b/.test(ctx.command))
83
- return null;
83
+ // The recommended `git checkout main && git pull origin main` — but ONLY in the primary
84
+ // clone. Inside a linked worktree that checkout FATALS ("'main' is already checked out at
85
+ // <primary>"), so waving it through here hands the AI a command that cannot work and costs
86
+ // it a turn to discover. Steer to the fetch, which is all a worktree needs.
87
+ if (/git\s+(?:checkout|switch)\s+main\b/.test(ctx.command)) {
88
+ if (this.recovery.kindOf(ctx.workspaceRoot) !== 'worktree')
89
+ return null;
90
+ // updateMainSteps already explains the worktree/fatal reasoning — don't say it twice.
91
+ return this.block(ctx, segment, ['Blocked.', ...this.recovery.updateMainSteps('worktree')].join('\n'));
92
+ }
84
93
  const currentBranch = (0, child_process_1.execSync)('git rev-parse --abbrev-ref HEAD', {
85
94
  cwd: ctx.workspaceRoot,
86
95
  encoding: 'utf8',
@@ -1 +1 @@
1
- {"version":3,"file":"redirect-how-to-merge-main.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/redirect-how-to-merge-main.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport { RedirectHowToMergeMainConfig, RepoRootFinder } 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 { CommandScanner } from '../command-scan';\n\nconst INSTRUCT_FILE = 'webpieces.git-workflow.md';\nconst UPDATE_COMMAND = 'pnpm wp-start-update';\n\nconst FIX_HINT = new FixHint(\n '`git merge` / `git rebase` are never run by AI — on any branch, in any form.',\n 'To bring main\\'s changes into your feature branch:\\n'\n + ` ${UPDATE_COMMAND} (then: pnpm wp-finish-upsert-pr)\\n`\n + 'That does a 3-point merge (fork-point=A, feature-HEAD=B, main-HEAD=C), which is what keeps PR\\n'\n + 'diffs clean. A raw `git merge`/`git rebase` destroys the fork-point system. The gated commands\\n'\n + 'merge internally as child processes this hook never sees, so they are unaffected by this guard.\\n'\n + '\\n'\n + 'If you believe a raw merge/rebase is genuinely required, do NOT run it and do NOT work around\\n'\n + 'this guard. STOP and ask the HUMAN to run that exact command themselves — and when you ask,\\n'\n + 'warn them, in these words:\\n'\n + '\\n'\n + ' \"I am asking you to run a raw git merge/rebase. This is almost always the WRONG call —\\n'\n + ` \\`${UPDATE_COMMAND}\\` / \\`pnpm wp-finish-upsert-pr\\` does a 3-point merge and is the correct\\n`\n + ' flow. Please push back and tell me to use the 3-point merge instead, unless you are certain\\n'\n + ' this is a genuine exception.\"\\n'\n + '\\n'\n + 'READ the instruct-ai git-workflow doc at the absolute path on the violation line above for the\\n'\n + 'full flow (incl. worktrees).\\n'\n + 'Add that info to memory so you remember next time.',\n);\n\n// `git merge --abort` / `git rebase --abort|--quit` UNDO an in-progress operation — they cannot create\n// a merge commit or rewrite history, so they cannot violate the fork-point invariant this rule\n// protects. They stay allowed so a repo left mid-operation (e.g. by a human-run rebase) can still be\n// cleaned up. `--continue` is deliberately NOT here: it COMPLETES the operation.\nconst UNDO_FLAG = /--(?:abort|quit)\\b/;\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\n// Switches to a branch OTHER than main. `git branch -D <x>` is not a checkout so it does not trip\n// this; `checkout main` and flag-only forms like `checkout -` do not count as a feature switch.\nconst SWITCHES_TO_NON_MAIN = /git\\s+(?:checkout|switch)\\s+(?!main\\b|-\\s|-$)\\S+/;\n\nexport class RedirectHowToMergeMainRule extends BashRuleBase<RedirectHowToMergeMainConfig> {\n private readonly scanner = new CommandScanner();\n\n constructor(config: RedirectHowToMergeMainConfig) { super(config, 'redirect-how-to-merge-main'); }\n\n readonly description = 'Block ALL `git merge`/`git rebase` (any branch, any form) and `git pull origin main` on a feature branch. Use the squash-update process instead.';\n readonly fixHint = FIX_HINT;\n\n check(ctx: BashContext): readonly Violation[] {\n for (const segment of this.scanner.commandSegments(ctx.command)) {\n const violation = this.checkSegment(ctx, segment);\n if (violation !== null) return [violation];\n }\n return [];\n }\n\n private checkSegment(ctx: BashContext, segment: string): Violation | null {\n // 1. merge/rebase: unconditional block. Deliberately NO branch lookup.\n //\n // This rule used to read hook-time HEAD and bail out when it was `main`. But a PreToolUse hook\n // runs BEFORE the command, so HEAD-at-hook-time is a value the command itself is about to\n // change: `git checkout feat && git rebase main`, issued while HEAD was still `main` from a\n // prior cleanup, read as \"we're on main, this is fine\" and was waved through. That is the\n // incident this rule exists to prevent. Since merge/rebase have no legitimate AI-run form on\n // ANY branch, there is no branch to consult — and so no HEAD to spoof.\n if (this.scanner.invokesGit(segment, 'merge') || this.scanner.invokesGit(segment, 'rebase')) {\n if (UNDO_FLAG.test(segment)) return null;\n return this.block(ctx, segment, 'Direct `git merge`/`git rebase` is blocked — AI never runs it, on any branch.');\n }\n\n // 2. pull: unlike merge/rebase this DOES retain a legitimate on-main form\n // (`git checkout main && git pull origin main`), so it must consult the branch — which is\n // exactly why it also needs the branch-switch check that (1) no longer requires.\n if (this.scanner.invokesGit(segment, 'pull') && /\\borigin\\s+main\\b/.test(segment)) {\n return this.checkPull(ctx, segment);\n }\n\n return null;\n }\n\n private checkPull(ctx: BashContext, segment: string): Violation | null {\n if (SWITCHES_TO_NON_MAIN.test(ctx.command)) {\n return this.block(ctx, segment, 'Blocked: this command switches to a feature branch and then pulls main into it.');\n }\n // The recommended `git checkout main && git pull origin main`.\n if (/git\\s+(?:checkout|switch)\\s+main\\b/.test(ctx.command)) return null;\n\n const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n if (currentBranch === 'main') return null;\n\n return this.block(ctx, segment, `Pulling main into feature branch '${currentBranch}' is blocked.`);\n }\n\n private block(ctx: BashContext, segment: string, what: string): Violation {\n const docPath = new RepoRootFinder().instructAiDocPath(ctx.workspaceRoot, INSTRUCT_FILE);\n return new V(\n 1,\n truncate(segment),\n `${what} Use '${UPDATE_COMMAND}' (3-point merge). If you truly need a raw merge/rebase, ask the HUMAN to run it — and warn them to push back. Full flow: READ ${docPath}.`,\n );\n }\n}\n"]}
1
+ {"version":3,"file":"redirect-how-to-merge-main.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/redirect-how-to-merge-main.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport { RedirectHowToMergeMainConfig, RepoRootFinder } 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 { CommandScanner } from '../command-scan';\nimport { TreeRecovery } from './tree-recovery';\n\nconst INSTRUCT_FILE = 'webpieces.git-workflow.md';\nconst UPDATE_COMMAND = 'pnpm wp-start-update';\n\nconst FIX_HINT = new FixHint(\n '`git merge` / `git rebase` are never run by AI — on any branch, in any form.',\n 'To bring main\\'s changes into your feature branch:\\n'\n + ` ${UPDATE_COMMAND} (then: pnpm wp-finish-upsert-pr)\\n`\n + 'That does a 3-point merge (fork-point=A, feature-HEAD=B, main-HEAD=C), which is what keeps PR\\n'\n + 'diffs clean. A raw `git merge`/`git rebase` destroys the fork-point system. The gated commands\\n'\n + 'merge internally as child processes this hook never sees, so they are unaffected by this guard.\\n'\n + '\\n'\n + 'If you believe a raw merge/rebase is genuinely required, do NOT run it and do NOT work around\\n'\n + 'this guard. STOP and ask the HUMAN to run that exact command themselves — and when you ask,\\n'\n + 'warn them, in these words:\\n'\n + '\\n'\n + ' \"I am asking you to run a raw git merge/rebase. This is almost always the WRONG call —\\n'\n + ` \\`${UPDATE_COMMAND}\\` / \\`pnpm wp-finish-upsert-pr\\` does a 3-point merge and is the correct\\n`\n + ' flow. Please push back and tell me to use the 3-point merge instead, unless you are certain\\n'\n + ' this is a genuine exception.\"\\n'\n + '\\n'\n + 'READ the instruct-ai git-workflow doc at the absolute path on the violation line above for the\\n'\n + 'full flow (incl. worktrees).\\n'\n + 'Add that info to memory so you remember next time.',\n);\n\n// `git merge --abort` / `git rebase --abort|--quit` UNDO an in-progress operation — they cannot create\n// a merge commit or rewrite history, so they cannot violate the fork-point invariant this rule\n// protects. They stay allowed so a repo left mid-operation (e.g. by a human-run rebase) can still be\n// cleaned up. `--continue` is deliberately NOT here: it COMPLETES the operation.\nconst UNDO_FLAG = /--(?:abort|quit)\\b/;\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\n// Switches to a branch OTHER than main. `git branch -D <x>` is not a checkout so it does not trip\n// this; `checkout main` and flag-only forms like `checkout -` do not count as a feature switch.\nconst SWITCHES_TO_NON_MAIN = /git\\s+(?:checkout|switch)\\s+(?!main\\b|-\\s|-$)\\S+/;\n\nexport class RedirectHowToMergeMainRule extends BashRuleBase<RedirectHowToMergeMainConfig> {\n private readonly scanner = new CommandScanner();\n private readonly recovery = new TreeRecovery();\n\n constructor(config: RedirectHowToMergeMainConfig) { super(config, 'redirect-how-to-merge-main'); }\n\n readonly description = 'Block ALL `git merge`/`git rebase` (any branch, any form) and `git pull origin main` on a feature branch. Use the squash-update process instead.';\n readonly fixHint = FIX_HINT;\n\n check(ctx: BashContext): readonly Violation[] {\n for (const segment of this.scanner.commandSegments(ctx.command)) {\n const violation = this.checkSegment(ctx, segment);\n if (violation !== null) return [violation];\n }\n return [];\n }\n\n private checkSegment(ctx: BashContext, segment: string): Violation | null {\n // 1. merge/rebase: unconditional block. Deliberately NO branch lookup.\n //\n // This rule used to read hook-time HEAD and bail out when it was `main`. But a PreToolUse hook\n // runs BEFORE the command, so HEAD-at-hook-time is a value the command itself is about to\n // change: `git checkout feat && git rebase main`, issued while HEAD was still `main` from a\n // prior cleanup, read as \"we're on main, this is fine\" and was waved through. That is the\n // incident this rule exists to prevent. Since merge/rebase have no legitimate AI-run form on\n // ANY branch, there is no branch to consult — and so no HEAD to spoof.\n if (this.scanner.invokesGit(segment, 'merge') || this.scanner.invokesGit(segment, 'rebase')) {\n if (UNDO_FLAG.test(segment)) return null;\n return this.block(ctx, segment, 'Direct `git merge`/`git rebase` is blocked — AI never runs it, on any branch.');\n }\n\n // 2. pull: unlike merge/rebase this DOES retain a legitimate on-main form\n // (`git checkout main && git pull origin main`), so it must consult the branch — which is\n // exactly why it also needs the branch-switch check that (1) no longer requires.\n if (this.scanner.invokesGit(segment, 'pull') && /\\borigin\\s+main\\b/.test(segment)) {\n return this.checkPull(ctx, segment);\n }\n\n return null;\n }\n\n private checkPull(ctx: BashContext, segment: string): Violation | null {\n if (SWITCHES_TO_NON_MAIN.test(ctx.command)) {\n return this.block(ctx, segment, 'Blocked: this command switches to a feature branch and then pulls main into it.');\n }\n // The recommended `git checkout main && git pull origin main` — but ONLY in the primary\n // clone. Inside a linked worktree that checkout FATALS (\"'main' is already checked out at\n // <primary>\"), so waving it through here hands the AI a command that cannot work and costs\n // it a turn to discover. Steer to the fetch, which is all a worktree needs.\n if (/git\\s+(?:checkout|switch)\\s+main\\b/.test(ctx.command)) {\n if (this.recovery.kindOf(ctx.workspaceRoot) !== 'worktree') return null;\n // updateMainSteps already explains the worktree/fatal reasoning — don't say it twice.\n return this.block(ctx, segment, ['Blocked.', ...this.recovery.updateMainSteps('worktree')].join('\\n'));\n }\n\n const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n if (currentBranch === 'main') return null;\n\n return this.block(ctx, segment, `Pulling main into feature branch '${currentBranch}' is blocked.`);\n }\n\n private block(ctx: BashContext, segment: string, what: string): Violation {\n const docPath = new RepoRootFinder().instructAiDocPath(ctx.workspaceRoot, INSTRUCT_FILE);\n return new V(\n 1,\n truncate(segment),\n `${what} Use '${UPDATE_COMMAND}' (3-point merge). If you truly need a raw merge/rebase, ask the HUMAN to run it — and warn them to push back. Full flow: READ ${docPath}.`,\n );\n }\n}\n"]}
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Renders the "get onto a healthy tree" commands, in the flavour of the tree the AI is standing in.
3
+ *
4
+ * WHY this exists: the SAME recovery advice takes different commands in a linked worktree than in
5
+ * the primary clone, and getting it wrong is not a cosmetic problem — the AI runs these strings
6
+ * literally:
7
+ *
8
+ * - `git checkout main` FATALS in a linked worktree ("main is already checked out at <primary>"),
9
+ * so any message that recommends it burns a turn and then strands the agent.
10
+ * - a dead linked worktree is reaped with prune → remove → `git branch -D`, in that exact order,
11
+ * because git flatly refuses to delete a branch a worktree still holds. `git branch -d` alone
12
+ * just fails.
13
+ *
14
+ * Four guards used to hand-write these two forms independently (feature-branch-guard,
15
+ * read-stale-guard, pr-merge-guard, redirect-how-to-merge-main), so they drifted. This is the one
16
+ * place they come from now.
17
+ *
18
+ * The `TreeKind` contract, and why UNKNOWN prints BOTH: detection is a cheap local probe that can
19
+ * fail (see WorktreeService.isLinkedWorktree). When we KNOW, we print exactly the one command that
20
+ * works there — no menu for the AI to mis-pick from. When we do NOT know, we print both, clearly
21
+ * labelled, because a labelled choice is recoverable and a confidently-wrong command is not.
22
+ */
23
+ export type TreeKind = 'worktree' | 'branch' | 'unknown';
24
+ export declare class TreeRecovery {
25
+ private readonly worktrees;
26
+ /** The kind of tree rooted at `root`, for callers that have a workspace root and no other info. */
27
+ kindOf(root: string): TreeKind;
28
+ /**
29
+ * Start fresh off current main. Both forms base explicitly on `origin/main` — the only base that
30
+ * works from ANY tree (branch-creation-guard allows it unconditionally for that reason).
31
+ */
32
+ freshStartSteps(kind: TreeKind, newBranchName?: string): string[];
33
+ /**
34
+ * Reap the tree you just finished with. The worktree order is load-bearing: prune clears
35
+ * worktrees whose directory is already gone (`git worktree remove` FAILS on those), and the
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.
47
+ */
48
+ cleanupSteps(kind: TreeKind, branch: string, worktreePath?: string): string[];
49
+ /**
50
+ * Bring main up to date. In a linked worktree there is nothing to check out — `main` lives in
51
+ * the primary clone — so the update is a plain fetch of the remote-tracking ref, which is all
52
+ * you need to then branch off `origin/main`.
53
+ */
54
+ updateMainSteps(kind: TreeKind): string[];
55
+ }
@@ -0,0 +1,110 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TreeRecovery = void 0;
4
+ const rules_config_1 = require("@webpieces/rules-config");
5
+ class TreeRecovery {
6
+ worktrees = new rules_config_1.WorktreeService();
7
+ /** The kind of tree rooted at `root`, for callers that have a workspace root and no other info. */
8
+ kindOf(root) {
9
+ return this.worktrees.isLinkedWorktree(root) ? 'worktree' : 'branch';
10
+ }
11
+ /**
12
+ * Start fresh off current main. Both forms base explicitly on `origin/main` — the only base that
13
+ * works from ANY tree (branch-creation-guard allows it unconditionally for that reason).
14
+ */
15
+ freshStartSteps(kind, newBranchName = '<new-feature-branch>') {
16
+ // The worktree DIRECTORY cannot carry the branch's slashes. When the branch name is itself a
17
+ // placeholder the AI must fill in, keep the directory a readable placeholder too — sanitizing
18
+ // `<new-feature-branch>` produced `../-new-feature-branch-`, which reads like a real path and
19
+ // is exactly the kind of thing an agent pastes verbatim.
20
+ const dir = newBranchName.includes('<')
21
+ ? '<feature-dir>'
22
+ : newBranchName.replace(/\//g, '-');
23
+ const branchForm = [
24
+ ' git fetch origin main',
25
+ ` git checkout -b ${newBranchName} origin/main`,
26
+ ];
27
+ const worktreeForm = [
28
+ ' git fetch origin main',
29
+ ` git worktree add ../${dir} -b ${newBranchName} origin/main`,
30
+ ];
31
+ if (kind === 'worktree') {
32
+ return ['You are in a linked worktree. Start the new work in its own worktree:', ...worktreeForm];
33
+ }
34
+ if (kind === 'branch') {
35
+ return ['Start fresh — branch off origin/main (never `git checkout main`):', ...branchForm];
36
+ }
37
+ return [
38
+ 'Start fresh off origin/main. Pick the form for the tree you are in:',
39
+ ' - in the primary clone:',
40
+ ...branchForm.map((line) => ` ${line}`),
41
+ ' - in a linked worktree (`git checkout main` fatals there):',
42
+ ...worktreeForm.map((line) => ` ${line}`),
43
+ ];
44
+ }
45
+ /**
46
+ * Reap the tree you just finished with. The worktree order is load-bearing: prune clears
47
+ * worktrees whose directory is already gone (`git worktree remove` FAILS on those), and the
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.
59
+ */
60
+ cleanupSteps(kind, branch, worktreePath = '<worktree-dir>') {
61
+ const branchForm = ' git checkout main && git pull origin main && pnpm wp-cleanup';
62
+ const worktreeForm = ` git worktree prune && git worktree remove ${worktreePath} && git branch -D ${branch}`;
63
+ if (kind === 'worktree') {
64
+ return [
65
+ 'You are in a linked worktree — remove the worktree first, then the branch (git refuses',
66
+ 'to delete a branch a worktree still holds). Run this from the PRIMARY clone:',
67
+ worktreeForm,
68
+ ];
69
+ }
70
+ if (kind === 'branch') {
71
+ return ['Clean up the merged branch:', branchForm];
72
+ }
73
+ return [
74
+ 'Clean up. Pick the form for the tree you are in:',
75
+ ' - in the primary clone:',
76
+ ` ${branchForm}`,
77
+ ' - for a linked worktree (run from the primary clone; `git branch -d` alone fails while',
78
+ ' a worktree still holds the branch):',
79
+ ` ${worktreeForm}`,
80
+ ];
81
+ }
82
+ /**
83
+ * Bring main up to date. In a linked worktree there is nothing to check out — `main` lives in
84
+ * the primary clone — so the update is a plain fetch of the remote-tracking ref, which is all
85
+ * you need to then branch off `origin/main`.
86
+ */
87
+ updateMainSteps(kind) {
88
+ const branchForm = ' git checkout main && git pull origin main';
89
+ const worktreeForm = ' git fetch origin main (then work off origin/main)';
90
+ if (kind === 'worktree') {
91
+ return [
92
+ 'You are in a linked worktree — `git checkout main` fatals here (main is checked out in',
93
+ 'the primary clone). Update the remote-tracking ref instead:',
94
+ worktreeForm,
95
+ ];
96
+ }
97
+ if (kind === 'branch') {
98
+ return ['Update main:', branchForm];
99
+ }
100
+ return [
101
+ 'Update main. Pick the form for the tree you are in:',
102
+ ' - in the primary clone:',
103
+ ` ${branchForm}`,
104
+ ' - in a linked worktree (`git checkout main` fatals there):',
105
+ ` ${worktreeForm}`,
106
+ ];
107
+ }
108
+ }
109
+ exports.TreeRecovery = TreeRecovery;
110
+ //# sourceMappingURL=tree-recovery.js.map
@@ -0,0 +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;;;;;;;;;;;;;;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"]}
@@ -172,7 +172,16 @@ elif [ -n "$DRIFT_PKG" ]; then
172
172
  # to 'pnpm install', which DOWNGRADES them further from correct.
173
173
  REASON="❌ webpieces version drift: package.json pins $DRIFT_PKG@$DRIFT_DECLARED but node_modules has $DRIFT_INSTALLED. Every call is blocked until they agree. WHICH ONE IS STALE decides the fix - compare the two versions above: (1) pin is NEWER than node_modules (you just pulled/switched to a branch pinning a newer webpieces) -> run 'pnpm install' to catch node_modules up. (2) pin is OLDER than node_modules (your checkout is behind origin, so the PIN is the stale side) -> 'pnpm install' would DOWNGRADE you: run 'git pull' first (or 'git merge --ff-only origin/main'), THEN 'pnpm install'. git pull/fetch/merge are allowed while this guard is up."
174
174
  else
175
- REASON="❌ @webpieces/ai-hook-rules is declared in package.json but is not installed (${BIN_NAME} not found). Run 'pnpm install' (or this repo's installer) to enable the webpieces AI guards, then retry. (If you removed @webpieces/ai-hook-rules on purpose, delete its hooks from .claude/settings.json.)"
175
+ # A LINKED WORKTREE is the overwhelmingly common way to land here with a perfectly healthy repo:
176
+ # git gives the new worktree a .git FILE (the primary clone has a .git directory) and copies no
177
+ # node_modules, so the very first tool call in a brand-new worktree fail-closes on a missing bin.
178
+ # Naming that explicitly turns a baffling "not installed" into a one-command fix, and the HERE is
179
+ # load-bearing: installing in the primary clone does nothing for this tree.
180
+ WORKTREE_NOTE=""
181
+ if [ -f "$ROOT/.git" ]; then
182
+ WORKTREE_NOTE=" NOTE: $ROOT is a LINKED WORKTREE - git does not copy node_modules into a new worktree, so this is expected on a fresh one. Run 'pnpm install' HERE (in this worktree), not in the primary clone."
183
+ fi
184
+ REASON="❌ @webpieces/ai-hook-rules is declared in package.json but is not installed (${BIN_NAME} not found). Run 'pnpm install' (or this repo's installer) to enable the webpieces AI guards, then retry.${WORKTREE_NOTE} (If you removed @webpieces/ai-hook-rules on purpose, delete its hooks from .claude/settings.json.)"
176
185
  fi
177
186
  if [ "$TOOL" = "Bash" ]; then
178
187
  BS='\' # one literal backslash, so the \u001b escape never sits in this source