@webpieces/ai-hook-rules 0.4.419 → 0.4.421

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/package.json +2 -2
  2. package/src/adapters/hook-core.js +1 -1
  3. package/src/adapters/hook-core.js.map +1 -1
  4. package/src/bin/shim.js +10 -1
  5. package/src/bin/shim.js.map +1 -1
  6. package/src/core/load-rules.js +2 -2
  7. package/src/core/load-rules.js.map +1 -1
  8. package/src/core/rules/branch-creation-guard.d.ts +14 -0
  9. package/src/core/rules/branch-creation-guard.js +43 -3
  10. package/src/core/rules/branch-creation-guard.js.map +1 -1
  11. package/src/core/rules/feature-branch-guard.js +9 -12
  12. package/src/core/rules/feature-branch-guard.js.map +1 -1
  13. package/src/core/rules/index.js +1 -1
  14. package/src/core/rules/index.js.map +1 -1
  15. package/src/core/rules/merged-branch-message.d.ts +28 -0
  16. package/src/core/rules/merged-branch-message.js +67 -0
  17. package/src/core/rules/merged-branch-message.js.map +1 -0
  18. package/src/core/rules/pr-merge-guard.d.ts +8 -0
  19. package/src/core/rules/pr-merge-guard.js +25 -4
  20. package/src/core/rules/pr-merge-guard.js.map +1 -1
  21. package/src/core/rules/{main-stale-guard.d.ts → read-stale-guard.d.ts} +39 -8
  22. package/src/core/rules/{main-stale-guard.js → read-stale-guard.js} +83 -21
  23. package/src/core/rules/read-stale-guard.js.map +1 -0
  24. package/src/core/rules/redirect-how-to-merge-main.d.ts +1 -0
  25. package/src/core/rules/redirect-how-to-merge-main.js +12 -3
  26. package/src/core/rules/redirect-how-to-merge-main.js.map +1 -1
  27. package/src/core/rules/tree-recovery.d.ts +45 -0
  28. package/src/core/rules/tree-recovery.js +100 -0
  29. package/src/core/rules/tree-recovery.js.map +1 -0
  30. package/src/core/runner.js +1 -1
  31. package/src/core/runner.js.map +1 -1
  32. package/src/core/types.js.map +1 -1
  33. package/templates/ai-hook.sh +10 -1
  34. package/src/core/rules/main-stale-guard.js.map +0 -1
@@ -1,13 +1,25 @@
1
- import { MainStaleGuardConfig } from '@webpieces/rules-config';
1
+ import { ReadStaleGuardConfig } from '@webpieces/rules-config';
2
2
  import type { FileContext, Violation } from '../types';
3
3
  import { FileRuleBase } from '../rule-base';
4
4
  import { FixHint } from '../fix-hint';
5
5
  /**
6
- * Blocks READS while you are sitting on a `main` that is behind `origin/main`.
6
+ * Blocks READS while the checked-out branch is a stale place to read from. TWO states:
7
7
  *
8
- * WHY READ, of all tools: a stale main means the AI reads stale FILE CONTENT and then reasons,
8
+ * A. on `main`, and local main is BEHIND origin/main
9
+ * B. on a feature branch whose PR is ALREADY MERGED (a pre-merge snapshot; origin/main has moved
10
+ * past it and a squash merge means its HEAD is not even an ancestor of main)
11
+ *
12
+ * WHY READ, of all tools: either state means the AI reads stale FILE CONTENT and then reasons,
9
13
  * plans and writes against code that no longer exists upstream. Blocking the write is too late —
10
- * the bad premise is already in context. So the block lands on the read.
14
+ * the bad premise is already in context. So the block lands on the read. (feature-branch-guard
15
+ * blocks the WRITE in state B; this guard is the read-side half of that same protection, and the
16
+ * two share one recovery message via MergedBranchMessage.)
17
+ *
18
+ * THE DIRTY-TREE ASYMMETRY is deliberate. State A fails OPEN on a dirty tree because `git pull` is
19
+ * then not a guaranteed fast-forward and the agent would be trapped away from the files it needs to
20
+ * resolve the conflict. State B blocks ANYWAY, because its cure — `git checkout -b <new>
21
+ * origin/main` — carries uncommitted changes onto the fresh branch, so there is nothing to resolve
22
+ * and nothing to be trapped by.
11
23
  *
12
24
  * WHY THIS CANNOT WEDGE: the block is scoped to Read ONLY. Every cure — `git pull origin main`,
13
25
  * `pnpm install`, any webpieces upgrade — is a Bash command, and this guard never looks at Bash.
@@ -20,7 +32,8 @@ import { FixHint } from '../fix-hint';
20
32
  *
21
33
  * 1. DIRTY TREE — uncommitted work on main means `git pull` is not a guaranteed fast-forward.
22
34
  * Blocking reads there would trap the agent: it could not read the files it
23
- * needs to resolve the very conflict blocking it. Allow.
35
+ * needs to resolve the very conflict blocking it. Allow. (State A ONLY — see
36
+ * the dirty-tree asymmetry above.)
24
37
  * 2. CACHE LAG — we do NOT compare hashes for equality. The cached `originMain` is written by
25
38
  * the detached refresher and is arbitrarily old, so `local !== origin` stays
26
39
  * true for a while AFTER a successful pull, which would spin the agent forever.
@@ -37,15 +50,33 @@ import { FixHint } from '../fix-hint';
37
50
  * never reaches the runner's rule loop). Fires the detached refresher on every call, which is also
38
51
  * what makes reads keep the shared main-sync cache warm for feature-branch-guard.
39
52
  */
