@webpieces/ai-hook-rules 0.4.508 → 0.4.510

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 (42) hide show
  1. package/package.json +2 -2
  2. package/src/bin/shim.d.ts +2 -0
  3. package/src/bin/shim.js +46 -19
  4. package/src/bin/shim.js.map +1 -1
  5. package/src/core/build-context.d.ts +2 -1
  6. package/src/core/build-context.js +5 -2
  7. package/src/core/build-context.js.map +1 -1
  8. package/src/core/effective-tree.d.ts +75 -0
  9. package/src/core/effective-tree.js +136 -0
  10. package/src/core/effective-tree.js.map +1 -0
  11. package/src/core/rules/branch-creation-guard.d.ts +20 -47
  12. package/src/core/rules/branch-creation-guard.js +58 -150
  13. package/src/core/rules/branch-creation-guard.js.map +1 -1
  14. package/src/core/rules/cap-remedies.d.ts +79 -0
  15. package/src/core/rules/cap-remedies.js +175 -0
  16. package/src/core/rules/cap-remedies.js.map +1 -0
  17. package/src/core/rules/content-read-scan.d.ts +29 -4
  18. package/src/core/rules/content-read-scan.js +54 -9
  19. package/src/core/rules/content-read-scan.js.map +1 -1
  20. package/src/core/rules/feature-branch-guard.js +1 -1
  21. package/src/core/rules/feature-branch-guard.js.map +1 -1
  22. package/src/core/rules/merged-branch-bash-guard.js +8 -2
  23. package/src/core/rules/merged-branch-bash-guard.js.map +1 -1
  24. package/src/core/rules/merged-branch-message.d.ts +8 -0
  25. package/src/core/rules/merged-branch-message.js +17 -1
  26. package/src/core/rules/merged-branch-message.js.map +1 -1
  27. package/src/core/rules/read-stale-guard.js +2 -2
  28. package/src/core/rules/read-stale-guard.js.map +1 -1
  29. package/src/core/rules/stale-main-bash-guard.js +2 -2
  30. package/src/core/rules/stale-main-bash-guard.js.map +1 -1
  31. package/src/core/rules/stale-main-message.d.ts +12 -0
  32. package/src/core/rules/stale-main-message.js +17 -1
  33. package/src/core/rules/stale-main-message.js.map +1 -1
  34. package/src/core/rules/tree-recovery.d.ts +16 -0
  35. package/src/core/rules/tree-recovery.js +28 -7
  36. package/src/core/rules/tree-recovery.js.map +1 -1
  37. package/src/core/runner.js +56 -77
  38. package/src/core/runner.js.map +1 -1
  39. package/src/core/types.d.ts +12 -1
  40. package/src/core/types.js +14 -1
  41. package/src/core/types.js.map +1 -1
  42. package/templates/ai-hook.sh +5 -5
