@webpieces/ai-hook-rules 0.4.667 → 0.4.669

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.
Files changed (37) hide show
  1. package/package.json +2 -2
  2. package/src/bin/l0-allowlist.d.ts +7 -1
  3. package/src/bin/l0-allowlist.js +26 -6
  4. package/src/bin/l0-allowlist.js.map +1 -1
  5. package/src/bin/shim-drift-fix.js +15 -1
  6. package/src/bin/shim-drift-fix.js.map +1 -1
  7. package/src/core/l0-matrix.js +6 -0
  8. package/src/core/l0-matrix.js.map +1 -1
  9. package/src/core/l1-rows.js +1 -1
  10. package/src/core/l1-rows.js.map +1 -1
  11. package/src/core/l2-doc.js +1 -1
  12. package/src/core/l2-doc.js.map +1 -1
  13. package/src/core/l2-rows.js +2 -2
  14. package/src/core/l2-rows.js.map +1 -1
  15. package/src/core/rules/branch-switch-scan.d.ts +5 -0
  16. package/src/core/rules/branch-switch-scan.js +5 -0
  17. package/src/core/rules/branch-switch-scan.js.map +1 -1
  18. package/src/core/rules/catch-error-pattern.d.ts +25 -0
  19. package/src/core/rules/catch-error-pattern.js +54 -16
  20. package/src/core/rules/catch-error-pattern.js.map +1 -1
  21. package/src/core/rules/merged-branch-bash-guard.js +1 -1
  22. package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
  23. package/src/core/rules/merged-branch-message.js +4 -3
  24. package/src/core/rules/merged-branch-message.js.map +1 -1
  25. package/src/core/rules/redirect-how-to-merge-main.js +9 -3
  26. package/src/core/rules/redirect-how-to-merge-main.js.map +1 -1
  27. package/src/core/rules/stale-main-bash-guard.d.ts +5 -2
  28. package/src/core/rules/stale-main-bash-guard.js +15 -10
  29. package/src/core/rules/stale-main-bash-guard.js.map +1 -1
  30. package/src/core/rules/tree-recovery.d.ts +26 -5
  31. package/src/core/rules/tree-recovery.js +33 -12
  32. package/src/core/rules/tree-recovery.js.map +1 -1
  33. package/src/core/runner.js +2 -0
  34. package/src/core/runner.js.map +1 -1
  35. package/src/core/version-sync.d.ts +28 -7
  36. package/src/core/version-sync.js +56 -22
  37. package/src/core/version-sync.js.map +1 -1
