@webpieces/ai-hook-rules 0.3.288 → 0.3.290

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webpieces/ai-hook-rules",
3
- "version": "0.3.288",
3
+ "version": "0.3.290",
4
4
  "description": "Pluggable write-time validation framework for AI coding agents (@webpieces/ai-hook-rules). Claude Code PreToolUse + openclaw before_tool_call adapters share one rule engine.",
5
5
  "type": "commonjs",
6
6
  "main": "./src/index.js",
@@ -35,7 +35,7 @@
35
35
  "directory": "packages/tooling/ai-hook-rules"
36
36
  },
37
37
  "dependencies": {
38
- "@webpieces/rules-config": "0.3.288"
38
+ "@webpieces/rules-config": "0.3.290"
39
39
  },
40
40
  "publishConfig": {
41
41
  "access": "public"
@@ -71,7 +71,7 @@ class FeatureBranchGuardRule extends rule_base_1.FileRuleBase {
71
71
  }
72
72
  // State 4: origin/main moved and touches files you changed.
73
73
  if (status.conflict) {
74
- return this.block(ctx, branch, 'main-moved-conflict', this.conflictMessage(status.conflictFiles), cache);
74
+ return this.block(ctx, branch, 'main-moved-conflict', this.conflictMessage(status.conflictFiles, status.openPr), cache);
75
75
  }
76
76
  return this.allow(ctx, branch, 'clean-feature-branch', cache);
77
77
  }
@@ -131,26 +131,41 @@ class FeatureBranchGuardRule extends rule_base_1.FileRuleBase {
131
131
  'Please add to memory: switch to main (and delete the branch) after a PR is merged.',
132
132
  ].join('\n');
133
133
  }
