@webpieces/ai-hook-rules 0.4.737 → 0.4.739

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"feature-branch-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/feature-branch-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport { BranchStateGuardConfig, BRANCH_STATE_GUARD_KEY, DEFAULT_HANG_TIMEOUT_MINUTES, readMainSyncStatus, squashRecoverySteps, MainSyncStatus, SyncFlowGuidance, Option } from '@webpieces/rules-config';\n\nimport type { FileContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { FileRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { hangTimeoutOf } from '../main-sync-timeout';\nimport { logGuardDecision, GuardDecision, Verdict, matrixL2Row } from '../decision-log';\nimport { writeBranchStateMatrixDoc, branchStateMatrixPointer } from '../l2-matrix-doc';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { MergedBranchMessage } from './merged-branch-message';\nimport { TreeRecovery } from './tree-recovery';\n\n/**\n * Comprehensive \"are you on a proper feature branch?\" guard — the single rule that blocks edits when\n * the branch isn't a healthy place to work. Four states, in priority order:\n * 1. On main (checked SYNCHRONOUSLY here) → block: create a feature branch.\n * 2. Branch already merged into main (merged PR) → block: your work is in main, branch off fresh.\n * 3. No fork point with origin/main → block: squash onto a new branch.\n * 4. origin/main moved & touches your files → block: merge main first.\n * States 2–4 are PRECOMPUTED into `.webpieces/main-sync-status.json` by the detached refresher, so\n * this check does NO network git (only a fast local `git rev-parse` for state 1). On every call it\n * fire-and-forget spawns the refresher so the NEXT call is fresh. Runs in the GUARDS hook (it's a\n * hookGuard); file-scoped, so only Write/Edit/MultiEdit are guarded — Bash passes through so the AI\n * can still run `pnpm wp-start-upsert-pr` and the rest of the recovery flow.\n *\n * ── STATE 1 IS UNCONDITIONAL, AND `B` NO LONGER TRACKS `E` HERE ──────────────────────────────────\n *\n * Earlier docblocks in this family argued that the Bash half of \"on `main`\" should match this one\n * exactly — one `git rev-parse`, no cache, so it fires on the first call of a session. That argument\n * has been split, deliberately, and the two halves now differ:\n *\n * `E` (here) blocks on ANY `main`, current or stale.\n * `B` (stale-main-bash-guard) blocks only once local `main` is KNOWN BEHIND `origin/main`.\n *\n * The hazards are not the same hazard. A WRITE on `main` puts work where it cannot be reviewed, cannot\n * be reverted as a unit, and is one `git checkout` from being lost — none of which depends on how\n * current `main` is, so nothing about freshness could make this block right or wrong. A READ or a\n * BUILD on a CURRENT `main` harms nothing at all, and denying it strands the agent immediately after\n * `pnpm wp-sync-main` — the very command this repo prescribes — put it there.\n *\n * So do NOT \"restore the symmetry\" by gating this on the cache. That would make writes on `main`\n * permitted for the whole first call of every session (the cache is populated for the NEXT call), and\n * permanently in a multi-worktree repo where another tree can hold the refresh lock. That ordering is\n * the most load-bearing thing in the L2 table, which is why row 5 sits ABOVE the cache divider.\n */\nexport class FeatureBranchGuardRule extends FileRuleBase<BranchStateGuardConfig> {\n // NAME is this class's operator identity (every `rule=` in the log, every deny header);\n // CONFIG KEY is the one branch-state policy entry all four of these guards read. See AbstractRule.\n constructor(config: BranchStateGuardConfig) { super(config, 'feature-branch-guard', BRANCH_STATE_GUARD_KEY); }\n\n readonly description = 'Block edits unless you are on a proper feature branch (not main, not already-merged, forked, in sync with main).';\n override readonly files = ['**/*'];\n override readonly defaultOptions = {\n branchNamingConvention: '{whoami}/{featurename}',\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'You are not on a clean, up-to-date feature branch.',\n 'You must be on a clean, up-to-date feature branch to edit code. Pick one:',\n [\n new Option('On main → create a feature branch. Already merged → branch off fresh main.', true),\n new Option('main moved/conflicts, NO PR yet → `pnpm wp-start-update` (merge), `/wp-merge` (resolve), `pnpm wp-finish-update`. An OPEN PR? then you MUST use `pnpm wp-start-upsert-pr` → `/wp-merge` → `pnpm wp-finish-upsert-pr` (the merge rewrites the branch, so the PR must be re-pointed in the same run). Never mix a start from one pair with a finish from the other.'),\n new Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),\n ],\n );\n\n check(ctx: FileContext): readonly Violation[] {\n // Only files inside the workspace root — guard has no jurisdiction, nothing worth logging.\n if (ctx.relativePath.startsWith('..')) return [];\n\n const branch = this.currentBranch(ctx.workspaceRoot);\n // Can't determine branch (e.g. not a git repo) → don't block. Fail-open.\n if (branch === null) return this.failOpen(ctx, branch, 'branch-undeterminable');\n\n // State 1: on main — synchronous, no cache needed.\n if (branch === 'main') {\n return this.block(ctx, branch, 'on-main', this.onMainMessage());\n }\n\n // Keep the cache warm for the next call. Detached; never blocks this edit.\n triggerMainSyncRefresh(ctx.workspaceRoot, hangTimeoutOf(this.config));\n\n const status = readMainSyncStatus(ctx.workspaceRoot, branch);\n // No cache yet (first edit of the session) → allow; the refresh we just spawned populates it\n // for the next call. Fail-open: never block on missing data.\n if (status === null) return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // BELT-AND-BRACES since the cache became branch-keyed: we looked this entry up BY `branch`, so\n // a mismatch now means the map's key and the entry's own `branch` field disagree — a shape bug,\n // not a normal state. Kept (rather than deleted) so such a bug degrades to an allow instead of\n // blocking on another branch's signals. Unreachable in normal operation.\n if (status.branch !== branch) return this.failOpen(ctx, branch, 'stale-cross-branch-cache', cache);\n\n // State 2: this feature branch was already merged into main.\n if (status.branchAlreadyMerged) {\n const pr = status.mergedPr !== '' ? status.mergedPr : '?';\n const merged = this.alreadyMergedMessage(ctx.workspaceRoot, branch, status.mergedPr);\n return this.block(ctx, branch, `already-merged PR#${pr}`, merged, cache);\n }\n // State 3: no fork point — main was merged into the branch.\n if (!status.hasForkPoint) {\n return this.block(ctx, branch, 'no-fork-point', this.noForkPointMessage(branch), cache);\n }\n // State 4: origin/main moved and touches files you changed.\n if (status.conflict) {\n return this.block(ctx, branch, 'main-moved-conflict', this.conflictMessage(status.conflictFiles, status.openPr), cache);\n }\n // NOT-MERGED, or NOT-ASKED? `branchAlreadyMerged: false` is produced both by \"this branch has\n // no merged PR\" and by \"the forge could not be reached\" (`gh` missing, unauthenticated,\n // rate-limited, offline). Same allow either way — never block on data you could not establish\n // — but the LOG must not call the second one an approval, or the trail cannot tell a policy\n // that is protecting something from one that is quietly standing down.\n // Reached only when the branch is NOT merged, HAS a fork point and does NOT conflict. The\n // last two are pure git; only the first depends on the forge, which is why an unreachable\n // forge downgrades this to an abstention rather than leaving it a clean approval.\n if (!status.forgeReachable) return this.failOpen(ctx, branch, 'no-forge', cache);\n return this.allow(ctx, branch, 'clean-feature-branch', cache);\n }\n\n // One-line summary of the async-written cache that drove this decision, for the SYNC log — so a\n // wrong allow/block is traceable to the exact (possibly stale) main-sync-status.json read.\n private cacheSummary(status: MainSyncStatus): string {\n const merged = status.branchAlreadyMerged ? `PR#${status.mergedPr !== '' ? status.mergedPr : '?'}` : 'no';\n return `cache=${status.branch} merged=${merged} fork=${String(status.hasForkPoint)} conflict=${String(status.conflict)} ts=${status.timestamp}`;\n }\n\n // Log + return for the allow path. Centralizes the decision-log call so every exit of check()\n // is recorded with its reason + the async cache it read (this is the audit trail for \"why didn't\n // the guard fire?\"). `cache` is the summary of the main-sync-status.json that drove the decision.\n /**\n * The guard could not ESTABLISH the state it judges on, so it judged nothing.\n *\n * A sibling of allow() rather than a reason string passed to it, because the difference has to\n * reach the LOG as a value: `ALLOW_FAIL_OPEN` vs `ALLOW`. It was previously a `' (fail-open)'`\n * suffix on the free-text reason, which meant an abstention and a real approval were the same\n * verdict and the abstentions could not be counted — so nobody could tell whether these guards\n * were protecting anything or quietly standing down. Never block on data you could not\n * establish; but say out loud, in a field, that you did not establish it.\n */\n private failOpen(ctx: FileContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW_FAIL_OPEN', reason, cache);\n return [];\n }\n\n private allow(ctx: 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_AI_CURE', reason, cache);\n // Deliver the matrix and name the row — see stale-main-bash-guard.block for why it is lazy.\n const pointer = branchStateMatrixPointer(writeBranchStateMatrixDoc(ctx.workspaceRoot), matrixL2Row(reason).row);\n return [new V(1, ctx.relativePath, message + pointer)];\n }\n\n private logDecision(ctx: FileContext, branch: string | null, verdict: Verdict, reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('feature-branch-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason, cache, L0_FAULT_NONE, matrixL2Row(reason)),\n );\n }\n\n private currentBranch(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot,\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 /**\n * The Write/Edit half of row 5. Same three points as stale-main-bash-guard's deny, told for THIS\n * surface: reading main while you plan is legitimate and is not what got blocked; the feature\n * branch is the unit of work; and a `main` that is behind makes the reads wrong too, so getting\n * current is not an extra step, it is what makes the plan you are about to write against correct.\n *\n * Per-surface, deliberately: this guard sees only Write/Edit, so it says the WRITE was blocked.\n * (read-stale-guard is the one that can close Read, and only once `main` falls behind.)\n *\n * ONE CURE, and it is the dirty-safe one. This half used to print `git pull origin main` and then\n * \"create a feature branch\" — but this guard fires on a WRITE, so uncommitted work is the LIKELY\n * state, and a pull is exactly the form that is not a clean fast-forward there. Its two siblings\n * (stale-main-bash-guard's rows-6/7 deny, StaleMainMessage.forReads) both prescribe\n * `git fetch origin main && git checkout -b <new> origin/main`, which fetches AND carries the\n * uncommitted work onto the new branch. Three halves of one row printing one cure is the point:\n * a fragile cure is a defect here even when it often happens to work.\n */\n private onMainMessage(): string {\n const convention = this.config.branchNamingConvention ?? '{whoami}/{featurename}';\n return [\n 'Blocked: this is a WRITE on main. Reading main to PLAN is fine — writing here is not, because the feature branch is the unit of work: reviewable, revertable, and not one `git checkout` from being lost.',\n 'And if local main is BEHIND origin/main, what you read here was out of date too — so the fetch below is not an extra step, it is what makes your reads true.',\n 'Branch off origin/main (it fetches first, and uncommitted work comes with you):',\n ' git fetch origin main && git checkout -b <new-branch> origin/main',\n `Name <new-branch> by the convention (from webpieces.config.json): ${convention}`,\n 'Example: git fetch origin main && git checkout -b ' + convention.replace(/<[^>]+>/g, 'value') + ' origin/main',\n ].join('\\n');\n }\n\n // Shared with read-stale-guard, which blocks READS in this same state — see MergedBranchMessage.\n // The tree kind picks the flavour of the cure: a dead LINKED WORKTREE is told to open a new\n // worktree off origin/main and reap this one; the primary clone is told to branch off origin/main.\n private alreadyMergedMessage(workspaceRoot: string, branch: string, mergedPr: string): string {\n return new MergedBranchMessage(workspaceRoot).forEdits(\n branch, mergedPr, new TreeRecovery().kindOf(workspaceRoot), workspaceRoot,\n );\n }\n\n private conflictMessage(conflictFiles: readonly string[], openPr: string): string {\n const files = conflictFiles.length > 0\n ? conflictFiles.map((f: string): string => ` - ${f}`).join('\\n')\n : ' (see git diff)';\n const header = [\n 'origin/main moved and touched files you also changed since your fork point:',\n files,\n '',\n ];\n // Steer EARLY: if a PR already tracks this branch, the update-only flow would just fail-fast\n // (a 3-point update strands the PR on the old branch generation), so recommend ONLY the PR\n // flow and don't waste the AI's tokens on wp-start-update.\n const guidance = new SyncFlowGuidance();\n // An OPEN PR removes the choice, so print ONLY the PR flow here — showing the update-only flow\n // as if it were an option just burns tokens on a command that fail-fasts.\n if (openPr !== '') {\n return header\n .concat([\n `An OPEN PR (#${openPr}) already tracks this branch, so the PR flow is the ONLY option`,\n 'here — it re-merges main AND re-points the PR in the same run:',\n '',\n ])\n .concat(guidance.prFlow())\n .concat(['', ...guidance.whyPrForcesFlowB()])\n .join('\\n');\n }\n return header\n .concat([\n 'You must merge main in before editing further. No PR is open for this branch, so flow A',\n 'below is the one to use — but use flow B the moment a PR exists:',\n '',\n ])\n .concat(guidance.flows())\n .join('\\n');\n }\n\n private noForkPointMessage(branch: string): string {\n return [\n 'No fork point with origin/main — main appears to have been merged into this branch,',\n 'so a clean squash-merge is impossible. A human must redo the work on a fresh branch:',\n '',\n ...squashRecoverySteps(branch),\n ].join('\\n');\n }\n}\n"]}
1
+ {"version":3,"file":"feature-branch-guard.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/feature-branch-guard.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport { BranchStateGuardConfig, BRANCH_STATE_GUARD_KEY, DEFAULT_HANG_TIMEOUT_MINUTES, readMainSyncStatus, squashRecoverySteps, MainSyncStatus, SyncFlowGuidance, Option } from '@webpieces/rules-config';\n\nimport type { FileContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { FileRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { toError } from '../to-error';\nimport { triggerMainSyncRefresh } from '../main-sync-refresh';\nimport { hangTimeoutOf } from '../main-sync-timeout';\nimport { logGuardDecision, GuardDecision, Verdict, matrixL2Row } from '../decision-log';\nimport { writeBranchStateMatrixDoc, branchStateMatrixPointer } from '../l2-matrix-doc';\nimport { L0_FAULT_NONE } from '../l0-fault-codes';\nimport { TargetTreeResolver } from '../target-tree';\nimport { MergedBranchMessage } from './merged-branch-message';\nimport { JudgedTree } from './judged-tree';\nimport { TreeRecovery } from './tree-recovery';\n\n/**\n * Comprehensive \"are you on a proper feature branch?\" guard — the single rule that blocks edits when\n * the branch isn't a healthy place to work. Four states, in priority order:\n * 1. On main (checked SYNCHRONOUSLY here) → block: create a feature branch.\n * 2. Branch already merged into main (merged PR) → block: your work is in main, branch off fresh.\n * 3. No fork point with origin/main → block: squash onto a new branch.\n * 4. origin/main moved & touches your files → block: merge main first.\n * States 2–4 are PRECOMPUTED into `.webpieces/main-sync-status.json` by the detached refresher, so\n * this check does NO network git (only a fast local `git rev-parse` for state 1). On every call it\n * fire-and-forget spawns the refresher so the NEXT call is fresh. Runs in the GUARDS hook (it's a\n * hookGuard); file-scoped, so only Write/Edit/MultiEdit are guarded — Bash passes through so the AI\n * can still run `pnpm wp-start-upsert-pr` and the rest of the recovery flow.\n *\n * ── STATE 1 IS UNCONDITIONAL, AND `B` NO LONGER TRACKS `E` HERE ──────────────────────────────────\n *\n * Earlier docblocks in this family argued that the Bash half of \"on `main`\" should match this one\n * exactly — one `git rev-parse`, no cache, so it fires on the first call of a session. That argument\n * has been split, deliberately, and the two halves now differ:\n *\n * `E` (here) blocks on ANY `main`, current or stale.\n * `B` (stale-main-bash-guard) blocks only once local `main` is KNOWN BEHIND `origin/main`.\n *\n * The hazards are not the same hazard. A WRITE on `main` puts work where it cannot be reviewed, cannot\n * be reverted as a unit, and is one `git checkout` from being lost — none of which depends on how\n * current `main` is, so nothing about freshness could make this block right or wrong. A READ or a\n * BUILD on a CURRENT `main` harms nothing at all, and denying it strands the agent immediately after\n * `pnpm wp-sync-main` — the very command this repo prescribes — put it there.\n *\n * So do NOT \"restore the symmetry\" by gating this on the cache. That would make writes on `main`\n * permitted for the whole first call of every session (the cache is populated for the NEXT call), and\n * permanently in a multi-worktree repo where another tree can hold the refresh lock. That ordering is\n * the most load-bearing thing in the L2 table, which is why row 5 sits ABOVE the cache divider.\n *\n * ── WHICH TREE IS JUDGED: THE ONE THAT OWNS THE FILE, NEVER THE ONE THE SESSION SITS IN ─────────────\n *\n * Every state above is a property of A BRANCH IN A CHECKOUT, so the first question this guard answers is\n * WHICH checkout — and the answer comes from the TARGET PATH, through TargetTreeResolver, which is\n * EffectiveTreeResolver asked about a file instead of a cwd. It is emphatically NOT `ctx.workspaceRoot`:\n * that is the walk-up from the session's cwd to the governing `webpieces.config.json`, and for a\n * main-session edit into an agent worktree it names the PRIMARY clone.\n *\n * Issue #851 is the incident. Claude Code checks a worktree out INSIDE the repo at\n * `<repo>/.claude/worktrees/agent-XXXX`, so a containment test answers \"primary\" for a path that is\n * plainly the worktree's. This guard then read the branch-keyed main-sync cache under the PRIMARY's\n * branch and enforced that verdict on a file belonging to a clean branch in another tree. The cache was\n * not stale and was not wrong — the correct entry sat in the same JSON file, one key over. Only the\n * lookup key was. Four tool calls were refused, including the `review.json` that `wp-review-upsert-pr`\n * requires before `wp-finish-upsert-pr` will open a PR, which is the shape where this wedges the\n * sanctioned flow rather than merely annoying somebody.\n *\n * Two consequences worth stating out loud, because both were argued the other way at some point:\n *\n * - The DENY NAMES THE TREE (JudgedTree.header) and, when the judged tree is not the session's, the\n * `pnpm --dir=<tree>` form of the cure. A resolution nobody can see is a resolution nobody can\n * contradict, and the printed cure was measurably a no-op for the tree it was aimed at.\n * - DETACHED HEAD in the target tree fails OPEN, logged. There is no branch name, so there is no map\n * key and nothing to judge — matrix row 14. It used to reach here as the literal branch `HEAD`, miss\n * in the cache and fail open as `no-sync-cache`, which is the right verdict recorded under a reason\n * that says something else happened.\n */\nexport class FeatureBranchGuardRule extends FileRuleBase<BranchStateGuardConfig> {\n // NAME is this class's operator identity (every `rule=` in the log, every deny header);\n // CONFIG KEY is the one branch-state policy entry all four of these guards read. See AbstractRule.\n constructor(config: BranchStateGuardConfig) { super(config, 'feature-branch-guard', BRANCH_STATE_GUARD_KEY); }\n\n readonly description = 'Block edits unless you are on a proper feature branch (not main, not already-merged, forked, in sync with main).';\n override readonly files = ['**/*'];\n override readonly defaultOptions = {\n branchNamingConvention: '{whoami}/{featurename}',\n hangTimeoutMinutes: DEFAULT_HANG_TIMEOUT_MINUTES,\n };\n readonly fixHint = new FixHint(\n 'You are not on a clean, up-to-date feature branch.',\n 'You must be on a clean, up-to-date feature branch to edit code. Pick one:',\n [\n new Option('On main → create a feature branch. Already merged → branch off fresh main.', true),\n new Option('main moved/conflicts, NO PR yet → `pnpm wp-start-update` (merge), `/wp-merge` (resolve), `pnpm wp-finish-update`. An OPEN PR? then you MUST use `pnpm wp-start-upsert-pr` → `/wp-merge` → `pnpm wp-finish-upsert-pr` (the merge rewrites the branch, so the PR must be re-pointed in the same run). Never mix a start from one pair with a finish from the other.'),\n new Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),\n ],\n );\n\n check(ctx: FileContext): readonly Violation[] {\n // Only files inside the workspace root — guard has no jurisdiction, nothing worth logging.\n if (ctx.relativePath.startsWith('..')) return [];\n\n // WHICH CHECKOUT owns this file? Git's answer, via the resolver the bash half already uses —\n // never a path-containment test, which is exactly what answered \"primary\" for a worktree\n // checked out INSIDE the repo (issue #851, and this class's header).\n const target = new TargetTreeResolver().resolve(ctx.filePath, ctx.workspaceRoot);\n // A NESTED CLONE under `repositories/**` is somebody else's repo. The bash path calls this\n // ALLOW_EXEMPT; here it is an abstention, because the state was not established, not waived.\n if (target.kind === 'foreign') return this.failOpen(ctx, null, 'target-tree-foreign');\n\n const judgedRoot = target.root;\n const branch = this.currentBranch(judgedRoot);\n // Can't determine branch (e.g. not a git repo) → don't block. Fail-open.\n if (branch === null) return this.failOpen(ctx, branch, 'branch-undeterminable', this.treeSummary(judgedRoot));\n // Mid-rebase / mid-bisect: `--abbrev-ref HEAD` prints the literal `HEAD`, there is no branch\n // name, and therefore no key into the branch-keyed cache. Matrix row 14 — abstain, out loud.\n if (branch === 'HEAD') return this.failOpen(ctx, branch, 'detached-head', this.treeSummary(judgedRoot));\n\n const judged = new JudgedTree(target, branch, target.governedRoot);\n\n // State 1: on main — synchronous, no cache needed.\n if (branch === 'main') {\n return this.block(ctx, branch, 'on-main', this.onMainMessage(judged), this.treeSummary(judgedRoot));\n }\n\n // Keep the cache warm for the next call. Detached; never blocks this edit. Rooted at the JUDGED\n // tree, so the refresher recomputes the entry this guard is about to read rather than a sibling's.\n triggerMainSyncRefresh(judgedRoot, hangTimeoutOf(this.config));\n\n const status = readMainSyncStatus(judgedRoot, branch);\n // No cache yet (first edit of the session) → allow; the refresh we just spawned populates it\n // for the next call. Fail-open: never block on missing data.\n if (status === null) return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');\n\n const cache = `${this.treeSummary(judgedRoot)} ${this.cacheSummary(status)}`;\n // BELT-AND-BRACES since the cache became branch-keyed: we looked this entry up BY `branch`, so\n // a mismatch now means the map's key and the entry's own `branch` field disagree — a shape bug,\n // not a normal state. Kept (rather than deleted) so such a bug degrades to an allow instead of\n // blocking on another branch's signals. Unreachable in normal operation.\n if (status.branch !== branch) return this.failOpen(ctx, branch, 'stale-cross-branch-cache', cache);\n\n // State 2: this feature branch was already merged into main.\n if (status.branchAlreadyMerged) {\n const pr = status.mergedPr !== '' ? status.mergedPr : '?';\n const merged = this.alreadyMergedMessage(judged, status.mergedPr);\n return this.block(ctx, branch, `already-merged PR#${pr}`, merged, cache);\n }\n // State 3: no fork point — main was merged into the branch.\n if (!status.hasForkPoint) {\n return this.block(ctx, branch, 'no-fork-point', this.noForkPointMessage(judged), cache);\n }\n // State 4: origin/main moved and touches files you changed.\n if (status.conflict) {\n return this.block(ctx, branch, 'main-moved-conflict', this.conflictMessage(judged, status.conflictFiles, status.openPr), cache);\n }\n // NOT-MERGED, or NOT-ASKED? `branchAlreadyMerged: false` is produced both by \"this branch has\n // no merged PR\" and by \"the forge could not be reached\" (`gh` missing, unauthenticated,\n // rate-limited, offline). Same allow either way — never block on data you could not establish\n // — but the LOG must not call the second one an approval, or the trail cannot tell a policy\n // that is protecting something from one that is quietly standing down.\n // Reached only when the branch is NOT merged, HAS a fork point and does NOT conflict. The\n // last two are pure git; only the first depends on the forge, which is why an unreachable\n // forge downgrades this to an abstention rather than leaving it a clean approval.\n if (!status.forgeReachable) return this.failOpen(ctx, branch, 'no-forge', cache);\n return this.allow(ctx, branch, 'clean-feature-branch', cache);\n }\n\n /**\n * WHICH TREE this decision was judged against, for the log.\n *\n * The trail already carried a `tree=` field, but it is stamped from the root the decision is LOGGED\n * to (the session's), so during issue #851 every misfired block recorded `tree=primary` for a path\n * under `.claude/worktrees/` and read as perfectly ordinary. This one is the root the verdict was\n * actually computed from, so the two disagreeing is the defect, visible in one grep.\n */\n private treeSummary(judgedRoot: string): string {\n return `judged=${judgedRoot}`;\n }\n\n // One-line summary of the async-written cache that drove this decision, for the SYNC log — so a\n // wrong allow/block is traceable to the exact (possibly stale) main-sync-status.json read.\n private cacheSummary(status: MainSyncStatus): string {\n const merged = status.branchAlreadyMerged ? `PR#${status.mergedPr !== '' ? status.mergedPr : '?'}` : 'no';\n return `cache=${status.branch} merged=${merged} fork=${String(status.hasForkPoint)} conflict=${String(status.conflict)} ts=${status.timestamp}`;\n }\n\n // Log + return for the allow path. Centralizes the decision-log call so every exit of check()\n // is recorded with its reason + the async cache it read (this is the audit trail for \"why didn't\n // the guard fire?\"). `cache` is the summary of the main-sync-status.json that drove the decision.\n /**\n * The guard could not ESTABLISH the state it judges on, so it judged nothing.\n *\n * A sibling of allow() rather than a reason string passed to it, because the difference has to\n * reach the LOG as a value: `ALLOW_FAIL_OPEN` vs `ALLOW`. It was previously a `' (fail-open)'`\n * suffix on the free-text reason, which meant an abstention and a real approval were the same\n * verdict and the abstentions could not be counted — so nobody could tell whether these guards\n * were protecting anything or quietly standing down. Never block on data you could not\n * establish; but say out loud, in a field, that you did not establish it.\n */\n private failOpen(ctx: FileContext, branch: string | null, reason: string, cache: string = '-'): readonly Violation[] {\n this.logDecision(ctx, branch, 'ALLOW_FAIL_OPEN', reason, cache);\n return [];\n }\n\n private allow(ctx: 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_AI_CURE', reason, cache);\n // Deliver the matrix and name the row — see stale-main-bash-guard.block for why it is lazy.\n const pointer = branchStateMatrixPointer(writeBranchStateMatrixDoc(ctx.workspaceRoot), matrixL2Row(reason).row);\n return [new V(1, ctx.relativePath, message + pointer)];\n }\n\n private logDecision(ctx: FileContext, branch: string | null, verdict: Verdict, reason: string, cache: string): void {\n logGuardDecision(\n ctx.workspaceRoot,\n new GuardDecision('feature-branch-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason, cache, L0_FAULT_NONE, matrixL2Row(reason)),\n );\n }\n\n private currentBranch(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot,\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 /**\n * The Write/Edit half of row 5. Same three points as stale-main-bash-guard's deny, told for THIS\n * surface: reading main while you plan is legitimate and is not what got blocked; the feature\n * branch is the unit of work; and a `main` that is behind makes the reads wrong too, so getting\n * current is not an extra step, it is what makes the plan you are about to write against correct.\n *\n * Per-surface, deliberately: this guard sees only Write/Edit, so it says the WRITE was blocked.\n * (read-stale-guard is the one that can close Read, and only once `main` falls behind.)\n *\n * ONE CURE, and it is the dirty-safe one. This half used to print `git pull origin main` and then\n * \"create a feature branch\" — but this guard fires on a WRITE, so uncommitted work is the LIKELY\n * state, and a pull is exactly the form that is not a clean fast-forward there. Its two siblings\n * (stale-main-bash-guard's rows-6/7 deny, StaleMainMessage.forReads) both prescribe\n * `git fetch origin main && git checkout -b <new> origin/main`, which fetches AND carries the\n * uncommitted work onto the new branch. Three halves of one row printing one cure is the point:\n * a fragile cure is a defect here even when it often happens to work.\n */\n private onMainMessage(judged: JudgedTree): string {\n const convention = this.config.branchNamingConvention ?? '{whoami}/{featurename}';\n return [\n judged.header(),\n 'Blocked: this is a WRITE on main. Reading main to PLAN is fine — writing here is not, because the feature branch is the unit of work: reviewable, revertable, and not one `git checkout` from being lost.',\n 'And if local main is BEHIND origin/main, what you read here was out of date too — so the fetch below is not an extra step, it is what makes your reads true.',\n 'Branch off origin/main (it fetches first, and uncommitted work comes with you):',\n ' git fetch origin main && git checkout -b <new-branch> origin/main',\n `Name <new-branch> by the convention (from webpieces.config.json): ${convention}`,\n 'Example: git fetch origin main && git checkout -b ' + convention.replace(/<[^>]+>/g, 'value') + ' origin/main',\n judged.redirectNote([judged.cdCure('git fetch origin main && git checkout -b <new-branch> origin/main')]),\n ].join('\\n');\n }\n\n // Shared with read-stale-guard, which blocks READS in this same state — see MergedBranchMessage.\n // The tree kind picks the flavour of the cure: a dead LINKED WORKTREE is told to open a new\n // worktree off origin/main and reap this one; the primary clone is told to branch off origin/main.\n // Every one of those questions is asked of the JUDGED tree, not the session's: the branch that is\n // merged, the checkout to recover, and the directory the cure has to run in are all properties of\n // the tree that owns the file (issue #851).\n private alreadyMergedMessage(judged: JudgedTree, mergedPr: string): string {\n const body = new MergedBranchMessage(judged.root).forEdits(\n judged.branch, mergedPr, new TreeRecovery().kindOf(judged.root), judged.root,\n );\n // NO redirect note here, deliberately. MergedBranchMessage was built with the JUDGED root and\n // already aims every command it prints through `atRoot` — and it picks the WORKTREE flavour of\n // the cure (open a NEW worktree off origin/main and reap this dead one) when that is what the\n // tree is. Appending a generic `checkout -b` would contradict the paragraph directly above it,\n // in exactly the redirected-and-a-worktree case where the worktree flavour is the right advice.\n return [judged.header(), body].join('\\n');\n }\n\n private conflictMessage(judged: JudgedTree, conflictFiles: readonly string[], openPr: string): string {\n const files = conflictFiles.length > 0\n ? conflictFiles.map((f: string): string => ` - ${f}`).join('\\n')\n : ' (see git diff)';\n // NAME THE BRANCH AND THE TREE. Without them this read \"files you also changed\", listing files\n // the edit had never touched on a branch it was not on, and there was nothing in the text to\n // contradict — see JudgedTree.\n const header = [\n judged.header(),\n `origin/main moved and touched files that \\`${judged.branch}\\` also changed since its fork point:`,\n files,\n '',\n ];\n const redirect = judged.redirectNote([\n judged.pnpmCure('wp-start-update'),\n `${judged.pnpmCure('wp-start-upsert-pr')} (instead, when a PR is open)`,\n ]);\n // Steer EARLY: if a PR already tracks this branch, the update-only flow would just fail-fast\n // (a 3-point update strands the PR on the old branch generation), so recommend ONLY the PR\n // flow and don't waste the AI's tokens on wp-start-update.\n const guidance = new SyncFlowGuidance();\n // An OPEN PR removes the choice, so print ONLY the PR flow here — showing the update-only flow\n // as if it were an option just burns tokens on a command that fail-fasts.\n if (openPr !== '') {\n return header\n .concat([\n `An OPEN PR (#${openPr}) already tracks this branch, so the PR flow is the ONLY option`,\n 'here — it re-merges main AND re-points the PR in the same run:',\n '',\n ])\n .concat(guidance.prFlow())\n .concat(['', ...guidance.whyPrForcesFlowB()])\n .concat([redirect])\n .join('\\n');\n }\n return header\n .concat([\n 'You must merge main in before editing further. No PR is open for this branch, so flow A',\n 'below is the one to use — but use flow B the moment a PR exists:',\n '',\n ])\n .concat(guidance.flows())\n .concat([redirect])\n .join('\\n');\n }\n\n private noForkPointMessage(judged: JudgedTree): string {\n return [\n judged.header(),\n 'No fork point with origin/main — main appears to have been merged into this branch,',\n 'so a clean squash-merge is impossible. A human must redo the work on a fresh branch:',\n '',\n ...squashRecoverySteps(judged.branch),\n judged.redirectNote([judged.pnpmCure('wp-start-update')]),\n ].join('\\n');\n }\n}\n"]}
@@ -0,0 +1,66 @@
1
+ import { EffectiveTree } from '../effective-tree';
2
+ /**
3
+ * The tree a file-scoped branch-state guard actually judged, plus the two things a deny must say about
4
+ * it: WHICH branch in WHICH tree, and — when that is not the tree the agent is standing in — how to aim
5
+ * the cure at it.
6
+ *
7
+ * BOTH halves come from one live incident (issue #851), and the second is why the first is not enough.
8
+ *
9
+ * 1. NAME THE TREE. The deny read *"origin/main moved and touched files you also changed since your
10
+ * fork point"* and then listed `pnpm-lock.yaml` and `pnpm-workspace.yaml` — files the edit had never
11
+ * touched, on a branch it was not on. With no branch and no tree in the text there was nothing to
12
+ * contradict, so the misfire looked like an ordinary block for four tool calls. A wrong resolution
13
+ * must be VISIBLE; that is the cheapest defence there is against the next one.
14
+ *
15
+ * 2. AIM THE CURE. `pnpm wp-start-update` acts on the tree it RUNS IN. When the judged tree is not the
16
+ * cwd, the cure as printed is a guaranteed no-op — measured: it ran, reported "Updated from main —
17
+ * clean", and the identical edit was blocked 20 seconds later against a cache recomputed 8 seconds
18
+ * AFTER the cure. The command was correct and the directory was not, and nothing on screen said so.
19
+ * `pnpm --dir=<tree>` is the same command with the missing half supplied.
20
+ *
21
+ * The redirect note is printed ONLY when the two trees actually differ. In the primary clone — the
22
+ * overwhelmingly common case — the header alone is all a reader gets, because a `--dir` that names the
23
+ * directory you are already in is noise that trains people to skip the paragraph.
24
+ */
25
+ export declare class JudgedTree {
26
+ /** The tree root whose HEAD and main-sync entry decided this verdict. */
27
+ readonly root: string;
28
+ /** That tree's branch — the key the branch-keyed main-sync cache was read under. */
29
+ readonly branch: string;
30
+ /** The tree the agent's own session is rooted in — where an unqualified `pnpm …` would run. */
31
+ readonly sessionRoot: string;
32
+ constructor(tree: EffectiveTree, branch: string, sessionRoot: string);
33
+ /** True when the file being judged belongs to a DIFFERENT checkout than the session's own. */
34
+ get redirected(): boolean;
35
+ /** The one line every deny from these guards opens with. */
36
+ header(): string;
37
+ /**
38
+ * One `pnpm` cure, AIMED. `bin` is the bare bin name (`wp-start-update`) — the `pnpm` is supplied
39
+ * here, so a caller passing the whole command would produce `pnpm --dir=… pnpm wp-start-update`.
40
+ *
41
+ * `--dir` only when it is needed: in the primary clone this renders the ordinary `pnpm wp-…` every
42
+ * other message in this repo prints, because a `--dir` naming the directory you are already standing
43
+ * in is noise that teaches readers to skip the line.
44
+ */
45
+ pnpmCure(bin: string): string;
46
+ /**
47
+ * One NON-pnpm cure, aimed — `cd '<root>' && <command>`, through the shared `atRoot`.
48
+ *
49
+ * NEVER `git -C '<root>' …`. That reads as the cure for "you are in the wrong tree" and is exactly
50
+ * the prescription `l1-matrix.spec.ts` exists to keep out of message-bearing modules: a subagent's
51
+ * `git -C <another tree>` is REFUSED, so an aimed cure written that way is a command the reader
52
+ * cannot run, arriving in the one place they have no reason to doubt it. `atRoot` is the ONE
53
+ * spelling every guard, message builder and pr-gate notice already emits, single quotes included,
54
+ * so a repo path with a space stays runnable.
55
+ */
56
+ cdCure(command: string): string;
57
+ /**
58
+ * The paragraph that turns a printed cure into a runnable one. EMPTY unless the two trees actually
59
+ * differ, for the same reason `pnpmCure` drops `--dir` there.
60
+ *
61
+ * `cures` are FULL command lines, already aimed — `pnpmCure()` for the `wp-*` bins, an explicit
62
+ * `git -C <root> …` for the git ones. Rendering them here from bare names would have to guess which
63
+ * tool takes which "run it over there" flag, and they do not agree.
64
+ */
65
+ redirectNote(cures: readonly string[]): string;
66
+ }
@@ -0,0 +1,97 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.JudgedTree = void 0;
4
+ const rules_config_1 = require("@webpieces/rules-config");
5
+ /**
6
+ * The tree a file-scoped branch-state guard actually judged, plus the two things a deny must say about
7
+ * it: WHICH branch in WHICH tree, and — when that is not the tree the agent is standing in — how to aim
8
+ * the cure at it.
9
+ *
10
+ * BOTH halves come from one live incident (issue #851), and the second is why the first is not enough.
11
+ *
12
+ * 1. NAME THE TREE. The deny read *"origin/main moved and touched files you also changed since your
13
+ * fork point"* and then listed `pnpm-lock.yaml` and `pnpm-workspace.yaml` — files the edit had never
14
+ * touched, on a branch it was not on. With no branch and no tree in the text there was nothing to
15
+ * contradict, so the misfire looked like an ordinary block for four tool calls. A wrong resolution
16
+ * must be VISIBLE; that is the cheapest defence there is against the next one.
17
+ *
18
+ * 2. AIM THE CURE. `pnpm wp-start-update` acts on the tree it RUNS IN. When the judged tree is not the
19
+ * cwd, the cure as printed is a guaranteed no-op — measured: it ran, reported "Updated from main —
20
+ * clean", and the identical edit was blocked 20 seconds later against a cache recomputed 8 seconds
21
+ * AFTER the cure. The command was correct and the directory was not, and nothing on screen said so.
22
+ * `pnpm --dir=<tree>` is the same command with the missing half supplied.
23
+ *
24
+ * The redirect note is printed ONLY when the two trees actually differ. In the primary clone — the
25
+ * overwhelmingly common case — the header alone is all a reader gets, because a `--dir` that names the
26
+ * directory you are already in is noise that trains people to skip the paragraph.
27
+ */
28
+ class JudgedTree {
29
+ /** The tree root whose HEAD and main-sync entry decided this verdict. */
30
+ root;
31
+ /** That tree's branch — the key the branch-keyed main-sync cache was read under. */
32
+ branch;
33
+ /** The tree the agent's own session is rooted in — where an unqualified `pnpm …` would run. */
34
+ sessionRoot;
35
+ constructor(tree, branch, sessionRoot) {
36
+ this.root = tree.root;
37
+ this.branch = branch;
38
+ this.sessionRoot = sessionRoot;
39
+ }
40
+ /** True when the file being judged belongs to a DIFFERENT checkout than the session's own. */
41
+ get redirected() {
42
+ return this.root !== this.sessionRoot;
43
+ }
44
+ /** The one line every deny from these guards opens with. */
45
+ header() {
46
+ return `Judged: branch \`${this.branch}\` in ${this.root}`;
47
+ }
48
+ /**
49
+ * One `pnpm` cure, AIMED. `bin` is the bare bin name (`wp-start-update`) — the `pnpm` is supplied
50
+ * here, so a caller passing the whole command would produce `pnpm --dir=… pnpm wp-start-update`.
51
+ *
52
+ * `--dir` only when it is needed: in the primary clone this renders the ordinary `pnpm wp-…` every
53
+ * other message in this repo prints, because a `--dir` naming the directory you are already standing
54
+ * in is noise that teaches readers to skip the line.
55
+ */
56
+ pnpmCure(bin) {
57
+ return this.redirected ? `pnpm --dir='${this.root}' ${bin}` : `pnpm ${bin}`;
58
+ }
59
+ /**
60
+ * One NON-pnpm cure, aimed — `cd '<root>' && <command>`, through the shared `atRoot`.
61
+ *
62
+ * NEVER `git -C '<root>' …`. That reads as the cure for "you are in the wrong tree" and is exactly
63
+ * the prescription `l1-matrix.spec.ts` exists to keep out of message-bearing modules: a subagent's
64
+ * `git -C <another tree>` is REFUSED, so an aimed cure written that way is a command the reader
65
+ * cannot run, arriving in the one place they have no reason to doubt it. `atRoot` is the ONE
66
+ * spelling every guard, message builder and pr-gate notice already emits, single quotes included,
67
+ * so a repo path with a space stays runnable.
68
+ */
69
+ cdCure(command) {
70
+ return this.redirected ? (0, rules_config_1.atRoot)(this.root, command) : command;
71
+ }
72
+ /**
73
+ * The paragraph that turns a printed cure into a runnable one. EMPTY unless the two trees actually
74
+ * differ, for the same reason `pnpmCure` drops `--dir` there.
75
+ *
76
+ * `cures` are FULL command lines, already aimed — `pnpmCure()` for the `wp-*` bins, an explicit
77
+ * `git -C <root> …` for the git ones. Rendering them here from bare names would have to guess which
78
+ * tool takes which "run it over there" flag, and they do not agree.
79
+ */
80
+ redirectNote(cures) {
81
+ if (!this.redirected)
82
+ return '';
83
+ return [
84
+ '',
85
+ 'THIS FILE IS NOT IN THE TREE YOU ARE STANDING IN, and the verdict above is about the tree',
86
+ 'that OWNS it:',
87
+ ` judged tree ${this.root} (branch ${this.branch})`,
88
+ ` your session ${this.sessionRoot}`,
89
+ 'Every command acts on the tree it RUNS IN, so an unqualified cure would repair the wrong',
90
+ 'checkout and change nothing here — it reports success and the next edit is blocked',
91
+ 'identically. Aim it:',
92
+ ...cures.map((cure) => ` ${cure}`),
93
+ ].join('\n');
94
+ }
95
+ }
96
+ exports.JudgedTree = JudgedTree;
97
+ //# sourceMappingURL=judged-tree.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"judged-tree.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/judged-tree.ts"],"names":[],"mappings":";;;AAAA,0DAAiD;AAIjD;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,MAAa,UAAU;IACnB,yEAAyE;IAChE,IAAI,CAAS;IACtB,oFAAoF;IAC3E,MAAM,CAAS;IACxB,+FAA+F;IACtF,WAAW,CAAS;IAE7B,YAAY,IAAmB,EAAE,MAAc,EAAE,WAAmB;QAChE,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,IAAI,CAAC;QACtB,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,WAAW,GAAG,WAAW,CAAC;IACnC,CAAC;IAED,8FAA8F;IAC9F,IAAI,UAAU;QACV,OAAO,IAAI,CAAC,IAAI,KAAK,IAAI,CAAC,WAAW,CAAC;IAC1C,CAAC;IAED,4DAA4D;IAC5D,MAAM;QACF,OAAO,oBAAoB,IAAI,CAAC,MAAM,SAAS,IAAI,CAAC,IAAI,EAAE,CAAC;IAC/D,CAAC;IAED;;;;;;;OAOG;IACH,QAAQ,CAAC,GAAW;QAChB,OAAO,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,eAAe,IAAI,CAAC,IAAI,KAAK,GAAG,EAAE,CAAC,CAAC,CAAC,QAAQ,GAAG,EAAE,CAAC;IAChF,CAAC;IAED;;;;;;;;;OASG;IACH,MAAM,CAAC,OAAe;QAClB,OAAO,IAAI,CAAC,UAAU,CAAC,CAAC,CAAC,IAAA,qBAAM,EAAC,IAAI,CAAC,IAAI,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC;IAClE,CAAC;IAED;;;;;;;OAOG;IACH,YAAY,CAAC,KAAwB;QACjC,IAAI,CAAC,IAAI,CAAC,UAAU;YAAE,OAAO,EAAE,CAAC;QAChC,OAAO;YACH,EAAE;YACF,2FAA2F;YAC3F,eAAe;YACf,mBAAmB,IAAI,CAAC,IAAI,cAAc,IAAI,CAAC,MAAM,GAAG;YACxD,mBAAmB,IAAI,CAAC,WAAW,EAAE;YACrC,0FAA0F;YAC1F,oFAAoF;YACpF,sBAAsB;YACtB,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,IAAY,EAAU,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC;SACtD,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACjB,CAAC;CACJ;AAxED,gCAwEC","sourcesContent":["import { atRoot } from '@webpieces/rules-config';\n\nimport { EffectiveTree } from '../effective-tree';\n\n/**\n * The tree a file-scoped branch-state guard actually judged, plus the two things a deny must say about\n * it: WHICH branch in WHICH tree, and — when that is not the tree the agent is standing in — how to aim\n * the cure at it.\n *\n * BOTH halves come from one live incident (issue #851), and the second is why the first is not enough.\n *\n * 1. NAME THE TREE. The deny read *\"origin/main moved and touched files you also changed since your\n * fork point\"* and then listed `pnpm-lock.yaml` and `pnpm-workspace.yaml` — files the edit had never\n * touched, on a branch it was not on. With no branch and no tree in the text there was nothing to\n * contradict, so the misfire looked like an ordinary block for four tool calls. A wrong resolution\n * must be VISIBLE; that is the cheapest defence there is against the next one.\n *\n * 2. AIM THE CURE. `pnpm wp-start-update` acts on the tree it RUNS IN. When the judged tree is not the\n * cwd, the cure as printed is a guaranteed no-op — measured: it ran, reported \"Updated from main —\n * clean\", and the identical edit was blocked 20 seconds later against a cache recomputed 8 seconds\n * AFTER the cure. The command was correct and the directory was not, and nothing on screen said so.\n * `pnpm --dir=<tree>` is the same command with the missing half supplied.\n *\n * The redirect note is printed ONLY when the two trees actually differ. In the primary clone — the\n * overwhelmingly common case — the header alone is all a reader gets, because a `--dir` that names the\n * directory you are already in is noise that trains people to skip the paragraph.\n */\nexport class JudgedTree {\n /** The tree root whose HEAD and main-sync entry decided this verdict. */\n readonly root: string;\n /** That tree's branch — the key the branch-keyed main-sync cache was read under. */\n readonly branch: string;\n /** The tree the agent's own session is rooted in — where an unqualified `pnpm …` would run. */\n readonly sessionRoot: string;\n\n constructor(tree: EffectiveTree, branch: string, sessionRoot: string) {\n this.root = tree.root;\n this.branch = branch;\n this.sessionRoot = sessionRoot;\n }\n\n /** True when the file being judged belongs to a DIFFERENT checkout than the session's own. */\n get redirected(): boolean {\n return this.root !== this.sessionRoot;\n }\n\n /** The one line every deny from these guards opens with. */\n header(): string {\n return `Judged: branch \\`${this.branch}\\` in ${this.root}`;\n }\n\n /**\n * One `pnpm` cure, AIMED. `bin` is the bare bin name (`wp-start-update`) — the `pnpm` is supplied\n * here, so a caller passing the whole command would produce `pnpm --dir=… pnpm wp-start-update`.\n *\n * `--dir` only when it is needed: in the primary clone this renders the ordinary `pnpm wp-…` every\n * other message in this repo prints, because a `--dir` naming the directory you are already standing\n * in is noise that teaches readers to skip the line.\n */\n pnpmCure(bin: string): string {\n return this.redirected ? `pnpm --dir='${this.root}' ${bin}` : `pnpm ${bin}`;\n }\n\n /**\n * One NON-pnpm cure, aimed — `cd '<root>' && <command>`, through the shared `atRoot`.\n *\n * NEVER `git -C '<root>' …`. That reads as the cure for \"you are in the wrong tree\" and is exactly\n * the prescription `l1-matrix.spec.ts` exists to keep out of message-bearing modules: a subagent's\n * `git -C <another tree>` is REFUSED, so an aimed cure written that way is a command the reader\n * cannot run, arriving in the one place they have no reason to doubt it. `atRoot` is the ONE\n * spelling every guard, message builder and pr-gate notice already emits, single quotes included,\n * so a repo path with a space stays runnable.\n */\n cdCure(command: string): string {\n return this.redirected ? atRoot(this.root, command) : command;\n }\n\n /**\n * The paragraph that turns a printed cure into a runnable one. EMPTY unless the two trees actually\n * differ, for the same reason `pnpmCure` drops `--dir` there.\n *\n * `cures` are FULL command lines, already aimed — `pnpmCure()` for the `wp-*` bins, an explicit\n * `git -C <root> …` for the git ones. Rendering them here from bare names would have to guess which\n * tool takes which \"run it over there\" flag, and they do not agree.\n */\n redirectNote(cures: readonly string[]): string {\n if (!this.redirected) return '';\n return [\n '',\n 'THIS FILE IS NOT IN THE TREE YOU ARE STANDING IN, and the verdict above is about the tree',\n 'that OWNS it:',\n ` judged tree ${this.root} (branch ${this.branch})`,\n ` your session ${this.sessionRoot}`,\n 'Every command acts on the tree it RUNS IN, so an unqualified cure would repair the wrong',\n 'checkout and change nothing here — it reports success and the next edit is blocked',\n 'identically. Aim it:',\n ...cures.map((cure: string): string => ` ${cure}`),\n ].join('\\n');\n }\n}\n"]}
@@ -72,6 +72,14 @@ export declare class ReadStaleGuardRule extends FileRuleBase<BranchStateGuardCon
72
72
  hangTimeoutMinutes: number;
