@webpieces/ai-hook-rules 0.4.420 → 0.4.421

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.
@@ -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":";;;AAAA,iDAAyC;AAEzC,0DAAuF;AAGvF,oCAA0C;AAC1C,4CAA4C;AAC5C,0CAAsC;AACtC,kDAAiD;AACjD,mDAA+C;AAE/C,MAAM,aAAa,GAAG,2BAA2B,CAAC;AAClD,MAAM,cAAc,GAAG,sBAAsB,CAAC;AAE9C,MAAM,QAAQ,GAAG,IAAI,kBAAO,CACxB,8EAA8E,EAC9E,sDAAsD;MACpD,KAAK,cAAc,4CAA4C;MAC/D,iGAAiG;MACjG,kGAAkG;MAClG,mGAAmG;MACnG,IAAI;MACJ,iGAAiG;MACjG,+FAA+F;MAC/F,8BAA8B;MAC9B,IAAI;MACJ,4FAA4F;MAC5F,QAAQ,cAAc,6EAA6E;MACnG,kGAAkG;MAClG,oCAAoC;MACpC,IAAI;MACJ,kGAAkG;MAClG,gCAAgC;MAChC,oDAAoD,CACzD,CAAC;AAEF,uGAAuG;AACvG,+FAA+F;AAC/F,qGAAqG;AACrG,iFAAiF;AACjF,MAAM,SAAS,GAAG,oBAAoB,CAAC;AAEvC,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,kGAAkG;AAClG,gGAAgG;AAChG,MAAM,oBAAoB,GAAG,kDAAkD,CAAC;AAEhF,MAAa,0BAA2B,SAAQ,wBAA0C;IACrE,OAAO,GAAG,IAAI,6BAAc,EAAE,CAAC;IAC/B,QAAQ,GAAG,IAAI,4BAAY,EAAE,CAAC;IAE/C,YAAY,MAAoC,IAAI,KAAK,CAAC,MAAM,EAAE,4BAA4B,CAAC,CAAC,CAAC,CAAC;IAEzF,WAAW,GAAG,kJAAkJ,CAAC;IACjK,OAAO,GAAG,QAAQ,CAAC;IAE5B,KAAK,CAAC,GAAgB;QAClB,KAAK,MAAM,OAAO,IAAI,IAAI,CAAC,OAAO,CAAC,eAAe,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;YAC9D,MAAM,SAAS,GAAG,IAAI,CAAC,YAAY,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;YAClD,IAAI,SAAS,KAAK,IAAI;gBAAE,OAAO,CAAC,SAAS,CAAC,CAAC;QAC/C,CAAC;QACD,OAAO,EAAE,CAAC;IACd,CAAC;IAEO,YAAY,CAAC,GAAgB,EAAE,OAAe;QAClD,uEAAuE;QACvE,EAAE;QACF,+FAA+F;QAC/F,0FAA0F;QAC1F,4FAA4F;QAC5F,0FAA0F;QAC1F,6FAA6F;QAC7F,uEAAuE;QACvE,IAAI,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,EAAE,OAAO,CAAC,IAAI,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,EAAE,QAAQ,CAAC,EAAE,CAAC;YAC1F,IAAI,SAAS,CAAC,IAAI,CAAC,OAAO,CAAC;gBAAE,OAAO,IAAI,CAAC;YACzC,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,+EAA+E,CAAC,CAAC;QACrH,CAAC;QAED,0EAA0E;QAC1E,0FAA0F;QAC1F,iFAAiF;QACjF,IAAI,IAAI,CAAC,OAAO,CAAC,UAAU,CAAC,OAAO,EAAE,MAAM,CAAC,IAAI,mBAAmB,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC;YAChF,OAAO,IAAI,CAAC,SAAS,CAAC,GAAG,EAAE,OAAO,CAAC,CAAC;QACxC,CAAC;QAED,OAAO,IAAI,CAAC;IAChB,CAAC;IAEO,SAAS,CAAC,GAAgB,EAAE,OAAe;QAC/C,IAAI,oBAAoB,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;YACzC,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,iFAAiF,CAAC,CAAC;QACvH,CAAC;QACD,wFAAwF;QACxF,0FAA0F;QAC1F,2FAA2F;QAC3F,4EAA4E;QAC5E,IAAI,oCAAoC,CAAC,IAAI,CAAC,GAAG,CAAC,OAAO,CAAC,EAAE,CAAC;YACzD,IAAI,IAAI,CAAC,QAAQ,CAAC,MAAM,CAAC,GAAG,CAAC,aAAa,CAAC,KAAK,UAAU;gBAAE,OAAO,IAAI,CAAC;YACxE,sFAAsF;YACtF,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,CAAC,UAAU,EAAE,GAAG,IAAI,CAAC,QAAQ,CAAC,eAAe,CAAC,UAAU,CAAC,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC;QAC3G,CAAC;QAED,MAAM,aAAa,GAAG,IAAA,wBAAQ,EAAC,iCAAiC,EAAE;YAC9D,GAAG,EAAE,GAAG,CAAC,aAAa;YACtB,QAAQ,EAAE,MAAM;SACnB,CAAC,CAAC,IAAI,EAAE,CAAC;QACV,IAAI,aAAa,KAAK,MAAM;YAAE,OAAO,IAAI,CAAC;QAE1C,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,OAAO,EAAE,qCAAqC,aAAa,eAAe,CAAC,CAAC;IACvG,CAAC;IAEO,KAAK,CAAC,GAAgB,EAAE,OAAe,EAAE,IAAY;QACzD,MAAM,OAAO,GAAG,IAAI,6BAAc,EAAE,CAAC,iBAAiB,CAAC,GAAG,CAAC,aAAa,EAAE,aAAa,CAAC,CAAC;QACzF,OAAO,IAAI,iBAAC,CACR,CAAC,EACD,QAAQ,CAAC,OAAO,CAAC,EACjB,GAAG,IAAI,SAAS,cAAc,kIAAkI,OAAO,GAAG,CAC7K,CAAC;IACN,CAAC;CACJ;AAxED,gEAwEC","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,45 @@
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
+ cleanupSteps(kind: TreeKind, branch: string, worktreePath?: string): string[];
39
+ /**
40
+ * Bring main up to date. In a linked worktree there is nothing to check out — `main` lives in
41
+ * the primary clone — so the update is a plain fetch of the remote-tracking ref, which is all
42
+ * you need to then branch off `origin/main`.
43
+ */
44
+ updateMainSteps(kind: TreeKind): string[];
45
+ }
@@ -0,0 +1,100 @@
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
+ cleanupSteps(kind, branch, worktreePath = '<worktree-dir>') {
51
+ const branchForm = ` git checkout main && git pull origin main && git branch -d ${branch}`;
52
+ const worktreeForm = ` git worktree prune && git worktree remove ${worktreePath} && git branch -D ${branch}`;
53
+ if (kind === 'worktree') {
54
+ return [
55
+ 'You are in a linked worktree — remove the worktree first, then the branch (git refuses',
56
+ 'to delete a branch a worktree still holds). Run this from the PRIMARY clone:',
57
+ worktreeForm,
58
+ ];
59
+ }
60
+ if (kind === 'branch') {
61
+ return ['Clean up the merged branch:', branchForm];
62
+ }
63
+ return [
64
+ 'Clean up. Pick the form for the tree you are in:',
65
+ ' - in the primary clone:',
66
+ ` ${branchForm}`,
67
+ ' - for a linked worktree (run from the primary clone; `git branch -d` alone fails while',
68
+ ' a worktree still holds the branch):',
69
+ ` ${worktreeForm}`,
70
+ ];
71
+ }
72
+ /**
73
+ * Bring main up to date. In a linked worktree there is nothing to check out — `main` lives in
74
+ * the primary clone — so the update is a plain fetch of the remote-tracking ref, which is all
75
+ * you need to then branch off `origin/main`.
76
+ */
77
+ updateMainSteps(kind) {
78
+ const branchForm = ' git checkout main && git pull origin main';
79
+ const worktreeForm = ' git fetch origin main (then work off origin/main)';
80
+ if (kind === 'worktree') {
81
+ return [
82
+ 'You are in a linked worktree — `git checkout main` fatals here (main is checked out in',
83
+ 'the primary clone). Update the remote-tracking ref instead:',
84
+ worktreeForm,
85
+ ];
86
+ }
87
+ if (kind === 'branch') {
88
+ return ['Update main:', branchForm];
89
+ }
90
+ return [
91
+ 'Update main. Pick the form for the tree you are in:',
92
+ ' - in the primary clone:',
93
+ ` ${branchForm}`,
94
+ ' - in a linked worktree (`git checkout main` fatals there):',
95
+ ` ${worktreeForm}`,
96
+ ];
97
+ }
98
+ }
99
+ exports.TreeRecovery = TreeRecovery;
100
+ //# 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;;;;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"]}
@@ -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