@@ -1 +1 @@
1
- {"version":3,"file":"read-stale-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/read-stale-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync, spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport {\n ReadStaleGuardConfig,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n readMainSyncStatus,\n MainSyncStatus,\n} from '@webpieces/rules-config';\n\nimport type { FileContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { FileRuleBase } from '../rule-base';\nimport { FixHint, Option } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { logGuardDecision, GuardDecision } from '../decision-log';\nimport { MergedBranchMessage } from './merged-branch-message';\nimport { StaleMainMessage } from './stale-main-message';\nimport { TreeRecovery } from './tree-recovery';\n\n/**\n * Blocks READS while the checked-out branch is a stale place to read from. TWO states:\n *\n * A. on `main`, and local main is BEHIND origin/main\n * B. on a feature branch whose PR is ALREADY MERGED (a pre-merge snapshot; origin/main has moved\n * past it and a squash merge means its HEAD is not even an ancestor of main)\n *\n * WHY READ, of all tools: either state means the AI reads stale FILE CONTENT and then reasons,\n * plans and writes against code that no longer exists upstream. Blocking the write is too late —\n * the bad premise is already in context. So the block lands on the read. (feature-branch-guard\n * blocks the WRITE in state B; this guard is the read-side half of that same protection, and the\n * two share one recovery message via MergedBranchMessage.)\n *\n * THE DIRTY-TREE ASYMMETRY is deliberate. State A fails OPEN on a dirty tree because `git pull` is\n * then not a guaranteed fast-forward and the agent would be trapped away from the files it needs to\n * resolve the conflict. State B blocks ANYWAY, because its cure — `git checkout -b <new>\n * origin/main` — carries uncommitted changes onto the fresh branch, so there is nothing to resolve\n * and nothing to be trapped by.\n *\n * WHY THIS CANNOT WEDGE: the block is scoped to Read ONLY. Every cure — `git pull origin main`,\n * `pnpm install`, any webpieces upgrade — is a Bash command, and this guard never looks at Bash.\n * So there is no command allowlist to maintain and no way to lock the agent out of its own fix.\n * (`git pull origin main` is explicitly permitted on main by redirect-how-to-merge-main, which\n * returns null when the branch IS main — the two guards are complementary, not stacked.)\n *\n * That scoping is also this guard's HOLE, and it is closed elsewhere rather than here: leaving Bash\n * entirely alone let a session `cat`/`grep`/`ls` the same stale tree the Read block was rejecting,\n * for a whole session, while the logs read \"read-stale-guard handled\". stale-main-bash-guard is the\n * State-A Bash counterpart (as merged-branch-bash-guard is State B's) and blocks only CONTENT-reading\n * commands, never the cure — which is why this guard can stay simple and Read-only.\n *\n * Everything here is FAIL-OPEN. A guard that blocks reads on bad data is far worse than one that\n * misses; every unknown resolves to \"allow\". The four deliberate escape valves:\n *\n * 1. DIRTY TREE — uncommitted work on main means `git pull` is not a guaranteed fast-forward.\n * Blocking reads there would trap the agent: it could not read the files it\n * needs to resolve the very conflict blocking it. Allow. (State A ONLY — see\n * the dirty-tree asymmetry above.)\n * 2. CACHE LAG — we do NOT compare hashes for equality. The cached `originMain` is written by\n * the detached refresher and is arbitrarily old, so `local !== origin` stays\n * true for a while AFTER a successful pull, which would spin the agent forever.\n * Instead: is the cached origin/main an ANCESTOR of local main? If local main\n * already contains it, we are not behind. That flips the instant the pull lands,\n * with no refresher round-trip. This is the single most important line here.\n * 3. CONFIG READ — webpieces.config.json stays readable so the agent can always read-then-edit\n * it to set `mode: OFF`. Its EDIT is already bypassed in runner.ts + hook-core;\n * this closes the read half of that same escape hatch.\n * 4. NO DATA — no cache, cache for another branch, empty originMain (offline), or no local\n * main at all (fresh clone / worktree) → allow.\n *\n * Runs from the Read fast path in hook-core (Read is neither a file-edit nor a bash payload, so it\n * never reaches the runner's rule loop). Fires the detached refresher on every call, which is also\n * what makes reads keep the shared main-sync cache warm for feature-branch-guard.\n */\nexport class ReadStaleGuardRule extends FileRuleBase<ReadStaleGuardConfig> {\n constructor(config: ReadStaleGuardConfig) { super(config, 'read-stale-guard'); }\n\n readonly description = 'Block reads on a branch that is stale to read from — a `main` behind origin/main, or a feature branch whose PR is already merged.';\n override readonly files = ['**/*'];\n override readonly defaultOptions = {\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'This branch is stale to read from — reading it would give you pre-merge/out-of-date content.',\n 'Get onto current code before reading anything else:',\n [\n new Option('On main, behind origin/main → git pull origin main. On an already-merged branch → git fetch origin main && git checkout -b <new-branch> origin/main. Then retry the read.', true),\n new Option(\"If that pull dies with 'fatal: Cannot fast-forward to multiple branches', .git/FETCH_HEAD holds a duplicate line — run 'git fetch --prune origin main' to rewrite it cleanly, then retry the pull.\"),\n new Option('Still allowed right now: Bash that does not read repo files (installs, upgrades, builds, tests, the pull itself, git/gh metadata), all Write/Edit, and reading webpieces.config.json. Content-reading Bash (cat/grep/ls/…) is blocked too on a stale main — see stale-main-bash-guard.'),\n new Option('Disable in webpieces.config.json under hookGuards → read-stale-guard (mode OFF) if intentional.'),\n ],\n );\n\n check(ctx: FileContext): readonly Violation[] {\n // Outside the workspace root — no jurisdiction.\n if (ctx.relativePath.startsWith('..')) return [];\n\n const branch = this.currentBranch(ctx.workspaceRoot);\n if (branch === null) return this.allow(ctx, branch, 'branch-undeterminable (fail-open)');\n\n // Keep the shared cache warm for the next call. Detached; never blocks this read. Fired for\n // BOTH states — the merged-branch signal comes out of that same cache.\n triggerMainSyncRefresh(ctx.workspaceRoot, this.config.hangTimeoutMinutes ?? DEFAULT_HANG_TIMEOUT_MINUTES);\n\n // Escape valve 3 — the read half of the config escape hatch. Ahead of BOTH states' blocks so\n // the agent can always read-then-edit the file that turns this guard off.\n if (this.isConfigFile(ctx.relativePath)) return this.allow(ctx, branch, 'webpieces-config-read (escape hatch)');\n\n return branch === 'main'\n ? this.checkStaleMain(ctx, branch)\n : this.checkMergedBranch(ctx, branch);\n }\n\n // State A — on main, possibly behind origin/main.\n private checkStaleMain(ctx: FileContext, branch: string): readonly Violation[] {\n const status = readMainSyncStatus(ctx.workspaceRoot);\n if (status === null) return this.allow(ctx, branch, 'no-sync-cache (fail-open)', 'cache=none');\n\n const cache = this.cacheSummary(status);\n if (status.branch !== 'main') return this.allow(ctx, branch, 'stale-cross-branch-cache (fail-open)', cache);\n // Offline / origin unresolvable, or no local main to compare against.\n if (status.originMain === '') return this.allow(ctx, branch, 'origin-main-unknown (fail-open)', cache);\n\n // Escape valve 2 — ancestry, NOT equality. See the class comment.\n if (this.contains(ctx.workspaceRoot, status.originMain)) {\n return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);\n }\n\n // Escape valve 1 — a dirty tree means the pull is not a clean fast-forward; do not trap\n // the agent away from the files it needs to resolve it.\n if (this.isDirty(ctx.workspaceRoot)) {\n return this.allow(ctx, branch, 'dirty-tree-on-main (fail-open)', cache);\n }\n\n return this.block(ctx, branch, 'on-stale-main', this.staleMainMessage(ctx.workspaceRoot), cache);\n }\n\n /**\n * State B — a feature branch whose PR is already merged. Reads a PRE-MERGE snapshot, so every\n * plan built from it is built on code origin/main has moved past.\n *\n * `branchAlreadyMerged` comes straight from the shared cache (the refresher's `gh pr list --state\n * merged`), so this path spawns nothing. No `gh` / offline → `mergedPr` is '' → not merged → allow,\n * which is the fail-open direction for free.\n *\n * The DIRTY-TREE escape valve is the same one state A has, for the same reason: uncommitted work\n * on a merged branch is work that exists nowhere else, and rescuing it means READING the files it\n * touches. `git checkout -b <new> origin/main` usually carries those changes across — but when it\n * does not (an overlapping change landed in main), a blocked read is an agent that cannot even\n * see what it is about to lose. feature-branch-guard still blocks the EDITS, so the state is\n * surfaced loudly either way; we just refuse to cut off the rescue path.\n */\n private checkMergedBranch(ctx: FileContext, branch: string): readonly Violation[] {\n const status = readMainSyncStatus(ctx.workspaceRoot);\n if (status === null) return this.allow(ctx, branch, 'no-sync-cache (fail-open)', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // Cache written for a DIFFERENT branch (just switched; the refresh for this one hasn't landed).\n // Never block on another branch's signals — this is also what un-blocks the instant the agent\n // follows the cure and checks out a fresh branch.\n if (status.branch !== branch) return this.allow(ctx, branch, 'stale-cross-branch-cache (fail-open)', cache);\n if (!status.branchAlreadyMerged) return this.allow(ctx, branch, 'clean-feature-branch', cache);\n if (this.isDirty(ctx.workspaceRoot)) {\n return this.allow(ctx, branch, 'dirty-merged-branch (fail-open)', cache);\n }\n\n const pr = status.mergedPr !== '' ? status.mergedPr : '?';\n return this.block(\n ctx,\n branch,\n `already-merged PR#${pr}`,\n this.mergedMessage(ctx.workspaceRoot, branch, status.mergedPr),\n cache,\n );\n }\n\n // The merged-branch text, told in the flavour of the tree we are standing in: a linked worktree\n // is told to open a NEW worktree off origin/main and reap this dead one; the primary clone is\n // told to branch off origin/main. Neither is ever told to `git checkout main` (fatal in a\n // worktree). Detection is one statSync — see WorktreeService.isLinkedWorktree.\n private mergedMessage(workspaceRoot: string, branch: string, mergedPr: string): string {\n const recovery = new TreeRecovery();\n return new MergedBranchMessage().forReads(\n branch, mergedPr, recovery.kindOf(workspaceRoot), workspaceRoot,\n );\n }\n\n // Is `commit` an ancestor of (i.e. already contained in) HEAD? Local-only and fast — no network.\n //\n // spawnSync, not execSync, precisely because the EXIT CODE is the answer and we must tell three\n // outcomes apart: 0 = ancestor (up to date), 1 = cleanly NOT an ancestor (genuinely behind),\n // anything else = git could not answer (bad/pruned object, not a repo) which must fail OPEN.\n // execSync collapses 1 and \"git broke\" into the same thrown Error, so it cannot make that call.\n // Arg-array form also means the commit hash is never parsed by a shell.\n private contains(workspaceRoot: string, commit: string): boolean {\n const result = spawnSync('git', ['merge-base', '--is-ancestor', commit, 'HEAD'], {\n cwd: workspaceRoot,\n encoding: 'utf8',\n });\n if (result.status === 0) return true;\n if (result.status === 1) return false;\n return true; // unknown/failed → treat as \"contained\" so the guard allows\n }\n\n private isDirty(workspaceRoot: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const out = execSync('git status --porcelain', {\n cwd: workspaceRoot,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n return out.trim().length > 0;\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n // Cannot tell → assume dirty, which is the fail-OPEN direction for this guard.\n return true;\n }\n }\n\n private isConfigFile(relativePath: string): boolean {\n return relativePath === 'webpieces.config.json';\n }\n\n // How far behind we are, for the message. Best-effort — a bare \"behind\" reads fine without it.\n private behindCount(workspaceRoot: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const out = execSync('git rev-list --count HEAD..origin/main', {\n cwd: workspaceRoot,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return /^\\d+$/.test(out) ? out : '?';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return '?';\n }\n }\n\n // Shared with stale-main-bash-guard (StaleMainMessage) so the two halves of the State-A block can\n // never prescribe different cures. Its \"still allowed\" tail no longer promises EVERY Bash command:\n // content-reading Bash is now blocked too, which is the whole point of the Bash counterpart.\n private staleMainMessage(workspaceRoot: string): string {\n return new StaleMainMessage().forReads(this.behindCount(workspaceRoot));\n }\n\n private cacheSummary(status: MainSyncStatus): string {\n const merged = status.branchAlreadyMerged ? `PR#${status.mergedPr !== '' ? status.mergedPr : '?'}` : 'no';\n return `cache=${status.branch} localMain=${status.localMain.slice(0, 8)} originMain=${status.originMain.slice(0, 8)} merged=${merged} ts=${status.timestamp}`;\n }\n\n private allow(ctx: FileContext, 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: FileContext, branch: string, reason: string, message: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'BLOCK', reason, cache);\n return [new V(1, ctx.relativePath, message)];\n }\n\n private logDecision(ctx: FileContext, branch: string | null, verdict: 'ALLOW' | 'BLOCK', reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('read-stale-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason, cache),\n );\n }\n\n /**\n * The current branch, WITHOUT spawning git on the common path.\n *\n * This runs on EVERY read, so it is the one call whose cost actually matters. Spawning\n * `git rev-parse --abbrev-ref HEAD` measures ~12ms — essentially all process-spawn overhead —\n * whereas `.git/HEAD` is a single tiny file whose read is microseconds. On a feature branch\n * (the overwhelmingly common case) that file read is the ONLY work this guard does before\n * short-circuiting, so reads stay effectively free.\n *\n * Falls back to spawning git whenever `.git/HEAD` cannot answer authoritatively:\n * - `.git` is a FILE, not a dir → we are in a worktree and HEAD lives elsewhere\n * - detached HEAD → the file holds a raw sha, not a `ref:` line\n * - anything unreadable/unexpected\n * The fallback is correct in all those cases; it is just slower, and they are rare.\n */\n private currentBranch(workspaceRoot: string): string | null {\n const fromHead = this.branchFromGitHead(workspaceRoot);\n if (fromHead !== null) return fromHead;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot,\n encoding: 'utf8',\n 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 // Parse `.git/HEAD` (\"ref: refs/heads/<branch>\"). null = cannot answer, caller must fall back.\n private branchFromGitHead(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const gitPath = path.join(workspaceRoot, '.git');\n // A worktree/submodule has `.git` as a file pointing at the real gitdir — HEAD is not here.\n if (!fs.statSync(gitPath).isDirectory()) return null;\n const head = fs.readFileSync(path.join(gitPath, 'HEAD'), 'utf8').trim();\n const match = /^ref:\\s*refs\\/heads\\/(.+)$/.exec(head);\n return match ? match[1] : null; // no match = detached HEAD → fall back\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n}\n"]}
1
+ {"version":3,"file":"read-stale-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/read-stale-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync, spawnSync } from 'child_process';\nimport * as fs from 'fs';\nimport * as path from 'path';\n\nimport {\n ReadStaleGuardConfig,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n readMainSyncStatus,\n MainSyncStatus,\n} from '@webpieces/rules-config';\n\nimport type { FileContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { FileRuleBase } from '../rule-base';\nimport { FixHint, Option } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { logGuardDecision, GuardDecision } from '../decision-log';\nimport { MergedBranchMessage } from './merged-branch-message';\nimport { StaleMainMessage } from './stale-main-message';\nimport { TreeRecovery } from './tree-recovery';\n\n/**\n * Blocks READS while the checked-out branch is a stale place to read from. TWO states:\n *\n * A. on `main`, and local main is BEHIND origin/main\n * B. on a feature branch whose PR is ALREADY MERGED (a pre-merge snapshot; origin/main has moved\n * past it and a squash merge means its HEAD is not even an ancestor of main)\n *\n * WHY READ, of all tools: either state means the AI reads stale FILE CONTENT and then reasons,\n * plans and writes against code that no longer exists upstream. Blocking the write is too late —\n * the bad premise is already in context. So the block lands on the read. (feature-branch-guard\n * blocks the WRITE in state B; this guard is the read-side half of that same protection, and the\n * two share one recovery message via MergedBranchMessage.)\n *\n * THE DIRTY-TREE ASYMMETRY is deliberate. State A fails OPEN on a dirty tree because `git pull` is\n * then not a guaranteed fast-forward and the agent would be trapped away from the files it needs to\n * resolve the conflict. State B blocks ANYWAY, because its cure — `git checkout -b <new>\n * origin/main` — carries uncommitted changes onto the fresh branch, so there is nothing to resolve\n * and nothing to be trapped by.\n *\n * WHY THIS CANNOT WEDGE: the block is scoped to Read ONLY. Every cure — `git pull origin main`,\n * `pnpm install`, any webpieces upgrade — is a Bash command, and this guard never looks at Bash.\n * So there is no command allowlist to maintain and no way to lock the agent out of its own fix.\n * (`git pull origin main` is explicitly permitted on main by redirect-how-to-merge-main, which\n * returns null when the branch IS main — the two guards are complementary, not stacked.)\n *\n * That scoping is also this guard's HOLE, and it is closed elsewhere rather than here: leaving Bash\n * entirely alone let a session `cat`/`grep`/`ls` the same stale tree the Read block was rejecting,\n * for a whole session, while the logs read \"read-stale-guard handled\". stale-main-bash-guard is the\n * State-A Bash counterpart (as merged-branch-bash-guard is State B's) and blocks only CONTENT-reading\n * commands, never the cure — which is why this guard can stay simple and Read-only.\n *\n * Everything here is FAIL-OPEN. A guard that blocks reads on bad data is far worse than one that\n * misses; every unknown resolves to \"allow\". The four deliberate escape valves:\n *\n * 1. DIRTY TREE — uncommitted work on main means `git pull` is not a guaranteed fast-forward.\n * Blocking reads there would trap the agent: it could not read the files it\n * needs to resolve the very conflict blocking it. Allow. (State A ONLY — see\n * the dirty-tree asymmetry above.)\n * 2. CACHE LAG — we do NOT compare hashes for equality. The cached `originMain` is written by\n * the detached refresher and is arbitrarily old, so `local !== origin` stays\n * true for a while AFTER a successful pull, which would spin the agent forever.\n * Instead: is the cached origin/main an ANCESTOR of local main? If local main\n * already contains it, we are not behind. That flips the instant the pull lands,\n * with no refresher round-trip. This is the single most important line here.\n * 3. CONFIG READ — webpieces.config.json stays readable so the agent can always read-then-edit\n * it to set `mode: OFF`. Its EDIT is already bypassed in runner.ts + hook-core;\n * this closes the read half of that same escape hatch.\n * 4. NO DATA — no cache, cache for another branch, empty originMain (offline), or no local\n * main at all (fresh clone / worktree) → allow.\n *\n * Runs from the Read fast path in hook-core (Read is neither a file-edit nor a bash payload, so it\n * never reaches the runner's rule loop). Fires the detached refresher on every call, which is also\n * what makes reads keep the shared main-sync cache warm for feature-branch-guard.\n */\nexport class ReadStaleGuardRule extends FileRuleBase<ReadStaleGuardConfig> {\n constructor(config: ReadStaleGuardConfig) { super(config, 'read-stale-guard'); }\n\n readonly description = 'Block reads on a branch that is stale to read from — a `main` behind origin/main, or a feature branch whose PR is already merged.';\n override readonly files = ['**/*'];\n override readonly defaultOptions = {\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'This branch is stale to read from — reading it would give you pre-merge/out-of-date content.',\n 'Get onto current code before reading anything else:',\n [\n new Option('On main, behind origin/main → git pull origin main. On an already-merged branch → git fetch origin main && git checkout -b <new-branch> origin/main. Then retry the read.', true),\n new Option(\"If that pull dies with 'fatal: Cannot fast-forward to multiple branches', .git/FETCH_HEAD holds a duplicate line — run 'git fetch --prune origin main' to rewrite it cleanly, then retry the pull.\"),\n new Option('Still allowed right now: Bash that does not read repo files (installs, upgrades, builds, tests, the pull itself, git/gh metadata), all Write/Edit, and reading webpieces.config.json. Content-reading Bash (cat/grep/ls/…) is blocked too on a stale main — see stale-main-bash-guard.'),\n new Option('Disable in webpieces.config.json under hookGuards → read-stale-guard (mode OFF) if intentional.'),\n ],\n );\n\n check(ctx: FileContext): readonly Violation[] {\n // Outside the workspace root — no jurisdiction.\n if (ctx.relativePath.startsWith('..')) return [];\n\n const branch = this.currentBranch(ctx.workspaceRoot);\n if (branch === null) return this.allow(ctx, branch, 'branch-undeterminable (fail-open)');\n\n // Keep the shared cache warm for the next call. Detached; never blocks this read. Fired for\n // BOTH states — the merged-branch signal comes out of that same cache.\n triggerMainSyncRefresh(ctx.workspaceRoot, this.config.hangTimeoutMinutes ?? DEFAULT_HANG_TIMEOUT_MINUTES);\n\n // Escape valve 3 — the read half of the config escape hatch. Ahead of BOTH states' blocks so\n // the agent can always read-then-edit the file that turns this guard off.\n if (this.isConfigFile(ctx.relativePath)) return this.allow(ctx, branch, 'webpieces-config-read (escape hatch)');\n\n return branch === 'main'\n ? this.checkStaleMain(ctx, branch)\n : this.checkMergedBranch(ctx, branch);\n }\n\n // State A — on main, possibly behind origin/main.\n private checkStaleMain(ctx: FileContext, branch: string): readonly Violation[] {\n const status = readMainSyncStatus(ctx.workspaceRoot);\n if (status === null) return this.allow(ctx, branch, 'no-sync-cache (fail-open)', 'cache=none');\n\n const cache = this.cacheSummary(status);\n if (status.branch !== 'main') return this.allow(ctx, branch, 'stale-cross-branch-cache (fail-open)', cache);\n // Offline / origin unresolvable, or no local main to compare against.\n if (status.originMain === '') return this.allow(ctx, branch, 'origin-main-unknown (fail-open)', cache);\n\n // Escape valve 2 — ancestry, NOT equality. See the class comment.\n if (this.contains(ctx.workspaceRoot, status.originMain)) {\n return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);\n }\n\n // Escape valve 1 — a dirty tree means the pull is not a clean fast-forward; do not trap\n // the agent away from the files it needs to resolve it.\n if (this.isDirty(ctx.workspaceRoot)) {\n return this.allow(ctx, branch, 'dirty-tree-on-main (fail-open)', cache);\n }\n\n return this.block(ctx, branch, 'on-stale-main', this.staleMainMessage(ctx.workspaceRoot), cache);\n }\n\n /**\n * State B — a feature branch whose PR is already merged. Reads a PRE-MERGE snapshot, so every\n * plan built from it is built on code origin/main has moved past.\n *\n * `branchAlreadyMerged` comes straight from the shared cache (the refresher's `gh pr list --state\n * merged`), so this path spawns nothing. No `gh` / offline → `mergedPr` is '' → not merged → allow,\n * which is the fail-open direction for free.\n *\n * The DIRTY-TREE escape valve is the same one state A has, for the same reason: uncommitted work\n * on a merged branch is work that exists nowhere else, and rescuing it means READING the files it\n * touches. `git checkout -b <new> origin/main` usually carries those changes across — but when it\n * does not (an overlapping change landed in main), a blocked read is an agent that cannot even\n * see what it is about to lose. feature-branch-guard still blocks the EDITS, so the state is\n * surfaced loudly either way; we just refuse to cut off the rescue path.\n */\n private checkMergedBranch(ctx: FileContext, branch: string): readonly Violation[] {\n const status = readMainSyncStatus(ctx.workspaceRoot);\n if (status === null) return this.allow(ctx, branch, 'no-sync-cache (fail-open)', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // Cache written for a DIFFERENT branch (just switched; the refresh for this one hasn't landed).\n // Never block on another branch's signals — this is also what un-blocks the instant the agent\n // follows the cure and checks out a fresh branch.\n if (status.branch !== branch) return this.allow(ctx, branch, 'stale-cross-branch-cache (fail-open)', cache);\n if (!status.branchAlreadyMerged) return this.allow(ctx, branch, 'clean-feature-branch', cache);\n if (this.isDirty(ctx.workspaceRoot)) {\n return this.allow(ctx, branch, 'dirty-merged-branch (fail-open)', cache);\n }\n\n const pr = status.mergedPr !== '' ? status.mergedPr : '?';\n return this.block(\n ctx,\n branch,\n `already-merged PR#${pr}`,\n this.mergedMessage(ctx.workspaceRoot, branch, status.mergedPr),\n cache,\n );\n }\n\n // The merged-branch text, told in the flavour of the tree we are standing in: a linked worktree\n // is told to open a NEW worktree off origin/main and reap this dead one; the primary clone is\n // told to branch off origin/main. Neither is ever told to `git checkout main` (fatal in a\n // worktree). Detection is one statSync — see WorktreeService.isLinkedWorktree.\n private mergedMessage(workspaceRoot: string, branch: string, mergedPr: string): string {\n const recovery = new TreeRecovery();\n return new MergedBranchMessage(workspaceRoot).forReads(\n branch, mergedPr, recovery.kindOf(workspaceRoot), workspaceRoot,\n );\n }\n\n // Is `commit` an ancestor of (i.e. already contained in) HEAD? Local-only and fast — no network.\n //\n // spawnSync, not execSync, precisely because the EXIT CODE is the answer and we must tell three\n // outcomes apart: 0 = ancestor (up to date), 1 = cleanly NOT an ancestor (genuinely behind),\n // anything else = git could not answer (bad/pruned object, not a repo) which must fail OPEN.\n // execSync collapses 1 and \"git broke\" into the same thrown Error, so it cannot make that call.\n // Arg-array form also means the commit hash is never parsed by a shell.\n private contains(workspaceRoot: string, commit: string): boolean {\n const result = spawnSync('git', ['merge-base', '--is-ancestor', commit, 'HEAD'], {\n cwd: workspaceRoot,\n encoding: 'utf8',\n });\n if (result.status === 0) return true;\n if (result.status === 1) return false;\n return true; // unknown/failed → treat as \"contained\" so the guard allows\n }\n\n private isDirty(workspaceRoot: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const out = execSync('git status --porcelain', {\n cwd: workspaceRoot,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n });\n return out.trim().length > 0;\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n // Cannot tell → assume dirty, which is the fail-OPEN direction for this guard.\n return true;\n }\n }\n\n private isConfigFile(relativePath: string): boolean {\n return relativePath === 'webpieces.config.json';\n }\n\n // How far behind we are, for the message. Best-effort — a bare \"behind\" reads fine without it.\n private behindCount(workspaceRoot: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const out = execSync('git rev-list --count HEAD..origin/main', {\n cwd: workspaceRoot,\n encoding: 'utf8',\n stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return /^\\d+$/.test(out) ? out : '?';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return '?';\n }\n }\n\n // Shared with stale-main-bash-guard (StaleMainMessage) so the two halves of the State-A block can\n // never prescribe different cures. Its \"still allowed\" tail no longer promises EVERY Bash command:\n // content-reading Bash is now blocked too, which is the whole point of the Bash counterpart.\n private staleMainMessage(workspaceRoot: string): string {\n return new StaleMainMessage(workspaceRoot).forReads(this.behindCount(workspaceRoot));\n }\n\n private cacheSummary(status: MainSyncStatus): string {\n const merged = status.branchAlreadyMerged ? `PR#${status.mergedPr !== '' ? status.mergedPr : '?'}` : 'no';\n return `cache=${status.branch} localMain=${status.localMain.slice(0, 8)} originMain=${status.originMain.slice(0, 8)} merged=${merged} ts=${status.timestamp}`;\n }\n\n private allow(ctx: FileContext, 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: FileContext, branch: string, reason: string, message: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'BLOCK', reason, cache);\n return [new V(1, ctx.relativePath, message)];\n }\n\n private logDecision(ctx: FileContext, branch: string | null, verdict: 'ALLOW' | 'BLOCK', reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('read-stale-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason, cache),\n );\n }\n\n /**\n * The current branch, WITHOUT spawning git on the common path.\n *\n * This runs on EVERY read, so it is the one call whose cost actually matters. Spawning\n * `git rev-parse --abbrev-ref HEAD` measures ~12ms — essentially all process-spawn overhead —\n * whereas `.git/HEAD` is a single tiny file whose read is microseconds. On a feature branch\n * (the overwhelmingly common case) that file read is the ONLY work this guard does before\n * short-circuiting, so reads stay effectively free.\n *\n * Falls back to spawning git whenever `.git/HEAD` cannot answer authoritatively:\n * - `.git` is a FILE, not a dir → we are in a worktree and HEAD lives elsewhere\n * - detached HEAD → the file holds a raw sha, not a `ref:` line\n * - anything unreadable/unexpected\n * The fallback is correct in all those cases; it is just slower, and they are rare.\n */\n private currentBranch(workspaceRoot: string): string | null {\n const fromHead = this.branchFromGitHead(workspaceRoot);\n if (fromHead !== null) return fromHead;\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot,\n encoding: 'utf8',\n 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 // Parse `.git/HEAD` (\"ref: refs/heads/<branch>\"). null = cannot answer, caller must fall back.\n private branchFromGitHead(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const gitPath = path.join(workspaceRoot, '.git');\n // A worktree/submodule has `.git` as a file pointing at the real gitdir — HEAD is not here.\n if (!fs.statSync(gitPath).isDirectory()) return null;\n const head = fs.readFileSync(path.join(gitPath, 'HEAD'), 'utf8').trim();\n const match = /^ref:\\s*refs\\/heads\\/(.+)$/.exec(head);\n return match ? match[1] : null; // no match = detached HEAD → fall back\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return null;\n }\n }\n}\n"]}
@@ -91,7 +91,7 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
91
91
  // command is scanned, not commandCode: this is a blocklist-shaped guard, so stripping quoted