73
73
  };
74
74
  readonly fixHint: FixHint;
75
+ /**
76
+ * WHICH TREE is judged — the one that owns the FILE BEING READ, resolved by git through
77
+ * TargetTreeResolver, never by `ctx.workspaceRoot`. Same fix and same reasoning as
78
+ * feature-branch-guard's (issue #851): `ctx.workspaceRoot` is the walk-up from the SESSION's cwd, so
79
+ * a main-session Read of a file inside an agent worktree was judged on the PRIMARY clone's branch —
80
+ * and a `main` that the primary happened to be sitting on would close reads of another tree's
81
+ * feature branch.
82
+ */
75
83
  check(ctx: FileContext): readonly Violation[];
76
84
  private checkStaleMain;
77
85
  /**
@@ -15,7 +15,9 @@ const main_sync_timeout_1 = require("../main-sync-timeout");
15
15
  const decision_log_1 = require("../decision-log");
16
16
  const l2_matrix_doc_1 = require("../l2-matrix-doc");
17
17
  const l0_fault_codes_1 = require("../l0-fault-codes");
18
+ const target_tree_1 = require("../target-tree");
18
19
  const merged_branch_message_1 = require("./merged-branch-message");
20
+ const judged_tree_1 = require("./judged-tree");
19
21
  const stale_main_message_1 = require("./stale-main-message");
20
22
  const main_freshness_1 = require("./main-freshness");
21
23
  const tree_recovery_1 = require("./tree-recovery");
@@ -96,27 +98,47 @@ class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
96
98
  new rules_config_1.Option('Still allowed right now: reading webpieces.config.json, and the Bash commands that get you OUT or tell you where you are — git checkout -b <new> origin/main, git switch, git pull/fetch, git status|log|diff|show|branch, git stash, gh, curl/wget, every wp-* bin, installs. Everything ELSE through Bash is blocked in this same state (a main that is behind → stale-main-bash-guard; a merged branch → merged-branch-bash-guard), and Write/Edit on main is blocked by feature-branch-guard however current main is. There is no side door: get onto a branch off origin/main.'),
97
99
  new rules_config_1.Option('Disable in webpieces.config.json under hookGuards → branch-state-guard (mode OFF) if intentional — that one key governs the Write, Read and Bash halves of this policy together.'),
98
100
  ]);
101
+ /**
102
+ * WHICH TREE is judged — the one that owns the FILE BEING READ, resolved by git through
103
+ * TargetTreeResolver, never by `ctx.workspaceRoot`. Same fix and same reasoning as
104
+ * feature-branch-guard's (issue #851): `ctx.workspaceRoot` is the walk-up from the SESSION's cwd, so
105
+ * a main-session Read of a file inside an agent worktree was judged on the PRIMARY clone's branch —
106
+ * and a `main` that the primary happened to be sitting on would close reads of another tree's
107
+ * feature branch.
108
+ */
99
109
  check(ctx) {
100
110
  // Outside the workspace root — no jurisdiction.
101
111
  if (ctx.relativePath.startsWith('..'))
102
112
  return [];
103
- const branch = this.currentBranch(ctx.workspaceRoot);
113
+ const target = new target_tree_1.TargetTreeResolver().resolve(ctx.filePath, ctx.workspaceRoot);
114
+ // A nested clone under `repositories/**` is out of scope, exactly as it is on the bash path.
115
+ if (target.kind === 'foreign')
116
+ return this.failOpen(ctx, null, 'target-tree-foreign');
117
+ const judgedRoot = target.root;
118
+ const branch = this.currentBranch(judgedRoot);
104
119
  if (branch === null)
105
120
  return this.failOpen(ctx, branch, 'branch-undeterminable');
121
+ // Mid-rebase in the target tree: no branch name, so no key into the branch-keyed cache and
122
+ // nothing to judge. Matrix row 14 — abstain, and say WHY rather than logging a cache miss.
123
+ if (branch === 'HEAD')
124
+ return this.failOpen(ctx, branch, 'detached-head');
106
125
  // Keep the shared cache warm for the next call. Detached; never blocks this read. Fired for
107
- // BOTH states — the merged-branch signal comes out of that same cache.
108
- (0, main_sync_refresh_1.triggerMainSyncRefresh)(ctx.workspaceRoot, (0, main_sync_timeout_1.hangTimeoutOf)(this.config));
126
+ // BOTH states — the merged-branch signal comes out of that same cache. Rooted at the JUDGED
127
+ // tree so the entry refreshed is the one this guard reads.
128
+ (0, main_sync_refresh_1.triggerMainSyncRefresh)(judgedRoot, (0, main_sync_timeout_1.hangTimeoutOf)(this.config));
109
129
  // Escape valve 3 — the read half of the config escape hatch. Ahead of BOTH states' blocks so
110
130
  // the agent can always read-then-edit the file that turns this guard off.
111
131
  if (this.isConfigFile(ctx.relativePath))
112
132
  return this.allow(ctx, branch, 'webpieces-config-read (escape hatch)');
133
+ const judged = new judged_tree_1.JudgedTree(target, branch, target.governedRoot);
113
134
  return branch === 'main'
114
- ? this.checkStaleMain(ctx, branch)
115
- : this.checkMergedBranch(ctx, branch);
135
+ ? this.checkStaleMain(ctx, judged)
136
+ : this.checkMergedBranch(ctx, judged);
116
137
  }