134
- conflictMessage(conflictFiles) {
134
+ conflictMessage(conflictFiles, openPr) {
135
135
  const files = conflictFiles.length > 0
136
136
  ? conflictFiles.map((f) => ` - ${f}`).join('\n')
137
137
  : ' (see git diff)';
138
- return [
138
+ const header = [
139
139
  'origin/main moved and touched files you also changed since your fork point:',
140
140
  files,
141
141
  '',
142
+ ];
143
+ // Steer EARLY: if a PR already tracks this branch, the update-only flow would just fail-fast
144
+ // (a 3-point update strands the PR on the old branch generation), so recommend ONLY the PR
145
+ // flow and don't waste the AI's tokens on wp-update-start.
146
+ if (openPr !== '') {
147
+ return header.concat([
148
+ `An OPEN PR (#${openPr}) already tracks this branch, so you MUST use the PR flow (it`,
149
+ 're-merges main AND updates the PR — the update-only flow would strand the PR on the',
150
+ 'old branch generation):',
151
+ ' 1. pnpm wp-start-upsert-pr ← merges main + writes 3-point context',
152
+ ' 2. /wp-merge ← resolve each conflicted file (only if conflicts)',
153
+ ' 3. pnpm wp-finish-upsert-pr ← validate, build, push, upsert the PR',
154
+ ]).join('\n');
155
+ }
156
+ return header.concat([
142
157
  'You must merge main in before editing further. If you are mid-work and just want to keep',
143
158
  'editing (no PR yet), use the UPDATE-ONLY flow:',
144
159
  ' 1. pnpm wp-update-start ← merges main (auto-finalizes if clean; renames <branch>wpN → wpN+1)',
145
160
  ' 2. /wp-merge ← resolve each conflicted file (only if there are conflicts)',
146
161
  ' 3. pnpm wp-update-end ← finalize the merge (only after resolving)',
147
162
  '',
148
- 'If you already have an OPEN PR for this branch, use the PR flow instead (it updates the',
149
- "PR's pushed branchwp-update-start does NOT push):",
163
+ 'If you already have an OPEN PR for this branch, use the PR flow instead (wp-update-start',
164
+ 'refuses when a PR exists it would strand the PR on the old branch generation):',
150
165
  ' 1. pnpm wp-start-upsert-pr ← merges main + writes 3-point context',
151
166
  ' 2. /wp-merge ← resolve each conflicted file',
152
167
  ' 3. pnpm wp-finish-upsert-pr ← validate, build, push, upsert the PR',
153
- ].join('\n');
168
+ ]).join('\n');
154
169
  }
155
170
  noForkPointMessage(branch) {
156
171
  return [
@@ -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 {\n FeatureBranchGuardConfig,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n readMainSyncStatus,\n squashRecoverySteps,\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';\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 */\nexport class FeatureBranchGuardRule extends FileRuleBase<FeatureBranchGuardConfig> {\n constructor(config: FeatureBranchGuardConfig) { super(config, 'feature-branch-guard'); }\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 (mid-work) → `pnpm wp-update-start` (merge), `/wp-merge` (resolve), `pnpm wp-update-end`. Have an OPEN PR? use `pnpm wp-start-upsert-pr` → `/wp-merge` → `pnpm wp-finish-upsert-pr`.'),\n new Option('Disable in webpieces.config.json under feature-branch-guard (mode OFF) if intentional.'),\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.allow(ctx, branch, 'branch-undeterminable (fail-open)');\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, this.config.hangTimeoutMinutes ?? DEFAULT_HANG_TIMEOUT_MINUTES);\n\n const status = readMainSyncStatus(ctx.workspaceRoot);\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.allow(ctx, branch, 'no-sync-cache (fail-open)', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // Stale cross-branch cache: the cached status is for a DIFFERENT branch (e.g. you just\n // switched branches and the refresh for this one hasn't landed yet). Never block on another\n // branch's signals — fail open; the refresh we just spawned rewrites it for this branch.\n if (status.branch !== branch) return this.allow(ctx, branch, 'stale-cross-branch-cache (fail-open)', 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 return this.block(ctx, branch, `already-merged PR#${pr}`, this.alreadyMergedMessage(branch, status.mergedPr), 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), cache);\n }\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 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('feature-branch-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason, cache),\n );\n }\n\n private currentBranch(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot,\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 private onMainMessage(): string {\n const convention = this.config.branchNamingConvention ?? '{whoami}/{featurename}';\n return [\n 'You should not be working on main.',\n 'Do a `git pull origin main` to get latest, then create a feature branch based on the naming convention.',\n `Branch naming convention (from webpieces.config.json): ${convention}`,\n 'Example: git checkout -b ' + convention.replace(/<[^>]+>/g, 'value'),\n ].join('\\n');\n }\n\n private alreadyMergedMessage(branch: string, mergedPr: string): string {\n const pr = mergedPr !== '' ? ` (merged PR #${mergedPr})` : '';\n return [\n `It looks like you forgot to switch to main and delete this branch \"${branch}\" — its PR is already merged into main${pr}.`,\n 'Your work is in main — do NOT keep editing this stale branch (you will reconflict with main).',\n 'Start fresh:',\n ' 1. git checkout main',\n ' 2. git pull',\n ' 3. git checkout -b <new-feature-branch>',\n 'Please add to memory: switch to main (and delete the branch) after a PR is merged.',\n ].join('\\n');\n }\n\n private conflictMessage(conflictFiles: readonly string[]): string {\n const files = conflictFiles.length > 0\n ? conflictFiles.map((f: string): string => ` - ${f}`).join('\\n')\n : ' (see git diff)';\n return [\n 'origin/main moved and touched files you also changed since your fork point:',\n files,\n '',\n 'You must merge main in before editing further. If you are mid-work and just want to keep',\n 'editing (no PR yet), use the UPDATE-ONLY flow:',\n ' 1. pnpm wp-update-start ← merges main (auto-finalizes if clean; renames <branch>wpN → wpN+1)',\n ' 2. /wp-merge ← resolve each conflicted file (only if there are conflicts)',\n ' 3. pnpm wp-update-end ← finalize the merge (only after resolving)',\n '',\n 'If you already have an OPEN PR for this branch, use the PR flow instead (it updates the',\n \"PR's pushed branch — wp-update-start does NOT push):\",\n ' 1. pnpm wp-start-upsert-pr ← merges main + writes 3-point context',\n ' 2. /wp-merge ← resolve each conflicted file',\n ' 3. pnpm wp-finish-upsert-pr ← validate, build, push, upsert the PR',\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 {\n FeatureBranchGuardConfig,\n DEFAULT_HANG_TIMEOUT_MINUTES,\n readMainSyncStatus,\n squashRecoverySteps,\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';\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 */\nexport class FeatureBranchGuardRule extends FileRuleBase<FeatureBranchGuardConfig> {\n constructor(config: FeatureBranchGuardConfig) { super(config, 'feature-branch-guard'); }\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 (mid-work) → `pnpm wp-update-start` (merge), `/wp-merge` (resolve), `pnpm wp-update-end`. Have an OPEN PR? use `pnpm wp-start-upsert-pr` → `/wp-merge` → `pnpm wp-finish-upsert-pr`.'),\n new Option('Disable in webpieces.config.json under feature-branch-guard (mode OFF) if intentional.'),\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.allow(ctx, branch, 'branch-undeterminable (fail-open)');\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, this.config.hangTimeoutMinutes ?? DEFAULT_HANG_TIMEOUT_MINUTES);\n\n const status = readMainSyncStatus(ctx.workspaceRoot);\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.allow(ctx, branch, 'no-sync-cache (fail-open)', 'cache=none');\n\n const cache = this.cacheSummary(status);\n // Stale cross-branch cache: the cached status is for a DIFFERENT branch (e.g. you just\n // switched branches and the refresh for this one hasn't landed yet). Never block on another\n // branch's signals — fail open; the refresh we just spawned rewrites it for this branch.\n if (status.branch !== branch) return this.allow(ctx, branch, 'stale-cross-branch-cache (fail-open)', 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 return this.block(ctx, branch, `already-merged PR#${pr}`, this.alreadyMergedMessage(branch, status.mergedPr), 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 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 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('feature-branch-guard', ctx.tool, ctx.relativePath, branch ?? 'unknown', verdict, reason, cache),\n );\n }\n\n private currentBranch(workspaceRoot: string): string | null {\n // eslint-disable-next-line @webpieces/no-unmanaged-exceptions\n try {\n return execSync('git rev-parse --abbrev-ref HEAD', {\n cwd: workspaceRoot,\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 private onMainMessage(): string {\n const convention = this.config.branchNamingConvention ?? '{whoami}/{featurename}';\n return [\n 'You should not be working on main.',\n 'Do a `git pull origin main` to get latest, then create a feature branch based on the naming convention.',\n `Branch naming convention (from webpieces.config.json): ${convention}`,\n 'Example: git checkout -b ' + convention.replace(/<[^>]+>/g, 'value'),\n ].join('\\n');\n }\n\n private alreadyMergedMessage(branch: string, mergedPr: string): string {\n const pr = mergedPr !== '' ? ` (merged PR #${mergedPr})` : '';\n return [\n `It looks like you forgot to switch to main and delete this branch \"${branch}\" — its PR is already merged into main${pr}.`,\n 'Your work is in main — do NOT keep editing this stale branch (you will reconflict with main).',\n 'Start fresh:',\n ' 1. git checkout main',\n ' 2. git pull',\n ' 3. git checkout -b <new-feature-branch>',\n 'Please add to memory: switch to main (and delete the branch) after a PR is merged.',\n ].join('\\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-update-start.\n if (openPr !== '') {\n return header.concat([\n `An OPEN PR (#${openPr}) already tracks this branch, so you MUST use the PR flow (it`,\n 're-merges main AND updates the PR — the update-only flow would strand the PR on the',\n 'old branch generation):',\n ' 1. pnpm wp-start-upsert-pr ← merges main + writes 3-point context',\n ' 2. /wp-merge ← resolve each conflicted file (only if conflicts)',\n ' 3. pnpm wp-finish-upsert-pr ← validate, build, push, upsert the PR',\n ]).join('\\n');\n }\n return header.concat([\n 'You must merge main in before editing further. If you are mid-work and just want to keep',\n 'editing (no PR yet), use the UPDATE-ONLY flow:',\n ' 1. pnpm wp-update-start ← merges main (auto-finalizes if clean; renames <branch>wpN → wpN+1)',\n ' 2. /wp-merge ← resolve each conflicted file (only if there are conflicts)',\n ' 3. pnpm wp-update-end ← finalize the merge (only after resolving)',\n '',\n 'If you already have an OPEN PR for this branch, use the PR flow instead (wp-update-start',\n 'refuses when a PR exists — it would strand the PR on the old branch generation):',\n ' 1. pnpm wp-start-upsert-pr ← merges main + writes 3-point context',\n ' 2. /wp-merge ← resolve each conflicted file',\n ' 3. pnpm wp-finish-upsert-pr ← validate, build, push, upsert the PR',\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"]}