92
92
  // prose can only ever block LESS (see BashContext.commandCode).
93
93
  staleContentRead(ctx) {
94
- const scan = new content_read_scan_1.ContentReadScan(this.scanner, ctx.workspaceRoot);
94
+ const scan = new content_read_scan_1.ContentReadScan(this.scanner, ctx.workspaceRoot, ctx.effectiveCwd);
95
95
  for (const segment of this.scanner.segmentsWithPipes(ctx.command)) {
96
96
  const hit = scan.readsStaleContent(segment);
97
97
  if (hit !== null)
@@ -127,7 +127,7 @@ class StaleMainBashGuardRule extends rule_base_1.BashRuleBase {
127
127
  }
128
128
  }
129
129
  staleMessage(workspaceRoot) {
130
- return new stale_main_message_1.StaleMainMessage().forBash(this.behindCount(workspaceRoot));
130
+ return new stale_main_message_1.StaleMainMessage(workspaceRoot).forBash(this.behindCount(workspaceRoot));
131
131
  }
132
132
  behindCount(workspaceRoot) {
133
133
  // eslint-disable-next-line @webpieces/no-unmanaged-exceptions
@@ -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, spawnSync } from 'child_process';\n\nimport {\n StaleMainBashGuardConfig,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n readMainSyncStatus,\n MainSyncStatus,\n} from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint, Option } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { logGuardDecision, GuardDecision } from '../decision-log';\nimport { CommandScanner } from '../command-scan';\nimport { StaleMainMessage } from './stale-main-message';\nimport { ContentReadScan } from './content-read-scan';\n\n/**\n * The BASH half of the STALE-MAIN protection (read-stale-guard's State A).\n *\n * read-stale-guard blocks the Read tool when local `main` is behind origin/main — but it looks at\n * nothing else, deliberately: \"every cure is a Bash command, so Bash is the escape hatch — never\n * wedge it.\" That reasoning is right about the CURE and wrong about `cat`/`grep`/`ls`. In the\n * incident this closes, an agent sat on a `main` 18 commits behind origin/main (108 files, +8069\n * −3692 upstream), had its Read tool blocked exactly as designed, and then spent the whole session\n * `ls`-ing, `grep`-ing and `cat`-ing the same stale tree through the side door — describing a CI\n * workflow set that was missing a 186-line workflow which existed upstream. The logs read\n * \"read-stale-guard handled\", which is worse than no guard: it looks covered.\n *\n * So this guard blocks CONTENT-READING Bash only, never the whole shell. Builds, tests, installs,\n * `git pull`, git METADATA (log/diff/show/status) — all still run. What is blocked is a command that\n * would put stale FILE CONTENT into context: `cat`/`head`/`grep`/`rg`/`sed`/`awk`/`ls`/`find`/… of a\n * path inside this workspace, and `git grep` / `git show <rev>:<path>` against a local rev. The same\n * line merged-branch-bash-guard already draws for State B, scoped tighter because State A's cure is\n * one command away and there is no reason to stop anything else.\n *\n * A piped consumer reads stdin, not the tree: `git log --oneline | grep fix` is allowed, because the\n * bytes came from git metadata, not from a stale file. That is why the scan needs the pipe flag.\n *\n * FAIL-OPEN, with read-stale-guard's own escape valves, so it can never wedge a session:\n * - branch undeterminable / not on `main` / no cache / cache for another branch → allow\n * - `originMain` unknown (offline) → allow\n * - origin/main already an ancestor of HEAD (ancestry, NOT equality) → allow the instant the pull lands\n * - DIRTY tree → allow: the pull is not a clean fast-forward, and resolving that means reading the\n * very files in conflict. Never trap the agent away from its own rescue.\n * - reading `webpieces.config.json` (the mode-OFF escape hatch) and `.webpieces/**` → allow\n */\nexport class StaleMainBashGuardRule extends BashRuleBase<StaleMainBashGuardConfig> {\n constructor(config: StaleMainBashGuardConfig) { super(config, 'stale-main-bash-guard'); }\n\n private readonly scanner = new CommandScanner();\n\n readonly description =\n 'Block content-reading Bash (cat/grep/ls/…) while local main is behind origin/main, so a ' +\n 'session cannot reason over a stale tree through the side door the Read-tool block leaves open.';\n override readonly defaultOptions = {\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'You are on main and main is behind origin/main — reading files here gives you stale content.',\n 'Update main first, then re-run your command:',\n [\n new Option('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.', true),\n new Option('Still allowed right now: builds, tests, installs, the pull itself, all git/gh METADATA (status|log|diff|show|branch), every Write/Edit, and reading webpieces.config.json.'),\n new Option('Disable in webpieces.config.json under hookGuards → stale-main-bash-guard (mode OFF) if intentional.'),\n ],\n );\n\n check(ctx: BashContext): readonly Violation[] {\n const branch = this.currentBranch(ctx.workspaceRoot);\n if (branch === null) return this.allow(ctx, branch, 'branch-undeterminable (fail-open)');\n\n // Keep the shared cache warm for the next call. Detached; never blocks this command.\n triggerMainSyncRefresh(ctx.workspaceRoot, this.config.hangTimeoutMinutes ?? DEFAULT_HANG_TIMEOUT_MINUTES);\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 const status = readMainSyncStatus(ctx.workspaceRoot);\n if (status === null) return this.allow(ctx, branch, 'no-sync-cache (fail-open)', 'cache=none');\n\n const cache = this.cacheSummary(status);\n if (status.branch !== 'main') return this.allow(ctx, branch, 'stale-cross-branch-cache (fail-open)', cache);\n // Offline / origin unresolvable — we have nothing to be stale RELATIVE TO.\n if (status.originMain === '') return this.allow(ctx, branch, 'origin-main-unknown (fail-open)', cache);\n\n // Ancestry, not equality: the moment the pull lands (or we are simply ahead), we are current.\n if (this.contains(ctx.workspaceRoot, status.originMain)) {\n return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);\n }\n\n // A dirty tree means the pull is not a clean fast-forward. Do not cut the agent off from the\n // files it must read to resolve that — the same valve read-stale-guard opens.\n if (this.isDirty(ctx.workspaceRoot)) {\n return this.allow(ctx, branch, 'dirty-tree-on-main (fail-open)', cache);\n }\n\n const reader = this.staleContentRead(ctx);\n if (reader === null) return this.allow(ctx, branch, 'not-a-content-read (cure/build/metadata)', cache);\n\n return this.block(ctx, branch, `stale-main content read (${reader})`, this.staleMessage(ctx.workspaceRoot), cache);\n }\n\n // The first segment that would read stale workspace content, or null when none does. The RAW\n // command is scanned, not commandCode: this is a blocklist-shaped guard, so stripping quoted\n // prose can only ever block LESS (see BashContext.commandCode).\n private staleContentRead(ctx: BashContext): string | null {\n const scan = new ContentReadScan(this.scanner, ctx.workspaceRoot);\n for (const segment of this.scanner.segmentsWithPipes(ctx.command)) {\n const hit = scan.readsStaleContent(segment);\n if (hit !== null) return hit;\n }\n return null;\n }\n\n // Is `commit` already contained in HEAD? Exit code IS the answer, so spawnSync: 0 = ancestor,\n // 1 = genuinely behind, anything else = git could not tell → fail OPEN. (Mirrors read-stale-guard.)\n private contains(workspaceRoot: string, commit: string): boolean {\n const result = spawnSync('git', ['merge-base', '--is-ancestor', commit, 'HEAD'], {\n cwd: workspaceRoot,\n encoding: 'utf8',\n });\n if (result.status === 0) return true;\n if (result.status === 1) return false;\n return true;\n }\n\n private isDirty(workspaceRoot: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const out = execSync('git status --porcelain', {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n });\n return out.trim().length > 0;\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return true; // cannot tell → assume dirty, the fail-OPEN direction here\n }\n }\n\n private staleMessage(workspaceRoot: string): string {\n return new StaleMainMessage().forBash(this.behindCount(workspaceRoot));\n }\n\n private behindCount(workspaceRoot: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const out = execSync('git rev-list --count HEAD..origin/main', {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return /^\\d+$/.test(out) ? out : '?';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return '?';\n }\n }\n\n private cacheSummary(status: MainSyncStatus): string {\n return `cache=${status.branch} localMain=${status.localMain.slice(0, 8)} originMain=${status.originMain.slice(0, 8)} ts=${status.timestamp}`;\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', reason, cache);\n return [new V(1, this.truncate(ctx.command), message)];\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: 'ALLOW' | 'BLOCK', 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),\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, spawnSync } from 'child_process';\n\nimport {\n StaleMainBashGuardConfig,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n readMainSyncStatus,\n MainSyncStatus,\n} from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint, Option } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { logGuardDecision, GuardDecision } from '../decision-log';\nimport { CommandScanner } from '../command-scan';\nimport { StaleMainMessage } from './stale-main-message';\nimport { ContentReadScan } from './content-read-scan';\n\n/**\n * The BASH half of the STALE-MAIN protection (read-stale-guard's State A).\n *\n * read-stale-guard blocks the Read tool when local `main` is behind origin/main — but it looks at\n * nothing else, deliberately: \"every cure is a Bash command, so Bash is the escape hatch — never\n * wedge it.\" That reasoning is right about the CURE and wrong about `cat`/`grep`/`ls`. In the\n * incident this closes, an agent sat on a `main` 18 commits behind origin/main (108 files, +8069\n * −3692 upstream), had its Read tool blocked exactly as designed, and then spent the whole session\n * `ls`-ing, `grep`-ing and `cat`-ing the same stale tree through the side door — describing a CI\n * workflow set that was missing a 186-line workflow which existed upstream. The logs read\n * \"read-stale-guard handled\", which is worse than no guard: it looks covered.\n *\n * So this guard blocks CONTENT-READING Bash only, never the whole shell. Builds, tests, installs,\n * `git pull`, git METADATA (log/diff/show/status) — all still run. What is blocked is a command that\n * would put stale FILE CONTENT into context: `cat`/`head`/`grep`/`rg`/`sed`/`awk`/`ls`/`find`/… of a\n * path inside this workspace, and `git grep` / `git show <rev>:<path>` against a local rev. The same\n * line merged-branch-bash-guard already draws for State B, scoped tighter because State A's cure is\n * one command away and there is no reason to stop anything else.\n *\n * A piped consumer reads stdin, not the tree: `git log --oneline | grep fix` is allowed, because the\n * bytes came from git metadata, not from a stale file. That is why the scan needs the pipe flag.\n *\n * FAIL-OPEN, with read-stale-guard's own escape valves, so it can never wedge a session:\n * - branch undeterminable / not on `main` / no cache / cache for another branch → allow\n * - `originMain` unknown (offline) → allow\n * - origin/main already an ancestor of HEAD (ancestry, NOT equality) → allow the instant the pull lands\n * - DIRTY tree → allow: the pull is not a clean fast-forward, and resolving that means reading the\n * very files in conflict. Never trap the agent away from its own rescue.\n * - reading `webpieces.config.json` (the mode-OFF escape hatch) and `.webpieces/**` → allow\n */\nexport class StaleMainBashGuardRule extends BashRuleBase<StaleMainBashGuardConfig> {\n constructor(config: StaleMainBashGuardConfig) { super(config, 'stale-main-bash-guard'); }\n\n private readonly scanner = new CommandScanner();\n\n readonly description =\n 'Block content-reading Bash (cat/grep/ls/…) while local main is behind origin/main, so a ' +\n 'session cannot reason over a stale tree through the side door the Read-tool block leaves open.';\n override readonly defaultOptions = {\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'You are on main and main is behind origin/main — reading files here gives you stale content.',\n 'Update main first, then re-run your command:',\n [\n new Option('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.', true),\n new Option('Still allowed right now: builds, tests, installs, the pull itself, all git/gh METADATA (status|log|diff|show|branch), every Write/Edit, and reading webpieces.config.json.'),\n new Option('Disable in webpieces.config.json under hookGuards → stale-main-bash-guard (mode OFF) if intentional.'),\n ],\n );\n\n check(ctx: BashContext): readonly Violation[] {\n const branch = this.currentBranch(ctx.workspaceRoot);\n if (branch === null) return this.allow(ctx, branch, 'branch-undeterminable (fail-open)');\n\n // Keep the shared cache warm for the next call. Detached; never blocks this command.\n triggerMainSyncRefresh(ctx.workspaceRoot, this.config.hangTimeoutMinutes ?? DEFAULT_HANG_TIMEOUT_MINUTES);\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 const status = readMainSyncStatus(ctx.workspaceRoot);\n if (status === null) return this.allow(ctx, branch, 'no-sync-cache (fail-open)', 'cache=none');\n\n const cache = this.cacheSummary(status);\n if (status.branch !== 'main') return this.allow(ctx, branch, 'stale-cross-branch-cache (fail-open)', cache);\n // Offline / origin unresolvable — we have nothing to be stale RELATIVE TO.\n if (status.originMain === '') return this.allow(ctx, branch, 'origin-main-unknown (fail-open)', cache);\n\n // Ancestry, not equality: the moment the pull lands (or we are simply ahead), we are current.\n if (this.contains(ctx.workspaceRoot, status.originMain)) {\n return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);\n }\n\n // A dirty tree means the pull is not a clean fast-forward. Do not cut the agent off from the\n // files it must read to resolve that — the same valve read-stale-guard opens.\n if (this.isDirty(ctx.workspaceRoot)) {\n return this.allow(ctx, branch, 'dirty-tree-on-main (fail-open)', cache);\n }\n\n const reader = this.staleContentRead(ctx);\n if (reader === null) return this.allow(ctx, branch, 'not-a-content-read (cure/build/metadata)', cache);\n\n return this.block(ctx, branch, `stale-main content read (${reader})`, this.staleMessage(ctx.workspaceRoot), cache);\n }\n\n // The first segment that would read stale workspace content, or null when none does. The RAW\n // command is scanned, not commandCode: this is a blocklist-shaped guard, so stripping quoted\n // prose can only ever block LESS (see BashContext.commandCode).\n private staleContentRead(ctx: BashContext): string | null {\n const scan = new ContentReadScan(this.scanner, ctx.workspaceRoot, ctx.effectiveCwd);\n for (const segment of this.scanner.segmentsWithPipes(ctx.command)) {\n const hit = scan.readsStaleContent(segment);\n if (hit !== null) return hit;\n }\n return null;\n }\n\n // Is `commit` already contained in HEAD? Exit code IS the answer, so spawnSync: 0 = ancestor,\n // 1 = genuinely behind, anything else = git could not tell → fail OPEN. (Mirrors read-stale-guard.)\n private contains(workspaceRoot: string, commit: string): boolean {\n const result = spawnSync('git', ['merge-base', '--is-ancestor', commit, 'HEAD'], {\n cwd: workspaceRoot,\n encoding: 'utf8',\n });\n if (result.status === 0) return true;\n if (result.status === 1) return false;\n return true;\n }\n\n private isDirty(workspaceRoot: string): boolean {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const out = execSync('git status --porcelain', {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n });\n return out.trim().length > 0;\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return true; // cannot tell → assume dirty, the fail-OPEN direction here\n }\n }\n\n private staleMessage(workspaceRoot: string): string {\n return new StaleMainMessage(workspaceRoot).forBash(this.behindCount(workspaceRoot));\n }\n\n private behindCount(workspaceRoot: string): string {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n const out = execSync('git rev-list --count HEAD..origin/main', {\n cwd: workspaceRoot, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'],\n }).trim();\n return /^\\d+$/.test(out) ? out : '?';\n } catch (err: unknown) {\n const error = toError(err);\n void error;\n return '?';\n }\n }\n\n private cacheSummary(status: MainSyncStatus): string {\n return `cache=${status.branch} localMain=${status.localMain.slice(0, 8)} originMain=${status.originMain.slice(0, 8)} ts=${status.timestamp}`;\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', reason, cache);\n return [new V(1, this.truncate(ctx.command), message)];\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: 'ALLOW' | 'BLOCK', 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),\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"]}
@@ -15,6 +15,18 @@
15
15
  * fails loudly without touching anything.
16
16
  */
17
17
  export declare class StaleMainMessage {
18
+ private readonly treeRoot;
19
+ /**
20
+ * `treeRoot` is the tree the guard JUDGED (which is NOT the shell's cwd when the command carried
21
+ * a leading `cd`). Pass it and the cure is rendered as `cd <treeRoot> && git pull …`, naming the
22
+ * directory outright.
23
+ *
24
+ * WHY that matters here specifically: in the field this guard told an agent working in a worktree
25
+ * to `git pull` — which, run from wherever the next tool call happened to start, meant pulling the
26
+ * PRIMARY CLONE, a tree that agent had been explicitly instructed not to touch. A remedy must
27
+ * never mutate a tree other than the one the command targeted, and naming it is how you ensure it.
28
+ */
29
+ constructor(treeRoot?: string);
18
30
  private common;
19
31
  forReads(behindCount: string): string;
20
32
  /**
@@ -18,15 +18,31 @@ exports.StaleMainMessage = void 0;
18
18
  * fails loudly without touching anything.
19
19
  */
20
20
  class StaleMainMessage {
21
+ treeRoot;
22
+ /**
23
+ * `treeRoot` is the tree the guard JUDGED (which is NOT the shell's cwd when the command carried
24
+ * a leading `cd`). Pass it and the cure is rendered as `cd <treeRoot> && git pull …`, naming the
25
+ * directory outright.
26
+ *
27
+ * WHY that matters here specifically: in the field this guard told an agent working in a worktree
28
+ * to `git pull` — which, run from wherever the next tool call happened to start, meant pulling the
29
+ * PRIMARY CLONE, a tree that agent had been explicitly instructed not to touch. A remedy must
30
+ * never mutate a tree other than the one the command targeted, and naming it is how you ensure it.
31
+ */
32
+ constructor(treeRoot = '') {
33
+ this.treeRoot = treeRoot;
34
+ }
21
35
  // The diagnosis + cure. Identical for both guards — the part that must never drift.
22
36
  common(behindCount) {
37
+ const pull = 'git pull --ff-only origin main';
23
38
  return [
24
39
  `You are on main and main is ${behindCount} commit(s) behind origin/main.`,
40
+ ...(this.treeRoot !== '' ? [`Evaluated against: ${this.treeRoot} (branch main)`] : []),
25
41
  'Anything you read here is STALE, and every plan built from it is built on code that no',
26
42
  'longer exists upstream.',
27
43
  '',
28
44
  'Run exactly this, then retry:',
29
- ' git pull --ff-only origin main',
45
+ ` ${this.treeRoot !== '' ? `cd ${this.treeRoot} && ${pull}` : pull}`,
30
46
  '',
31
47
  'If that fatals with "Cannot fast-forward to multiple branches", .git/FETCH_HEAD holds a',
32
48
  'duplicate entry — clear it with `git fetch --prune origin main`, then pull again.',
@@ -1 +1 @@
1
- {"version":3,"file":"stale-main-message.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/stale-main-message.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;;;GAeG;AACH,MAAa,gBAAgB;IACzB,oFAAoF;IAC5E,MAAM,CAAC,WAAmB;QAC9B,OAAO;YACH,+BAA+B,WAAW,gCAAgC;YAC1E,wFAAwF;YACxF,yBAAyB;YACzB,EAAE;YACF,+BAA+B;YAC/B,kCAAkC;YAClC,EAAE;YACF,yFAAyF;YACzF,mFAAmF;SACtF,CAAC;IACN,CAAC;IAED,QAAQ,CAAC,WAAmB;QACxB,OAAO,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC;YACnC,EAAE;YACF,uCAAuC;YACvC,2FAA2F;YAC3F,mDAAmD;YACnD,oEAAoE;YACpE,0FAA0F;SAC7F,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAClB,CAAC;IAED;;;;OAIG;IACH,OAAO,CAAC,WAAmB;QACvB,OAAO,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC;YACnC,EAAE;YACF,yFAAyF;YACzF,wFAAwF;YACxF,yFAAyF;YACzF,yFAAyF;YACzF,EAAE;YACF,qDAAqD;YACrD,wFAAwF;YACxF,uFAAuF;YACvF,sGAAsG;YACtG,uGAAuG;SAC1G,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAClB,CAAC;CACJ;AA/CD,4CA+CC","sourcesContent":["/**\n * The \"you are on a stale main\" text, shared by the TWO guards that detect the state from the same\n * cached signal (`MainSyncStatus.localMain` vs `originMain`):\n *\n * - read-stale-guard blocks the Read tool → {@link StaleMainMessage.forReads}\n * - stale-main-bash-guard blocks content-read Bash → {@link StaleMainMessage.forBash}\n *\n * One source of truth on purpose (same reason as MergedBranchMessage): the cure is an instruction the\n * AI follows literally, so two drifting copies mean two behaviors for one repo state. Only the\n * \"what is still allowed\" tail differs, because the two guards block different tools.\n *\n * The cure is `--ff-only` deliberately. A plain `git pull` on a stale main can start a MERGE, which\n * is the one thing redirect-how-to-merge-main exists to keep an AI away from; `--ff-only` either\n * fast-forwards (the case here, since the block only fires when the tree is clean and behind) or\n * fails loudly without touching anything.\n */\nexport class StaleMainMessage {\n // The diagnosis + cure. Identical for both guards — the part that must never drift.\n private common(behindCount: string): string[] {\n return [\n `You are on main and main is ${behindCount} commit(s) behind origin/main.`,\n 'Anything you read here is STALE, and every plan built from it is built on code that no',\n 'longer exists upstream.',\n '',\n 'Run exactly this, then retry:',\n ' git pull --ff-only origin main',\n '',\n 'If that fatals with \"Cannot fast-forward to multiple branches\", .git/FETCH_HEAD holds a',\n 'duplicate entry — clear it with `git fetch --prune origin main`, then pull again.',\n ];\n }\n\n forReads(behindCount: string): string {\n return this.common(behindCount).concat([\n '',\n 'Still allowed while this block is up:',\n ' - Bash that does not read repo files: builds, tests, installs, the pull itself, and all',\n ' git/gh METADATA (status|log|diff|show|branch)',\n ' - All Write/Edit (feature-branch-guard governs those separately)',\n ' - Reading and editing webpieces.config.json (set read-stale-guard mode OFF to disable)',\n ]).join('\\n');\n }\n\n /**\n * The Bash variant. stale-main-bash-guard blocks only CONTENT reads, so the message has to say\n * which shell is still open — an agent that reads \"Bash blocked\" and believes the whole shell is\n * gone will not run the cure, which is itself a Bash command.\n */\n forBash(behindCount: string): string {\n return this.common(behindCount).concat([\n '',\n 'This command was blocked because it reads FILE CONTENT out of the stale tree (cat/grep/',\n 'ls/find/sed/awk/git grep/git show <rev>:<path>). That is how stale bytes get into your',\n 'context and quietly poison everything you conclude — the incident behind this guard had',\n 'an agent describing a CI workflow set that was missing a whole workflow added upstream.',\n '',\n 'Still allowed right now (the cure is one of these):',\n ' - git pull/fetch, installs, upgrades, builds, tests, every other non-reading command',\n ' - git/gh METADATA: status, log, diff, show <rev>, branch, rev-list, gh pr list|view',\n ' - reads against the CURRENT upstream tree: git show origin/main:<path>, git grep <pat> origin/main',\n ' - all Write/Edit, and reading webpieces.config.json (set stale-main-bash-guard mode OFF to disable)',\n ]).join('\\n');\n }\n}\n"]}
1
+ {"version":3,"file":"stale-main-message.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/stale-main-message.ts"],"names":[],"mappings":";;;AAAA;;;;;;;;;;;;;;;GAeG;AACH,MAAa,gBAAgB;IAWI;IAV7B;;;;;;;;;OASG;IACH,YAA6B,WAAmB,EAAE;QAArB,aAAQ,GAAR,QAAQ,CAAa;IAAG,CAAC;IAEtD,oFAAoF;IAC5E,MAAM,CAAC,WAAmB;QAC9B,MAAM,IAAI,GAAG,gCAAgC,CAAC;QAC9C,OAAO;YACH,+BAA+B,WAAW,gCAAgC;YAC1E,GAAG,CAAC,IAAI,CAAC,QAAQ,KAAK,EAAE,CAAC,CAAC,CAAC,CAAC,sBAAsB,IAAI,CAAC,QAAQ,iBAAiB,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC;YACvF,wFAAwF;YACxF,yBAAyB;YACzB,EAAE;YACF,+BAA+B;YAC/B,KAAK,IAAI,CAAC,QAAQ,KAAK,EAAE,CAAC,CAAC,CAAC,MAAM,IAAI,CAAC,QAAQ,OAAO,IAAI,EAAE,CAAC,CAAC,CAAC,IAAI,EAAE;YACrE,EAAE;YACF,yFAAyF;YACzF,mFAAmF;SACtF,CAAC;IACN,CAAC;IAED,QAAQ,CAAC,WAAmB;QACxB,OAAO,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC;YACnC,EAAE;YACF,uCAAuC;YACvC,2FAA2F;YAC3F,mDAAmD;YACnD,oEAAoE;YACpE,0FAA0F;SAC7F,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAClB,CAAC;IAED;;;;OAIG;IACH,OAAO,CAAC,WAAmB;QACvB,OAAO,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,CAAC,MAAM,CAAC;YACnC,EAAE;YACF,yFAAyF;YACzF,wFAAwF;YACxF,yFAAyF;YACzF,yFAAyF;YACzF,EAAE;YACF,qDAAqD;YACrD,wFAAwF;YACxF,uFAAuF;YACvF,sGAAsG;YACtG,uGAAuG;SAC1G,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAClB,CAAC;CACJ;AA7DD,4CA6DC","sourcesContent":["/**\n * The \"you are on a stale main\" text, shared by the TWO guards that detect the state from the same\n * cached signal (`MainSyncStatus.localMain` vs `originMain`):\n *\n * - read-stale-guard blocks the Read tool → {@link StaleMainMessage.forReads}\n * - stale-main-bash-guard blocks content-read Bash → {@link StaleMainMessage.forBash}\n *\n * One source of truth on purpose (same reason as MergedBranchMessage): the cure is an instruction the\n * AI follows literally, so two drifting copies mean two behaviors for one repo state. Only the\n * \"what is still allowed\" tail differs, because the two guards block different tools.\n *\n * The cure is `--ff-only` deliberately. A plain `git pull` on a stale main can start a MERGE, which\n * is the one thing redirect-how-to-merge-main exists to keep an AI away from; `--ff-only` either\n * fast-forwards (the case here, since the block only fires when the tree is clean and behind) or\n * fails loudly without touching anything.\n */\nexport class StaleMainMessage {\n /**\n * `treeRoot` is the tree the guard JUDGED (which is NOT the shell's cwd when the command carried\n * a leading `cd`). Pass it and the cure is rendered as `cd <treeRoot> && git pull …`, naming the\n * directory outright.\n *\n * WHY that matters here specifically: in the field this guard told an agent working in a worktree\n * to `git pull` — which, run from wherever the next tool call happened to start, meant pulling the\n * PRIMARY CLONE, a tree that agent had been explicitly instructed not to touch. A remedy must\n * never mutate a tree other than the one the command targeted, and naming it is how you ensure it.\n */\n constructor(private readonly treeRoot: string = '') {}\n\n // The diagnosis + cure. Identical for both guards — the part that must never drift.\n private common(behindCount: string): string[] {\n const pull = 'git pull --ff-only origin main';\n return [\n `You are on main and main is ${behindCount} commit(s) behind origin/main.`,\n ...(this.treeRoot !== '' ? [`Evaluated against: ${this.treeRoot} (branch main)`] : []),\n 'Anything you read here is STALE, and every plan built from it is built on code that no',\n 'longer exists upstream.',\n '',\n 'Run exactly this, then retry:',\n ` ${this.treeRoot !== '' ? `cd ${this.treeRoot} && ${pull}` : pull}`,\n '',\n 'If that fatals with \"Cannot fast-forward to multiple branches\", .git/FETCH_HEAD holds a',\n 'duplicate entry — clear it with `git fetch --prune origin main`, then pull again.',\n ];\n }\n\n forReads(behindCount: string): string {\n return this.common(behindCount).concat([\n '',\n 'Still allowed while this block is up:',\n ' - Bash that does not read repo files: builds, tests, installs, the pull itself, and all',\n ' git/gh METADATA (status|log|diff|show|branch)',\n ' - All Write/Edit (feature-branch-guard governs those separately)',\n ' - Reading and editing webpieces.config.json (set read-stale-guard mode OFF to disable)',\n ]).join('\\n');\n }\n\n /**\n * The Bash variant. stale-main-bash-guard blocks only CONTENT reads, so the message has to say\n * which shell is still open — an agent that reads \"Bash blocked\" and believes the whole shell is\n * gone will not run the cure, which is itself a Bash command.\n */\n forBash(behindCount: string): string {\n return this.common(behindCount).concat([\n '',\n 'This command was blocked because it reads FILE CONTENT out of the stale tree (cat/grep/',\n 'ls/find/sed/awk/git grep/git show <rev>:<path>). That is how stale bytes get into your',\n 'context and quietly poison everything you conclude — the incident behind this guard had',\n 'an agent describing a CI workflow set that was missing a whole workflow added upstream.',\n '',\n 'Still allowed right now (the cure is one of these):',\n ' - git pull/fetch, installs, upgrades, builds, tests, every other non-reading command',\n ' - git/gh METADATA: status, log, diff, show <rev>, branch, rev-list, gh pr list|view',\n ' - reads against the CURRENT upstream tree: git show origin/main:<path>, git grep <pat> origin/main',\n ' - all Write/Edit, and reading webpieces.config.json (set stale-main-bash-guard mode OFF to disable)',\n ]).join('\\n');\n }\n}\n"]}
@@ -22,9 +22,25 @@
22
22
  */
23
23
  export type TreeKind = 'worktree' | 'branch' | 'unknown';
24
24
  export declare class TreeRecovery {
25
+ private readonly treeRoot;
25
26
  private readonly worktrees;
27
+ /**
28
+ * `treeRoot` is the tree the guard JUDGED. When it is given, every command below is rendered as
29
+ * `cd <treeRoot> && <command>`.
30
+ *
31
+ * WHY: a Bash tool call does NOT persist `cd` between calls, so an agent working in a linked
32
+ * worktree starts every call back in the primary clone. A bare `git fetch origin main` therefore
33
+ * runs against the WRONG tree — and in the field a guard prescribed `git pull` in a primary clone
34
+ * the agent had been explicitly forbidden to touch. Naming the directory in the command itself is
35
+ * the only form that is correct no matter where the next tool call starts. A leading `cd <path> &&`
36
+ * cannot change what a command does to a repo, so the guards accept it.
37
+ *
38
+ * Empty (the default) renders the bare commands, for callers with no root to name.
39
+ */
40
+ constructor(treeRoot?: string);
26
41
  /** The kind of tree rooted at `root`, for callers that have a workspace root and no other info. */
27
42
  kindOf(root: string): TreeKind;
43
+ private at;
28
44
  /**
29
45
  * Start fresh off current main. Both forms base explicitly on `origin/main` — the only base that
30
46
  * works from ANY tree (branch-creation-guard allows it unconditionally for that reason).
@@ -3,11 +3,32 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.TreeRecovery = void 0;
4
4
  const rules_config_1 = require("@webpieces/rules-config");
5
5
  class TreeRecovery {
6
+ treeRoot;
6
7
  worktrees = new rules_config_1.WorktreeService();
8
+ /**
9
+ * `treeRoot` is the tree the guard JUDGED. When it is given, every command below is rendered as
10
+ * `cd <treeRoot> && <command>`.
11
+ *
12
+ * WHY: a Bash tool call does NOT persist `cd` between calls, so an agent working in a linked
13
+ * worktree starts every call back in the primary clone. A bare `git fetch origin main` therefore
14
+ * runs against the WRONG tree — and in the field a guard prescribed `git pull` in a primary clone
15
+ * the agent had been explicitly forbidden to touch. Naming the directory in the command itself is
16
+ * the only form that is correct no matter where the next tool call starts. A leading `cd <path> &&`
17
+ * cannot change what a command does to a repo, so the guards accept it.
18
+ *
19
+ * Empty (the default) renders the bare commands, for callers with no root to name.
20
+ */
21
+ constructor(treeRoot = '') {
22
+ this.treeRoot = treeRoot;
23
+ }
7
24
  /** The kind of tree rooted at `root`, for callers that have a workspace root and no other info. */
8
25
  kindOf(root) {
9
26
  return this.worktrees.isLinkedWorktree(root) ? 'worktree' : 'branch';
10
27
  }
28
+ // Render one command in the form that survives a tool call: `cd <root> && <command>`.
29
+ at(command) {
30
+ return this.treeRoot === '' ? command : `cd ${this.treeRoot} && ${command}`;
31
+ }
11
32
  /**
12
33
  * Start fresh off current main. Both forms base explicitly on `origin/main` — the only base that
13
34
  * works from ANY tree (branch-creation-guard allows it unconditionally for that reason).
@@ -21,12 +42,12 @@ class TreeRecovery {
21
42
  ? '<feature-dir>'
22
43
  : newBranchName.replace(/\//g, '-');
23
44
  const branchForm = [
24
- ' git fetch origin main',
25
- ` git checkout -b ${newBranchName} origin/main`,
45
+ ` ${this.at('git fetch origin main')}`,
46
+ ` ${this.at(`git checkout -b ${newBranchName} origin/main`)}`,
26
47
  ];
27
48
  const worktreeForm = [
28
- ' git fetch origin main',
29
- ` git worktree add ../${dir} -b ${newBranchName} origin/main`,
49
+ ` ${this.at('git fetch origin main')}`,
50
+ ` ${this.at(`git worktree add ../${dir} -b ${newBranchName} origin/main`)}`,
30
51
  ];
31
52
  if (kind === 'worktree') {
32
53
  return [
@@ -68,7 +89,7 @@ class TreeRecovery {
68
89
  * delete ordering is the part that has to be exactly right.
69
90
  */
70
91
  cleanupSteps(kind, branch, worktreePath = '<worktree-dir>') {
71
- const branchForm = ' git checkout main && git pull origin main && pnpm wp-cleanup';
92
+ const branchForm = ` ${this.at('git checkout main && git pull origin main && pnpm wp-cleanup')}`;
72
93
  const worktreeForm = ` git worktree prune && git worktree remove ${worktreePath} && git branch -D ${branch}`;
73
94
  if (kind === 'worktree') {
74
95
  return [
@@ -95,8 +116,8 @@ class TreeRecovery {
95
116
  * you need to then branch off `origin/main`.
96
117
  */
97
118
  updateMainSteps(kind) {
98
- const branchForm = ' git checkout main && git pull origin main';
99
- const worktreeForm = ' git fetch origin main (then work off origin/main)';
119
+ const branchForm = ` ${this.at('git checkout main && git pull origin main')}`;
120
+ const worktreeForm = ` ${this.at('git fetch origin main')} (then work off origin/main)`;
100
121
  if (kind === 'worktree') {
101
122
  return [
102
123
  'You are in a linked worktree — `git checkout main` fatals here (main is checked out in',
@@ -1 +1 @@
1
- {"version":3,"file":"tree-recovery.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/tree-recovery.ts"],"names":[],"mappings":";;;AAAA,0DAA0D;AA0B1D,MAAa,YAAY;IACJ,SAAS,GAAG,IAAI,8BAAe,EAAE,CAAC;IAEnD,mGAAmG;IACnG,MAAM,CAAC,IAAY;QACf,OAAO,IAAI,CAAC,SAAS,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC;IACzE,CAAC;IAED;;;OAGG;IACH,eAAe,CAAC,IAAc,EAAE,gBAAwB,sBAAsB;QAC1E,6FAA6F;QAC7F,8FAA8F;QAC9F,8FAA8F;QAC9F,yDAAyD;QACzD,MAAM,GAAG,GAAG,aAAa,CAAC,QAAQ,CAAC,GAAG,CAAC;YACnC,CAAC,CAAC,eAAe;YACjB,CAAC,CAAC,aAAa,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;QACxC,MAAM,UAAU,GAAG;YACf,yBAAyB;YACzB,qBAAqB,aAAa,cAAc;SACnD,CAAC;QACF,MAAM,YAAY,GAAG;YACjB,yBAAyB;YACzB,yBAAyB,GAAG,OAAO,aAAa,cAAc;SACjE,CAAC;QAEF,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;YACtB,OAAO;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,gEAAgE,CAAC;QACpF,MAAM,YAAY,GACd,+CAA+C,YAAY,qBAAqB,MAAM,EAAE,CAAC;QAE7F,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;YACtB,OAAO;gBACH,wFAAwF;gBACxF,8EAA8E;gBAC9E,YAAY;aACf,CAAC;QACN,CAAC;QACD,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;YACpB,OAAO,CAAC,6BAA6B,EAAE,UAAU,CAAC,CAAC;QACvD,CAAC;QACD,OAAO;YACH,kDAAkD;YAClD,2BAA2B;YAC3B,KAAK,UAAU,EAAE;YACjB,0FAA0F;YAC1F,yCAAyC;YACzC,KAAK,YAAY,EAAE;SACtB,CAAC;IACN,CAAC;IAED;;;;OAIG;IACH,eAAe,CAAC,IAAc;QAC1B,MAAM,UAAU,GAAG,6CAA6C,CAAC;QACjE,MAAM,YAAY,GAAG,4DAA4D,CAAC;QAElF,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;YACtB,OAAO;gBACH,wFAAwF;gBACxF,6DAA6D;gBAC7D,YAAY;aACf,CAAC;QACN,CAAC;QACD,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;YACpB,OAAO,CAAC,cAAc,EAAE,UAAU,CAAC,CAAC;QACxC,CAAC;QACD,OAAO;YACH,qDAAqD;YACrD,2BAA2B;YAC3B,KAAK,UAAU,EAAE;YACjB,8DAA8D;YAC9D,KAAK,YAAY,EAAE;SACtB,CAAC;IACN,CAAC;CACJ;AAzHD,oCAyHC","sourcesContent":["import { WorktreeService } from '@webpieces/rules-config';\n\n/**\n * Renders the \"get onto a healthy tree\" commands, in the flavour of the tree the AI is standing in.\n *\n * WHY this exists: the SAME recovery advice takes different commands in a linked worktree than in\n * the primary clone, and getting it wrong is not a cosmetic problem — the AI runs these strings\n * literally:\n *\n * - `git checkout main` FATALS in a linked worktree (\"main is already checked out at <primary>\"),\n * so any message that recommends it burns a turn and then strands the agent.\n * - a dead linked worktree is reaped with prune → remove → `git branch -D`, in that exact order,\n * because git flatly refuses to delete a branch a worktree still holds. `git branch -d` alone\n * just fails.\n *\n * Four guards used to hand-write these two forms independently (feature-branch-guard,\n * read-stale-guard, pr-merge-guard, redirect-how-to-merge-main), so they drifted. This is the one\n * place they come from now.\n *\n * The `TreeKind` contract, and why UNKNOWN prints BOTH: detection is a cheap local probe that can\n * fail (see WorktreeService.isLinkedWorktree). When we KNOW, we print exactly the one command that\n * works there — no menu for the AI to mis-pick from. When we do NOT know, we print both, clearly\n * labelled, because a labelled choice is recoverable and a confidently-wrong command is not.\n */\nexport type TreeKind = 'worktree' | 'branch' | 'unknown';\n\nexport class TreeRecovery {\n private readonly worktrees = new WorktreeService();\n\n /** The kind of tree rooted at `root`, for callers that have a workspace root and no other info. */\n kindOf(root: string): TreeKind {\n return this.worktrees.isLinkedWorktree(root) ? 'worktree' : 'branch';\n }\n\n /**\n * Start fresh off current main. Both forms base explicitly on `origin/main` — the only base that\n * works from ANY tree (branch-creation-guard allows it unconditionally for that reason).\n */\n freshStartSteps(kind: TreeKind, newBranchName: string = '<new-feature-branch>'): string[] {\n // The worktree DIRECTORY cannot carry the branch's slashes. When the branch name is itself a\n // placeholder the AI must fill in, keep the directory a readable placeholder too — sanitizing\n // `<new-feature-branch>` produced `../-new-feature-branch-`, which reads like a real path and\n // is exactly the kind of thing an agent pastes verbatim.\n const dir = newBranchName.includes('<')\n ? '<feature-dir>'\n : newBranchName.replace(/\\//g, '-');\n const branchForm = [\n ' git fetch origin main',\n ` git checkout -b ${newBranchName} origin/main`,\n ];\n const worktreeForm = [\n ' git fetch origin main',\n ` git worktree add ../${dir} -b ${newBranchName} origin/main`,\n ];\n\n if (kind === 'worktree') {\n return [\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 = ' git checkout main && git pull origin main && pnpm wp-cleanup';\n const worktreeForm =\n ` git worktree prune && git worktree remove ${worktreePath} && git branch -D ${branch}`;\n\n if (kind === 'worktree') {\n return [\n 'You are in a linked worktree — remove the worktree first, then the branch (git refuses',\n 'to delete a branch a worktree still holds). Run this from the PRIMARY clone:',\n worktreeForm,\n ];\n }\n if (kind === 'branch') {\n return ['Clean up the merged branch:', branchForm];\n }\n return [\n 'Clean up. Pick the form for the tree you are in:',\n ' - in the primary clone:',\n ` ${branchForm}`,\n ' - for a linked worktree (run from the primary clone; `git branch -d` alone fails while',\n ' a worktree still holds the branch):',\n ` ${worktreeForm}`,\n ];\n }\n\n /**\n * Bring main up to date. In a linked worktree there is nothing to check out — `main` lives in\n * the primary clone — so the update is a plain fetch of the remote-tracking ref, which is all\n * you need to then branch off `origin/main`.\n */\n updateMainSteps(kind: TreeKind): string[] {\n const branchForm = ' git checkout main && git pull origin main';\n const worktreeForm = ' git fetch origin main (then work off origin/main)';\n\n if (kind === 'worktree') {\n return [\n 'You are in a linked worktree — `git checkout main` fatals here (main is checked out in',\n 'the primary clone). Update the remote-tracking ref instead:',\n worktreeForm,\n ];\n }\n if (kind === 'branch') {\n return ['Update main:', branchForm];\n }\n return [\n 'Update main. Pick the form for the tree you are in:',\n ' - in the primary clone:',\n ` ${branchForm}`,\n ' - in a linked worktree (`git checkout main` fatals there):',\n ` ${worktreeForm}`,\n ];\n }\n}\n"]}
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 } 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: a Bash tool call does NOT persist `cd` between calls, 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>`.\n private at(command: string): string {\n return this.treeRoot === '' ? command : `cd ${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"]}