117
138
  // State A — on main, possibly behind origin/main.
118
- checkStaleMain(ctx, branch) {
119
- const status = (0, rules_config_1.readMainSyncStatus)(ctx.workspaceRoot, 'main');
139
+ checkStaleMain(ctx, judged) {
140
+ const branch = judged.branch;
141
+ const status = (0, rules_config_1.readMainSyncStatus)(judged.root, 'main');
120
142
  if (status === null)
121
143
  return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');
122
144
  const cache = this.cacheSummary(status);
@@ -129,7 +151,7 @@ class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
129
151
  if (status.originMain === '')
130
152
  return this.failOpen(ctx, branch, 'origin-main-unknown', cache);
131
153
  // Escape valve 2 — ancestry, NOT equality. See the class comment.
132
- if (this.freshness.containsOriginMain(ctx.workspaceRoot, status.originMain)) {
154
+ if (this.freshness.containsOriginMain(judged.root, status.originMain)) {
133
155
  return this.allow(ctx, branch, 'local-main-contains-origin (up to date)', cache);
134
156
  }
135
157
  // NO DIRTY VALVE. It used to fail open here, on the argument that the prescribed in-place pull
@@ -140,7 +162,7 @@ class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
140
162
  // (StaleMainMessage.forReads), so the cure an agent reads is one it can actually run.
141
163
  // Residual, same as row 8: if origin/main touched the files you edited, git refuses the switch
142
164
  // — `git stash` is on the skip list and clears it. Two steps worst case, never a dead end.
143
- return this.block(ctx, branch, 'on-stale-main', this.staleMainMessage(ctx.workspaceRoot), cache);
165
+ return this.block(ctx, branch, 'on-stale-main', this.staleMainMessage(judged), cache);
144
166
  }
145
167
  /**
146
168
  * State B — a feature branch whose PR is already merged. Reads a PRE-MERGE snapshot, so every
@@ -157,8 +179,9 @@ class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
157
179
  * from the documented design, not a decision — this docblock described the strict behaviour for
158
180
  * releases while the code failed open.
159
181
  */
160
- checkMergedBranch(ctx, branch) {
161
- const status = (0, rules_config_1.readMainSyncStatus)(ctx.workspaceRoot, branch);
182
+ checkMergedBranch(ctx, judged) {
183
+ const branch = judged.branch;
184
+ const status = (0, rules_config_1.readMainSyncStatus)(judged.root, branch);
162
185
  if (status === null)
163
186
  return this.failOpen(ctx, branch, 'no-sync-cache', 'cache=none');
164
187
  const cache = this.cacheSummary(status);
@@ -183,15 +206,19 @@ class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
183
206
  // with you, so a dirty tree traps nobody. The valve was code drift from the documented design;
184
207
  // read-stale-guard's own class comment said so while the code did the opposite.
185
208
  const pr = status.mergedPr !== '' ? status.mergedPr : '?';
186
- return this.block(ctx, branch, `already-merged PR#${pr}`, this.mergedMessage(ctx.workspaceRoot, branch, status.mergedPr), cache);
209
+ return this.block(ctx, branch, `already-merged PR#${pr}`, this.mergedMessage(judged, status.mergedPr), cache);
187
210
  }
188
211
  // The merged-branch text, told in the flavour of the tree we are standing in: a linked worktree
189
212
  // is told to open a NEW worktree off origin/main and reap this dead one; the primary clone is
190
213
  // told to branch off origin/main. Neither is ever told to `git checkout main` (fatal in a
191
214
  // worktree). Detection is one statSync — see WorktreeService.isLinkedWorktree.
192
- mergedMessage(workspaceRoot, branch, mergedPr) {
215
+ mergedMessage(judged, mergedPr) {
193
216
  const recovery = new tree_recovery_1.TreeRecovery();
194
- return new merged_branch_message_1.MergedBranchMessage(workspaceRoot).forReads(branch, mergedPr, recovery.kindOf(workspaceRoot), workspaceRoot);
217
+ const body = new merged_branch_message_1.MergedBranchMessage(judged.root).forReads(judged.branch, mergedPr, recovery.kindOf(judged.root), judged.root);
218
+ // NO redirect note here, deliberately — see feature-branch-guard.alreadyMergedMessage for the
219
+ // argument. MergedBranchMessage already aims its commands at the judged root through `atRoot`
220
+ // and prints the WORKTREE flavour of the cure when the judged tree is one.
221
+ return [judged.header(), body].join('\n');
195
222
  }
196
223
  isConfigFile(relativePath) {
197
224
  return relativePath === 'webpieces.config.json';
@@ -216,8 +243,12 @@ class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
216
243
  // StaleMainMessage's remaining consumer. It used to be shared with stale-main-bash-guard so the two
217
244
  // halves of the State-A block could never prescribe different cures; that guard now blocks on the
218
245
  // BRANCH (row 5) rather than on staleness and carries its own message, so this is the only caller.
219
- staleMainMessage(workspaceRoot) {
220
- return new stale_main_message_1.StaleMainMessage(workspaceRoot).forReads(this.behindCount(workspaceRoot));
246
+ staleMainMessage(judged) {
247
+ return [
248
+ judged.header(),
249
+ new stale_main_message_1.StaleMainMessage(judged.root).forReads(this.behindCount(judged.root)),
250
+ judged.redirectNote([judged.pnpmCure('wp-sync-main')]),
251
+ ].join('\n');
221
252
  }
222
253
  cacheSummary(status) {
223
254
  return this.freshness.summarize(status);