Files
isphere-ai-bridge/docs/superpowers/plans/2026-07-09-n13-uia-selector-catalog-implementation.md

28 KiB

N13 UIA Selector Catalog Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build an offline, read-only N13 UIA selector catalog and matcher that can validate selectors against the accepted N12R UIA dump without adding live helper or MCP actions.

Architecture: Add a focused Go package internal/uiaselector with selector data types, a static first-pass catalog, UIA dump loading, and pure in-memory matching. Tests use a redacted N12R fixture and assert that matching returns structural node summaries without visible text.

Tech Stack: Go 1.23.x, standard library encoding/json, existing repository test style, PowerShell verification wrapper.


Scope and boundary

This plan implements only offline selector catalog and matching.

It does not modify these files:

native/ISphereWinHelper/Program.cs
internal/tools/winhelper.go
cmd/isphere-mcp/main.go

It does not add MCP tools or helper ops. It does not click, type, open conversations, send messages, send files, receive files, or read chat content.

The accepted N12R evidence used by tests is:

runs/internal-sandbox-live-capture-intake/2026-07-09-retry-2/2026-07-08-internal-sandbox-a/uia/dump-uia-main-redacted.json

File structure

Create these files:

internal/uiaselector/catalog.go
internal/uiaselector/catalog_test.go
internal/uiaselector/dump.go
internal/uiaselector/dump_test.go
internal/uiaselector/matcher.go
internal/uiaselector/matcher_test.go
internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json
scripts/verify-n13-uia-selectors.ps1

Responsibilities:

  • catalog.go: selector data structures and initial N13 selector catalog.
  • catalog_test.go: catalog completeness and boundary tests.
  • dump.go: UIA dump response and node loading helpers.
  • dump_test.go: fixture parse tests.
  • matcher.go: pure in-memory selector matcher.
  • matcher_test.go: matching behavior and no-visible-text result tests.
  • testdata/n12r-2026-07-09-uia-redacted.json: redacted accepted N12R UIA fixture copied from the ignored run artifact.
  • scripts/verify-n13-uia-selectors.ps1: convenience verification script for Go-enabled environments.

Task 1: Add N12R redacted UIA fixture

Files:

  • Create: internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json

  • Step 1: Create package directory and copy the accepted redacted fixture

Run:

