diff --git a/.ai-team/agents/bishop/history.md b/.ai-team/agents/bishop/history.md
index cf9b7aa3a..ff0e02e2b 100644
--- a/.ai-team/agents/bishop/history.md
+++ b/.ai-team/agents/bishop/history.md
@@ -105,3 +105,112 @@ Added `Convert-PageLifecycleMethods` (GAP-05) and `Convert-EventHandlerSignature
- GAP-05: Page_Load OnInitializedAsync, Page_Init OnInitialized, Page_PreRender OnAfterRenderAsync(bool firstRender)
- GAP-07: Standard EventArgs handlers strip both params; specialized *EventArgs handlers keep the EventArgs param, strip sender
- Updated 6 expected test files. All 21 L1 tests pass at 100% line accuracy.
+
+## Phase 3: Code-Behind C# Transforms — TC13-TC21 (2026-03-30)
+
+Ported all 10 code-behind transforms from PowerShell regex patterns to C# classes implementing `ICodeBehindTransform`.
+
+### Transforms Built (in pipeline order)
+| Order | Class | Coverage |
+|-------|-------|----------|
+| 10 | `TodoHeaderTransform` | Injects migration guidance header |
+| 100 | `UsingStripTransform` | Strips System.Web.*, Microsoft.AspNet.*, Microsoft.Owin.*, Owin usings |
+| 200 | `BaseClassStripTransform` | Removes `: System.Web.UI.Page` etc. from partial classes |
+| 300 | `ResponseRedirectTransform` | `Response.Redirect()` → `NavigationManager.NavigateTo()` + [Inject] injection |
+| 400 | `SessionDetectTransform` | Detects `Session["key"]`, generates migration guidance block |
+| 410 | `ViewStateDetectTransform` | Detects `ViewState["key"]`, generates field replacement suggestions |
+| 500 | `IsPostBackTransform` | Unwraps simple `if (!IsPostBack)` guards (brace-counting), TODO for else clauses |
+| 600 | `PageLifecycleTransform` | Page_Load→OnInitializedAsync, Page_Init→OnInitialized, Page_PreRender→OnAfterRenderAsync |
+| 700 | `EventHandlerSignatureTransform` | Strips (object sender, EventArgs e); keeps specialized EventArgs |
+| 800 | `DataBindTransform` | Cross-file DataSource/DataBind handling + InjectItemsAttributes for markup |
+| 900 | `UrlCleanupTransform` | .aspx URL literals → clean routes |
+
+### Infrastructure Changes
+- Added `TransformCodeBehind()` public method to `MigrationPipeline` for test access
+- Registered all 11 code-behind transforms in `Program.cs` DI container
+- Activated real pipeline in `TestHelpers.CreateDefaultPipeline()` (replaced TODO stubs)
+- Activated real assertions in `L1TransformTests` for both markup and code-behind tests
+- Fixed TC20/TC21 expected markup files: `OnClick="@Handler"` matches EventWiringTransform output
+
+### Key Learnings
+- **IDE0007 enforcement:** Project .editorconfig treats `var` preference as error. Always use `var` over explicit types.
+- **Transform ordering matters:** ResponseRedirect strips `~/` but preserves `.aspx`; UrlCleanup then handles `.aspx` patterns on `"~/..."` and relative NavigateTo forms. URLs like `/Products.aspx` survive because URL cleanup patterns don't match leading `/`.
+- **TodoHeader as standalone transform (Order 10):** Splitting the TODO header into its own transform class keeps Session/ViewState detect transforms cleaner — they find the marker and insert after it.
+- **Test discovery was previously placeholder:** The old tests only verified input ≠ expected. Wiring real pipeline exposed TC20/TC21 markup mismatches from EventWiringTransform `@` prefix.
+- **All 72 tests pass:** 21 markup + 8 code-behind + 4 infrastructure + 39 unit tests.
+
+### Phase 4: Scaffolding, Config Transforms, and Full Pipeline Wiring (Bishop)
+
+Ported scaffolding, config transforms, and OutputWriter from bwfc-migrate.ps1 to C#. Wired the full `migrate` command pipeline.
+
+#### New Files (9 total)
+| File | Purpose |
+|------|---------|
+| `Config/DatabaseProviderDetector.cs` | 3-pass DB provider detection from Web.config (providerName → conn string pattern → EntityClient inner) |
+| `Config/WebConfigTransformer.cs` | Parses Web.config appSettings + connectionStrings → appsettings.json (XDocument/LINQ to XML) |
+| `Io/OutputWriter.cs` | Centralized file writer: dry-run support, UTF-8 no BOM, directory creation, file tracking |
+| `Scaffolding/ProjectScaffolder.cs` | Generates .csproj, Program.cs, _Imports.razor, App.razor, Routes.razor, launchSettings.json |
+| `Scaffolding/GlobalUsingsGenerator.cs` | Generates GlobalUsings.cs with Blazor infrastructure + conditional Identity usings |
+| `Scaffolding/ShimGenerator.cs` | Generates WebFormsShims.cs + conditional IdentityShims.cs |
+
+#### Pipeline Changes
+- `MigrationPipeline.ExecuteAsync()` now runs: scaffold → config → per-file transforms → report
+- `MigrationReport` enhanced: JSON serialization (`--report`), console summary, manual items tracking
+- `Program.cs` DI wires all new services: ProjectScaffolder, GlobalUsingsGenerator, ShimGenerator, WebConfigTransformer, DatabaseProviderDetector, OutputWriter
+- 2-param constructor preserved on `MigrationPipeline` for backward-compatible test usage
+
+#### Key Patterns
+- **ProjectScaffolder detects HasModels/HasIdentity** from source directory structure (Models/, Account/, Login.aspx, Register.aspx) — adjusts .csproj packages and Program.cs boilerplate accordingly
+- **WebConfigTransformer skips built-in connection names** (LocalSqlServer, LocalMySqlServer) — matches PS behavior
+- **DatabaseProviderDetector maps 4 providers:** SqlClient→SqlServer, SQLite→Sqlite, Npgsql→PostgreSQL, MySql→MySql
+- **OutputWriter respects dry-run** — logs what would be written without touching disk
+- **All templates are string literals** — no external template files, matching PS approach
+- **Build clean:** 0 errors for both CLI and test projects
+
+### Phase 5: ClientScriptTransform — Phase 1 of PRD ClientScript Migration (Bishop)
+
+Added `ClientScriptTransform.cs` (Order 850) to the code-behind pipeline. Handles 6 ClientScript/ScriptManager patterns:
+
+#### Automatable (transforms to IJSRuntime skeleton):
+| Pattern | Action |
+|---------|--------|
+| `RegisterStartupScript()` with inline script | → `await JS.InvokeVoidAsync("eval", ...)` + TODO to refactor eval |
+| `RegisterClientScriptInclude()` with URL | → `// TODO: Add to _Host.cshtml or App.razor` |
+| `ScriptManager.RegisterStartupScript()` | → Same as RegisterStartupScript |
+
+#### Non-automatable (TODO markers):
+| Pattern | TODO |
+|---------|------|
+| `GetPostBackEventReference()` | Replace __doPostBack with @onclick or EventCallback |
+| `RegisterClientScriptBlock()` | Move script block to IJSRuntime or .js file |
+| `ScriptManager.GetCurrent()` | No Blazor equivalent — use IJSRuntime directly |
+
+#### Infrastructure:
+- Injects `[Inject] private IJSRuntime JS { get; set; }` when startup script conversions are made
+- Registered in `Program.cs` and `TestHelpers.CreateDefaultPipeline()`
+- Test data: TC33-ClientScript (input .aspx/.aspx.cs + expected .razor/.razor.cs)
+- Updated L1TransformTests counts: 34 markup, 13 code-behind
+- **All 330 tests pass** (0 failures, 0 regressions)
+
+#### Key Regex Learnings:
+- `[^)]*` fails inside method calls with nested parens (e.g., `this.GetType()`)
+- `[^;]*` fails when string args contain semicolons (e.g., `""`)
+- **Safe pattern:** `(?:"[^"]*"|[^"])*?` — alternates quoted strings and non-quote chars, handles both issues
+
+### ClientScriptTransform: Switched to Shim-Preserving Mode (Bishop)
+
+- **Date:** 2026-07-31
+- **What changed:** `ClientScriptTransform.cs` (Order 850) no longer rewrites ClientScript calls to IJSRuntime skeletons. Instead, it preserves calls for use with `ClientScriptShim`.
+- **Shim-compatible patterns (prefix stripping, calls preserved):**
+ - `Page.ClientScript.RegisterStartupScript(...)` → `ClientScript.RegisterStartupScript(...)` (strip prefix)
+ - `Page.ClientScript.RegisterClientScriptInclude(...)` → `ClientScript.RegisterClientScriptInclude(...)` (strip prefix)
+ - `Page.ClientScript.RegisterClientScriptBlock(...)` → `ClientScript.RegisterClientScriptBlock(...)` (strip prefix, shim now supports this)
+ - `ScriptManager.RegisterStartupScript(control, type, key, script, bool)` → `ClientScript.RegisterStartupScript(type, key, script, bool)` (drops first param)
+- **Still TODO-marked (no shim support):**
+ - `GetPostBackEventReference(...)` → TODO with @onclick/EventCallback guidance (shim throws NotSupportedException)
+ - `ScriptManager.GetCurrent(...)` → TODO with IJSRuntime guidance (no shim equivalent)
+- **Removed:** IJSRuntime `[Inject]` injection logic. Replaced with single-line `ClientScriptShim` dependency comment at class level.
+- **Key principle:** Jeff's directive — "Zero-rewrite shim approach is PRECISELY what we should be building." CLI preserves Web Forms API calls instead of rewriting them.
+- **Tests:** All 349 tests pass (same count as before), updated 20 unit test assertions + TC33 expected output file.
+- **Regex approach:** Single `PageOrThisPrefixRegex` with lookahead handles all three shim-compatible methods in one pass. Much simpler than the old per-pattern regexes with inline script extraction.
+
diff --git a/.ai-team/agents/forge/history.md b/.ai-team/agents/forge/history.md
index d278c6ade..3487cef3d 100644
--- a/.ai-team/agents/forge/history.md
+++ b/.ai-team/agents/forge/history.md
@@ -218,3 +218,56 @@ Team updates (2026-03-04-05): PRs upstream, reports in docs/migration-tests/, be
Team update (2026-03-06): WebFormsPageBase is the canonical base class for all migrated pages (not ComponentBase). All agents must use WebFormsPageBase decided by Jeffrey T. Fritz
Team update (2026-03-06): LoginView is a native BWFC component do NOT convert to AuthorizeView. Strip asp: prefix only decided by Jeffrey T. Fritz
+
+### CLI Gap Analysis — webforms-to-blazor Tool (2026-07-25)
+
+**Task:** Comprehensive gap analysis of `src/BlazorWebFormsComponents.Cli` migration tool.
+
+**Current state (14 markup + 12 code-behind transforms):**
+- Markup: AspPrefix, AjaxToolkit, Attributes, Content, DataSource, Events, Expressions, Form, LoginView, MasterPage, SelectMethod, Template, URL transforms
+- Code-behind: BaseClass, DataBind, EventHandler, GetRouteUrl, IsPostBack, PageLifecycle, Response, Session, Todo, UrlCleanup, Using, ViewState transforms
+- Scaffolding: .csproj, Program.cs, _Imports.razor, App.razor, Routes.razor, GlobalUsings.cs, WebFormsShims.cs
+
+**Critical gaps identified (6 — will break real migrations):**
+1. **FindControl()** — no transform → compile error (every Web Forms app uses this)
+2. **ClientScript / RegisterStartupScript** — no transform → runtime failure
+3. **FormsAuthentication** — mentioned in shims but no actual transform
+4. **Membership/Roles** — no transform → compile error
+5. **VB.NET code-behind** — detected but C# regex patterns fail silently
+6. **User controls (.ascx)** — prefix stripped but no cross-file correlation, no tests
+
+**High-value additions (8 — cover most real-world apps):**
+1. ScriptManager code-behind patterns (GetCurrent, RegisterAsyncPostBackControl)
+2. Validation group handling (Page.Validate, Page.IsValid)
+3. GridView/ListView code-behind patterns (DataKeys, EditIndex)
+4. Request object patterns (QueryString, Form, Cookies)
+5. Server.MapPath conversion
+6. Global.asax pattern extraction
+7. Enum attribute conversions (TextMode, Display, GridLines → strong-typed)
+8. Static file url(~/) transforms
+
+**Quality issues in existing transforms:**
+1. **ItemType bug** — blindly converts all to TItem, but GridView/ListView/FormView use ItemType
+2. **LoginViewTransform conflicts with BWFC component** — should be REMOVED per Jeff's directive
+3. IsPostBack edge cases (nested, else-if) produce unparseable output
+4. DataBindTransform.InjectItemsAttributes() may not be called from pipeline
+
+**Scaffold gaps:**
+- Missing MainLayout.razor (Routes.razor references it)
+- Missing `@using BlazorWebFormsComponents.Enums` in _Imports.razor
+- Missing `AddHttpContextAccessor()` in Program.cs
+
+**Test coverage gaps (32 current → 45+ needed):**
+- TC33: .ascx user control (P0)
+- TC35: FindControl patterns (P0)
+- TC36-38: ClientScript, Membership, FormsAuth (P1)
+- TC47: VB.NET code-behind (P1)
+- TC48: Enum string attributes (P1)
+
+**Priority fixes:**
+- P0: Remove LoginViewTransform, fix ItemType/TItem logic, add .ascx test
+- P1: EnumAttributeTransform, FindControlTransform, RequestAccessTransform, FormsAuthTransform, ScriptManagerCodeBehindTransform, scaffold fixes
+
+**Estimated coverage improvement:** 70% → 88% L1 mechanical coverage after P0+P1+P2
+
+**Decision:** `.squad/decisions/inbox/forge-cli-gap-analysis.md`
diff --git a/.ai-team/agents/rogue/history.md b/.ai-team/agents/rogue/history.md
index 78abfe896..78ea1d558 100644
--- a/.ai-team/agents/rogue/history.md
+++ b/.ai-team/agents/rogue/history.md
@@ -145,3 +145,26 @@ Updated all 12 LoginStatus bUnit tests: replaced manual `Mock> $GITHUB_OUTPUT
- else
- echo "has_script=false" >> $GITHUB_OUTPUT
- echo "⚠️ ralph-triage.js not found — run 'squad upgrade' to install"
- fi
-
- - name: Ralph — Smart triage
- if: steps.check-script.outputs.has_script == 'true'
- env:
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- node .squad/templates/ralph-triage.js \
- --squad-dir .squad \
- --output triage-results.json
-
- - name: Ralph — Apply triage decisions
- if: steps.check-script.outputs.has_script == 'true' && hashFiles('triage-results.json') != ''
- uses: actions/github-script@v7
- with:
- script: |
- const fs = require('fs');
- const path = 'triage-results.json';
- if (!fs.existsSync(path)) {
- core.info('No triage results — board is clear');
- return;
- }
-
- const results = JSON.parse(fs.readFileSync(path, 'utf8'));
- if (results.length === 0) {
- core.info('📋 Board is clear — Ralph found no untriaged issues');
- return;
- }
-
- for (const decision of results) {
- try {
- await github.rest.issues.addLabels({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: decision.issueNumber,
- labels: [decision.label]
- });
-
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: decision.issueNumber,
- body: [
- '### 🔄 Ralph — Auto-Triage',
- '',
- `**Assigned to:** ${decision.assignTo}`,
- `**Reason:** ${decision.reason}`,
- `**Source:** ${decision.source}`,
- '',
- '> Ralph auto-triaged this issue using routing rules.',
- '> To reassign, swap the `squad:*` label.'
- ].join('\n')
- });
-
- core.info(`Triaged #${decision.issueNumber} → ${decision.assignTo} (${decision.source})`);
- } catch (e) {
- core.warning(`Failed to triage #${decision.issueNumber}: ${e.message}`);
- }
- }
-
- core.info(`🔄 Ralph triaged ${results.length} issue(s)`);
-
- # Copilot auto-assign step (uses PAT if available)
- - name: Ralph — Assign @copilot issues
- if: success()
- uses: actions/github-script@v7
- with:
- github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN || secrets.GITHUB_TOKEN }}
- script: |
- const fs = require('fs');
-
- let teamFile = '.squad/team.md';
- if (!fs.existsSync(teamFile)) {
- teamFile = '.ai-team/team.md';
- }
- if (!fs.existsSync(teamFile)) return;
-
- const content = fs.readFileSync(teamFile, 'utf8');
-
- // Check if @copilot is on the team with auto-assign
- const hasCopilot = content.includes('🤖 Coding Agent') || content.includes('@copilot');
- const autoAssign = content.includes('');
- if (!hasCopilot || !autoAssign) return;
-
- // Find issues labeled squad:copilot with no assignee
- try {
- const { data: copilotIssues } = await github.rest.issues.listForRepo({
- owner: context.repo.owner,
- repo: context.repo.repo,
- labels: 'squad:copilot',
- state: 'open',
- per_page: 5
- });
-
- const unassigned = copilotIssues.filter(i =>
- !i.assignees || i.assignees.length === 0
- );
-
- if (unassigned.length === 0) {
- core.info('No unassigned squad:copilot issues');
- return;
- }
-
- // Get repo default branch
- const { data: repoData } = await github.rest.repos.get({
- owner: context.repo.owner,
- repo: context.repo.repo
- });
-
- for (const issue of unassigned) {
- try {
- await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- assignees: ['copilot-swe-agent[bot]'],
- agent_assignment: {
- target_repo: `${context.repo.owner}/${context.repo.repo}`,
- base_branch: repoData.default_branch,
- custom_instructions: `Read .squad/team.md (or .ai-team/team.md) for team context and .squad/routing.md (or .ai-team/routing.md) for routing rules.`
- }
- });
- core.info(`Assigned copilot-swe-agent[bot] to #${issue.number}`);
- } catch (e) {
- core.warning(`Failed to assign @copilot to #${issue.number}: ${e.message}`);
- }
- }
- } catch (e) {
- core.info(`No squad:copilot label found or error: ${e.message}`);
- }
diff --git a/.github/workflows/squad-insider-release.yml b/.github/workflows/squad-insider-release.yml
deleted file mode 100644
index 63c6e325e..000000000
--- a/.github/workflows/squad-insider-release.yml
+++ /dev/null
@@ -1,34 +0,0 @@
-name: Squad Insider Release
-# dotnet project — configure build, test, and insider release commands below
-
-on:
- push:
- branches: [insider]
-
-permissions:
- contents: write
-
-jobs:
- release:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Build and test
- run: |
- # TODO: Add your dotnet build/test commands here
- # Go: go test ./...
- # Python: pip install -r requirements.txt && pytest
- # .NET: dotnet test
- # Java (Maven): mvn test
- # Java (Gradle): ./gradlew test
- echo "No build commands configured — update squad-insider-release.yml"
-
- - name: Create insider release
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- # TODO: Add your insider/pre-release commands here
- echo "No release commands configured — update squad-insider-release.yml"
diff --git a/.github/workflows/squad-issue-assign.yml b/.github/workflows/squad-issue-assign.yml
deleted file mode 100644
index ad140f42d..000000000
--- a/.github/workflows/squad-issue-assign.yml
+++ /dev/null
@@ -1,161 +0,0 @@
-name: Squad Issue Assign
-
-on:
- issues:
- types: [labeled]
-
-permissions:
- issues: write
- contents: read
-
-jobs:
- assign-work:
- # Only trigger on squad:{member} labels (not the base "squad" label)
- if: startsWith(github.event.label.name, 'squad:')
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
-
- - name: Identify assigned member and trigger work
- uses: actions/github-script@v7
- with:
- script: |
- const fs = require('fs');
- const issue = context.payload.issue;
- const label = context.payload.label.name;
-
- // Extract member name from label (e.g., "squad:ripley" → "ripley")
- const memberName = label.replace('squad:', '').toLowerCase();
-
- // Read team roster — check .squad/ first, fall back to .ai-team/
- let teamFile = '.squad/team.md';
- if (!fs.existsSync(teamFile)) {
- teamFile = '.ai-team/team.md';
- }
- if (!fs.existsSync(teamFile)) {
- core.warning('No .squad/team.md or .ai-team/team.md found — cannot assign work');
- return;
- }
-
- const content = fs.readFileSync(teamFile, 'utf8');
- const lines = content.split('\n');
-
- // Check if this is a coding agent assignment
- const isCopilotAssignment = memberName === 'copilot';
-
- let assignedMember = null;
- if (isCopilotAssignment) {
- assignedMember = { name: '@copilot', role: 'Coding Agent' };
- } else {
- let inMembersTable = false;
- for (const line of lines) {
- if (line.match(/^##\s+(Members|Team Roster)/i)) {
- inMembersTable = true;
- continue;
- }
- if (inMembersTable && line.startsWith('## ')) {
- break;
- }
- if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) {
- const cells = line.split('|').map(c => c.trim()).filter(Boolean);
- if (cells.length >= 2 && cells[0].toLowerCase() === memberName) {
- assignedMember = { name: cells[0], role: cells[1] };
- break;
- }
- }
- }
- }
-
- if (!assignedMember) {
- core.warning(`No member found matching label "${label}"`);
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- body: `⚠️ No squad member found matching label \`${label}\`. Check \`.squad/team.md\` (or \`.ai-team/team.md\`) for valid member names.`
- });
- return;
- }
-
- // Post assignment acknowledgment
- let comment;
- if (isCopilotAssignment) {
- comment = [
- `### 🤖 Routed to @copilot (Coding Agent)`,
- '',
- `**Issue:** #${issue.number} — ${issue.title}`,
- '',
- `@copilot has been assigned and will pick this up automatically.`,
- '',
- `> The coding agent will create a \`copilot/*\` branch and open a draft PR.`,
- `> Review the PR as you would any team member's work.`,
- ].join('\n');
- } else {
- comment = [
- `### 📋 Assigned to ${assignedMember.name} (${assignedMember.role})`,
- '',
- `**Issue:** #${issue.number} — ${issue.title}`,
- '',
- `${assignedMember.name} will pick this up in the next Copilot session.`,
- '',
- `> **For Copilot coding agent:** If enabled, this issue will be worked automatically.`,
- `> Otherwise, start a Copilot session and say:`,
- `> \`${assignedMember.name}, work on issue #${issue.number}\``,
- ].join('\n');
- }
-
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- body: comment
- });
-
- core.info(`Issue #${issue.number} assigned to ${assignedMember.name} (${assignedMember.role})`);
-
- # Separate step: assign @copilot using PAT (required for coding agent)
- - name: Assign @copilot coding agent
- if: github.event.label.name == 'squad:copilot'
- uses: actions/github-script@v7
- with:
- github-token: ${{ secrets.COPILOT_ASSIGN_TOKEN }}
- script: |
- const owner = context.repo.owner;
- const repo = context.repo.repo;
- const issue_number = context.payload.issue.number;
-
- // Get the default branch name (main, master, etc.)
- const { data: repoData } = await github.rest.repos.get({ owner, repo });
- const baseBranch = repoData.default_branch;
-
- try {
- await github.request('POST /repos/{owner}/{repo}/issues/{issue_number}/assignees', {
- owner,
- repo,
- issue_number,
- assignees: ['copilot-swe-agent[bot]'],
- agent_assignment: {
- target_repo: `${owner}/${repo}`,
- base_branch: baseBranch,
- custom_instructions: '',
- custom_agent: '',
- model: ''
- },
- headers: {
- 'X-GitHub-Api-Version': '2022-11-28'
- }
- });
- core.info(`Assigned copilot-swe-agent to issue #${issue_number} (base: ${baseBranch})`);
- } catch (err) {
- core.warning(`Assignment with agent_assignment failed: ${err.message}`);
- // Fallback: try without agent_assignment
- try {
- await github.rest.issues.addAssignees({
- owner, repo, issue_number,
- assignees: ['copilot-swe-agent']
- });
- core.info(`Fallback assigned copilot-swe-agent to issue #${issue_number}`);
- } catch (err2) {
- core.warning(`Fallback also failed: ${err2.message}`);
- }
- }
diff --git a/.github/workflows/squad-label-enforce.yml b/.github/workflows/squad-label-enforce.yml
deleted file mode 100644
index 633d220df..000000000
--- a/.github/workflows/squad-label-enforce.yml
+++ /dev/null
@@ -1,181 +0,0 @@
-name: Squad Label Enforce
-
-on:
- issues:
- types: [labeled]
-
-permissions:
- issues: write
- contents: read
-
-jobs:
- enforce:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
-
- - name: Enforce mutual exclusivity
- uses: actions/github-script@v7
- with:
- script: |
- const issue = context.payload.issue;
- const appliedLabel = context.payload.label.name;
-
- // Namespaces with mutual exclusivity rules
- const EXCLUSIVE_PREFIXES = ['go:', 'release:', 'type:', 'priority:'];
-
- // Skip if not a managed namespace label
- if (!EXCLUSIVE_PREFIXES.some(p => appliedLabel.startsWith(p))) {
- core.info(`Label ${appliedLabel} is not in a managed namespace — skipping`);
- return;
- }
-
- const allLabels = issue.labels.map(l => l.name);
-
- // Handle go: namespace (mutual exclusivity)
- if (appliedLabel.startsWith('go:')) {
- const otherGoLabels = allLabels.filter(l =>
- l.startsWith('go:') && l !== appliedLabel
- );
-
- if (otherGoLabels.length > 0) {
- // Remove conflicting go: labels
- for (const label of otherGoLabels) {
- await github.rest.issues.removeLabel({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- name: label
- });
- core.info(`Removed conflicting label: ${label}`);
- }
-
- // Post update comment
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- body: `🏷️ Triage verdict updated → \`${appliedLabel}\``
- });
- }
-
- // Auto-apply release:backlog if go:yes and no release target
- if (appliedLabel === 'go:yes') {
- const hasReleaseLabel = allLabels.some(l => l.startsWith('release:'));
- if (!hasReleaseLabel) {
- await github.rest.issues.addLabels({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- labels: ['release:backlog']
- });
-
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- body: `📋 Marked as \`release:backlog\` — assign a release target when ready.`
- });
-
- core.info('Applied release:backlog for go:yes issue');
- }
- }
-
- // Remove release: labels if go:no
- if (appliedLabel === 'go:no') {
- const releaseLabels = allLabels.filter(l => l.startsWith('release:'));
- if (releaseLabels.length > 0) {
- for (const label of releaseLabels) {
- await github.rest.issues.removeLabel({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- name: label
- });
- core.info(`Removed release label from go:no issue: ${label}`);
- }
- }
- }
- }
-
- // Handle release: namespace (mutual exclusivity)
- if (appliedLabel.startsWith('release:')) {
- const otherReleaseLabels = allLabels.filter(l =>
- l.startsWith('release:') && l !== appliedLabel
- );
-
- if (otherReleaseLabels.length > 0) {
- // Remove conflicting release: labels
- for (const label of otherReleaseLabels) {
- await github.rest.issues.removeLabel({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- name: label
- });
- core.info(`Removed conflicting label: ${label}`);
- }
-
- // Post update comment
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- body: `🏷️ Release target updated → \`${appliedLabel}\``
- });
- }
- }
-
- // Handle type: namespace (mutual exclusivity)
- if (appliedLabel.startsWith('type:')) {
- const otherTypeLabels = allLabels.filter(l =>
- l.startsWith('type:') && l !== appliedLabel
- );
-
- if (otherTypeLabels.length > 0) {
- for (const label of otherTypeLabels) {
- await github.rest.issues.removeLabel({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- name: label
- });
- core.info(`Removed conflicting label: ${label}`);
- }
-
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- body: `🏷️ Issue type updated → \`${appliedLabel}\``
- });
- }
- }
-
- // Handle priority: namespace (mutual exclusivity)
- if (appliedLabel.startsWith('priority:')) {
- const otherPriorityLabels = allLabels.filter(l =>
- l.startsWith('priority:') && l !== appliedLabel
- );
-
- if (otherPriorityLabels.length > 0) {
- for (const label of otherPriorityLabels) {
- await github.rest.issues.removeLabel({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- name: label
- });
- core.info(`Removed conflicting label: ${label}`);
- }
-
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- body: `🏷️ Priority updated → \`${appliedLabel}\``
- });
- }
- }
-
- core.info(`Label enforcement complete for ${appliedLabel}`);
diff --git a/.github/workflows/squad-preview.yml b/.github/workflows/squad-preview.yml
deleted file mode 100644
index 44ccdc764..000000000
--- a/.github/workflows/squad-preview.yml
+++ /dev/null
@@ -1,30 +0,0 @@
-name: Squad Preview Validation
-# dotnet project — configure build, test, and validation commands below
-
-on:
- push:
- branches: [preview]
-
-permissions:
- contents: read
-
-jobs:
- validate:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
-
- - name: Build and test
- run: |
- # TODO: Add your dotnet build/test commands here
- # Go: go test ./...
- # Python: pip install -r requirements.txt && pytest
- # .NET: dotnet test
- # Java (Maven): mvn test
- # Java (Gradle): ./gradlew test
- echo "No build commands configured — update squad-preview.yml"
-
- - name: Validate
- run: |
- # TODO: Add pre-release validation commands here
- echo "No validation commands configured — update squad-preview.yml"
diff --git a/.github/workflows/squad-promote.yml b/.github/workflows/squad-promote.yml
deleted file mode 100644
index 9d315b1d1..000000000
--- a/.github/workflows/squad-promote.yml
+++ /dev/null
@@ -1,120 +0,0 @@
-name: Squad Promote
-
-on:
- workflow_dispatch:
- inputs:
- dry_run:
- description: 'Dry run — show what would happen without pushing'
- required: false
- default: 'false'
- type: choice
- options: ['false', 'true']
-
-permissions:
- contents: write
-
-jobs:
- dev-to-preview:
- name: Promote dev → preview
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
- token: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Configure git
- run: |
- git config user.name "github-actions[bot]"
- git config user.email "github-actions[bot]@users.noreply.github.com"
-
- - name: Fetch all branches
- run: git fetch --all
-
- - name: Show current state (dry run info)
- run: |
- echo "=== dev HEAD ===" && git log origin/dev -1 --oneline
- echo "=== preview HEAD ===" && git log origin/preview -1 --oneline
- echo "=== Files that would be stripped ==="
- git diff origin/preview..origin/dev --name-only | grep -E "^(\.(ai-team|squad|ai-team-templates)|team-docs/|docs/proposals/)" || echo "(none)"
-
- - name: Merge dev → preview (strip forbidden paths)
- if: ${{ inputs.dry_run == 'false' }}
- run: |
- git checkout preview
- git merge origin/dev --no-commit --no-ff -X theirs || true
-
- # Strip forbidden paths from merge commit
- git rm -rf --cached --ignore-unmatch \
- .ai-team/ \
- .squad/ \
- .ai-team-templates/ \
- team-docs/ \
- "docs/proposals/" || true
-
- # Commit if there are staged changes
- if ! git diff --cached --quiet; then
- git commit -m "chore: promote dev → preview (v$(node -e "console.log(require('./package.json').version)"))"
- git push origin preview
- echo "✅ Pushed preview branch"
- else
- echo "ℹ️ Nothing to commit — preview is already up to date"
- fi
-
- - name: Dry run complete
- if: ${{ inputs.dry_run == 'true' }}
- run: echo "🔍 Dry run complete — no changes pushed."
-
- preview-to-main:
- name: Promote preview → main (release)
- needs: dev-to-preview
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
- token: ${{ secrets.GITHUB_TOKEN }}
-
- - name: Configure git
- run: |
- git config user.name "github-actions[bot]"
- git config user.email "github-actions[bot]@users.noreply.github.com"
-
- - name: Fetch all branches
- run: git fetch --all
-
- - name: Show current state
- run: |
- echo "=== preview HEAD ===" && git log origin/preview -1 --oneline
- echo "=== main HEAD ===" && git log origin/main -1 --oneline
- echo "=== Version ===" && node -e "console.log('v' + require('./package.json').version)"
-
- - name: Validate preview is release-ready
- run: |
- git checkout preview
- VERSION=$(node -e "console.log(require('./package.json').version)")
- if ! grep -q "## \[$VERSION\]" CHANGELOG.md 2>/dev/null; then
- echo "::error::Version $VERSION not found in CHANGELOG.md — update before releasing"
- exit 1
- fi
- echo "✅ Version $VERSION has CHANGELOG entry"
-
- # Verify no forbidden files on preview
- FORBIDDEN=$(git ls-files | grep -E "^(\.(ai-team|squad|ai-team-templates)/|team-docs/|docs/proposals/)" || true)
- if [ -n "$FORBIDDEN" ]; then
- echo "::error::Forbidden files found on preview: $FORBIDDEN"
- exit 1
- fi
- echo "✅ No forbidden files on preview"
-
- - name: Merge preview → main
- if: ${{ inputs.dry_run == 'false' }}
- run: |
- git checkout main
- git merge origin/preview --no-ff -m "chore: promote preview → main (v$(node -e "console.log(require('./package.json').version)"))"
- git push origin main
- echo "✅ Pushed main — squad-release.yml will tag and publish the release"
-
- - name: Dry run complete
- if: ${{ inputs.dry_run == 'true' }}
- run: echo "🔍 Dry run complete — no changes pushed."
diff --git a/.github/workflows/squad-release.yml b/.github/workflows/squad-release.yml
deleted file mode 100644
index b5f2c62af..000000000
--- a/.github/workflows/squad-release.yml
+++ /dev/null
@@ -1,34 +0,0 @@
-name: Squad Release
-# dotnet project — configure build, test, and release commands below
-
-on:
- push:
- branches: [main]
-
-permissions:
- contents: write
-
-jobs:
- release:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
- with:
- fetch-depth: 0
-
- - name: Build and test
- run: |
- # TODO: Add your dotnet build/test commands here
- # Go: go test ./...
- # Python: pip install -r requirements.txt && pytest
- # .NET: dotnet test
- # Java (Maven): mvn test
- # Java (Gradle): ./gradlew test
- echo "No build commands configured — update squad-release.yml"
-
- - name: Create release
- env:
- GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- run: |
- # TODO: Add your release commands here (e.g., git tag, gh release create)
- echo "No release commands configured — update squad-release.yml"
diff --git a/.github/workflows/squad-triage.yml b/.github/workflows/squad-triage.yml
deleted file mode 100644
index a58be9b29..000000000
--- a/.github/workflows/squad-triage.yml
+++ /dev/null
@@ -1,260 +0,0 @@
-name: Squad Triage
-
-on:
- issues:
- types: [labeled]
-
-permissions:
- issues: write
- contents: read
-
-jobs:
- triage:
- if: github.event.label.name == 'squad'
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
-
- - name: Triage issue via Lead agent
- uses: actions/github-script@v7
- with:
- script: |
- const fs = require('fs');
- const issue = context.payload.issue;
-
- // Read team roster — check .squad/ first, fall back to .ai-team/
- let teamFile = '.squad/team.md';
- if (!fs.existsSync(teamFile)) {
- teamFile = '.ai-team/team.md';
- }
- if (!fs.existsSync(teamFile)) {
- core.warning('No .squad/team.md or .ai-team/team.md found — cannot triage');
- return;
- }
-
- const content = fs.readFileSync(teamFile, 'utf8');
- const lines = content.split('\n');
-
- // Check if @copilot is on the team
- const hasCopilot = content.includes('🤖 Coding Agent');
- const copilotAutoAssign = content.includes('');
-
- // Parse @copilot capability profile
- let goodFitKeywords = [];
- let needsReviewKeywords = [];
- let notSuitableKeywords = [];
-
- if (hasCopilot) {
- // Extract capability tiers from team.md
- const goodFitMatch = content.match(/🟢\s*Good fit[^:]*:\s*(.+)/i);
- const needsReviewMatch = content.match(/🟡\s*Needs review[^:]*:\s*(.+)/i);
- const notSuitableMatch = content.match(/🔴\s*Not suitable[^:]*:\s*(.+)/i);
-
- if (goodFitMatch) {
- goodFitKeywords = goodFitMatch[1].toLowerCase().split(',').map(s => s.trim());
- } else {
- goodFitKeywords = ['bug fix', 'test coverage', 'lint', 'format', 'dependency update', 'small feature', 'scaffolding', 'doc fix', 'documentation'];
- }
- if (needsReviewMatch) {
- needsReviewKeywords = needsReviewMatch[1].toLowerCase().split(',').map(s => s.trim());
- } else {
- needsReviewKeywords = ['medium feature', 'refactoring', 'api endpoint', 'migration'];
- }
- if (notSuitableMatch) {
- notSuitableKeywords = notSuitableMatch[1].toLowerCase().split(',').map(s => s.trim());
- } else {
- notSuitableKeywords = ['architecture', 'system design', 'security', 'auth', 'encryption', 'performance'];
- }
- }
-
- const members = [];
- let inMembersTable = false;
- for (const line of lines) {
- if (line.match(/^##\s+(Members|Team Roster)/i)) {
- inMembersTable = true;
- continue;
- }
- if (inMembersTable && line.startsWith('## ')) {
- break;
- }
- if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) {
- const cells = line.split('|').map(c => c.trim()).filter(Boolean);
- if (cells.length >= 2 && cells[0] !== 'Scribe') {
- members.push({
- name: cells[0],
- role: cells[1]
- });
- }
- }
- }
-
- // Read routing rules — check .squad/ first, fall back to .ai-team/
- let routingFile = '.squad/routing.md';
- if (!fs.existsSync(routingFile)) {
- routingFile = '.ai-team/routing.md';
- }
- let routingContent = '';
- if (fs.existsSync(routingFile)) {
- routingContent = fs.readFileSync(routingFile, 'utf8');
- }
-
- // Find the Lead
- const lead = members.find(m =>
- m.role.toLowerCase().includes('lead') ||
- m.role.toLowerCase().includes('architect') ||
- m.role.toLowerCase().includes('coordinator')
- );
-
- if (!lead) {
- core.warning('No Lead role found in team roster — cannot triage');
- return;
- }
-
- // Build triage context
- const memberList = members.map(m =>
- `- **${m.name}** (${m.role}) → label: \`squad:${m.name.toLowerCase()}\``
- ).join('\n');
-
- // Determine best assignee based on issue content and routing
- const issueText = `${issue.title}\n${issue.body || ''}`.toLowerCase();
-
- let assignedMember = null;
- let triageReason = '';
- let copilotTier = null;
-
- // First, evaluate @copilot fit if enabled
- if (hasCopilot) {
- const isNotSuitable = notSuitableKeywords.some(kw => issueText.includes(kw));
- const isGoodFit = !isNotSuitable && goodFitKeywords.some(kw => issueText.includes(kw));
- const isNeedsReview = !isNotSuitable && !isGoodFit && needsReviewKeywords.some(kw => issueText.includes(kw));
-
- if (isGoodFit) {
- copilotTier = 'good-fit';
- assignedMember = { name: '@copilot', role: 'Coding Agent' };
- triageReason = '🟢 Good fit for @copilot — matches capability profile';
- } else if (isNeedsReview) {
- copilotTier = 'needs-review';
- assignedMember = { name: '@copilot', role: 'Coding Agent' };
- triageReason = '🟡 Routing to @copilot (needs review) — a squad member should review the PR';
- } else if (isNotSuitable) {
- copilotTier = 'not-suitable';
- // Fall through to normal routing
- }
- }
-
- // If not routed to @copilot, use keyword-based routing
- if (!assignedMember) {
- for (const member of members) {
- const role = member.role.toLowerCase();
- if ((role.includes('frontend') || role.includes('ui')) &&
- (issueText.includes('ui') || issueText.includes('frontend') ||
- issueText.includes('css') || issueText.includes('component') ||
- issueText.includes('button') || issueText.includes('page') ||
- issueText.includes('layout') || issueText.includes('design'))) {
- assignedMember = member;
- triageReason = 'Issue relates to frontend/UI work';
- break;
- }
- if ((role.includes('backend') || role.includes('api') || role.includes('server')) &&
- (issueText.includes('api') || issueText.includes('backend') ||
- issueText.includes('database') || issueText.includes('endpoint') ||
- issueText.includes('server') || issueText.includes('auth'))) {
- assignedMember = member;
- triageReason = 'Issue relates to backend/API work';
- break;
- }
- if ((role.includes('test') || role.includes('qa') || role.includes('quality')) &&
- (issueText.includes('test') || issueText.includes('bug') ||
- issueText.includes('fix') || issueText.includes('regression') ||
- issueText.includes('coverage'))) {
- assignedMember = member;
- triageReason = 'Issue relates to testing/quality work';
- break;
- }
- if ((role.includes('devops') || role.includes('infra') || role.includes('ops')) &&
- (issueText.includes('deploy') || issueText.includes('ci') ||
- issueText.includes('pipeline') || issueText.includes('docker') ||
- issueText.includes('infrastructure'))) {
- assignedMember = member;
- triageReason = 'Issue relates to DevOps/infrastructure work';
- break;
- }
- }
- }
-
- // Default to Lead if no routing match
- if (!assignedMember) {
- assignedMember = lead;
- triageReason = 'No specific domain match — assigned to Lead for further analysis';
- }
-
- const isCopilot = assignedMember.name === '@copilot';
- const assignLabel = isCopilot ? 'squad:copilot' : `squad:${assignedMember.name.toLowerCase()}`;
-
- // Add the member-specific label
- await github.rest.issues.addLabels({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- labels: [assignLabel]
- });
-
- // Apply default triage verdict
- await github.rest.issues.addLabels({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- labels: ['go:needs-research']
- });
-
- // Auto-assign @copilot if enabled
- if (isCopilot && copilotAutoAssign) {
- try {
- await github.rest.issues.addAssignees({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- assignees: ['copilot']
- });
- } catch (err) {
- core.warning(`Could not auto-assign @copilot: ${err.message}`);
- }
- }
-
- // Build copilot evaluation note
- let copilotNote = '';
- if (hasCopilot && !isCopilot) {
- if (copilotTier === 'not-suitable') {
- copilotNote = `\n\n**@copilot evaluation:** 🔴 Not suitable — issue involves work outside the coding agent's capability profile.`;
- } else {
- copilotNote = `\n\n**@copilot evaluation:** No strong capability match — routed to squad member.`;
- }
- }
-
- // Post triage comment
- const comment = [
- `### 🏗️ Squad Triage — ${lead.name} (${lead.role})`,
- '',
- `**Issue:** #${issue.number} — ${issue.title}`,
- `**Assigned to:** ${assignedMember.name} (${assignedMember.role})`,
- `**Reason:** ${triageReason}`,
- copilotTier === 'needs-review' ? `\n⚠️ **PR review recommended** — a squad member should review @copilot's work on this one.` : '',
- copilotNote,
- '',
- `---`,
- '',
- `**Team roster:**`,
- memberList,
- hasCopilot ? `- **@copilot** (Coding Agent) → label: \`squad:copilot\`` : '',
- '',
- `> To reassign, remove the current \`squad:*\` label and add the correct one.`,
- ].filter(Boolean).join('\n');
-
- await github.rest.issues.createComment({
- owner: context.repo.owner,
- repo: context.repo.repo,
- issue_number: issue.number,
- body: comment
- });
-
- core.info(`Triaged issue #${issue.number} → ${assignedMember.name} (${assignLabel})`);
diff --git a/.github/workflows/sync-squad-labels.yml b/.github/workflows/sync-squad-labels.yml
deleted file mode 100644
index fbcfd9cc2..000000000
--- a/.github/workflows/sync-squad-labels.yml
+++ /dev/null
@@ -1,169 +0,0 @@
-name: Sync Squad Labels
-
-on:
- push:
- paths:
- - '.squad/team.md'
- - '.ai-team/team.md'
- workflow_dispatch:
-
-permissions:
- issues: write
- contents: read
-
-jobs:
- sync-labels:
- runs-on: ubuntu-latest
- steps:
- - uses: actions/checkout@v4
-
- - name: Parse roster and sync labels
- uses: actions/github-script@v7
- with:
- script: |
- const fs = require('fs');
- let teamFile = '.squad/team.md';
- if (!fs.existsSync(teamFile)) {
- teamFile = '.ai-team/team.md';
- }
-
- if (!fs.existsSync(teamFile)) {
- core.info('No .squad/team.md or .ai-team/team.md found — skipping label sync');
- return;
- }
-
- const content = fs.readFileSync(teamFile, 'utf8');
- const lines = content.split('\n');
-
- // Parse the Members table for agent names
- const members = [];
- let inMembersTable = false;
- for (const line of lines) {
- if (line.match(/^##\s+(Members|Team Roster)/i)) {
- inMembersTable = true;
- continue;
- }
- if (inMembersTable && line.startsWith('## ')) {
- break;
- }
- if (inMembersTable && line.startsWith('|') && !line.includes('---') && !line.includes('Name')) {
- const cells = line.split('|').map(c => c.trim()).filter(Boolean);
- if (cells.length >= 2 && cells[0] !== 'Scribe') {
- members.push({
- name: cells[0],
- role: cells[1]
- });
- }
- }
- }
-
- core.info(`Found ${members.length} squad members: ${members.map(m => m.name).join(', ')}`);
-
- // Check if @copilot is on the team
- const hasCopilot = content.includes('🤖 Coding Agent');
-
- // Define label color palette for squad labels
- const SQUAD_COLOR = '9B8FCC';
- const MEMBER_COLOR = '9B8FCC';
- const COPILOT_COLOR = '10b981';
-
- // Define go: and release: labels (static)
- const GO_LABELS = [
- { name: 'go:yes', color: '0E8A16', description: 'Ready to implement' },
- { name: 'go:no', color: 'B60205', description: 'Not pursuing' },
- { name: 'go:needs-research', color: 'FBCA04', description: 'Needs investigation' }
- ];
-
- const RELEASE_LABELS = [
- { name: 'release:v0.4.0', color: '6B8EB5', description: 'Targeted for v0.4.0' },
- { name: 'release:v0.5.0', color: '6B8EB5', description: 'Targeted for v0.5.0' },
- { name: 'release:v0.6.0', color: '8B7DB5', description: 'Targeted for v0.6.0' },
- { name: 'release:v1.0.0', color: '8B7DB5', description: 'Targeted for v1.0.0' },
- { name: 'release:backlog', color: 'D4E5F7', description: 'Not yet targeted' }
- ];
-
- const TYPE_LABELS = [
- { name: 'type:feature', color: 'DDD1F2', description: 'New capability' },
- { name: 'type:bug', color: 'FF0422', description: 'Something broken' },
- { name: 'type:spike', color: 'F2DDD4', description: 'Research/investigation — produces a plan, not code' },
- { name: 'type:docs', color: 'D4E5F7', description: 'Documentation work' },
- { name: 'type:chore', color: 'D4E5F7', description: 'Maintenance, refactoring, cleanup' },
- { name: 'type:epic', color: 'CC4455', description: 'Parent issue that decomposes into sub-issues' }
- ];
-
- // High-signal labels — these MUST visually dominate all others
- const SIGNAL_LABELS = [
- { name: 'bug', color: 'FF0422', description: 'Something isn\'t working' },
- { name: 'feedback', color: '00E5FF', description: 'User feedback — high signal, needs attention' }
- ];
-
- const PRIORITY_LABELS = [
- { name: 'priority:p0', color: 'B60205', description: 'Blocking release' },
- { name: 'priority:p1', color: 'D93F0B', description: 'This sprint' },
- { name: 'priority:p2', color: 'FBCA04', description: 'Next sprint' }
- ];
-
- // Ensure the base "squad" triage label exists
- const labels = [
- { name: 'squad', color: SQUAD_COLOR, description: 'Squad triage inbox — Lead will assign to a member' }
- ];
-
- for (const member of members) {
- labels.push({
- name: `squad:${member.name.toLowerCase()}`,
- color: MEMBER_COLOR,
- description: `Assigned to ${member.name} (${member.role})`
- });
- }
-
- // Add @copilot label if coding agent is on the team
- if (hasCopilot) {
- labels.push({
- name: 'squad:copilot',
- color: COPILOT_COLOR,
- description: 'Assigned to @copilot (Coding Agent) for autonomous work'
- });
- }
-
- // Add go:, release:, type:, priority:, and high-signal labels
- labels.push(...GO_LABELS);
- labels.push(...RELEASE_LABELS);
- labels.push(...TYPE_LABELS);
- labels.push(...PRIORITY_LABELS);
- labels.push(...SIGNAL_LABELS);
-
- // Sync labels (create or update)
- for (const label of labels) {
- try {
- await github.rest.issues.getLabel({
- owner: context.repo.owner,
- repo: context.repo.repo,
- name: label.name
- });
- // Label exists — update it
- await github.rest.issues.updateLabel({
- owner: context.repo.owner,
- repo: context.repo.repo,
- name: label.name,
- color: label.color,
- description: label.description
- });
- core.info(`Updated label: ${label.name}`);
- } catch (err) {
- if (err.status === 404) {
- // Label doesn't exist — create it
- await github.rest.issues.createLabel({
- owner: context.repo.owner,
- repo: context.repo.repo,
- name: label.name,
- color: label.color,
- description: label.description
- });
- core.info(`Created label: ${label.name}`);
- } else {
- throw err;
- }
- }
- }
-
- core.info(`Label sync complete: ${labels.length} labels synced`);
diff --git a/.squad/agents/beast/history.md b/.squad/agents/beast/history.md
index 30acb5376..1bab136bf 100644
--- a/.squad/agents/beast/history.md
+++ b/.squad/agents/beast/history.md
@@ -5,6 +5,12 @@
- **Stack:** C#, Blazor, .NET, ASP.NET Web Forms, bUnit, xUnit, MkDocs, Playwright
- **Created:** 2026-02-10
+
+📌 Team update (2026-08-XX): ClientScriptShim documentation delivery — Updated ClientScriptMigrationGuide.md with prominent new section positioning ClientScriptShim as zero-rewrite path (+2,100 lines total), including supported methods table, before/after example, "How It Works" technical explanation, and migration approach comparison (ClientScriptShim vs. manual IJSRuntime vs. JS modules). Updated BWFC022.md, BWFC023.md, BWFC024.md analyzer docs with cross-references to new ClientScriptShim guidance. mkdocs.yml verified (guide already in nav). Strategy: Lead with easiest path first (ClientScriptShim), then modern alternatives for teams ready to modernize. Enables rapid migration for large Web Forms codebases. — decided by Beast
+
+📌 Team update (2026-07-30): ClientScript Migration Support PRD delivered 9-section product requirements document (dev-docs/prd-clientscript-migration-support.md, 38K) covering analyzer improvements (BWFC022/023/024), CLI transforms (startup scripts, includes), safe automation boundaries, TODO guidance, documentation (ClientScriptMigrationGuide.md), testing (8 test cases), and 3-phase roadmap (P1: analyzers + transforms + docs, P2: samples, P3: runtime helpers). Based on Forge CLI Gap Analysis 1.2 (HIGH impact gap). Establishes BWFC position: prefer IJSRuntime over ClientScript shim; emit clear TODO for postback patterns; DO NOT emulate __doPostBack. Ready for implementation planning. decided by Beast
+
+ Team update (2026-04-02): Phase 5 delivery complete — 4 CLI reference docs (docs/cli/index.md, transforms.md, todo-conventions.md, report.md) with 1,378 lines and 81 code examples. mkdocs.yml navigation updated, README.md CLI tooling section added. All documentation standards (tabbed syntax, code examples, migration guides) applied. Build: 0 errors. Ready for merge to feature/global-tool-port. — decided by Scribe
📌 Team update (2026-03-24): Documentation task breakdown complete — 8 GitHub issues (#505–#512) created for component doc syntax conversions, ViewState/PostBack migration guide, and cross-linking. Issues labeled squad+type:docs. Coordinate with Forge for content review. #508 (ViewState docs) blocks on PR #503 merge. — decided by Forge
@@ -1111,3 +1117,136 @@ This wave establishes **documentation patterns** that will guide future control
- mkdocs.yml navigation nesting signals cognitive importance: placing Phase 1 after "Getting Started" (discovery) tells developers "start here before deeper migration"
- Cross-linking to Azure/AWS/ASP.NET docs increases doc value without duplicating content (readers can self-serve on secrets management, logging, etc.)
+
+### CLI Capability Writeup Transform Inventory & Before/After Example (2026-04-03)
+
+**Task:** Produce a shareable CLI capability summary for the user covering all implemented transforms.
+
+**Delivered:** Inline chat response (not committed to repo)
+
+**Contents:**
+- Full inventory of all 31 CLI transforms with one-line descriptions, grouped by pipeline stage (Directives, Expressions, Tag Prefixes, Attributes, Normalization, Scaffolding)
+- Before/after ASPX Razor example: realistic .aspx fragment (Page directive, MasterPageFile, asp:Button, asp:Label, Eval() binding, runat="server") fully migrated .razor output
+- CLI usage synopsis: wfc-migrate migrate --input ./src --output ./out --report migration-report.json
+- Coverage callouts: what the tool handles automatically vs. what lands in ManualItem/TODO comments
+
+**Learnings:**
+- Users respond well to before/after examples that use realistic patterns (not toy "Hello World" markup) showing a GridView with DataBind + Eval expressions, a MasterPageFile reference, and event wiring in a single snippet demonstrates breadth of tool coverage immediately
+- Transform inventory is most digestible when grouped by pipeline stage (matches mental model of "what gets processed when") rather than alphabetically
+- Always mention the --report flag when summarizing CLI capability: the JSON report is the bridge between automated migration and manual follow-up work
+
+---
+
+## Phase 1 ClientScript Migration Documentation (2026-07-30)
+
+**Task:** Implement Phase 1 deliverables from PRD: Create comprehensive ClientScript migration guide and analyzer reference pages (BWFC022, BWFC023, BWFC024).
+
+**Status:** ✅ DELIVERED
+
+**Deliverables:**
+
+1. **ClientScriptMigrationGuide.md (34.8K, 11 sections)**
+ - Overview: Why ClientScript patterns differ in Blazor
+ - Quick Reference Table: 8 major patterns with difficulty ratings
+ - Detailed Sections with before/after examples:
+ 1. Startup Scripts (RegisterStartupScript → OnAfterRenderAsync)
+ 2. Script Includes (RegisterClientScriptInclude →
+```
+
+**Automation Opportunity:** CLI can detect `RegisterClientScriptInclude`, extract the script path, and suggest adding a static `
+// 2. Or in component: await JS.InvokeAsync("import", "./lib/jquery-ui.min.js");
+//
+// TODO(bwfc-clientscript-include): Add to App layout
+```
+
+**Criteria:**
+- ✅ Detectable URL pattern: literal string or simple ResolveUrl()
+- ❌ Do NOT attempt: Dynamic URLs, conditional includes
+
+**Test case:** TC37-ClientScript-RegisterInclude.aspx
+
+**Phase:** P1
+
+---
+
+#### 6.3.3 PostBackEventTransform: Detect (Not Transform)
+
+**Goal:** Flag `Page.ClientScript.GetPostBackEventReference()` calls.
+
+**Transform Logic:**
+
+```csharp
+// Detect: Page.ClientScript.GetPostBackEventReference(...)
+// Generate TODO:
+//
+// TODO(bwfc-clientscript-postback): GetPostBackEventReference() is not available in Blazor.
+// This pattern registers a dynamic postback event handler. In Blazor, use EventCallback instead.
+// See: docs/Migration/ClientScriptMigrationGuide.md
+```
+
+**Criteria:**
+- ✅ Detectable pattern: simple method call
+- ❌ Do NOT attempt: Transformation (pattern is inherently postback-based)
+
+**Test case:** TC38-ClientScript-PostBackReference.aspx
+
+**Phase:** P1
+
+---
+
+### 6.4 TODO / Manual Rewrite Guidance
+
+#### 6.4.1 Postback Validation Patterns
+
+**Patterns:**
+- `Page.Validate(validationGroup)`
+- `Page.IsValid` checks
+- `ClientScript` + form validation
+
+**TODO Template:**
+```csharp
+// TODO(bwfc-clientscript-validation): Web Forms validation is postback-driven.
+// Blazor uses EditContext-based validation instead.
+//
+// Web Forms:
+// if (!Page.IsValid) { ... }
+//
+// Blazor equivalent:
+//
+//
+//
+//
+//
+// See: docs/Migration/ClientScriptMigrationGuide.md (Validation section)
+```
+
+#### 6.4.2 IPostBackEventHandler Implementation
+
+**TODO Template:**
+```csharp
+// TODO(bwfc-clientscript-ipostback): IPostBackEventHandler is not available in Blazor.
+// Replace with EventCallback:
+//
+// [Parameter]
+// public EventCallback OnPostBackEvent { get; set; }
+//
+// private async Task RaiseEvent(string argument)
+// {
+// await OnPostBackEvent.InvokeAsync(argument);
+// }
+//
+// See: docs/Migration/ClientScriptMigrationGuide.md
+```
+
+#### 6.4.3 ScriptManager Code-Behind Patterns
+
+**TODO Template (SetFocus):**
+```csharp
+// TODO(bwfc-clientscript-setfocus): ScriptManager.SetFocus() is not available in Blazor.
+// Use JavaScript interop to focus an element:
+//
+// @ref element="@inputRef"
+// protected ElementReference inputRef;
+//
+// await JS.InvokeVoidAsync("focus", inputRef);
+```
+
+**TODO Template (RegisterAsyncPostBackControl):**
+```csharp
+// TODO(bwfc-clientscript-asyncpostback): RegisterAsyncPostBackControl() is not available in Blazor.
+// Blazor does not use UpdatePanel async postback model.
+// Remove this call. If you need partial updates, use component parameter binding instead.
+```
+
+---
+
+### 6.5 Documentation & Testing Requirements
+
+#### 6.5.1 New Documentation: ClientScriptMigrationGuide.md
+
+**Location:** `docs/Migration/ClientScriptMigrationGuide.md`
+
+**Sections:**
+1. **Overview** — Why ClientScript patterns differ in Blazor
+2. **StartupScript Migration** — Simple startup scripts with `OnAfterRenderAsync` example
+3. **Script Includes** — Static `
+
+ ```
+
+### Common Fix: PostBack Event Reference
+
+=== "Web Forms (Before)"
+ ```csharp
+ public string GetDeleteButtonScript()
+ {
+ return Page.ClientScript.GetPostBackEventReference(
+ new PostBackOptions(btnDelete, "clicked"));
+ }
+ ```
+
+=== "Blazor (After)"
+ ```razor
+
+
+ @code {
+ private async Task HandleDelete()
+ {
+ await DeleteItemAsync();
+ }
+ }
+ ```
+
+---
+
+## Detailed Migration Paths
+
+For **comprehensive migration guidance** with code examples for each ClientScript method, see:
+
+📖 **[ClientScriptMigrationGuide.md](../Migration/ClientScriptMigrationGuide.md)**
+
+Sections:
+1. **Startup Scripts** — Most common pattern (Section 1)
+2. **Script Includes** — External `.js` files (Section 2)
+3. **Script Blocks** — Inline JavaScript (Section 3)
+4. **Postback Events** — Dynamic event references (Section 4)
+5. **Form Validation** — `Page.IsValid` patterns (Section 5)
+
+---
+
+## Common Mistakes
+
+### ❌ Don't: Use `eval()` for Complex Scripts
+
+```csharp
+// ❌ WRONG: Embedding complex logic in eval()
+await JS.InvokeVoidAsync("eval", @"
+ function processData() {
+ // 50 lines of logic...
+ }
+ processData();
+");
+```
+
+### ✅ Do: Define Functions in JavaScript Modules
+
+```javascript
+// app.js
+export function processData() {
+ // 50 lines of logic...
+}
+```
+
+```csharp
+// Component
+var module = await JS.InvokeAsync("import", "./app.js");
+await module.InvokeVoidAsync("processData");
+```
+
+### ❌ Don't: Skip the `firstRender` Guard
+
+```csharp
+// ❌ WRONG: Script runs on every render
+protected override async Task OnAfterRenderAsync(bool firstRender)
+{
+ await JS.InvokeVoidAsync("applyTheme");
+}
+```
+
+### ✅ Do: Guard with `if (firstRender)`
+
+```csharp
+// ✅ CORRECT: Script runs only on first render
+protected override async Task OnAfterRenderAsync(bool firstRender)
+{
+ if (firstRender)
+ {
+ await JS.InvokeVoidAsync("applyTheme");
+ }
+}
+```
+
+---
+
+## Related Analyzers
+
+- **[BWFC023](BWFC023.md)** — IPostBackEventHandler usage
+- **[BWFC024](BWFC024.md)** — ScriptManager code-behind usage
+
+---
+
+## Configuration
+
+To suppress this warning for a specific line:
+
+```csharp
+#pragma warning disable BWFC022
+Page.ClientScript.RegisterStartupScript(/* ... */);
+#pragma warning restore BWFC022
+```
+
+Or in `.editorconfig`:
+
+```ini
+[*.cs]
+dotnet_diagnostic.BWFC022.severity = silent
+```
+
+---
+
+## See Also
+
+- 📖 [ClientScriptMigrationGuide.md](../Migration/ClientScriptMigrationGuide.md) — Comprehensive migration guide
+- 📖 [IJSRuntime Documentation](https://learn.microsoft.com/en-us/aspnet/core/blazor/javascript-interoperability) — Blazor JS interop
+- 📖 [Component Lifecycle](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/lifecycle) — OnAfterRenderAsync and friends
+
+---
+
+**Status:** ✅ Active
+**Last Updated:** 2026-07-30
+**Owner:** Beast (Technical Writer)
diff --git a/docs/Analyzers/BWFC023.md b/docs/Analyzers/BWFC023.md
new file mode 100644
index 000000000..0f4dcfe32
--- /dev/null
+++ b/docs/Analyzers/BWFC023.md
@@ -0,0 +1,387 @@
+# BWFC023: IPostBackEventHandler Usage
+
+**Diagnostic ID:** `BWFC023`
+**Severity:** ⚠️ Warning
+**Category:** Migration
+**Status:** Active
+
+---
+
+## What It Detects
+
+This analyzer warns when you implement `IPostBackEventHandler` — a Web Forms interface for custom controls to raise server events in response to client-side actions.
+
+**Detected patterns:**
+- `class MyControl : Control, IPostBackEventHandler`
+- `public void RaisePostBackEvent(string eventArgument) { ... }`
+- Any implementation of the `IPostBackEventHandler` interface
+
+---
+
+## Example
+
+```csharp
+public partial class MyCustomControl : UserControl, IPostBackEventHandler
+{
+ // ⚠️ BWFC023: IPostBackEventHandler is not available in Blazor.
+ // Use EventCallback for event handling instead.
+
+ public event EventHandler OnCustomAction;
+
+ public void RaisePostBackEvent(string eventArgument)
+ {
+ if (eventArgument == "action")
+ {
+ OnCustomAction?.Invoke(this, EventArgs.Empty);
+ }
+ }
+}
+```
+
+---
+
+## Why It Matters
+
+`IPostBackEventHandler` is tightly coupled to Web Forms' **postback event model**:
+
+- A client-side event triggers `__doPostBack(controlId, eventArgument)`
+- The server receives the POST, decodes the event data, and calls `RaisePostBackEvent()`
+- The control can raise server-side events in response
+
+In Blazor:
+
+- **There is no postback cycle** — events are component method calls
+- **No `__doPostBack()` mechanism** — client actions invoke C# methods directly
+- **`EventCallback` replaces postback events** — provides parent-child component communication
+
+Without updating, your migrated code will have **no way to raise events** to parent components.
+
+---
+
+## How to Fix
+
+Replace `IPostBackEventHandler` with `EventCallback` parameters.
+
+### Simple Case: No Arguments
+
+=== "Web Forms (Before)"
+ ```csharp
+ public partial class MyControl : UserControl, IPostBackEventHandler
+ {
+ public event EventHandler OnAction;
+
+ public void RaisePostBackEvent(string eventArgument)
+ {
+ OnAction?.Invoke(this, EventArgs.Empty);
+ }
+ }
+ ```
+
+=== "Blazor (After)"
+ ```razor
+ @* MyControl.razor *@
+
+
+
+ @code {
+ [Parameter]
+ public EventCallback OnAction { get; set; }
+
+ private async Task RaiseAction()
+ {
+ await OnAction.InvokeAsync();
+ }
+ }
+
+ @* Parent.razor *@
+
+
+ @code {
+ private async Task HandleAction()
+ {
+ // Handle the event
+ }
+ }
+ ```
+
+### With Arguments: Single Value
+
+=== "Web Forms (Before)"
+ ```csharp
+ public partial class MyControl : UserControl, IPostBackEventHandler
+ {
+ public event EventHandler OnItemSelected;
+
+ public void RaisePostBackEvent(string eventArgument)
+ {
+ if (eventArgument.StartsWith("select-"))
+ {
+ string itemId = eventArgument.Substring("select-".Length);
+ OnItemSelected?.Invoke(this, new ItemSelectedEventArgs { ItemId = itemId });
+ }
+ }
+ }
+
+ public class ItemSelectedEventArgs : EventArgs
+ {
+ public string ItemId { get; set; }
+ }
+ ```
+
+=== "Blazor (After)"
+ ```razor
+ @* MyControl.razor *@
+
+ @foreach (var item in Items)
+ {
+
+ }
+
+ @code {
+ [Parameter]
+ public List- Items { get; set; }
+
+ [Parameter]
+ public EventCallback OnItemSelected { get; set; }
+
+ private async Task SelectItem(string itemId)
+ {
+ await OnItemSelected.InvokeAsync(itemId);
+ }
+ }
+
+ @* Parent.razor *@
+
+
+ @code {
+ private async Task HandleItemSelected(string itemId)
+ {
+ // Handle selection
+ }
+ }
+ ```
+
+### With Arguments: Multiple Values (EventArgs Class)
+
+If you need to pass multiple values, use a custom class:
+
+=== "Web Forms (Before)"
+ ```csharp
+ public partial class GridControl : UserControl, IPostBackEventHandler
+ {
+ public event EventHandler OnRowSelected;
+
+ public void RaisePostBackEvent(string eventArgument)
+ {
+ // eventArgument format: "rowId|action"
+ var parts = eventArgument.Split('|');
+ var args = new RowSelectedEventArgs
+ {
+ RowId = int.Parse(parts[0]),
+ Action = parts[1]
+ };
+ OnRowSelected?.Invoke(this, args);
+ }
+ }
+
+ public class RowSelectedEventArgs : EventArgs
+ {
+ public int RowId { get; set; }
+ public string Action { get; set; }
+ }
+ ```
+
+=== "Blazor (After)"
+ ```razor
+ @* GridControl.razor *@
+
+ @foreach (var row in Rows)
+ {
+
HandleRowClick(row.Id, "edit"))">
+ | @row.Name |
+
+ }
+
+ @code {
+ [Parameter]
+ public List Rows { get; set; }
+
+ [Parameter]
+ public EventCallback OnRowSelected { get; set; }
+
+ private async Task HandleRowClick(int rowId, string action)
+ {
+ var args = new RowSelectedEventArgs { RowId = rowId, Action = action };
+ await OnRowSelected.InvokeAsync(args);
+ }
+ }
+
+ public class RowSelectedEventArgs
+ {
+ public int RowId { get; set; }
+ public string Action { get; set; }
+ }
+
+ @* Parent.razor *@
+
+
+ @code {
+ private async Task HandleRowSelected(RowSelectedEventArgs args)
+ {
+ if (args.Action == "edit")
+ {
+ await EditRowAsync(args.RowId);
+ }
+ }
+ }
+ ```
+
+---
+
+## Key Differences
+
+| Aspect | Web Forms | Blazor |
+|--------|-----------|--------|
+| **Event mechanism** | IPostBackEventHandler + `__doPostBack()` | EventCallback |
+| **Event declaration** | `event EventHandler OnEvent` | `[Parameter] EventCallback OnEvent` |
+| **Event invocation** | `OnEvent?.Invoke(...)` in `RaisePostBackEvent()` | `await OnEvent.InvokeAsync(...)` |
+| **Data passing** | Custom EventArgs classes (optional) | Parameter type `T` (any type) |
+| **Parent binding** | Automatic via postback | Explicit `OnEvent="@handler"` parameter |
+
+---
+
+## Real-World Example: Custom Picker Control
+
+=== "Web Forms (Before)"
+ ```csharp
+ public partial class DatePickerControl : UserControl, IPostBackEventHandler
+ {
+ public event EventHandler OnDatePicked;
+
+ public void RaisePostBackEvent(string eventArgument)
+ {
+ if (DateTime.TryParse(eventArgument, out var date))
+ {
+ OnDatePicked?.Invoke(this, new DatePickedEventArgs { SelectedDate = date });
+ }
+ }
+ }
+
+ public class DatePickedEventArgs : EventArgs
+ {
+ public DateTime SelectedDate { get; set; }
+ }
+ ```
+
+=== "Blazor (After)"
+ ```razor
+ @* DatePickerControl.razor *@
+
+
+
+ @code {
+ [Parameter]
+ public EventCallback OnDatePicked { get; set; }
+
+ private async Task HandleDateChange(ChangeEventArgs e)
+ {
+ if (DateTime.TryParse(e.Value?.ToString(), out var date))
+ {
+ await OnDatePicked.InvokeAsync(date);
+ }
+ }
+ }
+
+ @* Parent.razor *@
+
+
+ @code {
+ private async Task HandleDatePicked(DateTime date)
+ {
+ SelectedDate = date;
+ }
+ }
+ ```
+
+---
+
+## Common Mistakes
+
+### ❌ Don't: Forget `await` When Invoking
+
+```csharp
+// ❌ WRONG: Not awaiting the callback
+private async Task SelectItem(string itemId)
+{
+ OnItemSelected.InvokeAsync(itemId); // Missing await!
+}
+```
+
+### ✅ Do: Always `await` EventCallback Invocations
+
+```csharp
+// ✅ CORRECT: Properly awaiting
+private async Task SelectItem(string itemId)
+{
+ await OnItemSelected.InvokeAsync(itemId);
+}
+```
+
+### ❌ Don't: Check if Callback is Null
+
+```csharp
+// ❌ WRONG: EventCallback is never null
+if (OnItemSelected != null)
+{
+ await OnItemSelected.InvokeAsync(itemId);
+}
+```
+
+### ✅ Do: Just Invoke (EventCallback Handles Null)
+
+```csharp
+// ✅ CORRECT: EventCallback is safe to invoke directly
+await OnItemSelected.InvokeAsync(itemId);
+```
+
+---
+
+## Related Analyzers
+
+- **[BWFC022](BWFC022.md)** — Page.ClientScript usage (see **ClientScriptShim** for easy migration)
+- **[BWFC024](BWFC024.md)** — ScriptManager code-behind usage
+
+---
+
+## Configuration
+
+To suppress this warning for a specific line:
+
+```csharp
+#pragma warning disable BWFC023
+public void RaisePostBackEvent(string eventArgument) { }
+#pragma warning restore BWFC023
+```
+
+Or in `.editorconfig`:
+
+```ini
+[*.cs]
+dotnet_diagnostic.BWFC023.severity = silent
+```
+
+---
+
+## See Also
+
+- 📖 [ClientScriptMigrationGuide.md](../Migration/ClientScriptMigrationGuide.md) — Section 6: IPostBackEventHandler
+- 📖 [EventCallback Documentation](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/event-handling) — Blazor event handling
+- 📖 [Component Parameters](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/index) — Parameter binding in Blazor
+
+---
+
+**Status:** ✅ Active
+**Last Updated:** 2026-07-30
+**Owner:** Beast (Technical Writer)
diff --git a/docs/Analyzers/BWFC024.md b/docs/Analyzers/BWFC024.md
new file mode 100644
index 000000000..c92a3262b
--- /dev/null
+++ b/docs/Analyzers/BWFC024.md
@@ -0,0 +1,436 @@
+# BWFC024: ScriptManager Code-Behind Usage
+
+**Diagnostic ID:** `BWFC024`
+**Severity:** ⚠️ Warning
+**Category:** Migration
+**Status:** Active
+
+---
+
+## What It Detects
+
+This analyzer warns when you use `ScriptManager` code-behind methods like `GetCurrent()`, `SetFocus()`, `RegisterAsyncPostBackControl()`, and similar — Web Forms APIs for managing client scripts and UpdatePanel behavior.
+
+**Detected patterns:**
+- `ScriptManager.GetCurrent(Page)` / `ScriptManager.GetCurrent(this)`
+- `.SetFocus(control)`
+- `.RegisterAsyncPostBackControl(control)`
+- `.RegisterUpdateProgress(...)`
+- `.RegisterPostBackControl(...)`
+
+---
+
+## Example
+
+```csharp
+protected void Page_Load(object sender, EventArgs e)
+{
+ // ⚠️ BWFC024: ScriptManager.GetCurrent() and related methods are not available in Blazor.
+ ScriptManager sm = ScriptManager.GetCurrent(Page);
+
+ // Set focus
+ sm.SetFocus(txtSearchBox);
+
+ // Register for async postback
+ sm.RegisterAsyncPostBackControl(gvData);
+}
+```
+
+---
+
+## Why It Matters
+
+`ScriptManager` methods are deeply tied to **Web Forms' postback and UpdatePanel architecture**:
+
+- `GetCurrent()` retrieves the page's ScriptManager instance
+- `SetFocus()` ensures client focus after postback
+- `RegisterAsyncPostBackControl()` enables AJAX partial-page updates
+- `RegisterUpdateProgress()` shows progress during UpdatePanel operations
+
+In Blazor:
+
+- **No `ScriptManager` instance** — script management is component-scoped
+- **No postback lifecycle** — focus handling is explicit
+- **No UpdatePanel model** — components handle their own updates natively
+- **No async postback concept** — Blazor uses real-time SignalR sync
+
+These methods have **no direct equivalents**. Each requires a different approach.
+
+---
+
+## How to Fix
+
+The fix depends on **which** ScriptManager method you're using.
+
+### Fix 1: SetFocus() → JavaScript Interop
+
+Focus management in Blazor uses `@ref` and `IJSRuntime`.
+
+=== "Web Forms (Before)"
+ ```csharp
+ protected void Page_Load(object sender, EventArgs e)
+ {
+ ScriptManager.SetFocus(txtSearchBox);
+ }
+ ```
+
+=== "Blazor (After)"
+ ```razor
+ @inject IJSRuntime JS
+
+
+
+ @code {
+ private ElementReference searchBox;
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender)
+ {
+ await JS.InvokeVoidAsync("focus", searchBox);
+ }
+ }
+ }
+ ```
+
+Or define a helper function in JavaScript:
+
+```javascript
+// app.js
+export function focusElement(element) {
+ element?.focus();
+}
+```
+
+```csharp
+// Component
+await module.InvokeVoidAsync("focusElement", searchBox);
+```
+
+### Fix 2: RegisterAsyncPostBackControl() → Remove
+
+`RegisterAsyncPostBackControl()` enables partial-page AJAX updates via UpdatePanel.
+
+**Blazor does NOT support UpdatePanel-style AJAX postbacks.** Blazor components handle all updates natively via parameter binding.
+
+=== "Web Forms (Before)"
+ ```csharp
+ protected void Page_Load(object sender, EventArgs e)
+ {
+ // Enable AJAX partial updates for GridView
+ ScriptManager.RegisterAsyncPostBackControl(gvData);
+ }
+
+ protected void gvData_SelectedIndexChanged(object sender, EventArgs e)
+ {
+ // Partial page refresh in UpdatePanel
+ gvData.DataSource = GetUpdatedData();
+ gvData.DataBind();
+ }
+ ```
+
+=== "Blazor (After)"
+ ```razor
+ @page "/data"
+ @inject HttpClient Http
+
+
+
+ @code {
+ private List- items;
+
+ protected override async Task OnInitializedAsync()
+ {
+ items = await Http.GetFromJsonAsync
>("/api/items");
+ }
+
+ private async Task HandleRowSelected(int itemId)
+ {
+ // Component automatically re-renders when data changes
+ items = await Http.GetFromJsonAsync>("/api/items");
+ }
+ }
+ ```
+
+**Key difference:** Blazor components automatically re-render when state changes. No manual registration needed.
+
+### Fix 3: RegisterUpdateProgress() → Use Component State
+
+`RegisterUpdateProgress()` shows a message or spinner during UpdatePanel operations.
+
+=== "Web Forms (Before)"
+ ```csharp
+ ScriptManager.RegisterUpdateProgress(updateProgress, masterUpdateProgress);
+
+ // During async postback, the UpdateProgress displays
+ protected void LongRunningOperation()
+ {
+ System.Threading.Thread.Sleep(5000); // Simulates work
+ }
+ ```
+
+=== "Blazor (After)"
+ ```razor
+ @if (isLoading)
+ {
+
+ }
+
+
+
+ @code {
+ private List- items;
+ private bool isLoading;
+
+ private async Task LoadData()
+ {
+ isLoading = true;
+ try
+ {
+ items = await Http.GetFromJsonAsync
>("/api/items");
+ }
+ finally
+ {
+ isLoading = false;
+ }
+ }
+ }
+ ```
+
+### Fix 4: GetCurrent() → Remove
+
+`ScriptManager.GetCurrent()` retrieves the page's ScriptManager instance for other operations.
+
+**In Blazor, there is no page-level `ScriptManager`.** Remove calls to `GetCurrent()` and replace the specific methods (SetFocus, RegisterAsyncPostBackControl, etc.) with the patterns above.
+
+=== "Web Forms (Before)"
+ ```csharp
+ ScriptManager sm = ScriptManager.GetCurrent(Page);
+ sm.SetFocus(txtField);
+ sm.RegisterAsyncPostBackControl(gridView);
+ ```
+
+=== "Blazor (After)"
+ ```razor
+ @inject IJSRuntime JS
+
+
+
+
+ @code {
+ private ElementReference field;
+ private List- items;
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender)
+ {
+ await JS.InvokeVoidAsync("focus", field);
+ }
+ }
+
+ private async Task HandleRowSelected(int id)
+ {
+ // Update component state; Blazor handles re-render
+ items = await FetchDataAsync();
+ }
+ }
+ ```
+
+---
+
+## Migration Quick Reference
+
+| Web Forms Method | Blazor Equivalent | Approach |
+|---|---|---|
+| `GetCurrent(Page)` | — | Remove; use component state |
+| `.SetFocus(control)` | `JS.InvokeVoidAsync("focus", @ref)` | JavaScript interop |
+| `.RegisterAsyncPostBackControl()` | Component parameter binding | Remove; use `@bind` or `EventCallback` |
+| `.RegisterPostBackControl()` | — | Remove; not needed in Blazor |
+| `.RegisterUpdateProgress()` | Component state flag | Use `@if (isLoading)` UI binding |
+| `.IsInAsyncPostBack` | — | Remove; always synchronous in Blazor |
+
+---
+
+## Real-World Example: Search Page with Loading Indicator
+
+=== "Web Forms (Before)"
+ ```csharp
+ public partial class SearchPage : Page
+ {
+ protected void Page_Load(object sender, EventArgs e)
+ {
+ ScriptManager sm = ScriptManager.GetCurrent(Page);
+ sm.SetFocus(txtSearchBox);
+ sm.RegisterUpdateProgress(updateProgress, masterUpdateProgress);
+ }
+
+ protected void btnSearch_Click(object sender, EventArgs e)
+ {
+ // Long-running search
+ var results = SearchDatabase(txtSearchBox.Text);
+ gvResults.DataSource = results;
+ gvResults.DataBind();
+ // UpdateProgress shows during postback
+ }
+ }
+ ```
+
+=== "Blazor (After)"
+ ```razor
+ @page "/search"
+ @inject IJSRuntime JS
+ @inject HttpClient Http
+
+
+
+
+ @if (isLoading)
+ {
+
+ }
+
+
+
+ @code {
+ private ElementReference searchBox;
+ private string searchQuery;
+ private List results = new();
+ private bool isLoading;
+
+ protected override async Task OnAfterRenderAsync(bool firstRender)
+ {
+ if (firstRender)
+ {
+ await JS.InvokeVoidAsync("focus", searchBox);
+ }
+ }
+
+ private async Task PerformSearch()
+ {
+ if (string.IsNullOrWhiteSpace(searchQuery)) return;
+
+ isLoading = true;
+ try
+ {
+ results = await Http.GetFromJsonAsync
>(
+ $"/api/search?q={Uri.EscapeDataString(searchQuery)}");
+ }
+ finally
+ {
+ isLoading = false;
+ }
+ }
+ }
+
+ public class SearchResult
+ {
+ public int Id { get; set; }
+ public string Title { get; set; }
+ }
+ ```
+
+---
+
+## Common Mistakes
+
+### ❌ Don't: Try to Call ScriptManager Methods
+
+```csharp
+// ❌ WRONG: ScriptManager.GetCurrent() returns null in Blazor
+ScriptManager sm = ScriptManager.GetCurrent(Page); // null
+sm.SetFocus(txtField); // NullReferenceException!
+```
+
+### ✅ Do: Use Component-Based Patterns
+
+```csharp
+// ✅ CORRECT: Use @ref and IJSRuntime
+@ref="field"
+await JS.InvokeVoidAsync("focus", field);
+```
+
+### ❌ Don't: Use RegisterAsyncPostBackControl() for Partial Updates
+
+```csharp
+// ❌ WRONG: UpdatePanel AJAX is not supported
+ScriptManager.RegisterAsyncPostBackControl(gridView);
+// gridView.DataBind() won't trigger partial refresh
+```
+
+### ✅ Do: Let Components Handle Their Own Updates
+
+```csharp
+// ✅ CORRECT: Component re-renders on state change
+items = await FetchUpdatedData();
+// Blazor automatically syncs the UI
+```
+
+### ❌ Don't: Check IsInAsyncPostBack
+
+```csharp
+// ❌ WRONG: No async postback concept in Blazor
+if (ScriptManager.GetCurrent(Page).IsInAsyncPostBack)
+{
+ // This code path doesn't exist
+}
+```
+
+### ✅ Do: Use Component Lifecycle Events
+
+```csharp
+// ✅ CORRECT: Blazor components are always "interactive"
+protected override async Task OnAfterRenderAsync(bool firstRender)
+{
+ if (firstRender)
+ {
+ // Component is interactive; safe to use JS interop
+ }
+}
+```
+
+---
+
+## Related Analyzers
+
+- **[BWFC022](BWFC022.md)** — Page.ClientScript usage (see **ClientScriptShim** for easy migration)
+- **[BWFC023](BWFC023.md)** — IPostBackEventHandler usage
+
+---
+
+## Configuration
+
+To suppress this warning for a specific line:
+
+```csharp
+#pragma warning disable BWFC024
+ScriptManager.GetCurrent(Page).SetFocus(txtField);
+#pragma warning restore BWFC024
+```
+
+Or in `.editorconfig`:
+
+```ini
+[*.cs]
+dotnet_diagnostic.BWFC024.severity = silent
+```
+
+---
+
+## See Also
+
+- 📖 [ClientScriptMigrationGuide.md](../Migration/ClientScriptMigrationGuide.md) — Section 7: ScriptManager Patterns
+- 📖 [IJSRuntime Documentation](https://learn.microsoft.com/en-us/aspnet/core/blazor/javascript-interoperability) — JavaScript interop
+- 📖 [Component Lifecycle](https://learn.microsoft.com/en-us/aspnet/core/blazor/components/lifecycle) — OnAfterRenderAsync
+- 📖 [EditForm Component](https://learn.microsoft.com/en-us/aspnet/core/blazor/forms-and-input-components) — Form handling in Blazor
+
+---
+
+**Status:** ✅ Active
+**Last Updated:** 2026-07-30
+**Owner:** Beast (Technical Writer)
diff --git a/docs/Migration/ClientScriptMigrationGuide.md b/docs/Migration/ClientScriptMigrationGuide.md
new file mode 100644
index 000000000..e71c738d2
--- /dev/null
+++ b/docs/Migration/ClientScriptMigrationGuide.md
@@ -0,0 +1,1381 @@
+# ClientScript Migration Guide
+
+When migrating from ASP.NET Web Forms to Blazor, one of the most critical patterns to understand is **JavaScript execution**. In Web Forms, `Page.ClientScript` and `ScriptManager` manage all client-side scripts. In Blazor, this responsibility falls to `IJSRuntime` and component lifecycle events.
+
+This guide covers the major ClientScript patterns, why they differ in Blazor, and how to migrate each one.
+
+---
+
+## 🎯 Recommended: ClientScriptShim (Zero-Rewrite Path)
+
+The **easiest path** for most migrations is the **ClientScriptShim** — a compatibility layer that provides the same API as `Page.ClientScript` but runs on Blazor's `IJSRuntime` internally.
+
+### What It Is
+
+`ClientScriptShim` is a scoped Blazor service included in BWFC that:
+- **Accepts the same method calls** as Web Forms' `Page.ClientScript`
+- **Requires zero code rewrites** — your existing `RegisterStartupScript()`, `RegisterClientScriptBlock()`, etc. calls work unchanged
+- **Queues scripts** during component initialization
+- **Auto-flushes** in `OnAfterRenderAsync` via `IJSRuntime`
+- **Handles deduplication** by type+key (same behavior as Web Forms)
+
+### Automatic Registration
+
+When you call `AddBlazorWebFormsComponents()` in your Startup, `ClientScriptShim` is registered as a scoped service and ready to use.
+
+### How to Use It
+
+**For components inheriting `BaseWebFormsComponent`:**
+
+The `ClientScript` property is automatically available — use it exactly as you would in Web Forms:
+
+```csharp
+protected override void OnInitialized()
+{
+ ClientScript.RegisterStartupScript(GetType(), "init",
+ "alert('Page loaded!');", true);
+}
+```
+
+**For any other component:**
+
+Inject `ClientScriptShim` and use it the same way:
+
+```razor
+@inject ClientScriptShim ClientScript
+
+@code {
+ protected override void OnInitialized()
+ {
+ ClientScript.RegisterStartupScript(GetType(), "init",
+ "alert('Page loaded!');", true);
+ }
+}
+```
+
+### Supported Methods
+
+| Method | Status | Notes |
+|--------|--------|-------|
+| `RegisterStartupScript(Type, string, string, bool)` | ✅ Supported | Executes in `OnAfterRenderAsync` |
+| `RegisterStartupScript(Type, string, string)` | ✅ Supported | `addScriptTags` defaults to false |
+| `RegisterClientScriptBlock(Type, string, string, bool)` | ✅ Supported | Executes before startup scripts |
+| `RegisterClientScriptBlock(Type, string, string)` | ✅ Supported | `addScriptTags` defaults to false |
+| `RegisterClientScriptInclude(string, string)` | ✅ Supported | Dynamically appends `
+
+
+
+
+
+
+