@@ -1 +1 @@
1
- {"version":3,"file":"merged-branch-message.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/merged-branch-message.ts"],"names":[],"mappings":"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","sourcesContent":["import { TreeRecovery, TreeKind } from './tree-recovery';\n\n/**\n * The \"this branch is already merged, start fresh\" text, shared by the TWO guards that detect the\n * state from the same cached signal (`MainSyncStatus.branchAlreadyMerged`):\n *\n * - feature-branch-guard blocks Write/Edit → {@link MergedBranchMessage.forEdits}\n * - read-stale-guard blocks Read → {@link MergedBranchMessage.forReads}\n *\n * One source of truth on purpose: the recovery steps are instructions the AI follows LITERALLY, so\n * two drifting copies would mean two different behaviors for the same repo state. Only the\n * \"what is still allowed\" tail differs, because the two guards block different tools.\n *\n * The steps themselves come from {@link TreeRecovery}, which renders them in the flavour of the tree\n * we are actually standing in — a merged LINKED WORKTREE is told to open a new worktree and remove\n * this dead one, a merged branch in the primary clone is told to `git checkout -b … origin/main`.\n *\n * ONE VOICE on `git checkout main`: only the WORKTREE flavour says never to run it (there it fatals —\n * main is checked out in the primary clone). In the primary clone it is a perfectly good move and the\n * allowance list below says so explicitly. The two used to disagree inside a single message — the\n * header forbade it while the allowance list permitted \"git checkout <other-branch>\", and `main` is an\n * other-branch — and an agent that resolved the contradiction in favour of the prohibition concluded\n * its only exit was creating a branch, which the branch cap then refused.\n */\nexport class MergedBranchMessage {\n private readonly recovery: TreeRecovery;\n\n /**\n * `treeRoot` is the tree the guard judged — pass it and every prescribed command comes out as\n * `cd <treeRoot> && …`. That form is the only one that is correct across tool calls: the harness\n * RESETS a cwd that left the workspace, so an agent in a linked worktree is back in the primary\n * clone by the time it runs the cure, and a bare `git checkout -b` would branch the WRONG tree.\n */\n constructor(private readonly treeRoot: string = '') {\n this.recovery = new TreeRecovery(treeRoot);\n }\n\n /**\n * The ONE allowance list, shared by every guard that blocks while this state is up.\n *\n * Each guard used to print its own view of the world: this one's narrow bash allowlist, and\n * read-stale-guard's \"EVERY Bash command\". Both statements were true of their own guard and false\n * of the session — on a merged branch BOTH fire, so the agent was told simultaneously that all\n * Bash runs and that most Bash is blocked. One list, printed by both.\n */\n private allowances(kind: TreeKind): string[] {\n const switching = kind === 'worktree'\n ? ' - switching away: git checkout/switch <other-branch> (NOT `git checkout main` — it fatals ' +\n 'in a worktree; use `git fetch origin main`), git worktree add/remove/prune'\n : ' - switching away: git checkout/switch <other-branch> — `main` included, so ' +\n '`git checkout main && git pull origin main && pnpm wp-cleanup` is allowed and is the ' +\n 'shortest exit; also git worktree add/remove/prune';\n return [\n 'Still allowed while this block is up (these get you OFF this branch — run one, then retry):',\n ' - the fresh-start / cleanup git commands above',\n ' - read-only orientation: git status|log|diff|show|branch, gh pr list|view|status, gh run view|list|watch',\n switching,\n ' - pnpm wp-cleanup and the gated wp-start-*/wp-finish-* commands, pnpm install / upgrades',\n ' - output shaping on any of the above: `… 2>&1 | tail -40`, `… | head -5`, `…; echo done`',\n ' - reading and editing webpieces.config.json (the mode-OFF escape hatch for these guards)',\n '',\n 'NOT allowed on this branch, by the sibling guards that fire on the same state: ordinary Bash',\n '(merged-branch-bash-guard), Read (read-stale-guard) and Write/Edit (feature-branch-guard).',\n 'One list — all three guards print exactly this one.',\n ];\n }\n\n // The diagnosis + cure. Identical for both guards — this is the part that must never drift.\n private common(branch: string, mergedPr: string, kind: TreeKind, worktreePath: string): string[] {\n const pr = mergedPr !== '' ? ` (merged PR #${mergedPr})` : '';\n const where = kind === 'worktree' ? 'worktree' : 'branch';\n const lines = [\n `It looks like you forgot to clean up this ${where} \"${branch}\" — its PR is already merged into main${pr}.`,\n // Name the tree that was judged. With several agents running in parallel worktrees, a guard\n // that reasons from the shell cwd can block a command while citing an UNRELATED agent's\n // branch — observed live. Printing the directory makes a wrong judgement visible instead of\n // baffling, and lets the reader see immediately that it is not the tree they meant.\n ...(this.treeRoot !== '' ? [`Evaluated against: ${this.treeRoot} (branch ${branch})`] : []),\n 'Your work is in main — do NOT keep working here (you will reconflict with main).',\n '',\n ...this.recovery.freshStartSteps(kind, '<new-feature-branch>'),\n ];\n\n // Only when we KNOW we are in a dead worktree: the branch cure alone leaves the worktree\n // sitting there, spending the worktree budget (branch-creation-guard.maxWorktrees) forever.\n if (kind === 'worktree') {\n lines.push('', 'Then reap this dead worktree:', ...this.recovery.cleanupSteps(kind, branch, worktreePath).slice(-1));\n }\n return lines;\n }\n\n forEdits(branch: string, mergedPr: string, kind: TreeKind = 'unknown', worktreePath: string = '<worktree-dir>'): string {\n return this.common(branch, mergedPr, kind, worktreePath).concat([\n '',\n ...this.allowances(kind),\n '',\n 'Please add to memory: start a new branch/worktree off origin/main after a PR is merged.',\n ]).join('\\n');\n }\n\n /**\n * The Bash variant. merged-branch-bash-guard DEFAULT-DENIES Bash on a merged branch, so the message\n * has to spell out the narrow allowlist — otherwise an agent reads \"blocked\" and believes it is\n * wedged. The cure commands it lists are exactly the ones the allowlist lets through (including the\n * `| tail`/`; echo` shaping an agent reflexively appends), so following this message can never hit\n * the guard again.\n */\n forBash(branch: string, mergedPr: string, kind: TreeKind = 'unknown', worktreePath: string = '<worktree-dir>'): string {\n return this.common(branch, mergedPr, kind, worktreePath).concat([\n '',\n 'Bash is blocked here because working on a merged branch (booting servers, running builds,',\n 'reading files with cat/ls) operates on a PRE-MERGE snapshot that origin/main has moved past.',\n '',\n ...this.allowances(kind),\n '',\n 'Please add to memory: start a new branch/worktree off origin/main after a PR is merged.',\n ]).join('\\n');\n }\n\n /**\n * The Read variant. Says WHY a read (not an edit) is blocked — reading this branch feeds the AI a\n * pre-merge snapshot of the codebase and every plan built on it is built on code main has already\n * moved past — and spells out the escape valves so the agent never believes it is stuck.\n */\n forReads(branch: string, mergedPr: string, kind: TreeKind = 'unknown', worktreePath: string = '<worktree-dir>'): string {\n return this.common(branch, mergedPr, kind, worktreePath).concat([\n '',\n 'Reads are blocked here because this tree is a PRE-MERGE snapshot: anything you read is',\n 'stale relative to origin/main, and a plan built on it is built on code that has moved.',\n '',\n ...this.allowances(kind),\n '',\n 'Please add to memory: start a new branch/worktree off origin/main after a PR is merged.',\n ]).join('\\n');\n }\n}\n"]}
1
+ {"version":3,"file":"merged-branch-message.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/merged-branch-message.ts"],"names":[],"mappings":"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","sourcesContent":["import { TreeRecovery, TreeKind } from './tree-recovery';\n\n/**\n * The \"this branch is already merged, start fresh\" text, shared by the TWO guards that detect the\n * state from the same cached signal (`MainSyncStatus.branchAlreadyMerged`):\n *\n * - feature-branch-guard blocks Write/Edit → {@link MergedBranchMessage.forEdits}\n * - read-stale-guard blocks Read → {@link MergedBranchMessage.forReads}\n *\n * One source of truth on purpose: the recovery steps are instructions the AI follows LITERALLY, so\n * two drifting copies would mean two different behaviors for the same repo state. Only the\n * \"what is still allowed\" tail differs, because the two guards block different tools.\n *\n * The steps themselves come from {@link TreeRecovery}, which renders them in the flavour of the tree\n * we are actually standing in — a merged LINKED WORKTREE is told to open a new worktree and remove\n * this dead one, a merged branch in the primary clone is told to `git checkout -b … origin/main`.\n *\n * ONE VOICE on `git checkout main`: only the WORKTREE flavour says never to run it (there it fatals —\n * main is checked out in the primary clone). In the primary clone it is a perfectly good move and the\n * allowance list below says so explicitly. The two used to disagree inside a single message — the\n * header forbade it while the allowance list permitted \"git checkout <other-branch>\", and `main` is an\n * other-branch — and an agent that resolved the contradiction in favour of the prohibition concluded\n * its only exit was creating a branch, which the branch cap then refused.\n */\nexport class MergedBranchMessage {\n private readonly recovery: TreeRecovery;\n\n /**\n * `treeRoot` is the tree the guard judged — pass it and every prescribed command comes out as\n * `cd <treeRoot> && …`. That form is the only one that is correct across tool calls: the harness\n * RESETS a cwd that left the workspace, so an agent in a linked worktree is back in the primary\n * clone by the time it runs the cure, and a bare `git checkout -b` would branch the WRONG tree.\n */\n constructor(private readonly treeRoot: string = '') {\n this.recovery = new TreeRecovery(treeRoot);\n }\n\n /**\n * The ONE allowance list, shared by every guard that blocks while this state is up.\n *\n * Each guard used to print its own view of the world: this one's narrow bash allowlist, and\n * read-stale-guard's \"EVERY Bash command\". Both statements were true of their own guard and false\n * of the session — on a merged branch BOTH fire, so the agent was told simultaneously that all\n * Bash runs and that most Bash is blocked. One list, printed by both.\n */\n private allowances(kind: TreeKind): string[] {\n const switching = kind === 'worktree'\n ? ' - switching away: git checkout/switch <other-branch> (NOT `git checkout main` — it fatals ' +\n 'in a worktree; use `git fetch origin main`), git worktree add/remove/prune'\n : ' - switching away: git checkout/switch <other-branch> — `main` included, so ' +\n '`pnpm wp-checkout-clean-main` (checkout main, pull it, reap dead branches and ' +\n 'worktrees, sweep orphan directories) is allowed and is the shortest exit; also ' +\n 'git worktree add/remove/prune';\n return [\n 'Still allowed while this block is up (these get you OFF this branch — run one, then retry):',\n ' - the fresh-start / cleanup git commands above',\n ' - read-only orientation: git status|log|diff|show|branch, gh pr list|view|status, gh run view|list|watch',\n switching,\n ' - pnpm wp-checkout-clean-main, pnpm wp-cleanup and the gated wp-start-*/wp-finish-* commands, pnpm install / upgrades',\n ' - output shaping on any of the above: `… 2>&1 | tail -40`, `… | head -5`, `…; echo done`',\n ' - reading and editing webpieces.config.json (the mode-OFF escape hatch for these guards)',\n '',\n 'NOT allowed on this branch, by the sibling guards that fire on the same state: ordinary Bash',\n '(merged-branch-bash-guard), Read (read-stale-guard) and Write/Edit (feature-branch-guard).',\n 'One list — all three guards print exactly this one.',\n ];\n }\n\n // The diagnosis + cure. Identical for both guards — this is the part that must never drift.\n private common(branch: string, mergedPr: string, kind: TreeKind, worktreePath: string): string[] {\n const pr = mergedPr !== '' ? ` (merged PR #${mergedPr})` : '';\n const where = kind === 'worktree' ? 'worktree' : 'branch';\n const lines = [\n `It looks like you forgot to clean up this ${where} \"${branch}\" — its PR is already merged into main${pr}.`,\n // Name the tree that was judged. With several agents running in parallel worktrees, a guard\n // that reasons from the shell cwd can block a command while citing an UNRELATED agent's\n // branch — observed live. Printing the directory makes a wrong judgement visible instead of\n // baffling, and lets the reader see immediately that it is not the tree they meant.\n ...(this.treeRoot !== '' ? [`Evaluated against: ${this.treeRoot} (branch ${branch})`] : []),\n 'Your work is in main — do NOT keep working here (you will reconflict with main).',\n '',\n ...this.recovery.freshStartSteps(kind, '<new-feature-branch>'),\n ];\n\n // Only when we KNOW we are in a dead worktree: the branch cure alone leaves the worktree\n // sitting there, spending the worktree budget (branch-creation-guard.maxWorktrees) forever.\n if (kind === 'worktree') {\n lines.push('', 'Then reap this dead worktree:', ...this.recovery.cleanupSteps(kind, branch, worktreePath).slice(-1));\n }\n return lines;\n }\n\n forEdits(branch: string, mergedPr: string, kind: TreeKind = 'unknown', worktreePath: string = '<worktree-dir>'): string {\n return this.common(branch, mergedPr, kind, worktreePath).concat([\n '',\n ...this.allowances(kind),\n '',\n 'Please add to memory: start a new branch/worktree off origin/main after a PR is merged.',\n ]).join('\\n');\n }\n\n /**\n * The Bash variant. merged-branch-bash-guard DEFAULT-DENIES Bash on a merged branch, so the message\n * has to spell out the narrow allowlist — otherwise an agent reads \"blocked\" and believes it is\n * wedged. The cure commands it lists are exactly the ones the allowlist lets through (including the\n * `| tail`/`; echo` shaping an agent reflexively appends), so following this message can never hit\n * the guard again.\n */\n forBash(branch: string, mergedPr: string, kind: TreeKind = 'unknown', worktreePath: string = '<worktree-dir>'): string {\n return this.common(branch, mergedPr, kind, worktreePath).concat([\n '',\n 'Bash is blocked here because working on a merged branch (booting servers, running builds,',\n 'reading files with cat/ls) operates on a PRE-MERGE snapshot that origin/main has moved past.',\n '',\n ...this.allowances(kind),\n '',\n 'Please add to memory: start a new branch/worktree off origin/main after a PR is merged.',\n ]).join('\\n');\n }\n\n /**\n * The Read variant. Says WHY a read (not an edit) is blocked — reading this branch feeds the AI a\n * pre-merge snapshot of the codebase and every plan built on it is built on code main has already\n * moved past — and spells out the escape valves so the agent never believes it is stuck.\n */\n forReads(branch: string, mergedPr: string, kind: TreeKind = 'unknown', worktreePath: string = '<worktree-dir>'): string {\n return this.common(branch, mergedPr, kind, worktreePath).concat([\n '',\n 'Reads are blocked here because this tree is a PRE-MERGE snapshot: anything you read is',\n 'stale relative to origin/main, and a plan built on it is built on code that has moved.',\n '',\n ...this.allowances(kind),\n '',\n 'Please add to memory: start a new branch/worktree off origin/main after a PR is merged.',\n ]).join('\\n');\n }\n}\n"]}
@@ -109,10 +109,16 @@ class RedirectHowToMergeMainRule extends rule_base_1.BashRuleBase {
109
109
  if (switches.some((s) => !this.switches.isExistingMain(s))) {
110
110
  return this.block(ctx, segment, 'Blocked: this command switches to a feature branch and then pulls main into it.');
111
111
  }
112
- // The recommended `git checkout main && git pull origin main` — but ONLY in the primary
113
- // clone. Inside a linked worktree that checkout FATALS ("'main' is already checked out at
114
- // <primary>"), so waving it through here hands the AI a command that cannot work and costs
112
+ // The hand-rolled `git checkout main && git pull origin main` — still ALLOWED, but ONLY in the
113
+ // primary clone. Inside a linked worktree that checkout FATALS ("'main' is already checked out
114
+ // at <primary>"), so waving it through here hands the AI a command that cannot work and costs
115
115
  // it a turn to discover. Steer to the fetch, which is all a worktree needs.
116
+ //
117
+ // Note this rule stays a pure ALLOW for the raw pair and always will: the workflow layer now
118
+ // PRESCRIBES `pnpm wp-checkout-clean-main` (see TreeRecovery.updateMainSteps, which block()
119
+ // below renders), but prescribing is not blocking. The pair is plain git, and it is the L0
120
+ // recovery cure — a state in which no `pnpm` bin can be trusted to run — so making it a block
121
+ // here would delete the only escape from a deadlock. What changed is what the guards TEACH.
116
122
  if (switches.length > 0) {
117
123
  if (this.recovery.kindOf(ctx.workspaceRoot) !== 'worktree')
118
124
  return null;
@@ -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 { PrLifecycleGuardConfig, PR_LIFECYCLE_GUARD_KEY, RepoRootFinder, SyncFlowGuidance, writeTemplate } 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';\nimport { BranchSwitch, BranchSwitchScan } from './branch-switch-scan';\n\nconst INSTRUCT_FILE = 'webpieces.git-workflow.md';\nconst GUIDANCE = new SyncFlowGuidance();\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 + '\\n'\n + GUIDANCE.flows().join('\\n') + '\\n'\n + '\\n'\n // The fork-point DERIVATION (why the invariant has two halves, and the three consumers a polluted\n // fork point corrupts) and the WORD-FOR-WORD warning to hand the human are NOT here on purpose.\n // Both are stated in full in the git-workflow doc this hint links by absolute path, and a message\n // that re-derives what the linked doc already says is a second copy to keep in step. What survives\n // here is only what changes what the AI types next.\n + 'Either flow does a 3-point merge, which keeps the fork point a pure main commit — what the build\\n'\n + 'gate\\'s --base and the PR review diff are computed from; a raw merge/rebase destroys it. The\\n'\n + 'gated commands merge internally as child processes this hook never sees.\\n'\n + '\\n'\n + GUIDANCE.readOnlyChecks().join('\\n') + '\\n'\n + '\\n'\n // The \"do not run it, ask the human, tell them to push back\" instruction is NOT repeated here: the\n // violation line the AI reads first already says it. This paragraph's job is only the pointer.\n // The liveness phrase is kept on ONE line deliberately: it is asserted in full, and a wrap would\n // split the assertion's substring across two chunks. The retired wording (\"a branch you do not own\")\n // was pinned only by a `never sync a branch` PREFIX, which passed under both framings — which is how\n // this hint kept teaching the removed rule after the doc it links had replaced it.\n + 'READ the instruct-ai git-workflow doc at the absolute path on the violation line above — the full\\n'\n + 'flow (incl. worktrees), the fork-point invariant, why you must\\n'\n + 'never sync a branch something is ACTIVELY holding (liveness, not authorship — a finished agent\\n'\n + 'holds nothing), and the exact words to warn a human with when you ask them to run a merge.\\n'\n // NOT \"add that info to memory\": \"that info\" was the whole git-workflow doc, which is REGENERATED\n // per repo and per version — memorizing it guarantees a stale recall in the next session. Only the\n // invariant is stable enough to persist; the doc must be re-read, never remembered.\n + 'Add to memory: never run a raw `git merge`/`git rebase` — use this repo\\'s gated sync flow and\\n'\n + 're-read its git-workflow doc each time rather than recalling it (regenerated per repo/version).',\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\n// Typed as a query (\"would this fast-forward?\"), but a successful --ff-only IS the merge.\nconst FF_ONLY = /--ff-only\\b/;\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\nexport class RedirectHowToMergeMainRule extends BashRuleBase<PrLifecycleGuardConfig> {\n private readonly scanner = new CommandScanner();\n private readonly recovery = new TreeRecovery();\n // \"Which branch does this land on?\" — see branch-switch-scan.ts. It replaced a pair of regexes\n // here (`/git\\s+(?:checkout|switch)\\s+main\\b/` and its negative-lookahead twin) that assumed the\n // branch name is the token immediately after the subcommand. One flag broke both halves at once:\n // `git checkout -q main && git pull -q origin main` missed the main EXEMPTION and then tripped the\n // feature-switch block, so this guard rejected the very command stale-main-bash-guard prescribes,\n // with a reason (\"switches to a feature branch\") that was the opposite of the truth.\n private readonly switches = new BranchSwitchScan(this.scanner);\n\n constructor(config: PrLifecycleGuardConfig) { super(config, 'redirect-how-to-merge-main', PR_LIFECYCLE_GUARD_KEY); }\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 // `--ff-only` reads like a probe (\"can I fast-forward?\") but it MUTATES whenever the answer\n // is yes, so say that here — the AI that typed it was usually only trying to look.\n const probe = FF_ONLY.test(segment)\n ? ' `--ff-only` is NOT a read-only check — it moves your branch whenever it succeeds; see the read-only checks below.'\n : '';\n return this.block(ctx, segment, 'Direct `git merge`/`git rebase` is blocked — AI never runs it, on any branch.' + probe);\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 // The two branch questions are COMPLEMENTS of one parse, so they are asked of one parse —\n // that is what keeps them from disagreeing. `git branch -D <x>` is not a checkout so it does\n // not trip this, and `git checkout -` (previous branch, unknowable at hook time) lands\n // nowhere we can name, so it counts as neither.\n const switches = this.switches.switchesIn(ctx.command);\n if (switches.some((s: BranchSwitch): boolean => !this.switches.isExistingMain(s))) {\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 (switches.length > 0) {\n if (this.recovery.kindOf(ctx.workspaceRoot) !== 'worktree') return null;\n // block() appends updateMainSteps for the tree we are in — don't say it twice here.\n return this.block(ctx, segment, 'Blocked.');\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 // Materialize the doc we are about to send the AI to. This guard fires long before any `wp-*`\n // command runs (those are what normally write it), so the linked path could easily not exist —\n // and a STALE copy from an older @webpieces is just as misleading, hence overwrite.\n writeTemplate(ctx.workspaceRoot, INSTRUCT_FILE);\n const docPath = new RepoRootFinder().instructAiDocPath(ctx.workspaceRoot, INSTRUCT_FILE);\n // \"How do I get MAIN itself current?\" is a different question from \"sync my feature branch\",\n // and it used to have no answer anywhere on this path — the flows cover feature branches and\n // the read-only checks cover looking. An AI on main with no third option improvises, and what\n // it improvises is `git reset --hard origin/main`. Answer the question instead, shaped to the\n // tree we are actually in (in a worktree `git checkout main` fatals).\n const updateMain = [\n '',\n 'On main and just wanted to bring MAIN itself up to date? That is a different question from',\n 'syncing a feature branch, and merge/reset is not the answer to it:',\n ...this.recovery.updateMainSteps(this.recovery.kindOf(ctx.workspaceRoot)),\n ].join('\\n');\n return new V(\n 1,\n truncate(segment),\n `${what} Use the gated 3-point flow instead: 'pnpm wp-start-update' → 'pnpm wp-finish-update' when NO PR is open, or 'pnpm wp-start-upsert-pr' → 'pnpm wp-finish-upsert-pr' when a PR IS open (required then — the merge rewrites the branch and the PR must be re-pointed in the same run). If you truly need a raw merge/rebase, ask the HUMAN to run it — and warn them to push back. Full flow: READ ${docPath}.${updateMain}`,\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 { PrLifecycleGuardConfig, PR_LIFECYCLE_GUARD_KEY, RepoRootFinder, SyncFlowGuidance, writeTemplate } 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';\nimport { BranchSwitch, BranchSwitchScan } from './branch-switch-scan';\n\nconst INSTRUCT_FILE = 'webpieces.git-workflow.md';\nconst GUIDANCE = new SyncFlowGuidance();\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 + '\\n'\n + GUIDANCE.flows().join('\\n') + '\\n'\n + '\\n'\n // The fork-point DERIVATION (why the invariant has two halves, and the three consumers a polluted\n // fork point corrupts) and the WORD-FOR-WORD warning to hand the human are NOT here on purpose.\n // Both are stated in full in the git-workflow doc this hint links by absolute path, and a message\n // that re-derives what the linked doc already says is a second copy to keep in step. What survives\n // here is only what changes what the AI types next.\n + 'Either flow does a 3-point merge, which keeps the fork point a pure main commit — what the build\\n'\n + 'gate\\'s --base and the PR review diff are computed from; a raw merge/rebase destroys it. The\\n'\n + 'gated commands merge internally as child processes this hook never sees.\\n'\n + '\\n'\n + GUIDANCE.readOnlyChecks().join('\\n') + '\\n'\n + '\\n'\n // The \"do not run it, ask the human, tell them to push back\" instruction is NOT repeated here: the\n // violation line the AI reads first already says it. This paragraph's job is only the pointer.\n // The liveness phrase is kept on ONE line deliberately: it is asserted in full, and a wrap would\n // split the assertion's substring across two chunks. The retired wording (\"a branch you do not own\")\n // was pinned only by a `never sync a branch` PREFIX, which passed under both framings — which is how\n // this hint kept teaching the removed rule after the doc it links had replaced it.\n + 'READ the instruct-ai git-workflow doc at the absolute path on the violation line above — the full\\n'\n + 'flow (incl. worktrees), the fork-point invariant, why you must\\n'\n + 'never sync a branch something is ACTIVELY holding (liveness, not authorship — a finished agent\\n'\n + 'holds nothing), and the exact words to warn a human with when you ask them to run a merge.\\n'\n // NOT \"add that info to memory\": \"that info\" was the whole git-workflow doc, which is REGENERATED\n // per repo and per version — memorizing it guarantees a stale recall in the next session. Only the\n // invariant is stable enough to persist; the doc must be re-read, never remembered.\n + 'Add to memory: never run a raw `git merge`/`git rebase` — use this repo\\'s gated sync flow and\\n'\n + 're-read its git-workflow doc each time rather than recalling it (regenerated per repo/version).',\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\n// Typed as a query (\"would this fast-forward?\"), but a successful --ff-only IS the merge.\nconst FF_ONLY = /--ff-only\\b/;\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\nexport class RedirectHowToMergeMainRule extends BashRuleBase<PrLifecycleGuardConfig> {\n private readonly scanner = new CommandScanner();\n private readonly recovery = new TreeRecovery();\n // \"Which branch does this land on?\" — see branch-switch-scan.ts. It replaced a pair of regexes\n // here (`/git\\s+(?:checkout|switch)\\s+main\\b/` and its negative-lookahead twin) that assumed the\n // branch name is the token immediately after the subcommand. One flag broke both halves at once:\n // `git checkout -q main && git pull -q origin main` missed the main EXEMPTION and then tripped the\n // feature-switch block, so this guard rejected the very command stale-main-bash-guard prescribes,\n // with a reason (\"switches to a feature branch\") that was the opposite of the truth.\n private readonly switches = new BranchSwitchScan(this.scanner);\n\n constructor(config: PrLifecycleGuardConfig) { super(config, 'redirect-how-to-merge-main', PR_LIFECYCLE_GUARD_KEY); }\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 // `--ff-only` reads like a probe (\"can I fast-forward?\") but it MUTATES whenever the answer\n // is yes, so say that here — the AI that typed it was usually only trying to look.\n const probe = FF_ONLY.test(segment)\n ? ' `--ff-only` is NOT a read-only check — it moves your branch whenever it succeeds; see the read-only checks below.'\n : '';\n return this.block(ctx, segment, 'Direct `git merge`/`git rebase` is blocked — AI never runs it, on any branch.' + probe);\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 // The two branch questions are COMPLEMENTS of one parse, so they are asked of one parse —\n // that is what keeps them from disagreeing. `git branch -D <x>` is not a checkout so it does\n // not trip this, and `git checkout -` (previous branch, unknowable at hook time) lands\n // nowhere we can name, so it counts as neither.\n const switches = this.switches.switchesIn(ctx.command);\n if (switches.some((s: BranchSwitch): boolean => !this.switches.isExistingMain(s))) {\n return this.block(ctx, segment, 'Blocked: this command switches to a feature branch and then pulls main into it.');\n }\n // The hand-rolled `git checkout main && git pull origin main` — still ALLOWED, but ONLY in the\n // primary clone. Inside a linked worktree that checkout FATALS (\"'main' is already checked out\n // at <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 //\n // Note this rule stays a pure ALLOW for the raw pair and always will: the workflow layer now\n // PRESCRIBES `pnpm wp-checkout-clean-main` (see TreeRecovery.updateMainSteps, which block()\n // below renders), but prescribing is not blocking. The pair is plain git, and it is the L0\n // recovery cure — a state in which no `pnpm` bin can be trusted to run — so making it a block\n // here would delete the only escape from a deadlock. What changed is what the guards TEACH.\n if (switches.length > 0) {\n if (this.recovery.kindOf(ctx.workspaceRoot) !== 'worktree') return null;\n // block() appends updateMainSteps for the tree we are in — don't say it twice here.\n return this.block(ctx, segment, 'Blocked.');\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 // Materialize the doc we are about to send the AI to. This guard fires long before any `wp-*`\n // command runs (those are what normally write it), so the linked path could easily not exist —\n // and a STALE copy from an older @webpieces is just as misleading, hence overwrite.\n writeTemplate(ctx.workspaceRoot, INSTRUCT_FILE);\n const docPath = new RepoRootFinder().instructAiDocPath(ctx.workspaceRoot, INSTRUCT_FILE);\n // \"How do I get MAIN itself current?\" is a different question from \"sync my feature branch\",\n // and it used to have no answer anywhere on this path — the flows cover feature branches and\n // the read-only checks cover looking. An AI on main with no third option improvises, and what\n // it improvises is `git reset --hard origin/main`. Answer the question instead, shaped to the\n // tree we are actually in (in a worktree `git checkout main` fatals).\n const updateMain = [\n '',\n 'On main and just wanted to bring MAIN itself up to date? That is a different question from',\n 'syncing a feature branch, and merge/reset is not the answer to it:',\n ...this.recovery.updateMainSteps(this.recovery.kindOf(ctx.workspaceRoot)),\n ].join('\\n');\n return new V(\n 1,\n truncate(segment),\n `${what} Use the gated 3-point flow instead: 'pnpm wp-start-update' → 'pnpm wp-finish-update' when NO PR is open, or 'pnpm wp-start-upsert-pr' → 'pnpm wp-finish-upsert-pr' when a PR IS open (required then — the merge rewrites the branch and the PR must be re-pointed in the same run). If you truly need a raw merge/rebase, ask the HUMAN to run it — and warn them to push back. Full flow: READ ${docPath}.${updateMain}`,\n );\n }\n}\n"]}
@@ -35,8 +35,11 @@ import { FixHint } from '../fix-hint';
35
35
  *
36
36
  * BLOCKED `git checkout main`, `git switch main` — with or without flags — when no `git pull`
37
37
  * appears anywhere in the SAME command.
38
- * ALLOWED `git checkout main && git pull origin main`, the pairing this forces, which is the
39
- * exact line the post-merge cleanup flow already prescribes.
38
+ * ALLOWED `git checkout main && git pull origin main`, the pairing this forces and
39
+ * `pnpm wp-checkout-clean-main`, which IS that pairing with the cleanup and the
40
+ * orphan-directory sweep welded on. The message prescribes the one command; the raw pair
41
+ * stays legal because it is plain git and because it is the L0 recovery cure, where
42
+ * `node_modules` is the thing in doubt and no `pnpm` bin can be relied on.
40
43
  * ALLOWED `git checkout -b <x> origin/main` (current by construction), `git checkout <sha>`,
41
44
  * `git checkout -- <file>`, and any other branch.
42
45
  *
@@ -49,8 +49,11 @@ const recovery_allowlist_1 = require("./recovery-allowlist");
49
49
  *
50
50
  * BLOCKED `git checkout main`, `git switch main` — with or without flags — when no `git pull`
51
51
  * appears anywhere in the SAME command.
52
- * ALLOWED `git checkout main && git pull origin main`, the pairing this forces, which is the
53
- * exact line the post-merge cleanup flow already prescribes.
52
+ * ALLOWED `git checkout main && git pull origin main`, the pairing this forces and
53
+ * `pnpm wp-checkout-clean-main`, which IS that pairing with the cleanup and the
54
+ * orphan-directory sweep welded on. The message prescribes the one command; the raw pair
55
+ * stays legal because it is plain git and because it is the L0 recovery cure, where
56
+ * `node_modules` is the thing in doubt and no `pnpm` bin can be relied on.
54
57
  * ALLOWED `git checkout -b <x> origin/main` (current by construction), `git checkout <sha>`,
55
58
  * `git checkout -- <file>`, and any other branch.
56
59
  *
@@ -98,22 +101,24 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
98
101
  // ROW 4, the skip list — the SAME instance-shape merged-branch-bash-guard uses, so the two states
99
102
  // cannot drift apart about what "gets you out" means. See recovery-allowlist.ts.
100
103
  recoveryList = new recovery_allowlist_1.RecoveryAllowlist(this.scanner);
101
- description = 'Block a bare `git checkout main` (chain the pull into the same command), and block Bash on ' +
104
+ description = 'Block a bare `git checkout main` (use `pnpm wp-checkout-clean-main`, or chain the pull into ' +
105
+ 'the same command), and block Bash on ' +
102
106
  'main outright — allowlisting only the commands that get you off it — so a session neither ' +
103
107
  'lands on main nor works there, whether or not main happens to be current.';
104
108
  defaultOptions = {
105
109
  hangTimeoutMinutes: rules_config_1.DEFAULT_HANG_TIMEOUT_MINUTES,
106
110
  };
107
- fixHint = new fix_hint_1.FixHint('Landing on `main` without pulling, or working on `main` at all, both put your work somewhere it does not belong.', 'Get onto a feature branch, or pair the checkout with the pull:', [
111
+ fixHint = new fix_hint_1.FixHint('Landing on `main` without pulling, or working on `main` at all, both put your work somewhere it does not belong.', 'Get onto a feature branch, or go to main with the one command that pulls it too:', [
108
112
  // TREE-SHAPED, from the one source of tree-shaped cures. A static rule-level hint has no
109
113
  // workspace root, so it renders the 'unknown' kind — TreeRecovery's deliberate answer for
110
114
  // "we cannot detect the tree": both forms, each labelled. That matters here because the
111
- // primary-clone form (`git checkout main && …`) is BLOCKED by redirect-how-to-merge-main
112
- // inside a linked worktree, so a preferred option naming it unconditionally hands the AI a
113
- // cure a sibling guard denies. The per-block message (pairingMessage) is detected and
114
- // prints exactly one form; this is the fallback for the hint that cannot look.
115
+ // primary-clone form goes to `main`, and a linked worktree has no `main` to go to it is
116
+ // checked out in the primary clone — so a preferred option naming it unconditionally hands
117
+ // the AI a cure that cannot work where it is standing. The per-block message
118
+ // (pairingMessage) is detected and prints exactly one form; this is the fallback for the
119
+ // hint that cannot look.
115
120
  new rules_config_1.Option(this.recovery.updateMainSteps('unknown').join('\n')
116
- + '\nWhichever form applies, the pull must be in the SAME command as the checkout.', true),
121
+ + '\nIf you hand-roll the git instead, the pull must be in the SAME command as the checkout.', true),
117
122
  new rules_config_1.Option('Already on main: git pull --ff-only origin main (then re-run). If that fatals with "Cannot fast-forward to multiple branches", .git/FETCH_HEAD has a duplicate line — run git fetch --prune origin main first.'),
118
123
  new rules_config_1.Option('Still allowed on main: everything that gets you OUT or tells you where you are — git checkout -b <new> origin/main, git switch, git pull/fetch, git status|log|diff|show|branch, gh pr view|list|status|checks, git stash, every wp-* bin, installs, and reading webpieces.config.json.'),
119
124
  new rules_config_1.Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),
@@ -203,7 +208,7 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
203
208
  // docblock above — the reader of THIS text needs only what changes what they type.
204
209
  return 'Blocked: a bare `git checkout main` lands you on whatever local `main` you last had — '
205
210
  + 'stale files, plus a reverted @webpieces pin and guard shim, so the drift guard then '
206
- + 'reports the drift BACKWARDS. Chain the pull into the same command:\n' + steps;
211
+ + 'reports the drift BACKWARDS. Go to main with the one command that also pulls it:\n' + steps;
207
212
  }
208
213
  /**
209
214
  * The guard could not ESTABLISH the state it judges on, so it judged nothing.
@@ -1 +1 @@
1
- {"version":3,"file":"stale-main-bash-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/stale-main-bash-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport { BranchStateGuardConfig, BRANCH_STATE_GUARD_KEY, DEFAULT_HANG_TIMEOUT_MINUTES, Option } 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 { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { hangTimeoutOf } from '../main-sync-timeout';\nimport { logGuardDecision, GuardDecision, Verdict, matrixL2Row } from '../decision-log';\nimport { writeBranchStateMatrixDoc, branchStateMatrixPointer } from '../l2-matrix-doc';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { CommandScanner } from '../command-scan';\nimport { TreeRecovery } from './tree-recovery';\nimport { BranchSwitchScan } from './branch-switch-scan';\nimport { RecoveryAllowlist } from './recovery-allowlist';\n\n/**\n * The BASH half of the STALE-MAIN protection (read-stale-guard's State A), in two halves of its own:\n * a PREVENTIVE check that stops a session landing on a stale `main`, and the REACTIVE check that\n * contains the damage once it is already there.\n *\n * ── PREVENTIVE: a bare `git checkout main` is blocked; the pull must ride along ──────────────────\n *\n * Everything below this paragraph fires only once the session is ALREADY sitting on a stale `main`.\n * Nothing stopped it ARRIVING there, and arriving is one keystroke. In the incident that added this\n * half, an agent ran `git checkout main` after a merge, in a clone whose local `main` was **157\n * commits behind** origin. That checkout did not merely produce stale files — it reverted:\n *\n * 1. `package.json`'s `@webpieces` pin, to a version OLDER than the installed `node_modules`;\n * 2. `.claude/webpieces/ai-hook.sh` — the version-drift guard ITSELF — to a 157-commit-old copy\n * whose message stated the drift BACKWARDS (\"your installed webpieces is older than required\")\n * and named a single cure, `pnpm install`;\n * 3. and so the agent's judgment: it ran that `pnpm install`, DOWNGRADING `node_modules` to match\n * the stale pin, and had to undo it with the `git pull` that should have come first.\n *\n * The shim on current main already diagnoses drift correctly — it distinguishes \"the pin is newer\"\n * from \"the pin is stale, and `pnpm install` would downgrade you\". None of that helped, because the\n * checkout had replaced the shim with the version that could not say it. **A guard a stale checkout\n * can revert cannot be relied on to catch a stale checkout**, which is why this check is preventive\n * and why it lives here rather than in a second rule: same failure, one step earlier, one switch.\n *\n * It matches on command TEXT alone and asks git nothing. That is not laziness — this runs BEFORE the\n * checkout, so the only `main` it could measure is the one it is about to leave. The interesting\n * `main` does not exist yet, and consulting HEAD-at-hook-time is the exact trap\n * `redirect-how-to-merge-main` documents at length. Pairing is unconditionally correct instead: when\n * `main` is already current the chained pull is a sub-second no-op, so no exception is worth carving.\n *\n * BLOCKED `git checkout main`, `git switch main` — with or without flags — when no `git pull`\n * appears anywhere in the SAME command.\n * ALLOWED `git checkout main && git pull origin main`, the pairing this forces, which is the\n * exact line the post-merge cleanup flow already prescribes.\n * ALLOWED `git checkout -b <x> origin/main` (current by construction), `git checkout <sha>`,\n * `git checkout -- <file>`, and any other branch.\n *\n * ── ROW 5: you are on `main`, and that is the whole finding ──────────────────────────────────────\n *\n * The second half USED to ask the main-sync cache whether `main` was BEHIND, and blocked only\n * CONTENT-READING Bash when it was. Both halves of that were wrong, and the table always said so —\n * row 5 reads `B E` / on `main` / block, with the cure `git checkout -b <new> origin/main`.\n *\n * FRESHNESS IS THE WRONG QUESTION. `main` is not a place to work even when it is perfectly current.\n * Staleness changes what you would READ; it does not change whether this is the branch to work on,\n * and the cure is not `git pull` but a new branch. Gating the block on the cache meant a current\n * `main` was treated as a fine place to run a build, an installer or a codegen step.\n *\n * THE CACHE IS THE WRONG PRECONDITION. It is written by a fire-and-forget refresher that populates it\n * for the NEXT call, so the FIRST call of every session has none — and in a multi-worktree repo\n * another tree can hold the refresh lock indefinitely. A block that needs the cache is off exactly\n * when a session is starting, which is precisely when an agent is still standing on `main`. Row 5's\n * Write/Edit half (feature-branch-guard) has always been one `git rev-parse` for this reason; this is\n * `B` being brought into line with `E`, which is the table's own rule, not a new policy.\n *\n * A CONTENT-READ BLOCKLIST COULD NOT HAVE CAUGHT THE WRITES. Enumerating readers catches `cat` and\n * `grep`; it structurally cannot catch `pnpm install`, `npx expo install`, a formatter, codegen or a\n * `>` redirect — commands whose stated purpose is something else and whose effect is to modify\n * tracked files. On `main` the polarity is therefore DEFAULT-DENY plus row 4's skip list, the same\n * shape merged-branch-bash-guard uses for state B, and via the same shared RecoveryAllowlist.\n *\n * BLOCKED anything on `main` that is not on the skip list — builds, tests, installers,\n * formatters, codegen, `cat`/`grep`/`ls` of the tree, git writes.\n * ALLOWED everything that gets you OUT or tells you where you are: `git checkout -b <new>\n * origin/main`, `git switch`, `git pull`/`fetch`, `git status|log|diff|show|branch`,\n * `gh pr view|list|status|checks`, `git stash`, every `wp-*` bin, installs.\n *\n * FAIL-OPEN is preserved where it still means anything: branch undeterminable → allow. The cache\n * valves (`no-sync-cache`, `origin-main-unknown`) are gone from THIS guard because it no longer reads\n * the cache. There is no dirty-tree valve here and none in read-stale-guard either: the cure is\n * `git checkout -b`, which CARRIES uncommitted work onto the new branch, so a dirty tree traps nobody\n * in any L2 state. (`git stash` covers the residual where origin/main touched the same files.)\n */\nexport class StaleMainBashGuardRule extends BashRuleBase<BranchStateGuardConfig> {\n constructor(config: BranchStateGuardConfig) { super(config, 'stale-main-bash-guard', BRANCH_STATE_GUARD_KEY); }\n\n private readonly scanner = new CommandScanner();\n private readonly recovery = new TreeRecovery();\n private readonly switches = new BranchSwitchScan(this.scanner);\n // ROW 4, the skip list — the SAME instance-shape merged-branch-bash-guard uses, so the two states\n // cannot drift apart about what \"gets you out\" means. See recovery-allowlist.ts.\n private readonly recoveryList = new RecoveryAllowlist(this.scanner);\n\n readonly description =\n 'Block a bare `git checkout main` (chain the pull into the same command), and block Bash on ' +\n 'main outright — allowlisting only the commands that get you off it — so a session neither ' +\n 'lands on main nor works there, whether or not main happens to be current.';\n override readonly defaultOptions = {\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'Landing on `main` without pulling, or working on `main` at all, both put your work somewhere it does not belong.',\n 'Get onto a feature branch, or pair the checkout with the pull:',\n [\n // TREE-SHAPED, from the one source of tree-shaped cures. A static rule-level hint has no\n // workspace root, so it renders the 'unknown' kind — TreeRecovery's deliberate answer for\n // \"we cannot detect the tree\": both forms, each labelled. That matters here because the\n // primary-clone form (`git checkout main && …`) is BLOCKED by redirect-how-to-merge-main\n // inside a linked worktree, so a preferred option naming it unconditionally hands the AI a\n // cure a sibling guard denies. The per-block message (pairingMessage) is detected and\n // prints exactly one form; this is the fallback for the hint that cannot look.\n new Option(this.recovery.updateMainSteps('unknown').join('\\n')\n + '\\nWhichever form applies, the pull must be in the SAME command as the checkout.', true),\n new Option('Already on main: git pull --ff-only origin main (then re-run). If that fatals with \"Cannot fast-forward to multiple branches\", .git/FETCH_HEAD has a duplicate line — run git fetch --prune origin main first.'),\n new Option('Still allowed on main: everything that gets you OUT or tells you where you are — git checkout -b <new> origin/main, git switch, git pull/fetch, git status|log|diff|show|branch, gh pr view|list|status|checks, git stash, every wp-* bin, installs, and reading webpieces.config.json.'),\n new Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),\n ],\n );\n\n check(ctx: BashContext): readonly Violation[] {\n // PREVENTIVE half, FIRST and unconditional. Deliberately ahead of every fail-open bailout\n // below: those all ask \"is the main we are ON stale?\", and this asks about the main we are\n // about to MOVE TO — a different branch, and one no cache can describe yet.\n const bare = this.bareCheckoutOfMain(ctx);\n if (bare !== null) {\n return this.block(ctx, 'any', `bare checkout of main (${bare})`, this.pairingMessage(ctx), '-');\n }\n\n const branch = this.currentBranch(ctx.workspaceRoot);\n if (branch === null) return this.failOpen(ctx, branch, 'branch-undeterminable');\n\n // Keep the shared cache warm for the next call. Detached; never blocks this command.\n triggerMainSyncRefresh(ctx.workspaceRoot, hangTimeoutOf(this.config));\n\n // State A is on `main` only. A merged feature branch is merged-branch-bash-guard's job.\n if (branch !== 'main') return this.allow(ctx, branch, 'not-on-main (state B is another guard)');\n\n // ROW 4 — the skip list, ahead of the block, so no command that gets you OUT is ever denied.\n if (this.recoveryList.isFullyRecovery(ctx)) {\n return this.allow(ctx, branch, 'not-a-content-read (cure/build/metadata)');\n }\n\n // ROW 5 — on `main`. NO CACHE IS READ ON THIS PATH, and that is the change.\n //\n // The old ladder asked the cache \"is main BEHIND?\" and only then blocked, and only content\n // READS. That made the whole Bash half of row 5 conditional on freshness, which is the wrong\n // question twice over:\n //\n // 1. Freshness is irrelevant to whether you should be working here. `main` is not a place to\n // work even when it is perfectly current — the cure is the same either way, and it is not\n // `git pull`, it is `git checkout -b`. Row 5's cure has always said so.\n // 2. The cache is populated by a FIRE-AND-FORGET refresher that fills it for the NEXT call,\n // so on the first call of every session there is none — and in a multi-worktree repo\n // another tree can hold the refresh lock indefinitely. A block that needs the cache is a\n // block that is off exactly when a session is starting, which is when an agent is most\n // likely to still be standing on `main`.\n //\n // So this is now one `git rev-parse` and a text scan, both of which fire on call #1 — the same\n // arrangement that has always governed row 5's Write/Edit half (feature-branch-guard). `B`\n // tracking `E` here is the table's own rule, not a new policy.\n //\n // The polarity flips with it: on `main` this is DEFAULT-DENY plus row 4's skip list, where it\n // used to be default-allow plus a content-read blocklist. That is what makes it catch the\n // commands a blocklist structurally cannot — an installer, a formatter or a codegen step that\n // WRITES tracked files while its stated purpose is something else. Blocking those was never\n // going to come from enumerating readers.\n return this.block(ctx, branch, 'on-main', this.onMainMessage(ctx.workspaceRoot), '-');\n }\n\n /**\n * The first segment that switches to the `main` BRANCH with no `git pull` anywhere in the same\n * command, or null. The pull is looked for across the WHOLE command, not the matched segment,\n * because `git checkout main && git pull origin main` splits into two segments and the pairing is\n * the point.\n */\n private bareCheckoutOfMain(ctx: BashContext): string | null {\n for (const segment of this.scanner.commandSegments(ctx.command)) {\n // BranchSwitchScan answers \"which branch does this land on\" for both guards, flag-tolerantly:\n // `git checkout -q main` lands on main exactly as the bare form does, while\n // `git checkout -b x origin/main` (creates), `git checkout -- main` (pathspec) and\n // `git checkout <sha>` do not. See branch-switch-scan.ts for why that lives in one place.\n if (!this.switches.landsOnExistingMain(segment)) continue;\n return this.scanner.commandInvokesAnyGit(ctx.command, ['pull']) ? null : segment;\n }\n return null;\n }\n\n /**\n * The row 5 deny. Deliberately SHORT, and deliberately NOT about staleness.\n *\n * The old message opened by reporting how many commits behind `main` was, which invited exactly\n * the wrong cure — an agent that reads \"behind\" reaches for `git pull`, ends up on a CURRENT\n * `main`, and is still on `main`. The finding is the branch, so that is the first thing said.\n */\n private onMainMessage(workspaceRoot: string): string {\n return 'Blocked: you are on `main`. `main` is not a place to work — whether or not it is '\n + 'current — because work here cannot be reviewed, cannot be reverted as a unit, and is '\n + 'one `git checkout` away from being lost. This is judged from the branch alone, so it '\n + 'fires on the first command of a session, before any freshness is known.\\n'\n + `Start a branch (uncommitted work comes with you):\\n cd '${workspaceRoot}' && git fetch origin main && git checkout -b <new-branch> origin/main`;\n }\n\n private pairingMessage(ctx: BashContext): string {\n const steps = this.recovery.updateMainSteps(this.recovery.kindOf(ctx.workspaceRoot)).join('\\n');\n // Deliberately SHORT. The incident that bought this guard (a main 157 commits behind; the\n // downgrade the reverted shim then prescribed) is maintainer material and lives in the class\n // docblock above — the reader of THIS text needs only what changes what they type.\n return 'Blocked: a bare `git checkout main` lands you on whatever local `main` you last had — '\n + 'stale files, plus a reverted @webpieces pin and guard shim, so the drift guard then '\n + 'reports the drift BACKWARDS. Chain the pull into the same command:\\n' + steps;\n }\n\n\n\n\n\n\n\n /**\n * The guard could not ESTABLISH the state it judges on, so it judged nothing.\n *\n * A sibling of allow() rather than a reason string passed to it, because the difference has to\n * reach the LOG as a value: `ALLOW_FAIL_OPEN` vs `ALLOW`. It was previously a `' (fail-open)'`\n * suffix on the free-text reason, which meant an abstention and a real approval were the same\n * verdict and the abstentions could not be counted — so nobody could tell whether these guards\n * were protecting anything or quietly standing down. Never block on data you could not\n * establish; but say out loud, in a field, that you did not establish it.\n */\n private failOpen(ctx: BashContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW_FAIL_OPEN', reason, cache);\n return [];\n }\n\n private allow(ctx: BashContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW', reason, cache);\n return [];\n }\n\n private block(ctx: BashContext, branch: string, reason: string, message: string, cache: string): readonly Violation[] {\n this.logDecision(ctx, branch, 'BLOCK_AI_CURE', reason, cache);\n // Deliver the matrix and name the row, the same way an L0 block does. The doc is written\n // LAZILY here rather than up front: only a blocked agent needs it, and this is the one path\n // that knows the row it should be opened at.\n const row = matrixL2Row(reason).row;\n const pointer = branchStateMatrixPointer(writeBranchStateMatrixDoc(ctx.workspaceRoot), row);\n return [new V(1, this.truncate(ctx.command), message + pointer)];\n }\n\n private truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n }\n\n private logDecision(ctx: BashContext, branch: string | null, verdict: Verdict, reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('stale-main-bash-guard', 'Bash', ctx.command, branch ?? 'unknown', verdict, reason, cache, L0_FAULT_NONE, matrixL2Row(reason)),\n );\n }\n\n private currentBranch(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n}\n"]}
1
+ {"version":3,"file":"stale-main-bash-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/stale-main-bash-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport { BranchStateGuardConfig, BRANCH_STATE_GUARD_KEY, DEFAULT_HANG_TIMEOUT_MINUTES, Option } 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 { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { hangTimeoutOf } from '../main-sync-timeout';\nimport { logGuardDecision, GuardDecision, Verdict, matrixL2Row } from '../decision-log';\nimport { writeBranchStateMatrixDoc, branchStateMatrixPointer } from '../l2-matrix-doc';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { CommandScanner } from '../command-scan';\nimport { TreeRecovery } from './tree-recovery';\nimport { BranchSwitchScan } from './branch-switch-scan';\nimport { RecoveryAllowlist } from './recovery-allowlist';\n\n/**\n * The BASH half of the STALE-MAIN protection (read-stale-guard's State A), in two halves of its own:\n * a PREVENTIVE check that stops a session landing on a stale `main`, and the REACTIVE check that\n * contains the damage once it is already there.\n *\n * ── PREVENTIVE: a bare `git checkout main` is blocked; the pull must ride along ──────────────────\n *\n * Everything below this paragraph fires only once the session is ALREADY sitting on a stale `main`.\n * Nothing stopped it ARRIVING there, and arriving is one keystroke. In the incident that added this\n * half, an agent ran `git checkout main` after a merge, in a clone whose local `main` was **157\n * commits behind** origin. That checkout did not merely produce stale files — it reverted:\n *\n * 1. `package.json`'s `@webpieces` pin, to a version OLDER than the installed `node_modules`;\n * 2. `.claude/webpieces/ai-hook.sh` — the version-drift guard ITSELF — to a 157-commit-old copy\n * whose message stated the drift BACKWARDS (\"your installed webpieces is older than required\")\n * and named a single cure, `pnpm install`;\n * 3. and so the agent's judgment: it ran that `pnpm install`, DOWNGRADING `node_modules` to match\n * the stale pin, and had to undo it with the `git pull` that should have come first.\n *\n * The shim on current main already diagnoses drift correctly — it distinguishes \"the pin is newer\"\n * from \"the pin is stale, and `pnpm install` would downgrade you\". None of that helped, because the\n * checkout had replaced the shim with the version that could not say it. **A guard a stale checkout\n * can revert cannot be relied on to catch a stale checkout**, which is why this check is preventive\n * and why it lives here rather than in a second rule: same failure, one step earlier, one switch.\n *\n * It matches on command TEXT alone and asks git nothing. That is not laziness — this runs BEFORE the\n * checkout, so the only `main` it could measure is the one it is about to leave. The interesting\n * `main` does not exist yet, and consulting HEAD-at-hook-time is the exact trap\n * `redirect-how-to-merge-main` documents at length. Pairing is unconditionally correct instead: when\n * `main` is already current the chained pull is a sub-second no-op, so no exception is worth carving.\n *\n * BLOCKED `git checkout main`, `git switch main` — with or without flags — when no `git pull`\n * appears anywhere in the SAME command.\n * ALLOWED `git checkout main && git pull origin main`, the pairing this forces — and\n * `pnpm wp-checkout-clean-main`, which IS that pairing with the cleanup and the\n * orphan-directory sweep welded on. The message prescribes the one command; the raw pair\n * stays legal because it is plain git and because it is the L0 recovery cure, where\n * `node_modules` is the thing in doubt and no `pnpm` bin can be relied on.\n * ALLOWED `git checkout -b <x> origin/main` (current by construction), `git checkout <sha>`,\n * `git checkout -- <file>`, and any other branch.\n *\n * ── ROW 5: you are on `main`, and that is the whole finding ──────────────────────────────────────\n *\n * The second half USED to ask the main-sync cache whether `main` was BEHIND, and blocked only\n * CONTENT-READING Bash when it was. Both halves of that were wrong, and the table always said so —\n * row 5 reads `B E` / on `main` / block, with the cure `git checkout -b <new> origin/main`.\n *\n * FRESHNESS IS THE WRONG QUESTION. `main` is not a place to work even when it is perfectly current.\n * Staleness changes what you would READ; it does not change whether this is the branch to work on,\n * and the cure is not `git pull` but a new branch. Gating the block on the cache meant a current\n * `main` was treated as a fine place to run a build, an installer or a codegen step.\n *\n * THE CACHE IS THE WRONG PRECONDITION. It is written by a fire-and-forget refresher that populates it\n * for the NEXT call, so the FIRST call of every session has none — and in a multi-worktree repo\n * another tree can hold the refresh lock indefinitely. A block that needs the cache is off exactly\n * when a session is starting, which is precisely when an agent is still standing on `main`. Row 5's\n * Write/Edit half (feature-branch-guard) has always been one `git rev-parse` for this reason; this is\n * `B` being brought into line with `E`, which is the table's own rule, not a new policy.\n *\n * A CONTENT-READ BLOCKLIST COULD NOT HAVE CAUGHT THE WRITES. Enumerating readers catches `cat` and\n * `grep`; it structurally cannot catch `pnpm install`, `npx expo install`, a formatter, codegen or a\n * `>` redirect — commands whose stated purpose is something else and whose effect is to modify\n * tracked files. On `main` the polarity is therefore DEFAULT-DENY plus row 4's skip list, the same\n * shape merged-branch-bash-guard uses for state B, and via the same shared RecoveryAllowlist.\n *\n * BLOCKED anything on `main` that is not on the skip list — builds, tests, installers,\n * formatters, codegen, `cat`/`grep`/`ls` of the tree, git writes.\n * ALLOWED everything that gets you OUT or tells you where you are: `git checkout -b <new>\n * origin/main`, `git switch`, `git pull`/`fetch`, `git status|log|diff|show|branch`,\n * `gh pr view|list|status|checks`, `git stash`, every `wp-*` bin, installs.\n *\n * FAIL-OPEN is preserved where it still means anything: branch undeterminable → allow. The cache\n * valves (`no-sync-cache`, `origin-main-unknown`) are gone from THIS guard because it no longer reads\n * the cache. There is no dirty-tree valve here and none in read-stale-guard either: the cure is\n * `git checkout -b`, which CARRIES uncommitted work onto the new branch, so a dirty tree traps nobody\n * in any L2 state. (`git stash` covers the residual where origin/main touched the same files.)\n */\nexport class StaleMainBashGuardRule extends BashRuleBase<BranchStateGuardConfig> {\n constructor(config: BranchStateGuardConfig) { super(config, 'stale-main-bash-guard', BRANCH_STATE_GUARD_KEY); }\n\n private readonly scanner = new CommandScanner();\n private readonly recovery = new TreeRecovery();\n private readonly switches = new BranchSwitchScan(this.scanner);\n // ROW 4, the skip list — the SAME instance-shape merged-branch-bash-guard uses, so the two states\n // cannot drift apart about what \"gets you out\" means. See recovery-allowlist.ts.\n private readonly recoveryList = new RecoveryAllowlist(this.scanner);\n\n readonly description =\n 'Block a bare `git checkout main` (use `pnpm wp-checkout-clean-main`, or chain the pull into ' +\n 'the same command), and block Bash on ' +\n 'main outright — allowlisting only the commands that get you off it — so a session neither ' +\n 'lands on main nor works there, whether or not main happens to be current.';\n override readonly defaultOptions = {\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'Landing on `main` without pulling, or working on `main` at all, both put your work somewhere it does not belong.',\n 'Get onto a feature branch, or go to main with the one command that pulls it too:',\n [\n // TREE-SHAPED, from the one source of tree-shaped cures. A static rule-level hint has no\n // workspace root, so it renders the 'unknown' kind — TreeRecovery's deliberate answer for\n // \"we cannot detect the tree\": both forms, each labelled. That matters here because the\n // primary-clone form goes to `main`, and a linked worktree has no `main` to go to — it is\n // checked out in the primary clone — so a preferred option naming it unconditionally hands\n // the AI a cure that cannot work where it is standing. The per-block message\n // (pairingMessage) is detected and prints exactly one form; this is the fallback for the\n // hint that cannot look.\n new Option(this.recovery.updateMainSteps('unknown').join('\\n')\n + '\\nIf you hand-roll the git instead, the pull must be in the SAME command as the checkout.', true),\n new Option('Already on main: git pull --ff-only origin main (then re-run). If that fatals with \"Cannot fast-forward to multiple branches\", .git/FETCH_HEAD has a duplicate line — run git fetch --prune origin main first.'),\n new Option('Still allowed on main: everything that gets you OUT or tells you where you are — git checkout -b <new> origin/main, git switch, git pull/fetch, git status|log|diff|show|branch, gh pr view|list|status|checks, git stash, every wp-* bin, installs, and reading webpieces.config.json.'),\n new Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),\n ],\n );\n\n check(ctx: BashContext): readonly Violation[] {\n // PREVENTIVE half, FIRST and unconditional. Deliberately ahead of every fail-open bailout\n // below: those all ask \"is the main we are ON stale?\", and this asks about the main we are\n // about to MOVE TO — a different branch, and one no cache can describe yet.\n const bare = this.bareCheckoutOfMain(ctx);\n if (bare !== null) {\n return this.block(ctx, 'any', `bare checkout of main (${bare})`, this.pairingMessage(ctx), '-');\n }\n\n const branch = this.currentBranch(ctx.workspaceRoot);\n if (branch === null) return this.failOpen(ctx, branch, 'branch-undeterminable');\n\n // Keep the shared cache warm for the next call. Detached; never blocks this command.\n triggerMainSyncRefresh(ctx.workspaceRoot, hangTimeoutOf(this.config));\n\n // State A is on `main` only. A merged feature branch is merged-branch-bash-guard's job.\n if (branch !== 'main') return this.allow(ctx, branch, 'not-on-main (state B is another guard)');\n\n // ROW 4 — the skip list, ahead of the block, so no command that gets you OUT is ever denied.\n if (this.recoveryList.isFullyRecovery(ctx)) {\n return this.allow(ctx, branch, 'not-a-content-read (cure/build/metadata)');\n }\n\n // ROW 5 — on `main`. NO CACHE IS READ ON THIS PATH, and that is the change.\n //\n // The old ladder asked the cache \"is main BEHIND?\" and only then blocked, and only content\n // READS. That made the whole Bash half of row 5 conditional on freshness, which is the wrong\n // question twice over:\n //\n // 1. Freshness is irrelevant to whether you should be working here. `main` is not a place to\n // work even when it is perfectly current — the cure is the same either way, and it is not\n // `git pull`, it is `git checkout -b`. Row 5's cure has always said so.\n // 2. The cache is populated by a FIRE-AND-FORGET refresher that fills it for the NEXT call,\n // so on the first call of every session there is none — and in a multi-worktree repo\n // another tree can hold the refresh lock indefinitely. A block that needs the cache is a\n // block that is off exactly when a session is starting, which is when an agent is most\n // likely to still be standing on `main`.\n //\n // So this is now one `git rev-parse` and a text scan, both of which fire on call #1 — the same\n // arrangement that has always governed row 5's Write/Edit half (feature-branch-guard). `B`\n // tracking `E` here is the table's own rule, not a new policy.\n //\n // The polarity flips with it: on `main` this is DEFAULT-DENY plus row 4's skip list, where it\n // used to be default-allow plus a content-read blocklist. That is what makes it catch the\n // commands a blocklist structurally cannot — an installer, a formatter or a codegen step that\n // WRITES tracked files while its stated purpose is something else. Blocking those was never\n // going to come from enumerating readers.\n return this.block(ctx, branch, 'on-main', this.onMainMessage(ctx.workspaceRoot), '-');\n }\n\n /**\n * The first segment that switches to the `main` BRANCH with no `git pull` anywhere in the same\n * command, or null. The pull is looked for across the WHOLE command, not the matched segment,\n * because `git checkout main && git pull origin main` splits into two segments and the pairing is\n * the point.\n */\n private bareCheckoutOfMain(ctx: BashContext): string | null {\n for (const segment of this.scanner.commandSegments(ctx.command)) {\n // BranchSwitchScan answers \"which branch does this land on\" for both guards, flag-tolerantly:\n // `git checkout -q main` lands on main exactly as the bare form does, while\n // `git checkout -b x origin/main` (creates), `git checkout -- main` (pathspec) and\n // `git checkout <sha>` do not. See branch-switch-scan.ts for why that lives in one place.\n if (!this.switches.landsOnExistingMain(segment)) continue;\n return this.scanner.commandInvokesAnyGit(ctx.command, ['pull']) ? null : segment;\n }\n return null;\n }\n\n /**\n * The row 5 deny. Deliberately SHORT, and deliberately NOT about staleness.\n *\n * The old message opened by reporting how many commits behind `main` was, which invited exactly\n * the wrong cure — an agent that reads \"behind\" reaches for `git pull`, ends up on a CURRENT\n * `main`, and is still on `main`. The finding is the branch, so that is the first thing said.\n */\n private onMainMessage(workspaceRoot: string): string {\n return 'Blocked: you are on `main`. `main` is not a place to work — whether or not it is '\n + 'current — because work here cannot be reviewed, cannot be reverted as a unit, and is '\n + 'one `git checkout` away from being lost. This is judged from the branch alone, so it '\n + 'fires on the first command of a session, before any freshness is known.\\n'\n + `Start a branch (uncommitted work comes with you):\\n cd '${workspaceRoot}' && git fetch origin main && git checkout -b <new-branch> origin/main`;\n }\n\n private pairingMessage(ctx: BashContext): string {\n const steps = this.recovery.updateMainSteps(this.recovery.kindOf(ctx.workspaceRoot)).join('\\n');\n // Deliberately SHORT. The incident that bought this guard (a main 157 commits behind; the\n // downgrade the reverted shim then prescribed) is maintainer material and lives in the class\n // docblock above — the reader of THIS text needs only what changes what they type.\n return 'Blocked: a bare `git checkout main` lands you on whatever local `main` you last had — '\n + 'stale files, plus a reverted @webpieces pin and guard shim, so the drift guard then '\n + 'reports the drift BACKWARDS. Go to main with the one command that also pulls it:\\n' + steps;\n }\n\n\n\n\n\n\n\n /**\n * The guard could not ESTABLISH the state it judges on, so it judged nothing.\n *\n * A sibling of allow() rather than a reason string passed to it, because the difference has to\n * reach the LOG as a value: `ALLOW_FAIL_OPEN` vs `ALLOW`. It was previously a `' (fail-open)'`\n * suffix on the free-text reason, which meant an abstention and a real approval were the same\n * verdict and the abstentions could not be counted — so nobody could tell whether these guards\n * were protecting anything or quietly standing down. Never block on data you could not\n * establish; but say out loud, in a field, that you did not establish it.\n */\n private failOpen(ctx: BashContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW_FAIL_OPEN', reason, cache);\n return [];\n }\n\n private allow(ctx: BashContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW', reason, cache);\n return [];\n }\n\n private block(ctx: BashContext, branch: string, reason: string, message: string, cache: string): readonly Violation[] {\n this.logDecision(ctx, branch, 'BLOCK_AI_CURE', reason, cache);\n // Deliver the matrix and name the row, the same way an L0 block does. The doc is written\n // LAZILY here rather than up front: only a blocked agent needs it, and this is the one path\n // that knows the row it should be opened at.\n const row = matrixL2Row(reason).row;\n const pointer = branchStateMatrixPointer(writeBranchStateMatrixDoc(ctx.workspaceRoot), row);\n return [new V(1, this.truncate(ctx.command), message + pointer)];\n }\n\n private truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n }\n\n private logDecision(ctx: BashContext, branch: string | null, verdict: Verdict, reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('stale-main-bash-guard', 'Bash', ctx.command, branch ?? 'unknown', verdict, reason, cache, L0_FAULT_NONE, matrixL2Row(reason)),\n );\n }\n\n private currentBranch(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n}\n"]}
@@ -51,11 +51,25 @@ export declare class TreeRecovery {
51
51
  * worktrees whose directory is already gone (`git worktree remove` FAILS on those), and the
52
52
  * branch delete must come LAST because git refuses to delete a branch a worktree still holds.
53
53
  *
54
- * The BRANCH form ends in `pnpm wp-cleanup`, not `git branch -d <branch>`. An agent reads a bare
55
- * `-d`/`-D` as destructive and stops to ask permission, so the branch survives the turn and local
56
- * branches pile up the exact failure this whole cleanup path exists to prevent. wp-cleanup is
57
- * one named command that deletes only provably-dead branches (and reaps every OTHER dead one at
58
- * the same time), so it is safe to allowlist and never needs a judgement call.
54
+ * The BRANCH form is ONE command, `pnpm wp-checkout-clean-main`, and both halves of that matter.
55
+ *
56
+ * It does not end in `git branch -d <branch>`, because an agent reads a bare `-d`/`-D` as
57
+ * destructive and stops to ask permission, so the branch survives the turn and local branches pile
58
+ * up — the exact failure this whole cleanup path exists to prevent. The cleanup it runs deletes only
59
+ * provably-dead branches (and reaps every OTHER dead one at the same time), so it is safe to
60
+ * allowlist and never needs a judgement call.
61
+ *
62
+ * And it is not spelled `git checkout main && git pull origin main && pnpm wp-cleanup` any more,
63
+ * even though that pair is still perfectly legal to type. The one command is checkout + pull +
64
+ * cleanup + the orphan-directory sweep; the hand-chained form is the same thing minus the sweep, so
65
+ * printing both is two spellings of one intention where one silently does less, and the corpses the
66
+ * sweep exists to collect simply never got collected. See CheckoutCleanMainCommand's docblock for
67
+ * why going to main is the right moment to sweep.
68
+ *
69
+ * The RAW PAIR IS NOT GONE — it is still the L0 recovery cure (CHECKOUT_MAIN_PULL_CMD in
70
+ * `src/bin/l0-allowlist.ts`), because in an L0 block `node_modules` is exactly what is in doubt and
71
+ * a `pnpm wp-*` bin cannot be relied on to run. Two layers, two spellings, for a reason that is not
72
+ * back-compat: this is the WORKFLOW layer, where the bin is known to work.
59
73
  *
60
74
  * The WORKTREE form still spells out git commands: wp-cleanup deliberately reaps parked branches
61
75
  * only — a worktree-held branch is spared — so it cannot do this job, and the prune → remove →
@@ -66,6 +80,13 @@ export declare class TreeRecovery {
66
80
  * Bring main up to date. In a linked worktree there is nothing to check out — `main` lives in
67
81
  * the primary clone — so the update is a plain fetch of the remote-tracking ref, which is all
68
82
  * you need to then branch off `origin/main`.
83
+ *
84
+ * The primary-clone form is `pnpm wp-checkout-clean-main`, not the `git checkout main && git pull
85
+ * origin main` pair it used to print, for the reason spelled out on cleanupSteps() above: the pair
86
+ * is that command minus the cleanup and the sweep, and an agent handed both types whichever it read
87
+ * last. The pair remains a legal thing to run — it is still the L0 recovery cure, where a `pnpm`
88
+ * bin cannot be trusted — but this is the workflow layer and it prescribes the one that finishes
89
+ * the job.
69
90
  */
70
91
  updateMainSteps(kind: TreeKind): string[];
71
92
  }
@@ -60,10 +60,10 @@ class TreeRecovery {
60
60
  }
61
61
  // The primary clone. NO "never `git checkout main`" here: that is a WORKTREE-only truth, and
62
62
  // printing it in the primary clone forbids the shortest exit off a merged branch
63
- // (`git checkout main && git pull origin main && pnpm wp-cleanup` the exact command a human
64
- // had to hand an agent that had wedged itself following this very message). Branching off
65
- // origin/main is still what we RECOMMEND, because it works from any tree; it is no longer
66
- // dressed up as the only legal move.
63
+ // (`pnpm wp-checkout-clean-main` the one command form of the git pair a human had to hand an
64
+ // agent that had wedged itself following this very message). Branching off origin/main is still
65
+ // what we RECOMMEND, because it works from any tree; it is no longer dressed up as the only
66
+ // legal move.
67
67
  if (kind === 'branch') {
68
68
  return ['Start fresh — branch off origin/main (works from here and from any worktree):', ...branchForm];
69
69
  }
@@ -80,18 +80,32 @@ class TreeRecovery {
80
80
  * worktrees whose directory is already gone (`git worktree remove` FAILS on those), and the
81
81
  * branch delete must come LAST because git refuses to delete a branch a worktree still holds.
82
82
  *
83
- * The BRANCH form ends in `pnpm wp-cleanup`, not `git branch -d <branch>`. An agent reads a bare
84
- * `-d`/`-D` as destructive and stops to ask permission, so the branch survives the turn and local
85
- * branches pile up the exact failure this whole cleanup path exists to prevent. wp-cleanup is
86
- * one named command that deletes only provably-dead branches (and reaps every OTHER dead one at
87
- * the same time), so it is safe to allowlist and never needs a judgement call.
83
+ * The BRANCH form is ONE command, `pnpm wp-checkout-clean-main`, and both halves of that matter.
84
+ *
85
+ * It does not end in `git branch -d <branch>`, because an agent reads a bare `-d`/`-D` as
86
+ * destructive and stops to ask permission, so the branch survives the turn and local branches pile
87
+ * up — the exact failure this whole cleanup path exists to prevent. The cleanup it runs deletes only
88
+ * provably-dead branches (and reaps every OTHER dead one at the same time), so it is safe to
89
+ * allowlist and never needs a judgement call.
90
+ *
91
+ * And it is not spelled `git checkout main && git pull origin main && pnpm wp-cleanup` any more,
92
+ * even though that pair is still perfectly legal to type. The one command is checkout + pull +
93
+ * cleanup + the orphan-directory sweep; the hand-chained form is the same thing minus the sweep, so
94
+ * printing both is two spellings of one intention where one silently does less, and the corpses the
95
+ * sweep exists to collect simply never got collected. See CheckoutCleanMainCommand's docblock for
96
+ * why going to main is the right moment to sweep.
97
+ *
98
+ * The RAW PAIR IS NOT GONE — it is still the L0 recovery cure (CHECKOUT_MAIN_PULL_CMD in
99
+ * `src/bin/l0-allowlist.ts`), because in an L0 block `node_modules` is exactly what is in doubt and
100
+ * a `pnpm wp-*` bin cannot be relied on to run. Two layers, two spellings, for a reason that is not
101
+ * back-compat: this is the WORKFLOW layer, where the bin is known to work.
88
102
  *
89
103
  * The WORKTREE form still spells out git commands: wp-cleanup deliberately reaps parked branches
90
104
  * only — a worktree-held branch is spared — so it cannot do this job, and the prune → remove →
91
105
  * delete ordering is the part that has to be exactly right.
92
106
  */
93
107
  cleanupSteps(kind, branch, worktreePath = '<worktree-dir>') {
94
- const branchForm = ` ${this.at('git checkout main && git pull origin main && pnpm wp-cleanup')}`;
108
+ const branchForm = ` ${this.at('pnpm wp-checkout-clean-main')}`;
95
109
  const worktreeForm = ` git worktree prune && git worktree remove ${worktreePath} && git branch -D ${branch}`;
96
110
  if (kind === 'worktree') {
97
111
  return [
@@ -116,9 +130,16 @@ class TreeRecovery {
116
130
  * Bring main up to date. In a linked worktree there is nothing to check out — `main` lives in
117
131
  * the primary clone — so the update is a plain fetch of the remote-tracking ref, which is all
118
132
  * you need to then branch off `origin/main`.
133
+ *
134
+ * The primary-clone form is `pnpm wp-checkout-clean-main`, not the `git checkout main && git pull
135
+ * origin main` pair it used to print, for the reason spelled out on cleanupSteps() above: the pair
136
+ * is that command minus the cleanup and the sweep, and an agent handed both types whichever it read
137
+ * last. The pair remains a legal thing to run — it is still the L0 recovery cure, where a `pnpm`
138
+ * bin cannot be trusted — but this is the workflow layer and it prescribes the one that finishes
139
+ * the job.
119
140
  */
120
141
  updateMainSteps(kind) {
121
- const branchForm = ` ${this.at('git checkout main && git pull origin main')}`;
142
+ const branchForm = ` ${this.at('pnpm wp-checkout-clean-main')}`;
122
143
  const worktreeForm = ` ${this.at('git fetch origin main')} (then work off origin/main)`;
123
144
  if (kind === 'worktree') {
124
145
  return [
@@ -134,7 +155,7 @@ class TreeRecovery {
134
155
  'Update main. Pick the form for the tree you are in:',
135
156
  ' - in the primary clone:',
136
157
  ` ${branchForm}`,
137
- ' - in a linked worktree (`git checkout main` fatals there):',
158
+ ' - in a linked worktree (there is no `main` here to check out — it lives in the primary clone):',
138
159
  ` ${worktreeForm}`,
139
160
  ];
140
161
  }
@@ -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,0DAAkE;AA0BlE,MAAa,YAAY;IAgBQ;IAfZ,SAAS,GAAG,IAAI,8BAAe,EAAE,CAAC;IAEnD;;;;;;;;;;;;OAYG;IACH,YAA6B,WAAmB,EAAE;QAArB,aAAQ,GAAR,QAAQ,CAAa;IAAG,CAAC;IAEtD,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,4FAA4F;IAC5F,kGAAkG;IAClG,oDAAoD;IAC5C,EAAE,CAAC,OAAe;QACtB,OAAO,IAAI,CAAC,QAAQ,KAAK,EAAE,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,IAAA,qBAAM,EAAC,IAAI,CAAC,QAAQ,EAAE,OAAO,CAAC,CAAC;IAC3E,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,KAAK,IAAI,CAAC,EAAE,CAAC,uBAAuB,CAAC,EAAE;YACvC,KAAK,IAAI,CAAC,EAAE,CAAC,mBAAmB,aAAa,cAAc,CAAC,EAAE;SACjE,CAAC;QACF,MAAM,YAAY,GAAG;YACjB,KAAK,IAAI,CAAC,EAAE,CAAC,uBAAuB,CAAC,EAAE;YACvC,KAAK,IAAI,CAAC,EAAE,CAAC,uBAAuB,GAAG,OAAO,aAAa,cAAc,CAAC,EAAE;SAC/E,CAAC;QAEF,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;YACtB,OAAO;gBACH,wFAAwF;gBACxF,6DAA6D;gBAC7D,GAAG,YAAY;aAClB,CAAC;QACN,CAAC;QACD,6FAA6F;QAC7F,iFAAiF;QACjF,8FAA8F;QAC9F,0FAA0F;QAC1F,0FAA0F;QAC1F,qCAAqC;QACrC,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;YACpB,OAAO,CAAC,+EAA+E,EAAE,GAAG,UAAU,CAAC,CAAC;QAC5G,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,KAAK,IAAI,CAAC,EAAE,CAAC,8DAA8D,CAAC,EAAE,CAAC;QAClG,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,KAAK,IAAI,CAAC,EAAE,CAAC,2CAA2C,CAAC,EAAE,CAAC;QAC/E,MAAM,YAAY,GAAG,KAAK,IAAI,CAAC,EAAE,CAAC,uBAAuB,CAAC,qCAAqC,CAAC;QAEhG,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/ID,oCA+IC","sourcesContent":["import { WorktreeService, atRoot } 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 /**\n * `treeRoot` is the tree the guard JUDGED. When it is given, every command below is rendered as\n * `cd <treeRoot> && <command>`.\n *\n * WHY: the harness RESETS a cwd that left the workspace, so an agent working in a linked\n * worktree starts every call back in the primary clone. A bare `git fetch origin main` therefore\n * runs against the WRONG tree — and in the field a guard prescribed `git pull` in a primary clone\n * the agent had been explicitly forbidden to touch. Naming the directory in the command itself is\n * the only form that is correct no matter where the next tool call starts. A leading `cd <path> &&`\n * cannot change what a command does to a repo, so the guards accept it.\n *\n * Empty (the default) renders the bare commands, for callers with no root to name.\n */\n constructor(private readonly treeRoot: string = '') {}\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 // Render one command in the form that survives a tool call: `cd '<root>' && <command>`. The\n // formatting (single quotes included, so a repo path with a space still runs) is atRoot's, shared\n // with every other remedy builder — see its header.\n private at(command: string): string {\n return this.treeRoot === '' ? command : atRoot(this.treeRoot, command);\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 ` ${this.at('git fetch origin main')}`,\n ` ${this.at(`git checkout -b ${newBranchName} origin/main`)}`,\n ];\n const worktreeForm = [\n ` ${this.at('git fetch origin main')}`,\n ` ${this.at(`git worktree add ../${dir} -b ${newBranchName} origin/main`)}`,\n ];\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). Start the new work in its own worktree:',\n ...worktreeForm,\n ];\n }\n // The primary clone. NO \"never `git checkout main`\" here: that is a WORKTREE-only truth, and\n // printing it in the primary clone forbids the shortest exit off a merged branch\n // (`git checkout main && git pull origin main && pnpm wp-cleanup` — the exact command a human\n // had to hand an agent that had wedged itself following this very message). Branching off\n // origin/main is still what we RECOMMEND, because it works from any tree; it is no longer\n // dressed up as the only legal move.\n if (kind === 'branch') {\n return ['Start fresh — branch off origin/main (works from here and from any worktree):', ...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 = ` ${this.at('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 = ` ${this.at('git checkout main && git pull origin main')}`;\n const worktreeForm = ` ${this.at('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":"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","sourcesContent":["import { WorktreeService, atRoot } 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 /**\n * `treeRoot` is the tree the guard JUDGED. When it is given, every command below is rendered as\n * `cd <treeRoot> && <command>`.\n *\n * WHY: the harness RESETS a cwd that left the workspace, so an agent working in a linked\n * worktree starts every call back in the primary clone. A bare `git fetch origin main` therefore\n * runs against the WRONG tree — and in the field a guard prescribed `git pull` in a primary clone\n * the agent had been explicitly forbidden to touch. Naming the directory in the command itself is\n * the only form that is correct no matter where the next tool call starts. A leading `cd <path> &&`\n * cannot change what a command does to a repo, so the guards accept it.\n *\n * Empty (the default) renders the bare commands, for callers with no root to name.\n */\n constructor(private readonly treeRoot: string = '') {}\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 // Render one command in the form that survives a tool call: `cd '<root>' && <command>`. The\n // formatting (single quotes included, so a repo path with a space still runs) is atRoot's, shared\n // with every other remedy builder — see its header.\n private at(command: string): string {\n return this.treeRoot === '' ? command : atRoot(this.treeRoot, command);\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 ` ${this.at('git fetch origin main')}`,\n ` ${this.at(`git checkout -b ${newBranchName} origin/main`)}`,\n ];\n const worktreeForm = [\n ` ${this.at('git fetch origin main')}`,\n ` ${this.at(`git worktree add ../${dir} -b ${newBranchName} origin/main`)}`,\n ];\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). Start the new work in its own worktree:',\n ...worktreeForm,\n ];\n }\n // The primary clone. NO \"never `git checkout main`\" here: that is a WORKTREE-only truth, and\n // printing it in the primary clone forbids the shortest exit off a merged branch\n // (`pnpm wp-checkout-clean-main` — the one command form of the git pair a human had to hand an\n // agent that had wedged itself following this very message). Branching off origin/main is still\n // what we RECOMMEND, because it works from any tree; it is no longer dressed up as the only\n // legal move.\n if (kind === 'branch') {\n return ['Start fresh — branch off origin/main (works from here and from any worktree):', ...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 is ONE command, `pnpm wp-checkout-clean-main`, and both halves of that matter.\n *\n * It does not end in `git branch -d <branch>`, because an agent reads a bare `-d`/`-D` as\n * destructive and stops to ask permission, so the branch survives the turn and local branches pile\n * up — the exact failure this whole cleanup path exists to prevent. The cleanup it runs deletes only\n * provably-dead branches (and reaps every OTHER dead one at the same time), so it is safe to\n * allowlist and never needs a judgement call.\n *\n * And it is not spelled `git checkout main && git pull origin main && pnpm wp-cleanup` any more,\n * even though that pair is still perfectly legal to type. The one command is checkout + pull +\n * cleanup + the orphan-directory sweep; the hand-chained form is the same thing minus the sweep, so\n * printing both is two spellings of one intention where one silently does less, and the corpses the\n * sweep exists to collect simply never got collected. See CheckoutCleanMainCommand's docblock for\n * why going to main is the right moment to sweep.\n *\n * The RAW PAIR IS NOT GONE — it is still the L0 recovery cure (CHECKOUT_MAIN_PULL_CMD in\n * `src/bin/l0-allowlist.ts`), because in an L0 block `node_modules` is exactly what is in doubt and\n * a `pnpm wp-*` bin cannot be relied on to run. Two layers, two spellings, for a reason that is not\n * back-compat: this is the WORKFLOW layer, where the bin is known to work.\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 = ` ${this.at('pnpm wp-checkout-clean-main')}`;\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 * The primary-clone form is `pnpm wp-checkout-clean-main`, not the `git checkout main && git pull\n * origin main` pair it used to print, for the reason spelled out on cleanupSteps() above: the pair\n * is that command minus the cleanup and the sweep, and an agent handed both types whichever it read\n * last. The pair remains a legal thing to run — it is still the L0 recovery cure, where a `pnpm`\n * bin cannot be trusted — but this is the workflow layer and it prescribes the one that finishes\n * the job.\n */\n updateMainSteps(kind: TreeKind): string[] {\n const branchForm = ` ${this.at('pnpm wp-checkout-clean-main')}`;\n const worktreeForm = ` ${this.at('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 (there is no `main` here to check out — it lives in the primary clone):',\n ` ${worktreeForm}`,\n ];\n }\n}\n"]}
@@ -360,6 +360,8 @@ function misplacedCdBlock(command, tree) {
360
360
  ` Fix Option 3: if the SECOND \`cd\` was there to point a tool at a directory, use that tool's own\n` +
361
361
  ` directory flag instead — \`git -C <dir>\`, \`tar -C <dir>\`, \`npm --prefix <dir>\`,\n` +
362
362
  ` \`npm pack --pack-destination <dir>\`, \`pnpm --dir <dir>\`. One \`cd\`, same result.\n` +
363
+ ` ONLY for a dir INSIDE this tree. \`git -C <another tree>\` is REFUSED to a subagent,\n` +
364
+ ` and is never the cure for a version skew — for that, tell the MAIN agent.\n` +
363
365
  ` Fix Option 4: split it — run the work in one call, the \`cd\` in another (a \`cd\` alone still\n` +
364
366
  ` moves nothing the guards judge; see EffectiveTree on why cwd cannot be assumed).`;
365
367
  return new types_1.BlockedResult(report);