New-Item -ItemType Directory -Force -Path internal\uiaselector\testdata | Out-Null
Copy-Item `
  -LiteralPath runs\internal-sandbox-live-capture-intake\2026-07-09-retry-2\2026-07-08-internal-sandbox-a\uia\dump-uia-main-redacted.json `
  -Destination internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json `
  -Force

Expected: internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json exists.

  • Step 2: Verify fixture parses as JSON

Run:

Get-Content internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json -Encoding UTF8 | ConvertFrom-Json | Out-Null

Expected: command exits 0.

  • Step 3: Commit fixture
git add internal\uiaselector\testdata\n12r-2026-07-09-uia-redacted.json
git commit -m "test: add N13 redacted UIA fixture"

Task 2: Define selector catalog types and initial catalog

Files:

  • Create: internal/uiaselector/catalog_test.go

  • Create: internal/uiaselector/catalog.go

  • Step 1: Write failing catalog tests

Create internal/uiaselector/catalog_test.go:

package uiaselector

import (
	"strings"
	"testing"
)

func TestDefaultCatalogContainsInitialN13Selectors(t *testing.T) {
	catalog := DefaultCatalog()
	wantIDs := []string{
		"main_window",
		"main_plugin_tab",
		"home_page_window",
		"tab_page_content",
		"left_panel",
		"roster_panel",
		"roster_view",
		"fuzzy_query_panel",
		"fuzzy_query_window",
		"fuzzy_search_edit",
	}

	byID := map[string]Selector{}
	for _, selector := range catalog {
		byID[selector.ID] = selector
	}

	for _, id := range wantIDs {
		selector, ok := byID[id]
		if !ok {
			t.Fatalf("DefaultCatalog missing selector %q", id)
		}
		if selector.AllowedUse != AllowedUseReadOnlyLocate {
			t.Fatalf("selector %q allowed_use = %q, want %q", id, selector.AllowedUse, AllowedUseReadOnlyLocate)
		}
		if selector.Required.AutomationID == "" {
			t.Fatalf("selector %q must require automation_id", id)
		}
		if selector.Required.ControlType == "" {
			t.Fatalf("selector %q must require control_type", id)
		}
		if selector.StabilityScore < 80 {
			t.Fatalf("selector %q stability_score = %d, want >= 80", id, selector.StabilityScore)
		}
	}
}

func TestDefaultCatalogDoesNotDescribeActionTools(t *testing.T) {
	for _, selector := range DefaultCatalog() {
		if selector.AllowedUse != AllowedUseReadOnlyLocate {
			t.Fatalf("selector %q allowed_use = %q, want read-only", selector.ID, selector.AllowedUse)
		}
		lower := strings.ToLower(selector.ID + " " + selector.Description)
		for _, forbidden := range []string{"send", "upload", "download", "receive", "conversation", "login", "file_transfer"} {
			if strings.Contains(lower, forbidden) {
				t.Fatalf("selector %q contains action word %q", selector.ID, forbidden)
			}
		}
	}
}
  • Step 2: Run catalog tests and verify they fail

Run:

go test ./internal/uiaselector -run TestDefaultCatalog -count=1

Expected: FAIL because package internal/uiaselector or DefaultCatalog does not exist.

If this environment reports go not found, stop execution and record the environment gap. Continue only in a Go 1.23.x environment.

  • Step 3: Implement catalog types and selectors

Create internal/uiaselector/catalog.go:

package uiaselector

const AllowedUseReadOnlyLocate = "read_only_locate"

type Selector struct {
	ID             string             `json:"id"`
	Description    string             `json:"description"`
	AllowedUse     string             `json:"allowed_use"`
	Required       Criteria           `json:"required"`
	Preferred      PreferredCriteria  `json:"preferred"`
	Fallback       []FallbackCriteria `json:"fallback"`
	StabilityScore int                `json:"stability_score"`
	Evidence       Evidence           `json:"evidence"`
}

type Criteria struct {
	AutomationID  string `json:"automation_id,omitempty"`
	ControlType   string `json:"control_type,omitempty"`
	FrameworkID   string `json:"framework_id,omitempty"`
	ClassName     string `json:"class_name,omitempty"`
	ClassPrefix   string `json:"class_name_prefix,omitempty"`
	AncestorID    string `json:"ancestor_selector,omitempty"`
}

type PreferredCriteria struct {
	ClassName    string `json:"class_name,omitempty"`
	ClassPrefix  string `json:"class_name_prefix,omitempty"`
	PathHint     string `json:"path_hint,omitempty"`
	AncestorID   string `json:"ancestor_selector,omitempty"`
}

type FallbackCriteria struct {
	AncestorID                  string   `json:"ancestor_selector,omitempty"`
	ControlType                 string   `json:"control_type,omitempty"`
	ClassPrefix                 string   `json:"class_name_prefix,omitempty"`
	ChildAutomationIDContains   []string `json:"child_automation_id_contains,omitempty"`
	ChildControlTypeContains    []string `json:"child_control_type_contains,omitempty"`
}

type Evidence struct {
	CaptureID string `json:"capture_id"`
	UIAPath   string `json:"uia_path"`
}

func DefaultCatalog() []Selector {
	return []Selector{
		{
			ID:          "main_window",
			Description: "iSphere / IMPlatformClient 主窗口",
			AllowedUse:  AllowedUseReadOnlyLocate,
			Required: Criteria{AutomationID: "frmMain", ControlType: "Window", FrameworkID: "WinForm"},
			Preferred: PreferredCriteria{ClassPrefix: "WindowsForms10.Window.8.app.0", PathHint: "root"},
			Fallback: []FallbackCriteria{{ControlType: "Window", ClassPrefix: "WindowsForms10.Window.8.app.0", ChildAutomationIDContains: []string{"panelLeft", "ucPluginTabControl1"}}},
			StabilityScore: 95,
			Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root"},
		},
		{
			ID:          "main_plugin_tab",
			Description: "主功能 tab 容器",
			AllowedUse:  AllowedUseReadOnlyLocate,
			Required: Criteria{AutomationID: "ucPluginTabControl1", ControlType: "Pane", FrameworkID: "WinForm"},
			Preferred: PreferredCriteria{PathHint: "root/3", AncestorID: "main_window"},
			Fallback: []FallbackCriteria{{AncestorID: "main_window", ControlType: "Pane", ChildAutomationIDContains: []string{"FrmHomePage", "ucTabPage"}}},
			StabilityScore: 90,
			Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3"},
		},
		{
			ID:          "home_page_window",
			Description: "首页内容窗口",
			AllowedUse:  AllowedUseReadOnlyLocate,
			Required: Criteria{AutomationID: "FrmHomePage", ControlType: "Window", FrameworkID: "WinForm"},
			Preferred: PreferredCriteria{PathHint: "root/3/0/0", AncestorID: "main_plugin_tab"},
			Fallback: []FallbackCriteria{{AncestorID: "main_plugin_tab", ControlType: "Window", ClassPrefix: "WindowsForms10.Window.8.app.0"}},
			StabilityScore: 82,
			Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3/0/0"},
		},
		{
			ID:          "tab_page_content",
			Description: "tab 页内容窗口",
			AllowedUse:  AllowedUseReadOnlyLocate,
			Required: Criteria{AutomationID: "FrmTabPageContent", ControlType: "Window", FrameworkID: "WinForm"},
			Preferred: PreferredCriteria{PathHint: "root/3/1/0", AncestorID: "main_plugin_tab"},
			Fallback: []FallbackCriteria{{AncestorID: "main_plugin_tab", ControlType: "Window", ChildControlTypeContains: []string{"TitleBar"}}},
			StabilityScore: 82,
			Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/3/1/0"},
		},
		{
			ID:          "left_panel",
			Description: "左侧主面板",
			AllowedUse:  AllowedUseReadOnlyLocate,
			Required: Criteria{AutomationID: "panelLeft", ControlType: "Pane", FrameworkID: "WinForm"},
			Preferred: PreferredCriteria{PathHint: "root/4", AncestorID: "main_window"},
			Fallback: []FallbackCriteria{{AncestorID: "main_window", ControlType: "Pane", ChildAutomationIDContains: []string{"ucPnlRoster1", "ucFuzzyQuery1"}}},
			StabilityScore: 92,
			Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4"},
		},
		{
			ID:          "roster_panel",
			Description: "组织/联系人容器外层",
			AllowedUse:  AllowedUseReadOnlyLocate,
			Required: Criteria{AutomationID: "ucPnlRoster1", ControlType: "Pane", FrameworkID: "WinForm"},
			Preferred: PreferredCriteria{PathHint: "root/4/0/0", AncestorID: "left_panel"},
			Fallback: []FallbackCriteria{{AncestorID: "left_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"ucRoster1"}}},
			StabilityScore: 88,
			Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/0/0"},
		},
		{
			ID:          "roster_view",
			Description: "组织/联系人列表区域",
			AllowedUse:  AllowedUseReadOnlyLocate,
			Required: Criteria{AutomationID: "ucRoster1", ControlType: "Pane", FrameworkID: "WinForm"},
			Preferred: PreferredCriteria{PathHint: "root/4/0/0/0", AncestorID: "roster_panel"},
			Fallback: []FallbackCriteria{{AncestorID: "roster_panel", ControlType: "Pane", ClassPrefix: "WindowsForms10.Window.8.app.0"}},
			StabilityScore: 86,
			Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/0/0/0"},
		},
		{
			ID:          "fuzzy_query_panel",
			Description: "模糊搜索组件外层",
			AllowedUse:  AllowedUseReadOnlyLocate,
			Required: Criteria{AutomationID: "ucFuzzyQuery1", ControlType: "Pane", FrameworkID: "WinForm"},
			Preferred: PreferredCriteria{PathHint: "root/4/1/0", AncestorID: "left_panel"},
			Fallback: []FallbackCriteria{{AncestorID: "left_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"FrmFuzzyQuery"}}},
			StabilityScore: 90,
			Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0"},
		},
		{
			ID:          "fuzzy_query_window",
			Description: "模糊搜索内部窗口",
			AllowedUse:  AllowedUseReadOnlyLocate,
			Required: Criteria{AutomationID: "FrmFuzzyQuery", ControlType: "Pane", FrameworkID: "WinForm"},
			Preferred: PreferredCriteria{PathHint: "root/4/1/0/0", AncestorID: "fuzzy_query_panel"},
			Fallback: []FallbackCriteria{{AncestorID: "fuzzy_query_panel", ControlType: "Pane", ChildAutomationIDContains: []string{"skinAlphaTxt"}}},
			StabilityScore: 88,
			Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0/0"},
		},
		{
			ID:          "fuzzy_search_edit",
			Description: "模糊搜索输入框",
			AllowedUse:  AllowedUseReadOnlyLocate,
			Required: Criteria{AutomationID: "skinAlphaTxt", ControlType: "Edit", FrameworkID: "WinForm"},
			Preferred: PreferredCriteria{ClassName: "WindowsForms10.EDIT.app.0.d3a00f_r29_ad1", PathHint: "root/4/1/0/0/2", AncestorID: "fuzzy_query_window"},
			Fallback: []FallbackCriteria{{AncestorID: "fuzzy_query_window", ControlType: "Edit", ClassPrefix: "WindowsForms10.EDIT.app.0"}},
			StabilityScore: 93,
			Evidence: Evidence{CaptureID: "2026-07-08-internal-sandbox-a", UIAPath: "root/4/1/0/0/2"},
		},
	}
}
  • Step 4: Run catalog tests and verify they pass

Run:

go test ./internal/uiaselector -run TestDefaultCatalog -count=1

Expected: PASS.

  • Step 5: Commit catalog
git add internal\uiaselector\catalog.go internal\uiaselector\catalog_test.go
git commit -m "feat: add N13 UIA selector catalog"

Task 3: Load redacted UIA dump fixture

Files:

  • Create: internal/uiaselector/dump_test.go

  • Create: internal/uiaselector/dump.go

  • Step 1: Write failing dump loading tests

Create internal/uiaselector/dump_test.go:

package uiaselector

import "testing"

func TestLoadDumpFixtureParsesAcceptedN12RTree(t *testing.T) {
	root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
	if err != nil {
		t.Fatalf("LoadDumpFile: %v", err)
	}
	if root.AutomationID != "frmMain" {
		t.Fatalf("root automation_id = %q, want frmMain", root.AutomationID)
	}
	if root.ControlType != "Window" {
		t.Fatalf("root control_type = %q, want Window", root.ControlType)
	}
	if got := CountNodes(root); got != 66 {
		t.Fatalf("CountNodes = %d, want 66", got)
	}
}
  • Step 2: Run dump tests and verify they fail

Run:

go test ./internal/uiaselector -run TestLoadDumpFixture -count=1

Expected: FAIL because LoadDumpFile and CountNodes do not exist.

  • Step 3: Implement dump loading

Create internal/uiaselector/dump.go:

package uiaselector

import (
	"encoding/json"
	"fmt"
	"os"
)

type DumpResponse struct {
	OK    bool      `json:"ok"`
	Data  *DumpData `json:"data"`
	Error any       `json:"error"`
}

type DumpData struct {
	Root *Node `json:"root"`
}

type Node struct {
	Name         string `json:"name"`
	ControlType  string `json:"control_type"`
	AutomationID string `json:"automation_id"`
	ClassName    string `json:"class_name"`
	FrameworkID  string `json:"framework_id"`
	IsEnabled    *bool  `json:"is_enabled"`
	IsOffscreen  *bool  `json:"is_offscreen"`
	Bounds       any    `json:"bounds"`
	Children     []Node `json:"children"`
}

func LoadDumpFile(path string) (*Node, error) {
	content, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}
	var response DumpResponse
	if err := json.Unmarshal(content, &response); err != nil {
		return nil, err
	}
	if !response.OK {
		return nil, fmt.Errorf("dump response ok=false: %v", response.Error)
	}
	if response.Data == nil || response.Data.Root == nil {
		return nil, fmt.Errorf("dump response has no data.root")
	}
	return response.Data.Root, nil
}

func CountNodes(root *Node) int {
	if root == nil {
		return 0
	}
	count := 1
	for i := range root.Children {
		count += CountNodes(&root.Children[i])
	}
	return count
}
  • Step 4: Run dump tests and verify they pass

Run:

go test ./internal/uiaselector -run TestLoadDumpFixture -count=1

Expected: PASS.

  • Step 5: Commit dump loader
git add internal\uiaselector\dump.go internal\uiaselector\dump_test.go
git commit -m "feat: load N13 UIA dump fixture"

Task 4: Implement read-only selector matcher

Files:

  • Create: internal/uiaselector/matcher_test.go

  • Create: internal/uiaselector/matcher.go

  • Step 1: Write failing matcher tests

Create internal/uiaselector/matcher_test.go:

package uiaselector

import "testing"

func TestMatchSelectorFindsKnownN12RNodes(t *testing.T) {
	root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
	if err != nil {
		t.Fatalf("LoadDumpFile: %v", err)
	}

	tests := []struct {
		selectorID string
		path       string
		control    string
		autoID     string
	}{
		{selectorID: "main_window", path: "root", control: "Window", autoID: "frmMain"},
		{selectorID: "left_panel", path: "root/4", control: "Pane", autoID: "panelLeft"},
		{selectorID: "fuzzy_search_edit", path: "root/4/1/0/0/2", control: "Edit", autoID: "skinAlphaTxt"},
	}

	for _, tt := range tests {
		result := MatchSelector(root, DefaultCatalog(), tt.selectorID)
		if !result.OK {
			t.Fatalf("%s OK=false: %#v", tt.selectorID, result.Error)
		}
		if !result.Matched {
			t.Fatalf("%s matched=false", tt.selectorID)
		}
		if result.MatchCount != 1 {
			t.Fatalf("%s match_count = %d, want 1", tt.selectorID, result.MatchCount)
		}
		match := result.Matches[0]
		if match.Path != tt.path || match.ControlType != tt.control || match.AutomationID != tt.autoID {
			t.Fatalf("%s match = %#v", tt.selectorID, match)
		}
	}
}

func TestMatchSelectorDoesNotReturnVisibleText(t *testing.T) {
	root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
	if err != nil {
		t.Fatalf("LoadDumpFile: %v", err)
	}
	result := MatchSelector(root, DefaultCatalog(), "main_window")
	if !result.OK || !result.Matched || len(result.Matches) != 1 {
		t.Fatalf("unexpected result: %#v", result)
	}
	match := result.Matches[0]
	if match.Name != "" {
		t.Fatalf("match returned visible name text %q", match.Name)
	}
	if !match.HasName {
		t.Fatalf("main_window should report has_name=true")
	}
	if match.NameLength == 0 {
		t.Fatalf("main_window should report non-zero name_length")
	}
}

func TestMatchSelectorUnknownSelectorIsStructuredFailure(t *testing.T) {
	root, err := LoadDumpFile("testdata/n12r-2026-07-09-uia-redacted.json")
	if err != nil {
		t.Fatalf("LoadDumpFile: %v", err)
	}
	result := MatchSelector(root, DefaultCatalog(), "missing_selector")
	if result.OK {
		t.Fatalf("OK=true for missing selector: %#v", result)
	}
	if result.Error.Code != "UNKNOWN_SELECTOR" {
		t.Fatalf("error code = %q, want UNKNOWN_SELECTOR", result.Error.Code)
	}
}
  • Step 2: Run matcher tests and verify they fail

Run:

go test ./internal/uiaselector -run TestMatchSelector -count=1

Expected: FAIL because MatchSelector and result types do not exist.

  • Step 3: Implement matcher

Create internal/uiaselector/matcher.go:

package uiaselector

import "strings"

type MatchResult struct {
	OK         bool        `json:"ok"`
	SelectorID string      `json:"selector_id"`
	Matched    bool        `json:"matched"`
	MatchCount int         `json:"match_count"`
	Matches    []NodeMatch `json:"matches"`
	Warnings   []string    `json:"warnings"`
	Error      MatchError  `json:"error,omitempty"`
}

type MatchError struct {
	Code    string `json:"code"`
	Message string `json:"message"`
}

type NodeMatch struct {
	Path         string `json:"path"`
	ControlType  string `json:"control_type"`
	AutomationID string `json:"automation_id"`
	ClassName    string `json:"class_name"`
	FrameworkID  string `json:"framework_id"`
	HasName      bool   `json:"has_name"`
	NameLength   int    `json:"name_length"`
	Name         string `json:"-"`
}

type nodeRef struct {
	path string
	node *Node
}

func MatchSelector(root *Node, catalog []Selector, selectorID string) MatchResult {
	selector, ok := findSelector(catalog, selectorID)
	if !ok {
		return MatchResult{OK: false, SelectorID: selectorID, Error: MatchError{Code: "UNKNOWN_SELECTOR", Message: "selector is not in catalog"}}
	}
	if selector.AllowedUse != AllowedUseReadOnlyLocate {
		return MatchResult{OK: false, SelectorID: selectorID, Error: MatchError{Code: "INVALID_SELECTOR", Message: "selector is not read-only"}}
	}
	refs := flatten(root)
	matches := matchCriteria(refs, selector.Required)
	if len(matches) == 0 {
		for _, fallback := range selector.Fallback {
			matches = matchFallback(refs, fallback)
			if len(matches) > 0 {
				break
			}
		}
	}
	result := MatchResult{OK: true, SelectorID: selectorID, Matched: len(matches) > 0, MatchCount: len(matches), Warnings: []string{}}
	for _, ref := range matches {
		result.Matches = append(result.Matches, summarizeNode(ref))
	}
	if !result.Matched {
		result.Warnings = append(result.Warnings, "selector_not_found")
	}
	return result
}

func findSelector(catalog []Selector, selectorID string) (Selector, bool) {
	for _, selector := range catalog {
		if selector.ID == selectorID {
			return selector, true
		}
	}
	return Selector{}, false
}

func flatten(root *Node) []nodeRef {
	refs := []nodeRef{}
	var walk func(node *Node, path string)
	walk = func(node *Node, path string) {
		if node == nil {
			return
		}
		refs = append(refs, nodeRef{path: path, node: node})
		for i := range node.Children {
			walk(&node.Children[i], path+"/"+itoa(i))
		}
	}
	walk(root, "root")
	return refs
}

func itoa(value int) string {
	if value == 0 {
		return "0"
	}
	digits := []byte{}
	for value > 0 {
		digits = append([]byte{byte('0' + value%10)}, digits...)
		value = value / 10
	}
	return string(digits)
}

func matchCriteria(refs []nodeRef, criteria Criteria) []nodeRef {
	matches := []nodeRef{}
	for _, ref := range refs {
		if criteria.AutomationID != "" && ref.node.AutomationID != criteria.AutomationID {
			continue
		}
		if criteria.ControlType != "" && ref.node.ControlType != criteria.ControlType {
			continue
		}
		if criteria.FrameworkID != "" && ref.node.FrameworkID != criteria.FrameworkID {
			continue
		}
		if criteria.ClassName != "" && ref.node.ClassName != criteria.ClassName {
			continue
		}
		if criteria.ClassPrefix != "" && !strings.HasPrefix(ref.node.ClassName, criteria.ClassPrefix) {
			continue
		}
		matches = append(matches, ref)
	}
	return matches
}

func matchFallback(refs []nodeRef, fallback FallbackCriteria) []nodeRef {
	matches := []nodeRef{}
	for _, ref := range refs {
		if fallback.ControlType != "" && ref.node.ControlType != fallback.ControlType {
			continue
		}
		if fallback.ClassPrefix != "" && !strings.HasPrefix(ref.node.ClassName, fallback.ClassPrefix) {
			continue
		}
		if !hasChildAutomationIDs(ref.node, fallback.ChildAutomationIDContains) {
			continue
		}
		if !hasChildControlTypes(ref.node, fallback.ChildControlTypeContains) {
			continue
		}
		matches = append(matches, ref)
	}
	return matches
}

func hasChildAutomationIDs(node *Node, ids []string) bool {
	for _, id := range ids {
		found := false
		for i := range node.Children {
			if node.Children[i].AutomationID == id {
				found = true
				break
			}
		}
		if !found {
			return false
		}
	}
	return true
}

func hasChildControlTypes(node *Node, controlTypes []string) bool {
	for _, controlType := range controlTypes {
		found := false
		for i := range node.Children {
			if node.Children[i].ControlType == controlType {
				found = true
				break
			}
		}
		if !found {
			return false
		}
	}
	return true
}

func summarizeNode(ref nodeRef) NodeMatch {
	return NodeMatch{
		Path:         ref.path,
		ControlType:  ref.node.ControlType,
		AutomationID: ref.node.AutomationID,
		ClassName:    ref.node.ClassName,
		FrameworkID:  ref.node.FrameworkID,
		HasName:      ref.node.Name != "",
		NameLength:   len(ref.node.Name),
	}
}
  • Step 4: Run matcher tests and verify they pass

Run:

go test ./internal/uiaselector -run TestMatchSelector -count=1

Expected: PASS.

  • Step 5: Commit matcher
git add internal\uiaselector\matcher.go internal\uiaselector\matcher_test.go
git commit -m "feat: match N13 UIA selectors offline"

Task 5: Add verification script

Files:

  • Create: scripts/verify-n13-uia-selectors.ps1

  • Step 1: Create verification script

Create scripts/verify-n13-uia-selectors.ps1:

$ErrorActionPreference = "Stop"
Set-StrictMode -Version Latest

$repo = (Resolve-Path -LiteralPath (Join-Path $PSScriptRoot "..")).Path
Set-Location -LiteralPath $repo

if (-not (Get-Command go -ErrorAction SilentlyContinue)) {
    throw "go was not found. Run this verifier in a Go 1.23.x environment."
}

Write-Host "## go test ./internal/uiaselector"
go test ./internal/uiaselector -count=1
if ($LASTEXITCODE -ne 0) {
    throw "go test ./internal/uiaselector failed with exit code $LASTEXITCODE"
}

Write-Host "## boundary grep"
$forbidden = @(
    "send_after_approval",
    "send_file_after_approval",
    "receive_file_after_approval",
    "open_conversation",
    "ValuePattern.SetValue",
    "InvokePattern"
)
$files = @(
    "internal\uiaselector\catalog.go",
    "internal\uiaselector\matcher.go"
)
foreach ($term in $forbidden) {
    $hit = Select-String -LiteralPath $files -Pattern $term -SimpleMatch -ErrorAction SilentlyContinue
    if ($hit) {
        throw "forbidden action term found in selector implementation: $term"
    }
}

Write-Host "N13 UIA selector verification passed."
  • Step 2: Run script and verify it passes

Run:

powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n13-uia-selectors.ps1

Expected in Go-enabled environment:

## go test ./internal/uiaselector
ok   isphere-ai-bridge/internal/uiaselector
## boundary grep
N13 UIA selector verification passed.

If go is unavailable, expected failure is:

go was not found. Run this verifier in a Go 1.23.x environment.
  • Step 3: Commit verification script
git add scripts\verify-n13-uia-selectors.ps1
git commit -m "test: add N13 selector verifier"

Task 6: Final verification and checkpoint

Files:

  • No new files.

  • Verify: internal/uiaselector/*, scripts/verify-n13-uia-selectors.ps1

  • Step 1: Run package tests

Run:

go test ./internal/uiaselector -count=1

Expected:

ok   isphere-ai-bridge/internal/uiaselector
  • Step 2: Run repository tests if Go is available

Run:

go test ./...

Expected: PASS for all packages.

If the environment lacks Go, do not mark implementation complete. Move to a Go 1.23.x environment and rerun.

  • Step 3: Run N13 verifier

Run:

powershell -NoProfile -ExecutionPolicy Bypass -File scripts\verify-n13-uia-selectors.ps1

Expected: PASS in Go-enabled environment.

  • Step 4: Check no MCP/helper action surface changed

Run:

git diff --name-only origin/main..HEAD

Expected changed implementation files are limited to:

internal/uiaselector/catalog.go
internal/uiaselector/catalog_test.go
internal/uiaselector/dump.go
internal/uiaselector/dump_test.go
internal/uiaselector/matcher.go
internal/uiaselector/matcher_test.go
internal/uiaselector/testdata/n12r-2026-07-09-uia-redacted.json
scripts/verify-n13-uia-selectors.ps1

If native/ISphereWinHelper/Program.cs, internal/tools/winhelper.go, or cmd/isphere-mcp/main.go appears, stop and review because N13 is not supposed to add live tools.

  • Step 5: Commit final checkpoint if needed

If any verification-only documentation changes were made, run:

git add <changed verification docs>
git commit -m "docs: record N13 selector verification"

If no files changed, do not create an empty commit.

Self-review checklist

  • Spec coverage: this plan covers selector catalog, fixture loading, read-only matcher, no visible text in result, and boundary verification.
  • Boundary: no helper op, no MCP tool, no click/input/send/file/conversation behavior.
  • TDD: every implementation task starts with failing tests before code.
  • Commit cadence: fixture, catalog, loader, matcher, verifier are separate commits.
  • Environment caveat: Go 1.23.x is required for implementation verification.