40
- export declare class MainStaleGuardRule extends FileRuleBase<MainStaleGuardConfig> {
41
- constructor(config: MainStaleGuardConfig);
42
- readonly description = "Block reads while on a `main` branch that is behind origin/main \u2014 stale main means the AI reads stale files.";
53
+ export declare class ReadStaleGuardRule extends FileRuleBase<ReadStaleGuardConfig> {
54
+ constructor(config: ReadStaleGuardConfig);
55
+ readonly description = "Block reads on a branch that is stale to read from \u2014 a `main` behind origin/main, or a feature branch whose PR is already merged.";
43
56
  readonly files: string[];
44
57
  readonly defaultOptions: {
45
58
  hangTimeoutMinutes: number;
46
59
  };
47
60
  readonly fixHint: FixHint;
48
61
  check(ctx: FileContext): readonly Violation[];
62
+ private checkStaleMain;
63
+ /**
64
+ * State B — a feature branch whose PR is already merged. Reads a PRE-MERGE snapshot, so every
65
+ * plan built from it is built on code origin/main has moved past.
66
+ *
67
+ * `branchAlreadyMerged` comes straight from the shared cache (the refresher's `gh pr list --state
68
+ * merged`), so this path spawns nothing. No `gh` / offline → `mergedPr` is '' → not merged → allow,
69
+ * which is the fail-open direction for free.
70
+ *
71
+ * The DIRTY-TREE escape valve is the same one state A has, for the same reason: uncommitted work
72
+ * on a merged branch is work that exists nowhere else, and rescuing it means READING the files it
73
+ * touches. `git checkout -b <new> origin/main` usually carries those changes across — but when it
74
+ * does not (an overlapping change landed in main), a blocked read is an agent that cannot even
75
+ * see what it is about to lose. feature-branch-guard still blocks the EDITS, so the state is
76
+ * surfaced loudly either way; we just refuse to cut off the rescue path.
77
+ */
78
+ private checkMergedBranch;
79
+ private mergedMessage;
49
80
  private contains;
50
81
  private isDirty;
51
82
  private isConfigFile;
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.MainStaleGuardRule = void 0;
3
+ exports.ReadStaleGuardRule = void 0;
4
4
  const tslib_1 = require("tslib");
5
5
  const child_process_1 = require("child_process");
6
6
  const fs = tslib_1.__importStar(require("fs"));
@@ -12,12 +12,26 @@ const fix_hint_1 = require("../fix-hint");
12
12
  const to_error_1 = require("../to-error");
13
13
  const main_sync_refresh_1 = require("../main-sync-refresh");
14
14
  const decision_log_1 = require("../decision-log");
15
+ const merged_branch_message_1 = require("./merged-branch-message");
16
+ const tree_recovery_1 = require("./tree-recovery");
15
17
  /**
16
- * Blocks READS while you are sitting on a `main` that is behind `origin/main`.
18
+ * Blocks READS while the checked-out branch is a stale place to read from. TWO states:
17
19
  *
18
- * WHY READ, of all tools: a stale main means the AI reads stale FILE CONTENT and then reasons,
20
+ * A. on `main`, and local main is BEHIND origin/main
21
+ * B. on a feature branch whose PR is ALREADY MERGED (a pre-merge snapshot; origin/main has moved
22
+ * past it and a squash merge means its HEAD is not even an ancestor of main)
23
+ *
24
+ * WHY READ, of all tools: either state means the AI reads stale FILE CONTENT and then reasons,
19
25
  * plans and writes against code that no longer exists upstream. Blocking the write is too late —
20
- * the bad premise is already in context. So the block lands on the read.
26
+ * the bad premise is already in context. So the block lands on the read. (feature-branch-guard
27
+ * blocks the WRITE in state B; this guard is the read-side half of that same protection, and the
28
+ * two share one recovery message via MergedBranchMessage.)
29
+ *
30
+ * THE DIRTY-TREE ASYMMETRY is deliberate. State A fails OPEN on a dirty tree because `git pull` is
31
+ * then not a guaranteed fast-forward and the agent would be trapped away from the files it needs to
32
+ * resolve the conflict. State B blocks ANYWAY, because its cure — `git checkout -b <new>
33
+ * origin/main` — carries uncommitted changes onto the fresh branch, so there is nothing to resolve
34
+ * and nothing to be trapped by.
21
35
  *
22
36
  * WHY THIS CANNOT WEDGE: the block is scoped to Read ONLY. Every cure — `git pull origin main`,
23
37
  * `pnpm install`, any webpieces upgrade — is a Bash command, and this guard never looks at Bash.
@@ -30,7 +44,8 @@ const decision_log_1 = require("../decision-log");
30
44
  *
31
45
  * 1. DIRTY TREE — uncommitted work on main means `git pull` is not a guaranteed fast-forward.
32
46
  * Blocking reads there would trap the agent: it could not read the files it
33
- * needs to resolve the very conflict blocking it. Allow.
47
+ * needs to resolve the very conflict blocking it. Allow. (State A ONLY — see
48
+ * the dirty-tree asymmetry above.)
34
49
  * 2. CACHE LAG — we do NOT compare hashes for equality. The cached `originMain` is written by
35
50
  * the detached refresher and is arbitrarily old, so `local !== origin` stays
36
51
  * true for a while AFTER a successful pull, which would spin the agent forever.
@@ -47,17 +62,17 @@ const decision_log_1 = require("../decision-log");
47
62
  * never reaches the runner's rule loop). Fires the detached refresher on every call, which is also
48
63
  * what makes reads keep the shared main-sync cache warm for feature-branch-guard.
49
64
  */
50
- class MainStaleGuardRule extends rule_base_1.FileRuleBase {
51
- constructor(config) { super(config, 'main-stale-guard'); }
52
- description = 'Block reads while on a `main` branch that is behind origin/main stale main means the AI reads stale files.';
65
+ class ReadStaleGuardRule extends rule_base_1.FileRuleBase {
66
+ constructor(config) { super(config, 'read-stale-guard'); }
67
+ 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.';
53
68
  files = ['**/*'];
54
69
  defaultOptions = {
55
70
  hangTimeoutMinutes: rules_config_1.DEFAULT_HANG_TIMEOUT_MINUTES,
56
71
  };
57
- fixHint = new fix_hint_1.FixHint('You are on main and main is behind origin/main — reading files would give you stale content.', 'Bring main up to date before reading anything else:', [
58
- new fix_hint_1.Option('git pull origin main the fix. Then simply retry the read.', true),
72
+ fixHint = new fix_hint_1.FixHint('This branch is stale to read from — reading it would give you pre-merge/out-of-date content.', 'Get onto current code before reading anything else:', [
73
+ new fix_hint_1.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),
59
74
  new fix_hint_1.Option('Still allowed right now: EVERY Bash command (installs, upgrades, builds), all Write/Edit, and reading webpieces.config.json.'),
60
- new fix_hint_1.Option('Disable in webpieces.config.json under hookGuards → main-stale-guard (mode OFF) if intentional.'),
75
+ new fix_hint_1.Option('Disable in webpieces.config.json under hookGuards → read-stale-guard (mode OFF) if intentional.'),
61
76
  ]);
62
77
  check(ctx) {
63
78
  // Outside the workspace root — no jurisdiction.
@@ -66,14 +81,19 @@ class MainStaleGuardRule extends rule_base_1.FileRuleBase {
66
81
  const branch = this.currentBranch(ctx.workspaceRoot);
67
82
  if (branch === null)
68
83
  return this.allow(ctx, branch, 'branch-undeterminable (fail-open)');
69
- // The whole guard is about a checked-out main. Anything else is feature-branch-guard's job.
70
- if (branch !== 'main')
71
- return this.allow(ctx, branch, 'not-on-main');
72
- // Keep the shared cache warm for the next call. Detached; never blocks this read.
84
+ // Keep the shared cache warm for the next call. Detached; never blocks this read. Fired for
85
+ // BOTH states — the merged-branch signal comes out of that same cache.
73
86
  (0, main_sync_refresh_1.triggerMainSyncRefresh)(ctx.workspaceRoot, this.config.hangTimeoutMinutes ?? rules_config_1.DEFAULT_HANG_TIMEOUT_MINUTES);
74
- // Escape valve 3 — the read half of the config escape hatch.
87
+ // Escape valve 3 — the read half of the config escape hatch. Ahead of BOTH states' blocks so
88
+ // the agent can always read-then-edit the file that turns this guard off.
75
89
  if (this.isConfigFile(ctx.relativePath))
76
90
  return this.allow(ctx, branch, 'webpieces-config-read (escape hatch)');
91
+ return branch === 'main'
92
+ ? this.checkStaleMain(ctx, branch)
93
+ : this.checkMergedBranch(ctx, branch);
94
+ }
95
+ // State A — on main, possibly behind origin/main.
96
+ checkStaleMain(ctx, branch) {
77
97
  const status = (0, rules_config_1.readMainSyncStatus)(ctx.workspaceRoot);
78
98
  if (status === null)
79
99
  return this.allow(ctx, branch, 'no-sync-cache (fail-open)', 'cache=none');
@@ -94,6 +114,47 @@ class MainStaleGuardRule extends rule_base_1.FileRuleBase {
94
114
  }
95
115
  return this.block(ctx, branch, 'on-stale-main', this.staleMainMessage(ctx.workspaceRoot), cache);
96
116
  }
117
+ /**
118
+ * State B — a feature branch whose PR is already merged. Reads a PRE-MERGE snapshot, so every
119
+ * plan built from it is built on code origin/main has moved past.
120
+ *
121
+ * `branchAlreadyMerged` comes straight from the shared cache (the refresher's `gh pr list --state
122
+ * merged`), so this path spawns nothing. No `gh` / offline → `mergedPr` is '' → not merged → allow,
123
+ * which is the fail-open direction for free.
124
+ *
125
+ * The DIRTY-TREE escape valve is the same one state A has, for the same reason: uncommitted work
126
+ * on a merged branch is work that exists nowhere else, and rescuing it means READING the files it
127
+ * touches. `git checkout -b <new> origin/main` usually carries those changes across — but when it
128
+ * does not (an overlapping change landed in main), a blocked read is an agent that cannot even
129
+ * see what it is about to lose. feature-branch-guard still blocks the EDITS, so the state is
130
+ * surfaced loudly either way; we just refuse to cut off the rescue path.
131
+ */
132
+ checkMergedBranch(ctx, branch) {
133
+ const status = (0, rules_config_1.readMainSyncStatus)(ctx.workspaceRoot);
134
+ if (status === null)
135
+ return this.allow(ctx, branch, 'no-sync-cache (fail-open)', 'cache=none');
136
+ const cache = this.cacheSummary(status);
137
+ // Cache written for a DIFFERENT branch (just switched; the refresh for this one hasn't landed).
138
+ // Never block on another branch's signals — this is also what un-blocks the instant the agent
139
+ // follows the cure and checks out a fresh branch.
140
+ if (status.branch !== branch)
141
+ return this.allow(ctx, branch, 'stale-cross-branch-cache (fail-open)', cache);
142
+ if (!status.branchAlreadyMerged)
143
+ return this.allow(ctx, branch, 'clean-feature-branch', cache);
144
+ if (this.isDirty(ctx.workspaceRoot)) {
145
+ return this.allow(ctx, branch, 'dirty-merged-branch (fail-open)', cache);
146
+ }
147
+ const pr = status.mergedPr !== '' ? status.mergedPr : '?';
148
+ return this.block(ctx, branch, `already-merged PR#${pr}`, this.mergedMessage(ctx.workspaceRoot, branch, status.mergedPr), cache);
149
+ }
150
+ // The merged-branch text, told in the flavour of the tree we are standing in: a linked worktree
151
+ // is told to open a NEW worktree off origin/main and reap this dead one; the primary clone is
152
+ // told to branch off origin/main. Neither is ever told to `git checkout main` (fatal in a
153
+ // worktree). Detection is one statSync — see WorktreeService.isLinkedWorktree.
154
+ mergedMessage(workspaceRoot, branch, mergedPr) {
155
+ const recovery = new tree_recovery_1.TreeRecovery();
156
+ return new merged_branch_message_1.MergedBranchMessage().forReads(branch, mergedPr, recovery.kindOf(workspaceRoot), workspaceRoot);
157
+ }
97
158
  // Is `commit` an ancestor of (i.e. already contained in) HEAD? Local-only and fast — no network.
98
159
  //
99
160
  // spawnSync, not execSync, precisely because the EXIT CODE is the answer and we must tell three
@@ -161,11 +222,12 @@ class MainStaleGuardRule extends rule_base_1.FileRuleBase {
161
222
  'Still allowed while this block is up:',
162
223
  ' - EVERY Bash command (pnpm install, any webpieces upgrade, builds, all git/gh)',
163
224
  ' - All Write/Edit (feature-branch-guard governs those separately)',
164
- ' - Reading and editing webpieces.config.json (set main-stale-guard mode OFF to disable)',
225
+ ' - Reading and editing webpieces.config.json (set read-stale-guard mode OFF to disable)',
165
226
  ].join('\n');
166
227
  }
167
228
  cacheSummary(status) {
168
- return `cache=${status.branch} localMain=${status.localMain.slice(0, 8)} originMain=${status.originMain.slice(0, 8)} ts=${status.timestamp}`;
229
+ const merged = status.branchAlreadyMerged ? `PR#${status.mergedPr !== '' ? status.mergedPr : '?'}` : 'no';
230
+ return `cache=${status.branch} localMain=${status.localMain.slice(0, 8)} originMain=${status.originMain.slice(0, 8)} merged=${merged} ts=${status.timestamp}`;
169
231
  }
170
232
  allow(ctx, branch, reason, cache = '-') {
171
233
  this.logDecision(ctx, branch, 'ALLOW', reason, cache);
@@ -176,7 +238,7 @@ class MainStaleGuardRule extends rule_base_1.FileRuleBase {
176
238
  return [new types_1.Violation(1, ctx.relativePath, message)];
177
239
  }
178
240
  logDecision(ctx, branch, verdict, reason, cache) {
179
- (0, decision_log_1.logGuardDecision)(ctx.workspaceRoot, new decision_log_1.GuardDecision('main-stale-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason, cache));
241
+ (0, decision_log_1.logGuardDecision)(ctx.workspaceRoot, new decision_log_1.GuardDecision('read-stale-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason, cache));
180
242
  }
181
243
  /**
182
244
  * The current branch, WITHOUT spawning git on the common path.
@@ -230,5 +292,5 @@ class MainStaleGuardRule extends rule_base_1.FileRuleBase {
230
292
  }
231
293
  }
232
294
  }
233
- exports.MainStaleGuardRule = MainStaleGuardRule;
234
- //# sourceMappingURL=main-stale-guard.js.map
295
+ exports.ReadStaleGuardRule = ReadStaleGuardRule;
296
+ //# sourceMappingURL=read-stale-guard.js.map
@@ -0,0 +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 { 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 * 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('Still allowed right now: EVERY Bash command (installs, upgrades, builds), all Write/Edit, and reading webpieces.config.json.'),\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 private staleMainMessage(workspaceRoot: string): string {\n return [\n `You are on main and main is ${this.behindCount(workspaceRoot)} commit(s) behind origin/main.`,\n 'Reading files right now would give you STALE content and everything you plan from it',\n 'would be built on code that no longer exists upstream. Reads are blocked until you update.',\n '',\n 'Run exactly this, then retry the read:',\n ' git pull origin main',\n '',\n 'Still allowed while this block is up:',\n ' - EVERY Bash command (pnpm install, any webpieces upgrade, builds, all git/gh)',\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 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"]}
@@ -4,6 +4,7 @@ import { BashRuleBase } from '../rule-base';
4
4
  import { FixHint } from '../fix-hint';
5
5
  export declare class RedirectHowToMergeMainRule extends BashRuleBase<RedirectHowToMergeMainConfig> {
6
6
  private readonly scanner;
7
+ private readonly recovery;
7
8
  constructor(config: RedirectHowToMergeMainConfig);
8
9
  readonly description = "Block ALL `git merge`/`git rebase` (any branch, any form) and `git pull origin main` on a feature branch. Use the squash-update process instead.";
9
10
  readonly fixHint: FixHint;
@@ -7,6 +7,7 @@ const types_1 = require("../types");
7
7
  const rule_base_1 = require("../rule-base");
8
8
  const fix_hint_1 = require("../fix-hint");
9
9
  const command_scan_1 = require("../command-scan");
10
+ const tree_recovery_1 = require("./tree-recovery");
10
11
  const INSTRUCT_FILE = 'webpieces.git-workflow.md';
11
12
  const UPDATE_COMMAND = 'pnpm wp-start-update';
12
13
  const FIX_HINT = new fix_hint_1.FixHint('`git merge` / `git rebase` are never run by AI — on any branch, in any form.', 'To bring main\'s changes into your feature branch:\n'
@@ -41,6 +42,7 @@ function truncate(s) {
41
42
  const SWITCHES_TO_NON_MAIN = /git\s+(?:checkout|switch)\s+(?!main\b|-\s|-$)\S+/;
42
43
  class RedirectHowToMergeMainRule extends rule_base_1.BashRuleBase {
43
44
  scanner = new command_scan_1.CommandScanner();
45
+ recovery = new tree_recovery_1.TreeRecovery();
44
46
  constructor(config) { super(config, 'redirect-how-to-merge-main'); }
45
47
  description = 'Block ALL `git merge`/`git rebase` (any branch, any form) and `git pull origin main` on a feature branch. Use the squash-update process instead.';
46
48
  fixHint = FIX_HINT;
@@ -78,9 +80,16 @@ class RedirectHowToMergeMainRule extends rule_base_1.BashRuleBase {
78
80
  if (SWITCHES_TO_NON_MAIN.test(ctx.command)) {
79
81
  return this.block(ctx, segment, 'Blocked: this command switches to a feature branch and then pulls main into it.');
80
82
  }
81
- // The recommended `git checkout main && git pull origin main`.
82
- if (/git\s+(?:checkout|switch)\s+main\b/.test(ctx.command))
83
- return null;
83
+ // The recommended `git checkout main && git pull origin main` — but ONLY in the primary
84
+ // clone. Inside a linked worktree that checkout FATALS ("'main' is already checked out at
85
+ // <primary>"), so waving it through here hands the AI a command that cannot work and costs
86
+ // it a turn to discover. Steer to the fetch, which is all a worktree needs.
87
+ if (/git\s+(?:checkout|switch)\s+main\b/.test(ctx.command)) {
88
+ if (this.recovery.kindOf(ctx.workspaceRoot) !== 'worktree')
89
+ return null;
90
+ // updateMainSteps already explains the worktree/fatal reasoning — don't say it twice.
91
+ return this.block(ctx, segment, ['Blocked.', ...this.recovery.updateMainSteps('worktree')].join('\n'));
92
+ }
84
93
  const currentBranch = (0, child_process_1.execSync)('git rev-parse --abbrev-ref HEAD', {
85
94
  cwd: ctx.workspaceRoot,
86
95
  encoding: 'utf8',
@@ -1 +1 @@
1
- {"version":3,"file":"redirect-how-to-merge-main.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/redirect-how-to-merge-main.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport { RedirectHowToMergeMainConfig, RepoRootFinder } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { CommandScanner } from '../command-scan';\n\nconst INSTRUCT_FILE = 'webpieces.git-workflow.md';\nconst UPDATE_COMMAND = 'pnpm wp-start-update';\n\nconst FIX_HINT = new FixHint(\n '`git merge` / `git rebase` are never run by AI — on any branch, in any form.',\n 'To bring main\\'s changes into your feature branch:\\n'\n + ` ${UPDATE_COMMAND} (then: pnpm wp-finish-upsert-pr)\\n`\n + 'That does a 3-point merge (fork-point=A, feature-HEAD=B, main-HEAD=C), which is what keeps PR\\n'\n + 'diffs clean. A raw `git merge`/`git rebase` destroys the fork-point system. The gated commands\\n'\n + 'merge internally as child processes this hook never sees, so they are unaffected by this guard.\\n'\n + '\\n'\n + 'If you believe a raw merge/rebase is genuinely required, do NOT run it and do NOT work around\\n'\n + 'this guard. STOP and ask the HUMAN to run that exact command themselves — and when you ask,\\n'\n + 'warn them, in these words:\\n'\n + '\\n'\n + ' \"I am asking you to run a raw git merge/rebase. This is almost always the WRONG call —\\n'\n + ` \\`${UPDATE_COMMAND}\\` / \\`pnpm wp-finish-upsert-pr\\` does a 3-point merge and is the correct\\n`\n + ' flow. Please push back and tell me to use the 3-point merge instead, unless you are certain\\n'\n + ' this is a genuine exception.\"\\n'\n + '\\n'\n + 'READ the instruct-ai git-workflow doc at the absolute path on the violation line above for the\\n'\n + 'full flow (incl. worktrees).\\n'\n + 'Add that info to memory so you remember next time.',\n);\n\n// `git merge --abort` / `git rebase --abort|--quit` UNDO an in-progress operation — they cannot create\n// a merge commit or rewrite history, so they cannot violate the fork-point invariant this rule\n// protects. They stay allowed so a repo left mid-operation (e.g. by a human-run rebase) can still be\n// cleaned up. `--continue` is deliberately NOT here: it COMPLETES the operation.\nconst UNDO_FLAG = /--(?:abort|quit)\\b/;\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\n// Switches to a branch OTHER than main. `git branch -D <x>` is not a checkout so it does not trip\n// this; `checkout main` and flag-only forms like `checkout -` do not count as a feature switch.\nconst SWITCHES_TO_NON_MAIN = /git\\s+(?:checkout|switch)\\s+(?!main\\b|-\\s|-$)\\S+/;\n\nexport class RedirectHowToMergeMainRule extends BashRuleBase<RedirectHowToMergeMainConfig> {\n private readonly scanner = new CommandScanner();\n\n constructor(config: RedirectHowToMergeMainConfig) { super(config, 'redirect-how-to-merge-main'); }\n\n readonly description = 'Block ALL `git merge`/`git rebase` (any branch, any form) and `git pull origin main` on a feature branch. Use the squash-update process instead.';\n readonly fixHint = FIX_HINT;\n\n check(ctx: BashContext): readonly Violation[] {\n for (const segment of this.scanner.commandSegments(ctx.command)) {\n const violation = this.checkSegment(ctx, segment);\n if (violation !== null) return [violation];\n }\n return [];\n }\n\n private checkSegment(ctx: BashContext, segment: string): Violation | null {\n // 1. merge/rebase: unconditional block. Deliberately NO branch lookup.\n //\n // This rule used to read hook-time HEAD and bail out when it was `main`. But a PreToolUse hook\n // runs BEFORE the command, so HEAD-at-hook-time is a value the command itself is about to\n // change: `git checkout feat && git rebase main`, issued while HEAD was still `main` from a\n // prior cleanup, read as \"we're on main, this is fine\" and was waved through. That is the\n // incident this rule exists to prevent. Since merge/rebase have no legitimate AI-run form on\n // ANY branch, there is no branch to consult — and so no HEAD to spoof.\n if (this.scanner.invokesGit(segment, 'merge') || this.scanner.invokesGit(segment, 'rebase')) {\n if (UNDO_FLAG.test(segment)) return null;\n return this.block(ctx, segment, 'Direct `git merge`/`git rebase` is blocked — AI never runs it, on any branch.');\n }\n\n // 2. pull: unlike merge/rebase this DOES retain a legitimate on-main form\n // (`git checkout main && git pull origin main`), so it must consult the branch — which is\n // exactly why it also needs the branch-switch check that (1) no longer requires.\n if (this.scanner.invokesGit(segment, 'pull') && /\\borigin\\s+main\\b/.test(segment)) {\n return this.checkPull(ctx, segment);\n }\n\n return null;\n }\n\n private checkPull(ctx: BashContext, segment: string): Violation | null {\n if (SWITCHES_TO_NON_MAIN.test(ctx.command)) {\n return this.block(ctx, segment, 'Blocked: this command switches to a feature branch and then pulls main into it.');\n }\n // The recommended `git checkout main && git pull origin main`.\n if (/git\\s+(?:checkout|switch)\\s+main\\b/.test(ctx.command)) return null;\n\n const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n if (currentBranch === 'main') return null;\n\n return this.block(ctx, segment, `Pulling main into feature branch '${currentBranch}' is blocked.`);\n }\n\n private block(ctx: BashContext, segment: string, what: string): Violation {\n const docPath = new RepoRootFinder().instructAiDocPath(ctx.workspaceRoot, INSTRUCT_FILE);\n return new V(\n 1,\n truncate(segment),\n `${what} Use '${UPDATE_COMMAND}' (3-point merge). If you truly need a raw merge/rebase, ask the HUMAN to run it — and warn them to push back. Full flow: READ ${docPath}.`,\n );\n }\n}\n"]}
1
+ {"version":3,"file":"redirect-how-to-merge-main.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/redirect-how-to-merge-main.ts"],"names":[],"mappings":"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","sourcesContent":["import { execSync } from 'child_process';\n\nimport { RedirectHowToMergeMainConfig, RepoRootFinder } from '@webpieces/rules-config';\n\nimport type { BashContext, Violation } from '../types';\nimport { Violation as V } from '../types';\nimport { BashRuleBase } from '../rule-base';\nimport { FixHint } from '../fix-hint';\nimport { CommandScanner } from '../command-scan';\nimport { TreeRecovery } from './tree-recovery';\n\nconst INSTRUCT_FILE = 'webpieces.git-workflow.md';\nconst UPDATE_COMMAND = 'pnpm wp-start-update';\n\nconst FIX_HINT = new FixHint(\n '`git merge` / `git rebase` are never run by AI — on any branch, in any form.',\n 'To bring main\\'s changes into your feature branch:\\n'\n + ` ${UPDATE_COMMAND} (then: pnpm wp-finish-upsert-pr)\\n`\n + 'That does a 3-point merge (fork-point=A, feature-HEAD=B, main-HEAD=C), which is what keeps PR\\n'\n + 'diffs clean. A raw `git merge`/`git rebase` destroys the fork-point system. The gated commands\\n'\n + 'merge internally as child processes this hook never sees, so they are unaffected by this guard.\\n'\n + '\\n'\n + 'If you believe a raw merge/rebase is genuinely required, do NOT run it and do NOT work around\\n'\n + 'this guard. STOP and ask the HUMAN to run that exact command themselves — and when you ask,\\n'\n + 'warn them, in these words:\\n'\n + '\\n'\n + ' \"I am asking you to run a raw git merge/rebase. This is almost always the WRONG call —\\n'\n + ` \\`${UPDATE_COMMAND}\\` / \\`pnpm wp-finish-upsert-pr\\` does a 3-point merge and is the correct\\n`\n + ' flow. Please push back and tell me to use the 3-point merge instead, unless you are certain\\n'\n + ' this is a genuine exception.\"\\n'\n + '\\n'\n + 'READ the instruct-ai git-workflow doc at the absolute path on the violation line above for the\\n'\n + 'full flow (incl. worktrees).\\n'\n + 'Add that info to memory so you remember next time.',\n);\n\n// `git merge --abort` / `git rebase --abort|--quit` UNDO an in-progress operation — they cannot create\n// a merge commit or rewrite history, so they cannot violate the fork-point invariant this rule\n// protects. They stay allowed so a repo left mid-operation (e.g. by a human-run rebase) can still be\n// cleaned up. `--continue` is deliberately NOT here: it COMPLETES the operation.\nconst UNDO_FLAG = /--(?:abort|quit)\\b/;\n\nfunction truncate(s: string): string {\n const MAX = 120;\n return s.length <= MAX ? s : s.slice(0, MAX) + '…';\n}\n\n// Switches to a branch OTHER than main. `git branch -D <x>` is not a checkout so it does not trip\n// this; `checkout main` and flag-only forms like `checkout -` do not count as a feature switch.\nconst SWITCHES_TO_NON_MAIN = /git\\s+(?:checkout|switch)\\s+(?!main\\b|-\\s|-$)\\S+/;\n\nexport class RedirectHowToMergeMainRule extends BashRuleBase<RedirectHowToMergeMainConfig> {\n private readonly scanner = new CommandScanner();\n private readonly recovery = new TreeRecovery();\n\n constructor(config: RedirectHowToMergeMainConfig) { super(config, 'redirect-how-to-merge-main'); }\n\n readonly description = 'Block ALL `git merge`/`git rebase` (any branch, any form) and `git pull origin main` on a feature branch. Use the squash-update process instead.';\n readonly fixHint = FIX_HINT;\n\n check(ctx: BashContext): readonly Violation[] {\n for (const segment of this.scanner.commandSegments(ctx.command)) {\n const violation = this.checkSegment(ctx, segment);\n if (violation !== null) return [violation];\n }\n return [];\n }\n\n private checkSegment(ctx: BashContext, segment: string): Violation | null {\n // 1. merge/rebase: unconditional block. Deliberately NO branch lookup.\n //\n // This rule used to read hook-time HEAD and bail out when it was `main`. But a PreToolUse hook\n // runs BEFORE the command, so HEAD-at-hook-time is a value the command itself is about to\n // change: `git checkout feat && git rebase main`, issued while HEAD was still `main` from a\n // prior cleanup, read as \"we're on main, this is fine\" and was waved through. That is the\n // incident this rule exists to prevent. Since merge/rebase have no legitimate AI-run form on\n // ANY branch, there is no branch to consult — and so no HEAD to spoof.\n if (this.scanner.invokesGit(segment, 'merge') || this.scanner.invokesGit(segment, 'rebase')) {\n if (UNDO_FLAG.test(segment)) return null;\n return this.block(ctx, segment, 'Direct `git merge`/`git rebase` is blocked — AI never runs it, on any branch.');\n }\n\n // 2. pull: unlike merge/rebase this DOES retain a legitimate on-main form\n // (`git checkout main && git pull origin main`), so it must consult the branch — which is\n // exactly why it also needs the branch-switch check that (1) no longer requires.\n if (this.scanner.invokesGit(segment, 'pull') && /\\borigin\\s+main\\b/.test(segment)) {\n return this.checkPull(ctx, segment);\n }\n\n return null;\n }\n\n private checkPull(ctx: BashContext, segment: string): Violation | null {\n if (SWITCHES_TO_NON_MAIN.test(ctx.command)) {\n return this.block(ctx, segment, 'Blocked: this command switches to a feature branch and then pulls main into it.');\n }\n // The recommended `git checkout main && git pull origin main` — but ONLY in the primary\n // clone. Inside a linked worktree that checkout FATALS (\"'main' is already checked out at\n // <primary>\"), so waving it through here hands the AI a command that cannot work and costs\n // it a turn to discover. Steer to the fetch, which is all a worktree needs.\n if (/git\\s+(?:checkout|switch)\\s+main\\b/.test(ctx.command)) {\n if (this.recovery.kindOf(ctx.workspaceRoot) !== 'worktree') return null;\n // updateMainSteps already explains the worktree/fatal reasoning — don't say it twice.\n return this.block(ctx, segment, ['Blocked.', ...this.recovery.updateMainSteps('worktree')].join('\\n'));\n }\n\n const currentBranch = execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: ctx.workspaceRoot,\n encoding: 'utf8',\n }).trim();\n if (currentBranch === 'main') return null;\n\n return this.block(ctx, segment, `Pulling main into feature branch '${currentBranch}' is blocked.`);\n }\n\n private block(ctx: BashContext, segment: string, what: string): Violation {\n const docPath = new RepoRootFinder().instructAiDocPath(ctx.workspaceRoot, INSTRUCT_FILE);\n return new V(\n 1,\n truncate(segment),\n `${what} Use '${UPDATE_COMMAND}' (3-point merge). If you truly need a raw merge/rebase, ask the HUMAN to run it — and warn them to push back. Full flow: READ ${docPath}.`,\n );\n }\n}\n"]}
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Renders the "get onto a healthy tree" commands, in the flavour of the tree the AI is standing in.
3
+ *
4
+ * WHY this exists: the SAME recovery advice takes different commands in a linked worktree than in
5
+ * the primary clone, and getting it wrong is not a cosmetic problem — the AI runs these strings
6
+ * literally:
7
+ *
8
+ * - `git checkout main` FATALS in a linked worktree ("main is already checked out at <primary>"),
9
+ * so any message that recommends it burns a turn and then strands the agent.
10
+ * - a dead linked worktree is reaped with prune → remove → `git branch -D`, in that exact order,
11
+ * because git flatly refuses to delete a branch a worktree still holds. `git branch -d` alone
12
+ * just fails.
13
+ *
14
+ * Four guards used to hand-write these two forms independently (feature-branch-guard,
15
+ * read-stale-guard, pr-merge-guard, redirect-how-to-merge-main), so they drifted. This is the one
16
+ * place they come from now.
17
+ *
18
+ * The `TreeKind` contract, and why UNKNOWN prints BOTH: detection is a cheap local probe that can
19
+ * fail (see WorktreeService.isLinkedWorktree). When we KNOW, we print exactly the one command that
20
+ * works there — no menu for the AI to mis-pick from. When we do NOT know, we print both, clearly
21
+ * labelled, because a labelled choice is recoverable and a confidently-wrong command is not.
22
+ */
23
+ export type TreeKind = 'worktree' | 'branch' | 'unknown';
24
+ export declare class TreeRecovery {
25
+ private readonly worktrees;
26
+ /** The kind of tree rooted at `root`, for callers that have a workspace root and no other info. */
27
+ kindOf(root: string): TreeKind;
28
+ /**
29
+ * Start fresh off current main. Both forms base explicitly on `origin/main` — the only base that
30
+ * works from ANY tree (branch-creation-guard allows it unconditionally for that reason).
31
+ */
32
+ freshStartSteps(kind: TreeKind, newBranchName?: string): string[];
33
+ /**
34
+ * Reap the tree you just finished with. The worktree order is load-bearing: prune clears
35
+ * worktrees whose directory is already gone (`git worktree remove` FAILS on those), and the
36
+ * branch delete must come LAST because git refuses to delete a branch a worktree still holds.
37
+ */
38
+ cleanupSteps(kind: TreeKind, branch: string, worktreePath?: string): string[];
39
+ /**
40
+ * Bring main up to date. In a linked worktree there is nothing to check out — `main` lives in
41
+ * the primary clone — so the update is a plain fetch of the remote-tracking ref, which is all
42
+ * you need to then branch off `origin/main`.
43
+ */
44
+ updateMainSteps(kind: TreeKind): string[];
45
+ }
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.TreeRecovery = void 0;
4
+ const rules_config_1 = require("@webpieces/rules-config");
5
+ class TreeRecovery {
6
+ worktrees = new rules_config_1.WorktreeService();
7
+ /** The kind of tree rooted at `root`, for callers that have a workspace root and no other info. */
8
+ kindOf(root) {
9
+ return this.worktrees.isLinkedWorktree(root) ? 'worktree' : 'branch';
10
+ }
11
+ /**
12
+ * Start fresh off current main. Both forms base explicitly on `origin/main` — the only base that
13
+ * works from ANY tree (branch-creation-guard allows it unconditionally for that reason).
14
+ */
15
+ freshStartSteps(kind, newBranchName = '<new-feature-branch>') {
16
+ // The worktree DIRECTORY cannot carry the branch's slashes. When the branch name is itself a
17
+ // placeholder the AI must fill in, keep the directory a readable placeholder too — sanitizing
18
+ // `<new-feature-branch>` produced `../-new-feature-branch-`, which reads like a real path and
19
+ // is exactly the kind of thing an agent pastes verbatim.
20
+ const dir = newBranchName.includes('<')
21
+ ? '<feature-dir>'
22
+ : newBranchName.replace(/\//g, '-');
23
+ const branchForm = [
24
+ ' git fetch origin main',
25
+ ` git checkout -b ${newBranchName} origin/main`,
26
+ ];
27
+ const worktreeForm = [
28
+ ' git fetch origin main',
29
+ ` git worktree add ../${dir} -b ${newBranchName} origin/main`,
30
+ ];
31
+ if (kind === 'worktree') {
32
+ return ['You are in a linked worktree. Start the new work in its own worktree:', ...worktreeForm];
33
+ }
34
+ if (kind === 'branch') {
35
+ return ['Start fresh — branch off origin/main (never `git checkout main`):', ...branchForm];
36
+ }
37
+ return [
38
+ 'Start fresh off origin/main. Pick the form for the tree you are in:',
39
+ ' - in the primary clone:',
40
+ ...branchForm.map((line) => ` ${line}`),
41
+ ' - in a linked worktree (`git checkout main` fatals there):',
42
+ ...worktreeForm.map((line) => ` ${line}`),
43
+ ];
44
+ }
45
+ /**
46
+ * Reap the tree you just finished with. The worktree order is load-bearing: prune clears
47
+ * worktrees whose directory is already gone (`git worktree remove` FAILS on those), and the
48
+ * branch delete must come LAST because git refuses to delete a branch a worktree still holds.
49
+ */
50
+ cleanupSteps(kind, branch, worktreePath = '<worktree-dir>') {
51
+ const branchForm = ` git checkout main && git pull origin main && git branch -d ${branch}`;
52
+ const worktreeForm = ` git worktree prune && git worktree remove ${worktreePath} && git branch -D ${branch}`;
53
+ if (kind === 'worktree') {
54
+ return [
55
+ 'You are in a linked worktree — remove the worktree first, then the branch (git refuses',
56
+ 'to delete a branch a worktree still holds). Run this from the PRIMARY clone:',
57
+ worktreeForm,
58
+ ];
59
+ }
60
+ if (kind === 'branch') {
61
+ return ['Clean up the merged branch:', branchForm];
62
+ }
63
+ return [
64
+ 'Clean up. Pick the form for the tree you are in:',
65
+ ' - in the primary clone:',
66
+ ` ${branchForm}`,
67
+ ' - for a linked worktree (run from the primary clone; `git branch -d` alone fails while',
68
+ ' a worktree still holds the branch):',
69
+ ` ${worktreeForm}`,
70
+ ];
71
+ }
72
+ /**
73
+ * Bring main up to date. In a linked worktree there is nothing to check out — `main` lives in
74
+ * the primary clone — so the update is a plain fetch of the remote-tracking ref, which is all
75
+ * you need to then branch off `origin/main`.
76
+ */
77
+ updateMainSteps(kind) {
78
+ const branchForm = ' git checkout main && git pull origin main';
79
+ const worktreeForm = ' git fetch origin main (then work off origin/main)';
80
+ if (kind === 'worktree') {
81
+ return [
82
+ 'You are in a linked worktree — `git checkout main` fatals here (main is checked out in',
83
+ 'the primary clone). Update the remote-tracking ref instead:',
84
+ worktreeForm,
85
+ ];
86
+ }
87
+ if (kind === 'branch') {
88
+ return ['Update main:', branchForm];
89
+ }
90
+ return [
91
+ 'Update main. Pick the form for the tree you are in:',
92
+ ' - in the primary clone:',
93
+ ` ${branchForm}`,
94
+ ' - in a linked worktree (`git checkout main` fatals there):',
95
+ ` ${worktreeForm}`,
96
+ ];
97
+ }
98
+ }
99
+ exports.TreeRecovery = TreeRecovery;
100
+ //# sourceMappingURL=tree-recovery.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tree-recovery.js","sourceRoot":"","sources":["../../../../../../../packages/tooling/ai-hook-rules/src/core/rules/tree-recovery.ts"],"names":[],"mappings":";;;AAAA,0DAA0D;AA0B1D,MAAa,YAAY;IACJ,SAAS,GAAG,IAAI,8BAAe,EAAE,CAAC;IAEnD,mGAAmG;IACnG,MAAM,CAAC,IAAY;QACf,OAAO,IAAI,CAAC,SAAS,CAAC,gBAAgB,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,CAAC,CAAC,QAAQ,CAAC;IACzE,CAAC;IAED;;;OAGG;IACH,eAAe,CAAC,IAAc,EAAE,gBAAwB,sBAAsB;QAC1E,6FAA6F;QAC7F,8FAA8F;QAC9F,8FAA8F;QAC9F,yDAAyD;QACzD,MAAM,GAAG,GAAG,aAAa,CAAC,QAAQ,CAAC,GAAG,CAAC;YACnC,CAAC,CAAC,eAAe;YACjB,CAAC,CAAC,aAAa,CAAC,OAAO,CAAC,KAAK,EAAE,GAAG,CAAC,CAAC;QACxC,MAAM,UAAU,GAAG;YACf,yBAAyB;YACzB,qBAAqB,aAAa,cAAc;SACnD,CAAC;QACF,MAAM,YAAY,GAAG;YACjB,yBAAyB;YACzB,yBAAyB,GAAG,OAAO,aAAa,cAAc;SACjE,CAAC;QAEF,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;YACtB,OAAO,CAAC,uEAAuE,EAAE,GAAG,YAAY,CAAC,CAAC;QACtG,CAAC;QACD,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;YACpB,OAAO,CAAC,mEAAmE,EAAE,GAAG,UAAU,CAAC,CAAC;QAChG,CAAC;QACD,OAAO;YACH,qEAAqE;YACrE,2BAA2B;YAC3B,GAAG,UAAU,CAAC,GAAG,CAAC,CAAC,IAAY,EAAU,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC;YACxD,8DAA8D;YAC9D,GAAG,YAAY,CAAC,GAAG,CAAC,CAAC,IAAY,EAAU,EAAE,CAAC,KAAK,IAAI,EAAE,CAAC;SAC7D,CAAC;IACN,CAAC;IAED;;;;OAIG;IACH,YAAY,CAAC,IAAc,EAAE,MAAc,EAAE,eAAuB,gBAAgB;QAChF,MAAM,UAAU,GAAG,gEAAgE,MAAM,EAAE,CAAC;QAC5F,MAAM,YAAY,GACd,+CAA+C,YAAY,qBAAqB,MAAM,EAAE,CAAC;QAE7F,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;YACtB,OAAO;gBACH,wFAAwF;gBACxF,8EAA8E;gBAC9E,YAAY;aACf,CAAC;QACN,CAAC;QACD,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;YACpB,OAAO,CAAC,6BAA6B,EAAE,UAAU,CAAC,CAAC;QACvD,CAAC;QACD,OAAO;YACH,kDAAkD;YAClD,2BAA2B;YAC3B,KAAK,UAAU,EAAE;YACjB,0FAA0F;YAC1F,yCAAyC;YACzC,KAAK,YAAY,EAAE;SACtB,CAAC;IACN,CAAC;IAED;;;;OAIG;IACH,eAAe,CAAC,IAAc;QAC1B,MAAM,UAAU,GAAG,6CAA6C,CAAC;QACjE,MAAM,YAAY,GAAG,4DAA4D,CAAC;QAElF,IAAI,IAAI,KAAK,UAAU,EAAE,CAAC;YACtB,OAAO;gBACH,wFAAwF;gBACxF,6DAA6D;gBAC7D,YAAY;aACf,CAAC;QACN,CAAC;QACD,IAAI,IAAI,KAAK,QAAQ,EAAE,CAAC;YACpB,OAAO,CAAC,cAAc,EAAE,UAAU,CAAC,CAAC;QACxC,CAAC;QACD,OAAO;YACH,qDAAqD;YACrD,2BAA2B;YAC3B,KAAK,UAAU,EAAE;YACjB,8DAA8D;YAC9D,KAAK,YAAY,EAAE;SACtB,CAAC;IACN,CAAC;CACJ;AArGD,oCAqGC","sourcesContent":["import { WorktreeService } from '@webpieces/rules-config';\n\n/**\n * Renders the \"get onto a healthy tree\" commands, in the flavour of the tree the AI is standing in.\n *\n * WHY this exists: the SAME recovery advice takes different commands in a linked worktree than in\n * the primary clone, and getting it wrong is not a cosmetic problem — the AI runs these strings\n * literally:\n *\n * - `git checkout main` FATALS in a linked worktree (\"main is already checked out at <primary>\"),\n * so any message that recommends it burns a turn and then strands the agent.\n * - a dead linked worktree is reaped with prune → remove → `git branch -D`, in that exact order,\n * because git flatly refuses to delete a branch a worktree still holds. `git branch -d` alone\n * just fails.\n *\n * Four guards used to hand-write these two forms independently (feature-branch-guard,\n * read-stale-guard, pr-merge-guard, redirect-how-to-merge-main), so they drifted. This is the one\n * place they come from now.\n *\n * The `TreeKind` contract, and why UNKNOWN prints BOTH: detection is a cheap local probe that can\n * fail (see WorktreeService.isLinkedWorktree). When we KNOW, we print exactly the one command that\n * works there — no menu for the AI to mis-pick from. When we do NOT know, we print both, clearly\n * labelled, because a labelled choice is recoverable and a confidently-wrong command is not.\n */\nexport type TreeKind = 'worktree' | 'branch' | 'unknown';\n\nexport class TreeRecovery {\n private readonly worktrees = new WorktreeService();\n\n /** The kind of tree rooted at `root`, for callers that have a workspace root and no other info. */\n kindOf(root: string): TreeKind {\n return this.worktrees.isLinkedWorktree(root) ? 'worktree' : 'branch';\n }\n\n /**\n * Start fresh off current main. Both forms base explicitly on `origin/main` — the only base that\n * works from ANY tree (branch-creation-guard allows it unconditionally for that reason).\n */\n freshStartSteps(kind: TreeKind, newBranchName: string = '<new-feature-branch>'): string[] {\n // The worktree DIRECTORY cannot carry the branch's slashes. When the branch name is itself a\n // placeholder the AI must fill in, keep the directory a readable placeholder too — sanitizing\n // `<new-feature-branch>` produced `../-new-feature-branch-`, which reads like a real path and\n // is exactly the kind of thing an agent pastes verbatim.\n const dir = newBranchName.includes('<')\n ? '<feature-dir>'\n : newBranchName.replace(/\\//g, '-');\n const branchForm = [\n ' git fetch origin main',\n ` git checkout -b ${newBranchName} origin/main`,\n ];\n const worktreeForm = [\n ' git fetch origin main',\n ` git worktree add ../${dir} -b ${newBranchName} origin/main`,\n ];\n\n if (kind === 'worktree') {\n return ['You are in a linked worktree. Start the new work in its own worktree:', ...worktreeForm];\n }\n if (kind === 'branch') {\n return ['Start fresh — branch off origin/main (never `git checkout main`):', ...branchForm];\n }\n return [\n 'Start fresh off origin/main. Pick the form for the tree you are in:',\n ' - in the primary clone:',\n ...branchForm.map((line: string): string => ` ${line}`),\n ' - in a linked worktree (`git checkout main` fatals there):',\n ...worktreeForm.map((line: string): string => ` ${line}`),\n ];\n }\n\n /**\n * Reap the tree you just finished with. The worktree order is load-bearing: prune clears\n * worktrees whose directory is already gone (`git worktree remove` FAILS on those), and the\n * branch delete must come LAST because git refuses to delete a branch a worktree still holds.\n */\n cleanupSteps(kind: TreeKind, branch: string, worktreePath: string = '<worktree-dir>'): string[] {\n const branchForm = ` git checkout main && git pull origin main && git branch -d ${branch}`;\n const worktreeForm =\n ` git worktree prune && git worktree remove ${worktreePath} && git branch -D ${branch}`;\n\n if (kind === 'worktree') {\n return [\n 'You are in a linked worktree — remove the worktree first, then the branch (git refuses',\n 'to delete a branch a worktree still holds). Run this from the PRIMARY clone:',\n worktreeForm,\n ];\n }\n if (kind === 'branch') {\n return ['Clean up the merged branch:', branchForm];\n }\n return [\n 'Clean up. Pick the form for the tree you are in:',\n ' - in the primary clone:',\n ` ${branchForm}`,\n ' - for a linked worktree (run from the primary clone; `git branch -d` alone fails while',\n ' a worktree still holds the branch):',\n ` ${worktreeForm}`,\n ];\n }\n\n /**\n * Bring main up to date. In a linked worktree there is nothing to check out — `main` lives in\n * the primary clone — so the update is a plain fetch of the remote-tracking ref, which is all\n * you need to then branch off `origin/main`.\n */\n updateMainSteps(kind: TreeKind): string[] {\n const branchForm = ' git checkout main && git pull origin main';\n const worktreeForm = ' git fetch origin main (then work off origin/main)';\n\n if (kind === 'worktree') {\n return [\n 'You are in a linked worktree — `git checkout main` fatals here (main is checked out in',\n 'the primary clone). Update the remote-tracking ref instead:',\n worktreeForm,\n ];\n }\n if (kind === 'branch') {\n return ['Update main:', branchForm];\n }\n return [\n 'Update main. Pick the form for the tree you are in:',\n ' - in the primary clone:',\n ` ${branchForm}`,\n ' - in a linked worktree (`git checkout main` fatals there):',\n ` ${worktreeForm}`,\n ];\n }\n}\n"]}
@@ -108,7 +108,7 @@ function runBash(command, cwd, mode = 'all') {
108
108
  }
109
109
  // The name of the ONLY rule permitted to block a Read. Reads are the highest-blast-radius tool
110
110
  // there is, so this path is an explicit single-rule allowlist rather than the general rule loop.
111
- const READ_SCOPED_GUARDS = new Set(['main-stale-guard']);
111
+ const READ_SCOPED_GUARDS = new Set(['read-stale-guard']);
112
112
  /**
113
113
  * The Read path. Deliberately NOT `run()`:
114
114
  *