diff --git a/WHATS_NEW.md b/WHATS_NEW.md index c595e324..3be380b4 100644 --- a/WHATS_NEW.md +++ b/WHATS_NEW.md @@ -1,1105 +1,1137 @@ # What's New — AutoControl -## What's new (2026-06-25) — Reactive UIA Event Wait (focus change) +## What's new (2026-06-25) + +### Resolve the App Registered for a File Type + +Find out *which* app opens a file type — assert "PDFs open in Acrobat, not the browser". Full reference: [`docs/source/Eng/doc/new_features/v205_features_doc.rst`](docs/source/Eng/doc/new_features/v205_features_doc.rst). + +- **`normalize_ext` / `file_association`** (`AC_normalize_ext`, `AC_file_association`): `open_path` (`shell_open`) opens a file with whatever app is registered for it; this answers the inverse, read-only question — *which* app is that? Given `report.pdf` (or a bare `.pdf` / `pdf`) `file_association` returns the registered executable, friendly app name, open command line and MIME content type via the Windows `AssocQueryStringW` shell API. `normalize_ext` is the pure path/`.ext`/bare-`ext` → `.ext` helper. The assembly logic is unit-testable without Windows through an injectable `resolver` seam (the real shell API by default). The natural companion to `open_path`: one tells you what would open a file, the other opens it. Third feature of the ROUND-15 cross-app OS lane. No `PySide6`. + +### Idle Detection + Keep the Machine Awake + +Run only when the user has stepped away, and stop an overnight run from sleeping. Full reference: [`docs/source/Eng/doc/new_features/v204_features_doc.rst`](docs/source/Eng/doc/new_features/v204_features_doc.rst). + +- **`idle_seconds` / `is_idle` / `keep_awake` / `keep_awake_on` / `allow_sleep` / `plan_keep_awake`** (`AC_idle_seconds`, `AC_is_idle`, `AC_plan_keep_awake`, `AC_keep_awake_on`, `AC_allow_sleep`): long unattended runs get derailed two ways — the screensaver / power policy sleeps the box mid-run, or the run should hold while a human is actively using the machine. The framework had neither signal. `idle_seconds` / `is_idle` report time since the last keyboard / mouse input (`GetLastInputInfo` on Windows) through an injectable `probe`; `keep_awake` (scoped context manager) and `keep_awake_on` / `allow_sleep` (process-global on/off for JSON flows) stop the system and display sleeping, applied through an injectable `driver` (`SetThreadExecutionState` / `caffeinate` / `systemd-inhibit` by default) and restored on release. `plan_keep_awake` is the pure planner. All logic is unit-testable without touching the OS via the injected probe/driver. Second feature of the ROUND-15 cross-app OS lane. No `PySide6`. + +### Open Files / URLs with the Default App + +Hand a file to its default app, print it, or open a URL in the browser. Full reference: [`docs/source/Eng/doc/new_features/v203_features_doc.rst`](docs/source/Eng/doc/new_features/v203_features_doc.rst). + +- **`open_path` / `plan_open`** (`AC_open_path`, `AC_plan_open`): the framework could launch a literal `.exe`, but not the most common "hand off to another app" step — open `report.pdf` with its registered app, `print` a document, or open a URL in the default browser. This routes per-OS to `os.startfile` / `open` / `xdg-open` / `webbrowser`. `plan_open` is a pure planner that classifies the target (URL vs file path), validates it (URL scheme allow-list; `realpath` for files — a Windows drive `C:\` is correctly a path, not a scheme) and returns the dispatch descriptor; `open_path` runs it through an injectable `opener` (the real OS call by default), so the logic is unit-testable without launching anything. First feature of the ROUND-15 cross-app OS lane. No `PySide6`. + +### Reactive UIA Event Wait (focus change) Wait until focus lands on the dialog — a real, zero-latency UIA event, not polling. Full reference: [`docs/source/Eng/doc/new_features/v202_features_doc.rst`](docs/source/Eng/doc/new_features/v202_features_doc.rst). - **`wait_for_focus_change`** (`AC_wait_for_focus_change`): the accessibility recorder *polls* focus every ~250 ms, so it can miss a fast transition and reacts late. This blocks on the native `AddFocusChangedEventHandler` and returns the moment focus moves — the zero-latency, miss-free "wait until focus lands on the dialog" primitive, the accessibility-tree analogue of `wait_for_window` / `wait_for_image`. Returns the newly-focused element (or `None` on timeout). The real event subscription is registered/unregistered under a lock on the calling thread; dispatched through the injectable accessibility backend seam (headless-testable via a fake backend; real UIA in the Windows backend). No `PySide6`. -## What's new (2026-06-25) — Container Selection + View Switching (Selection / MultipleView) +### Container Selection + View Switching (Selection / MultipleView) Read what's selected in a listbox/grid, and switch Explorer-style views. Full reference: [`docs/source/Eng/doc/new_features/v201_features_doc.rst`](docs/source/Eng/doc/new_features/v201_features_doc.rst). - **`get_selection` / `list_views` / `set_view`** (`AC_get_selection`, `AC_list_views`, `AC_set_view`): `select_control_item` selects *one* item, but the container-level `SelectionPattern` answers "what is currently selected, and may it select multiple?" — the assertion target after selecting. `MultipleViewPattern` switches a control between its views (Explorer's list / details / tile / thumbnail), a precondition that otherwise needs fragile menu clicking. `get_selection` returns `{items, can_select_multiple, is_required}`, `list_views` returns `{current, views}`, and `set_view` switches by view name. Dispatched through the injectable accessibility backend seam (headless-testable via a fake backend; real UIA in the Windows backend). No `PySide6`. -## What's new (2026-06-25) — Advanced TextPattern (find / select / read attributes) +### Advanced TextPattern (find / select / read attributes) Search a control's text, select a match to replace it, and read font/colour formatting. Full reference: [`docs/source/Eng/doc/new_features/v200_features_doc.rst`](docs/source/Eng/doc/new_features/v200_features_doc.rst). - **`find_control_text` / `select_control_text` / `control_text_attributes`** (`AC_find_control_text`, `AC_select_control_text`, `AC_control_text_attributes`): `ax_text` shipped the three whole-range *reads*, but couldn't search for a substring, select a found range, or read text formatting — needed to assert "the error word is red and bold" or to place the selection at matched text before typing. This rounds out TextPattern: `find_control_text` searches the real content (not OCR) via `FindText`, `select_control_text` finds + selects a range so the next keystrokes replace it, and `control_text_attributes` reads `{font_name, font_size, bold, italic, foreground_color}`. Dispatched through the injectable accessibility backend seam (headless-testable via a fake backend; real UIA in the Windows backend). No `PySide6`. -## What's new (2026-06-25) — MSAA Bridge for Legacy Controls (LegacyIAccessible) +### MSAA Bridge for Legacy Controls (LegacyIAccessible) Automate the long tail of old Win32 controls that expose nothing via modern UIA. Full reference: [`docs/source/Eng/doc/new_features/v199_features_doc.rst`](docs/source/Eng/doc/new_features/v199_features_doc.rst). - **`legacy_info` / `legacy_default_action`** (`AC_legacy_info`, `AC_legacy_default_action`): many legacy Win32 / MFC / Delphi controls expose nothing useful via modern UIA patterns (`control_get_value` / `control_invoke` / `control_toggle` all return None), yet they're fully described through the MSAA `IAccessible` bridge — Name, Value, Description, Role, State and a **DefaultAction**. This reads that info and fires the default action via `LegacyIAccessiblePattern` — the last-resort fallback that makes old apps automatable. Dispatched through the injectable accessibility backend seam (headless-testable via a fake backend; real UIA in the Windows backend). No `PySide6`. -## What's new (2026-06-25) — Move / Resize Elements + Window State (UIA Transform + Window) +### Move / Resize Elements + Window State (UIA Transform + Window) Move a floating panel, resize a control, and know if a window is modal-blocked. Full reference: [`docs/source/Eng/doc/new_features/v198_features_doc.rst`](docs/source/Eng/doc/new_features/v198_features_doc.rst). - **`move_element` / `resize_element` / `set_window_state` / `window_interaction_state`** (`AC_move_element`, `AC_resize_element`, `AC_set_window_state`, `AC_window_interaction_state`): this is UIA-**element-level**, not the HWND/title-level geometry in `window_layout`. `TransformPattern` moves/resizes a specific control or floating panel (dockable toolbars, MDI children, splitters) with no top-level window of its own; `WindowPattern` minimizes/maximizes a window and reports its **interaction state** (`ready` / `blocked_by_modal` / `not_responding`) — a reliable "is this window ready or modal-blocked?" signal pixel/title polling can't give. Dispatched through the injectable accessibility backend seam (headless-testable via a fake backend; real UIA in the Windows backend). No `PySide6`. -## What's new (2026-06-25) — Table Headers + Cell Addressing (UIA TablePattern) +### Table Headers + Cell Addressing (UIA TablePattern) Assert "the Status column of row 5 says Shipped" — by header, not by guessing indices. Full reference: [`docs/source/Eng/doc/new_features/v197_features_doc.rst`](docs/source/Eng/doc/new_features/v197_features_doc.rst). - **`table_headers` / `table_cell` / `cell_by_header`** (`AC_table_headers`, `AC_table_cell`, `AC_cell_by_header`): `read_control_table` (GridPattern) dumps a flat 2-D list of cell names with no header labels and no way to address one cell by (header, row) — you can dump a grid but not test one. This adds the missing half: `table_headers` reads the row/column header labels (TablePattern), `table_cell` reads the cell at `(row, column)` with its span (GridItemPattern), and `cell_by_header` resolves the column index from the headers so you can read the cell at `(row, "Status")` directly. Dispatched through the injectable accessibility backend seam (headless-testable via a fake backend; real UIA in the Windows backend). No `PySide6`. -## What's new (2026-06-25) — Rich UIA Element Properties +### Rich UIA Element Properties Know if a control is enabled / off-screen / has a tooltip before you act. Full reference: [`docs/source/Eng/doc/new_features/v196_features_doc.rst`](docs/source/Eng/doc/new_features/v196_features_doc.rst). - **`get_element_properties` / `is_element_enabled`** (`AC_get_element_properties`): the flat element list carries only name/role/bounds/app/id, but automation needs more before it acts — **is the control enabled** (don't click a disabled button), **is it off-screen**, its **item_status** (field validation/error), **help_text** (tooltip), and **accelerator_key** (drive via hotkey). This reads those high-value UIA properties (`enabled`/`offscreen`/`help_text`/`item_status`/`accelerator_key`/`access_key`/`orientation`); `is_element_enabled` is the common pre-action guard. Dispatched through the injectable accessibility backend seam (headless-testable via a fake backend; real UIA reads in the Windows backend). No `PySide6`. -## What's new (2026-06-25) — Realize Off-Screen Items in Virtualized Lists / Grids +### Realize Off-Screen Items in Virtualized Lists / Grids Reach a row that isn't scrolled into view yet — the "element not found in a long list" fix. Full reference: [`docs/source/Eng/doc/new_features/v195_features_doc.rst`](docs/source/Eng/doc/new_features/v195_features_doc.rst). - **`realize_item`** (`AC_realize_item`): long lists / data grids / trees only materialize visible rows, so an off-screen row has no accessibility element at all — `list_accessibility_elements` / `read_control_table` / `select_control_item` can't see it, and `scroll_control_into_view` can't help because the element doesn't exist yet. This locates the item by property (UIA `ItemContainerPattern.FindItemByProperty`) and realizes it (`VirtualizedItemPattern.Realize`) so it becomes a real, clickable element. Match `by` name (default) or `automation_id`; locate the container by name/role/app. Dispatched through the injectable accessibility backend seam (headless-testable via a fake backend; real UIA in the Windows backend). No `PySide6`. -## What's new (2026-06-25) — Per-Run Step Timeline (waterfall + bottleneck steps) +### Per-Run Step Timeline (waterfall + bottleneck steps) Read why *this* run was slow — a step waterfall and its bottlenecks. Full reference: [`docs/source/Eng/doc/new_features/v194_features_doc.rst`](docs/source/Eng/doc/new_features/v194_features_doc.rst). - **`build_timeline` / `critical_steps`** (`AC_build_timeline`, `AC_critical_steps`): the action profiler aggregates timings by step *name* across runs — useless for "why was *this* run slow". This turns one run's ordered steps into a waterfall (each step's offset, duration, and `pct` share of the total) with the `bottleneck` step and a `parallelism` ratio (`> 1` when steps overlap via explicit `start` times); `critical_steps` ranks the dominant steps to optimise. A step is any `{name, duration, start?}` dict. Pure stdlib. No `PySide6`. -## What's new (2026-06-25) — Flaky-Test Co-Failure Clustering +### Flaky-Test Co-Failure Clustering Find the tests that flake *together* — and the shared root cause behind them. Full reference: [`docs/source/Eng/doc/new_features/v193_features_doc.rst`](docs/source/Eng/doc/new_features/v193_features_doc.rst). - **`cofailure_pairs` / `failure_clusters`** (`AC_cofailure_pairs`, `AC_failure_clusters`): flaky tests are rarely independent — a wobbly fixture or noisy dependency makes a *group* fail in the same runs (~75% of flaky tests cluster). Ranking tests one-by-one by flip rate misses that. This measures how often each pair of tests fails in the *same* runs (Jaccard over their failing-run sets) and groups tests above a threshold into connected clusters with a cohesion score — so you chase one root cause instead of N symptoms. Input is a list of runs, each the test names that failed in it. Pure stdlib. No `PySide6`. -## What's new (2026-06-25) — Run-Trace Diff (what changed between two executions) +### Run-Trace Diff (what changed between two executions) See exactly what changed between a passing run and a failing one. Full reference: [`docs/source/Eng/doc/new_features/v192_features_doc.rst`](docs/source/Eng/doc/new_features/v192_features_doc.rst). - **`diff_runs` / `summarize_run_diff`** (`AC_diff_runs`): a run history says a run *failed* but not *what changed* from the run that passed. This aligns two step sequences with a longest-common-subsequence walk (so an inserted/removed step shifts the rest into place instead of mis-pairing everything) and classifies the differences: **added**/**removed** steps, **status_flips** (an aligned step that changed status — with the new failure's `failure_signature` when it carries an error), and **timing_regressions** (a step that got `regress_factor`× slower). `summarize_run_diff` renders a one-line summary. Pure stdlib over lists of `{name,status,duration,error}` step dicts. No `PySide6`. -## What's new (2026-06-25) — Stable Failure Signatures +### Stable Failure Signatures Match the *same kind* of failure across runs, despite differing paths and ids. Full reference: [`docs/source/Eng/doc/new_features/v191_features_doc.rst`](docs/source/Eng/doc/new_features/v191_features_doc.rst). - **`normalize_error` / `failure_signature` / `group_failures`** (`AC_failure_signature`, `AC_group_failures`): two runs that failed the same way rarely have byte-identical error text — paths, line numbers, addresses, ids and timestamps differ every time — which defeats "is this the same failure?" and "which tests fail together?". This strips the variable parts of an error to a canonical form and hashes it (SHA-256), so the same kind of failure gets the same short signature across runs — the join key the rest of the test-robustness tools (run diffing, flake clustering) group on. `group_failures` buckets a list of errors by signature, most frequent first. Pure stdlib (`re` + `hashlib`). No `PySide6`. -## What's new (2026-06-24) — Visual Saliency (where to look — spectral-residual) +## What's new (2026-06-24) + +### Visual Saliency (where to look — spectral-residual) Find the region that stands out, with no template / colour / text. Full reference: [`docs/source/Eng/doc/new_features/v190_features_doc.rst`](docs/source/Eng/doc/new_features/v190_features_doc.rst). - **`saliency_map` / `salient_regions` / `most_salient`** (`AC_salient_regions`, `AC_most_salient`): when there's no template, colour or text to key on, an agent still needs a cue for *where to look*. This computes the spectral-residual saliency map (Hou & Zhang 2007 — log amplitude minus its local average, reconstructed through the phase) and turns it into ranked salient boxes in source pixel coordinates. The transform is a pure numpy FFT (`cv2.saliency` is in the forbidden opencv-contrib package, so it's re-implemented over base opencv); it reuses `visual_match`'s grayscale loader and `cv2_utils.blobs.connected_boxes`. Regions threshold at `mean + 2·std` by default. A coarse attention cue to *narrow* where a template / OCR pass then looks. No `PySide6`. -## What's new (2026-06-24) — Display-Scale / Visual-DPI Detection +### Display-Scale / Visual-DPI Detection Infer which display scale (DPI) a template renders at — and how confidently. Full reference: [`docs/source/Eng/doc/new_features/v189_features_doc.rst`](docs/source/Eng/doc/new_features/v189_features_doc.rst). - **`detect_scale` / `scale_sweep`** (`AC_detect_scale`, `AC_scale_sweep`): a template cropped at 100% scale won't match on a 150%-DPI machine, and `match_template` returns only the single best match — discarding the per-scale scores. This keeps the whole profile: `scale_sweep` scores the template at every scale, and `detect_scale` reports the winning scale as a DPI inference (`scale_percent`) with a confidence `margin` (how far it beats the runner-up). Reuses `visual_match._score_map` per scale; source is any ndarray / path / PIL image (or the live screen); scales default to the common Windows values. cv2/numpy lazily imported. No `PySide6`. -## What's new (2026-06-24) — Image Quality Scoring (sharpness / contrast / brightness gate) +### Image Quality Scoring (sharpness / contrast / brightness gate) Refuse to OCR a blurry or washed-out frame — score quality and gate before recognition. Full reference: [`docs/source/Eng/doc/new_features/v188_features_doc.rst`](docs/source/Eng/doc/new_features/v188_features_doc.rst). - **`image_quality` / `is_blurry` / `quality_gate`** (`AC_image_quality`, `AC_quality_gate`): OCR and template matching quietly fail on a blurry, washed-out or too-dark capture, and the caller can't tell a *missing* element from an *unreadable* one. This measures sharpness (variance of the Laplacian), contrast (grayscale stddev) and brightness (mean 0–255); `quality_gate` turns them into `{passed, issues}` flagging `blurry` / `low_contrast` / `too_dark` / `too_bright` so a script can pre-process or re-capture before OCR. Reuses `visual_match`'s grayscale loader (any ndarray / path / PIL image, or the live screen); cv2/numpy lazily imported. No `PySide6`. -## What's new (2026-06-24) — Drop Files onto a Window (WM_DROPFILES) +### Drop Files onto a Window (WM_DROPFILES) Complete a drag-and-drop programmatically — drop files onto a target window. Full reference: [`docs/source/Eng/doc/new_features/v187_features_doc.rst`](docs/source/Eng/doc/new_features/v187_features_doc.rst). - **`plan_file_drop` / `drop_files`** (`AC_plan_file_drop`, `AC_drop_files`): `clipboard_files` *stages* a file list on the clipboard for `Ctrl+V`; this actively **drops** files onto a target window by posting a `WM_DROPFILES` message. It reuses `clipboard_files.build_dropfiles` to pack the `DROPFILES` blob (shared byte layout, not re-implemented) and dispatches through an injectable driver seam, so the build-and-dispatch logic is unit-testable with a fake driver; the real `GlobalAlloc` + `PostMessage` lives in the default Win32 driver. `plan_file_drop` is a pure dry-run returning `{message, paths, point, wide, blob_size}`. No `PySide6`. -## What's new (2026-06-24) — Clipboard Format Inspection (classify / diff available formats) +### Clipboard Format Inspection (classify / diff available formats) See which formats are on the clipboard, and detect when its shape changes. Full reference: [`docs/source/Eng/doc/new_features/v186_features_doc.rst`](docs/source/Eng/doc/new_features/v186_features_doc.rst). - **`classify_format` / `classify_formats` / `diff_formats` / `list_clipboard_formats` / `clipboard_formats`** (`AC_clipboard_formats`, `AC_classify_formats`, `AC_diff_formats`): the clipboard usually holds the same content in several formats at once (a Word copy = text + HTML + RTF; a file copy = CF_HDROP; a screenshot = CF_DIB). This enumerates the live clipboard (`EnumClipboardFormats`) without consuming anything and classifies each format into a friendly category (text/image/files/html/rtf/csv/audio/…); `diff_formats` is a pure monitor primitive returning `{added, removed, changed}` between two snapshots. The classifier and diff are pure (registered names take priority over dynamic ids); only the live enumeration is Win32. No `PySide6`. -## What's new (2026-06-24) — Rich Clipboard Formats (RTF and CSV/TSV) +### Rich Clipboard Formats (RTF and CSV/TSV) Put styled text and tables on the clipboard for cross-app paste into Word and Excel. Full reference: [`docs/source/Eng/doc/new_features/v185_features_doc.rst`](docs/source/Eng/doc/new_features/v185_features_doc.rst). - **`build_rtf` / `rtf_to_text` / `rows_to_csv` / `csv_to_rows` + `set_clipboard_rtf` / `get_clipboard_rtf` / `set_clipboard_csv` / `get_clipboard_csv`** (`AC_set_clipboard_rtf`, `AC_get_clipboard_rtf`, `AC_set_clipboard_csv`, `AC_get_clipboard_csv`): `rich_clipboard` added CF_HTML, but RTF (the format rich editors accept) and the `Csv` format Excel reads were still missing. This adds both: `build_rtf`/`rtf_to_text` build and strip RTF control words and `\uNNNN` / `\'XX` escapes in pure Python (fully unit-testable round-trip), and `rows_to_csv`/`csv_to_rows` wrap the stdlib `csv` module (delimiter-parametrised, so `\t` gives TSV). The codecs are platform-independent; the Win32 get/set share one generic byte-transfer helper, and the sets seed plain text so plain editors still paste. No `PySide6`. -## What's new (2026-06-24) — Keyboard Focus Order (Tab sequence / WCAG audit / set-focus) +### Keyboard Focus Order (Tab sequence / WCAG audit / set-focus) Reason about keyboard navigation: the Tab order, a WCAG focus-order audit, and set-focus. Full reference: [`docs/source/Eng/doc/new_features/v184_features_doc.rst`](docs/source/Eng/doc/new_features/v184_features_doc.rst). - **`is_interactive_role` / `tab_order` / `audit_focus_order` / `focus_control`** (`AC_tab_order`, `AC_audit_focus_order`, `AC_focus_control`): nothing reasoned about *keyboard* navigation — only mouse coordinates and element values. This adds the keyboard layer: `tab_order` returns the focusable elements in the order Tab visits them (reading order), `audit_focus_order` is a WCAG 2.4.x report (the sequence + flagged problems like a focusable element with no visible area), and `focus_control` sets keyboard focus via UIA `SetFocus`. The first three are pure functions over `AccessibilityElement` lists — `tab_order` reuses `element_parse.reading_order` and `is_interactive_role` reuses `ax_tree_walk.humanize_role`, so no logic is duplicated; `focus_control` dispatches the injectable backend seam (real `SetFocus` in the Windows backend). No `PySide6`. -## What's new (2026-06-24) — Readable, Addressable Accessibility Tree (role names + node paths) +### Readable, Addressable Accessibility Tree (role names + node paths) Turn a raw `ControlType_50000` tree dump into readable roles with a stable path per node. Full reference: [`docs/source/Eng/doc/new_features/v183_features_doc.rst`](docs/source/Eng/doc/new_features/v183_features_doc.rst). - **`control_type_name` / `humanize_role` / `humanize_tree` / `assign_node_paths` / `find_by_path`** (`AC_walk_tree`, `AC_humanize_role`): `dump_accessibility_tree` emits the platform's raw role (on Windows the bare UIA ControlType id, e.g. `ControlType_50000` for a button) and carries no stable per-node identity once serialised. This adds the pure post-processing it lacks: translate ControlType ids to friendly names, deep-copy a tree with every role humanised, stamp each node with a stable positional `path` (`"0.2.1"` — a pure stand-in for RuntimeId), and resolve a node back by path. `AC_walk_tree` is the readable counterpart to `AC_a11y_dump`. Pure-stdlib over `AXTreeNode`; unknown / non-UIA roles pass through unchanged. No `PySide6`. -## What's new (2026-06-24) — Native Text Reading via the UIA TextPattern (document / selection / visible) +### Native Text Reading via the UIA TextPattern (document / selection / visible) Read the text in multiline editors and document controls where ValuePattern returns nothing. Full reference: [`docs/source/Eng/doc/new_features/v182_features_doc.rst`](docs/source/Eng/doc/new_features/v182_features_doc.rst). - **`get_control_text` / `get_selected_text` / `get_visible_text`** (`AC_get_control_text`, `AC_get_selected_text`, `AC_get_visible_text`): `control_get_value` reads through UIA ValuePattern, which returns an empty string on multiline edits, RichEdit / document controls and web text areas — exactly the controls whose text you most want. This reads through `TextPattern` instead: `get_control_text` returns the whole `DocumentRange`, `get_selected_text` the current `GetSelection`, `get_visible_text` only the on-screen `GetVisibleRanges`. Dispatched through the injectable `accessibility.backends.get_backend()` seam (headless-testable via a fake backend; real UIA calls in the Windows backend), returning `{text}` from the executor/MCP. No `PySide6`. -## What's new (2026-06-24) — Extended UIA Control Patterns (Expand / Select / Range / Scroll) +### Extended UIA Control Patterns (Expand / Select / Range / Scroll) Drive tree nodes, list/combo items, sliders and scroll natively, not by pixel guessing. Full reference: [`docs/source/Eng/doc/new_features/v181_features_doc.rst`](docs/source/Eng/doc/new_features/v181_features_doc.rst). - **`expand_control` / `collapse_control` / `control_expand_state` / `select_control_item` / `control_range` / `set_control_range` / `scroll_control_into_view`** (`AC_expand_control`, `AC_select_control_item`, `AC_set_control_range`, …): the accessibility backend had only Value/Invoke/Toggle/Grid-read patterns, so treeviews, listboxes/combos, sliders and off-screen rows had no native call path. This adds ExpandCollapse / SelectionItem / RangeValue / ScrollItem patterns on top of the existing backend ABC, dispatched through the injectable `accessibility.backends.get_backend()` seam (headless-testable via a fake backend; real UIA calls in the Windows backend). No `PySide6`. -## What's new (2026-06-24) — Pre-Match Settle Gating + Match Persistence +### Pre-Match Settle Gating + Match Persistence Avoid matching mid-animation, and confirm a hit holds steady across frames. Full reference: [`docs/source/Eng/doc/new_features/v180_features_doc.rst`](docs/source/Eng/doc/new_features/v180_features_doc.rst). - **`region_stability` / `match_persistence`** (`AC_region_stability`, `AC_match_persistence`): `smart_waits.wait_until_screen_stable` gates a live loop with a boolean — it can't score stability on an injectable frame sequence or check whether a *match* held steady. `region_stability` scores consecutive-frame SSIM (`{stable, mean_ssim, min_ssim}`); `match_persistence` confirms a template is found in *every* frame with the centres agreeing within `agree_px` (`{persisted, n_hits, jitter}`). Reuses `ssim` + `visual_match` + `grounding_consensus`; injectable frames; no `PySide6`. -## What's new (2026-06-24) — Colour-Aware Template Matching (HSV) +### Colour-Aware Template Matching (HSV) Tell a red status dot from a green one of identical shape. Full reference: [`docs/source/Eng/doc/new_features/v179_features_doc.rst`](docs/source/Eng/doc/new_features/v179_features_doc.rst). - **`match_color` / `match_color_all`** (`AC_match_color`, `AC_match_color_all`): every `visual_match` matcher grayscales first, so red vs green of identical shape is indistinguishable; `color_region` finds known-colour blobs but can't template-match a multi-colour glyph. This matches on HSV hue/saturation with a colour-*distance* metric (`TM_SQDIFF_NORMED` — correlation would normalise away the absolute hue, scoring a red→green edge same as black→blue). Reuses `color_region`'s RGB loaders + `visual_match`'s resize/NMS/`Match`. `channels` default `("h","s")` (use `("h",)` for flat-saturation targets); for solid blobs use `find_color_region`. No `PySide6`. -## What's new (2026-06-24) — Multi-Template Consensus Matching +### Multi-Template Consensus Matching Vote several reference crops of one target into a single trustworthy location. Full reference: [`docs/source/Eng/doc/new_features/v178_features_doc.rst`](docs/source/Eng/doc/new_features/v178_features_doc.rst). - **`match_ensemble` / `vote_centers`** (`AC_match_ensemble`, `AC_vote_centers`): a button renders in several states (default/hover/pressed) but is one logical target; `ab_locator` picks one strategy and `match_template(scales=...)` sweeps one template — neither fuses multiple references. This matches each reference, clusters the hit centres, and accepts a target only when ≥ `min_votes` agree within `agree_px`, returning `{point, votes, n_candidates, spread}` — cutting false positives on themed/animated UI. Reuses `visual_match.match_template` + `grounding_consensus`; `vote_centers` is the pure voting core. No `PySide6`. -## What's new (2026-06-24) — Per-Step Critic Features + Rule-Based Step Scorer +### Per-Step Critic Features + Rule-Based Step Scorer Bundle the evidence to score an agent step, with a built-in rule-based scorer. Full reference: [`docs/source/Eng/doc/new_features/v177_features_doc.rst`](docs/source/Eng/doc/new_features/v177_features_doc.rst). - **`build_critic_record` / `score_step_rule_based` / `to_judge_prompt`** (`AC_build_critic_record`, `AC_score_step`): `trajectory_eval` scores a whole trajectory with no per-step evidence; `agent_trace` emits spans not quality; `agent_replay` stores steps but doesn't score. This composes `action_effect` + `observation_delta` + `postcondition` into one per-step record, then `score_step_rule_based` gives a deterministic `{outcome, process_score, reasons}` (no model needed) and `to_judge_prompt` renders it for an optional LLM-as-judge. Pure-stdlib aggregator; no `PySide6`. -## What's new (2026-06-24) — Heading vs Body Classification + Document Outline +### Heading vs Body Classification + Document Outline Tell headings from body text by height and build a document outline. Full reference: [`docs/source/Eng/doc/new_features/v176_features_doc.rst`](docs/source/Eng/doc/new_features/v176_features_doc.rst). - **`classify_lines` / `outline`** (`AC_classify_lines`, `AC_outline`): nothing mapped line height to heading levels or built a section outline — `ocr/structure` / `element_parse` are positional and `text_blocks` doesn't rank. This applies the standard heuristic: a line taller than `heading_ratio` × the median line height is a heading, and distinct heading heights become levels (tallest = 1). `classify_lines` tags each line `{box, text, role, level}`; `outline` returns the headings in order as a table of contents. Pure-stdlib over line dicts; no `PySide6`. -## What's new (2026-06-24) — Settle Detection Over a Churn Series +### Settle Detection Over a Churn Series Decide when the UI has gone quiet — as a pure, testable function over a change series. Full reference: [`docs/source/Eng/doc/new_features/v175_features_doc.rst`](docs/source/Eng/doc/new_features/v175_features_doc.rst). - **`settle_point` / `is_settled` / `SettleTracker`** (`AC_settle_point`): `smart_waits.wait_until_screen_stable` bakes the settle logic inside a `time.sleep` loop over live frames — you can't feed it a recorded series or unit-test the decision. This extracts it: given a stream of *churn* values (pixel delta / element-count delta / 0-1 digest-changed), it reports when churn stayed ≤ `max_churn` for `quiet_samples` in a row (a spike resets the run). `settle_point` returns the settle index, `SettleTracker` is the incremental form for a live loop. Pure-stdlib, no clock, no capture; no `PySide6`. -## What's new (2026-06-24) — Paragraph & List Grouping of OCR Lines +### Paragraph & List Grouping of OCR Lines Group OCR lines into paragraphs and detect bulleted / numbered lists. Full reference: [`docs/source/Eng/doc/new_features/v174_features_doc.rst`](docs/source/Eng/doc/new_features/v174_features_doc.rst). - **`group_paragraphs` / `detect_lists`** (`AC_group_paragraphs`, `AC_detect_lists`): `text_regions` merges glyphs into lines but nothing grouped those lines into paragraphs or detected lists; `ocr/structure` stops at flat rows. `group_paragraphs` starts a new paragraph wherever the vertical gap exceeds `line_gap_factor` × the median line height; `detect_lists` recognises bullet (`•`/`-`/`*`) or ordinal (`1.`/`2)`/`a.`) items, returning `{text, marker, indent, box}`. Pure-stdlib over line dicts; reuses `table_grid_fill`'s box reader; no `PySide6`. -## What's new (2026-06-24) — Column-Aware Reading Order (XY-Cut) +### Column-Aware Reading Order (XY-Cut) Read multi-column layouts down each column instead of interleaving them. Full reference: [`docs/source/Eng/doc/new_features/v173_features_doc.rst`](docs/source/Eng/doc/new_features/v173_features_doc.rst). - **`flow_order` / `xy_cut` / `to_blocks`** (`AC_flow_order`, `AC_xy_cut`): `element_parse.reading_order` is a flat top-to-bottom sort that interleaves columns (reads A1, B1, A2, B2…). This recovers the correct order with recursive XY-cut — split at the widest whitespace valley (vertical → columns, horizontal → rows), so a two-column page reads A1, A2, B1, B2. `flow_order` returns the same `index`-tagged contract as `reading_order` (a drop-in column-aware upgrade, named to not shadow it); `xy_cut` exposes the region tree; `to_blocks` lists the leaf blocks. Pure-stdlib; no `PySide6`. -## What's new (2026-06-24) — Grounding Self-Consistency (Consensus Over Proposals) +### Grounding Self-Consistency (Consensus Over Proposals) Fuse several grounding proposals into one agreed target with an agreement score. Full reference: [`docs/source/Eng/doc/new_features/v172_features_doc.rst`](docs/source/Eng/doc/new_features/v172_features_doc.rst). - **`consensus_point` / `consensus_element` / `is_confident`** (`AC_consensus_point`, `AC_consensus_element`): a target can be grounded several ways at once (set-of-marks / OCR / template / a11y / N model samples) and they don't always agree. `ab_locator`/`element_scoring` rank *strategies* by history; `snap_to_element` snaps a *single* coordinate — neither fuses *simultaneous* proposals. This clusters candidate points (or votes candidate elements), returns the agreed `point` + an `agreement` fraction + `spread`, and `is_confident` flags low-agreement targets so the agent zooms / asks instead of clicking blind. Pure-stdlib; no `PySide6`. -## What's new (2026-06-24) — Sub-Pixel Template-Match Refinement +### Sub-Pixel Template-Match Refinement Refine a match's centre to a fraction of a pixel for drag / slider / high-DPI precision. Full reference: [`docs/source/Eng/doc/new_features/v171_features_doc.rst`](docs/source/Eng/doc/new_features/v171_features_doc.rst). - **`match_subpixel` / `refine_peak`** (`AC_match_subpixel`): every matcher returns *integer* coordinates from `cv2.minMaxLoc` — for a drag handle, fine slider or high-DPI display that rounding is the dominant click-placement error. This fits a parabola to the 3×3 score neighbourhood around the peak (independently on x/y, the standard NCC sub-pixel method) and returns a `SubPixelMatch` with float `cx`/`cy` + the applied `offset_x`/`offset_y`. Reuses `visual_match._score_map`; injectable `haystack`; no `PySide6`. -## What's new (2026-06-24) — Repair-Tactic Policy for Failed / No-Effect Actions +### Repair-Tactic Policy for Failed / No-Effect Actions Pick the next repair tactic when an action does nothing — and drive the retry loop. Full reference: [`docs/source/Eng/doc/new_features/v170_features_doc.rst`](docs/source/Eng/doc/new_features/v170_features_doc.rst). - **`plan_repair` / `next_tactic` / `run_with_repair`** (`AC_plan_repair`): `self_healing`/`locator_repair` only fix a locator that *didn't resolve*; `loop_guard` only *detects* a stuck loop with no tactic selection. This consumes an effect verdict (e.g. from `action_effect`) and returns the ordered tactics to try — `wait_retry` / `relocate` / `nudge` / `scroll_into_view` / `escalate` — then `run_with_repair` drives a bounded retry loop with injected `act` / `verify` / `apply_tactic` / `verdict_for` / `sleep` seams, returning a `RepairOutcome`. Pure-stdlib state machine; no `PySide6`. Completes the self-correction trio with `action_effect` + `postcondition`. -## What's new (2026-06-24) — Declarative Action Postconditions +### Declarative Action Postconditions Assert an action's expected outcome as a JSON spec, diffed against the before-frame. Full reference: [`docs/source/Eng/doc/new_features/v169_features_doc.rst`](docs/source/Eng/doc/new_features/v169_features_doc.rst). - **`check_postcondition` / `compile_postcondition`** (`AC_check_postcondition`): `expect_poll`/`assert_eventually` poll a single condition with no action-bound spec and no before-baseline (so they can't express "a *new* dialog appeared"); `trajectory_eval` is whole-trajectory. This evaluates a small JSON spec of clauses — `appears`/`disappears` (diffed vs `before`), `enabled`/`disabled`, `text_present`/`text_absent`, `count` — against the after-observation, returning a per-clause `{ok, clauses, failed}` report. `compile_postcondition` turns a spec into an `after -> bool` predicate for `expect_poll`. Pure-stdlib; no `PySide6`. -## What's new (2026-06-24) — Edge-Shape (Chamfer) Template Matching +### Edge-Shape (Chamfer) Template Matching Locate flat icons by outline, robust to fill / theme / anti-aliasing. Full reference: [`docs/source/Eng/doc/new_features/v168_features_doc.rst`](docs/source/Eng/doc/new_features/v168_features_doc.rst). - **`edge_match` / `edge_match_all` / `chamfer_distance`** (`AC_edge_match`, `AC_edge_match_all`): intensity NCC (`visual_match`) drops when a control is re-filled / re-themed, and ORB (`feature_match`) needs corner texture flat-design glyphs lack. This matches by *edge shape*: Canny both images, distance-transform the scene edges, slide the template's edges over it and score by mean edge-to-edge distance (Chamfer). A perfect outline aligns at ~0 cost regardless of fill. Reuses `visual_match`'s loaders / resize / NMS / `Match` and `edge_lines`'s Canny default. Injectable `haystack`; no `PySide6`. -## What's new (2026-06-24) — Action-Effect Classification (Did My Click Do Anything?) +### Action-Effect Classification (Did My Click Do Anything?) Tell an agent whether a click did anything — and whether it happened where it aimed. Full reference: [`docs/source/Eng/doc/new_features/v167_features_doc.rst`](docs/source/Eng/doc/new_features/v167_features_doc.rst). - **`classify_effect` / `effect_near_point` / `is_no_op`** (`AC_classify_effect`, `AC_effect_near_point`): `screen_state`/`element_diff` report what changed but never tie it to the action; `loop_guard` only flags a no-op after N repeats. This diffs the before/after observation and, given the action's target point, classifies the result on the *first* step as `no_op` / `changed_near_target` / `changed_elsewhere` (a surprise dialog) / `changed`, returning an `EffectVerdict` with the changed centres and a reason. Reuses `element_diff.match_elements` + `observation_delta`'s field-change check. Pure-stdlib; no `PySide6`. -## What's new (2026-06-24) — Form Field Association (Multi-Direction) + Checkbox State +### Form Field Association (Multi-Direction) + Checkbox State Pair form labels with values even when the value is below or right-aligned, and read checkbox state. Full reference: [`docs/source/Eng/doc/new_features/v166_features_doc.rst`](docs/source/Eng/doc/new_features/v166_features_doc.rst). - **`associate_fields` / `match_labels_to_widgets` / `checkbox_state`** (`AC_associate_fields`, `AC_match_labels_to_widgets`): `ocr/structure` only pairs a `label:` with the *immediately next* cell — it can't handle label-above-value, two-column key/value, right-aligned values, or non-text widgets, and has no checkbox notion. This pairs each label with the nearest aligned value across *directions* (right / below) within `max_gap`, matches free-standing widgets (checkbox/radio/input) to their nearest label, and reads checkbox state from the box's dark-pixel fill ratio. Association is pure-stdlib; only `checkbox_state` touches pixels (behind the `visual_match` gray loader). No `PySide6`. -## What's new (2026-06-24) — Whitespace-Projection Columns (Borderless Tables) +### Whitespace-Projection Columns (Borderless Tables) Read borderless tables by inferring columns from the whitespace gaps. Full reference: [`docs/source/Eng/doc/new_features/v165_features_doc.rst`](docs/source/Eng/doc/new_features/v165_features_doc.rst). - **`detect_borderless_table` / `column_gutters` / `assign_columns` / `vertical_projection`** (`AC_detect_borderless_table`, `AC_column_gutters`): `ocr/structure` only detects a table when every row's cell-left-x matches — it fails on ragged / borderless / right-aligned columns; `edge_lines.find_grid` needs ruling lines a whitespace table doesn't have. This finds columns by the *gaps*: project OCR boxes onto the x-axis, read the persistent empty vertical bands as gutters, assign column indices, bucket rows by spacing, and emit `{n_rows, n_cols, rows, columns}`. Pure-stdlib difference-array projection (no numpy); reuses `table_grid_fill`'s box reader. No `PySide6`. -## What's new (2026-06-24) — Auto-Thresholded Template Matching (Otsu on the Score Map) +### Auto-Thresholded Template Matching (Otsu on the Score Map) No more hand-tuned `min_score` — derive the match threshold from the score map. Full reference: [`docs/source/Eng/doc/new_features/v164_features_doc.rst`](docs/source/Eng/doc/new_features/v164_features_doc.rst). - **`match_auto` / `auto_threshold`** (`AC_match_auto`, `AC_auto_threshold`): every `match_template_all` call forces you to guess `min_score` (too low floods NMS, too high drops re-themed targets, and it differs per asset). This runs Otsu on the *correlation score histogram* to find the valley between background correlation and real matches, returns that cut-off plus a *separability* score (near 0 = unimodal, no clear match → don't trust it). `match_auto` returns one peak per above-threshold region (via `connected_boxes`, avoiding duplicate hits on a wide peak), clamped by a `floor`. Reuses the new `visual_match._score_map`; injectable `haystack`; no `PySide6`. -## What's new (2026-06-24) — Token-Budgeted Observation Delta (What Changed) +### Token-Budgeted Observation Delta (What Changed) Tell an agent *what changed* since the last step, not the whole screen again. Full reference: [`docs/source/Eng/doc/new_features/v163_features_doc.rst`](docs/source/Eng/doc/new_features/v163_features_doc.rst). - **`delta_observation` / `delta_index` / `summarize_delta`** (`AC_delta_observation`): `serialize_observation` renders one full frame (blows the token budget every turn); `element_diff` gives the stable-ID correspondence but stops at matched/added/removed element pairs. This is the missing serializer — it diffs two frames, classifies matched elements as changed (role/name/enabled/value/moved) or stable, and renders only the churn as `+ [i] role "name"` / `~ [i] … (fields)` / `- …` lines (added & changed first, stable dropped, capped at `max_lines`). Reuses `element_diff.match_elements` + `observation.observation_index`. Pure-stdlib; no `PySide6`. -## What's new (2026-06-24) — Fill a Ruling-Line Grid With OCR Text (Addressable Tables) +### Fill a Ruling-Line Grid With OCR Text (Addressable Tables) Turn a bordered table's lines + OCR words into an addressable `R x C` table. Full reference: [`docs/source/Eng/doc/new_features/v162_features_doc.rst`](docs/source/Eng/doc/new_features/v162_features_doc.rst). - **`populate_table` / `assign_text_to_grid` / `table_to_records` / `table_to_csv`** (`AC_populate_table`): `edge_lines.find_grid` recovers a table's ruling-line geometry but the cells come back *empty*; OCR gives the text but no structure — nothing joined them. This drops OCR boxes into the grid (assigned by cell-centre, gated by an overlap fraction so a box straddling a thin rule isn't double-counted), concatenates each cell's text in reading order, flags merged-cell spans, and converts straight to records / CSV. Pure-stdlib over plain dicts — no image, no OCR engine, no device. No `PySide6`. -## What's new (2026-06-24) — Trust-Scored Template Matching (Ambiguity / PSR) +### Trust-Scored Template Matching (Ambiguity / PSR) Know when a template match is strong but *ambiguous* before clicking it. Full reference: [`docs/source/Eng/doc/new_features/v161_features_doc.rst`](docs/source/Eng/doc/new_features/v161_features_doc.rst). - **`match_with_trust` / `score_peaks`** (`AC_match_with_trust`): `match_template` returns only the top score and clicks it — but a button repeated in a toolbar or a near-identical sibling correlates ~0.95 in two places, so a high score is not an *unambiguous* match. This adds a Lowe-style ratio test *for pixel templates* (ORB got one via `feature_match`; `match_template` never did): it inspects the whole correlation surface, compares the global peak to the next-best peak outside an exclusion window, computes the peak-to-sidelobe ratio (PSR), and returns a `TrustedMatch` with `second_score` / `peak_ratio` / `psr` / `is_ambiguous`. Reuses a new `visual_match._score_map` (the full `matchTemplate` surface the public matchers discard) — no matching code duplicated. Injectable `haystack`; no `PySide6`. -## What's new (2026-06-23) — Clipboard File-Drop List (CF_HDROP) +## What's new (2026-06-23) + +### Clipboard File-Drop List (CF_HDROP) Put a list of files on the clipboard, ready to paste into Explorer. Full reference: [`docs/source/Eng/doc/new_features/v160_features_doc.rst`](docs/source/Eng/doc/new_features/v160_features_doc.rst). - **`build_dropfiles` / `parse_dropfiles` / `set_clipboard_files` / `get_clipboard_files`** (`AC_set_clipboard_files`, `AC_get_clipboard_files`): the clipboard carried text, images and (via `rich_clipboard`) HTML, but never a *file list* — the `CF_HDROP` payload Explorer reads to paste files as a real copy. Building it is fiddly (20-byte `DROPFILES` header + double-null-terminated UTF-16 path list + `pFiles` offset). This isolates the packing into pure, fully testable `build_dropfiles` / `parse_dropfiles` byte functions, with thin Windows-only `set`/`get_clipboard_files` wrappers on top — the same split `rich_clipboard` uses for `CF_HTML`. No `PySide6`. -## What's new (2026-06-23) — Coarse Labelled Screen Grid (VLM Grounding) +### Coarse Labelled Screen Grid (VLM Grounding) Refer to screen regions as grid cells ("click C3") instead of raw pixels. Full reference: [`docs/source/Eng/doc/new_features/v159_features_doc.rst`](docs/source/Eng/doc/new_features/v159_features_doc.rst). - **`grid_cells` / `cell_for_point` / `point_for_cell`** (`AC_grid_cells`, `AC_cell_for_point`, `AC_point_for_cell`): VLM grounding is far more reliable when a model names a coarse cell than when it emits hallucinated pixel coordinates. This lays an `rows`x`cols` grid over the screen (or a `region`), labels each cell spreadsheet-style (`A1` top-left, past `Z` → `AA`), and maps both ways — point → containing cell, named cell → centre point (ready to click). Pure-stdlib geometry; the only device-bound path is the default that reads the live screen size, so every function is headless-testable with an explicit `region`. No `PySide6`. -## What's new (2026-06-23) — Rotation- & Scale-Tolerant Template Matching +### Rotation- & Scale-Tolerant Template Matching Find templates that are rotated or skewed, not just scaled. Full reference: [`docs/source/Eng/doc/new_features/v158_features_doc.rst`](docs/source/Eng/doc/new_features/v158_features_doc.rst). - **`match_rotated` / `match_rotated_all` / `scale_space`** (`AC_match_rotated`, `AC_match_rotated_all`): `match_template` sweeps *scales* but assumes axis-aligned — OpenCV's `matchTemplate` isn't rotation-invariant, so a skewed control, a rotated icon or a dial at a different angle is missed. This sweeps `angles` (each warped with `cv2.warpAffine`) crossed with a `np.linspace` scale-space, returns the best-correlating `RotatedMatch` carrying the recovered `scale` + `angle` (the `*_all` form NMS-dedupes neighbouring angles/scales). Reuses `visual_match`'s loaders / resize / method table / NMS — no matching or geometry code duplicated. Injectable `haystack`; headless-testable; no `PySide6`. -## What's new (2026-06-23) — Barcode Decoding (1-D) +### Barcode Decoding (1-D) Read EAN / UPC / Code-128 barcodes off the screen or an image. Full reference: [`docs/source/Eng/doc/new_features/v157_features_doc.rst`](docs/source/Eng/doc/new_features/v157_features_doc.rst). - **`read_barcodes`** (`AC_read_barcodes`): the framework decoded QR codes (`read_qr`) but had no reader for the *1-D* barcodes (EAN-13/8, UPC-A, Code-128) that label physical goods, inventory tickets and shipping labels. This decodes them via OpenCV's `cv2.barcode.BarcodeDetector`, returning `{text, type, points}` per code. The decode step is an injectable seam (default calls OpenCV; tests pass their own `decoder`), so it's fully headless-testable and degrades gracefully — an OpenCV build without the `barcode` module returns `[]` instead of raising. Reuses the shared `visual_match` haystack loader; no `PySide6`. -## What's new (2026-06-23) — Weighted Candidate Scoring +### Weighted Candidate Scoring Rank ambiguous element candidates by a confidence score. Full reference: [`docs/source/Eng/doc/new_features/v156_features_doc.rst`](docs/source/Eng/doc/new_features/v156_features_doc.rst). - **`score_candidates` / `best_candidate`** (`AC_score_candidates`, `AC_best_candidate`): `anchor_locator` is a single relation + distance sort and `ab_locator` races whole strategies by elapsed time — neither ranks ambiguous candidates by a *weighted* mix of role match + fuzzy name similarity + anchor proximity + enabled-state. This returns `ScoredCandidate`s best-first with a `matched_on` breakdown; the name similarity is injectable (default `fuzzy_ratio`, reused — no new string-distance code). Pure-stdlib over element dicts; powers self-heal / grounding when several boxes could be the target. Headless-testable. -## What's new (2026-06-23) — Geometry-Aware Element Diff & Stable IDs +### Geometry-Aware Element Diff & Stable IDs Track elements across frames by overlap, with stable IDs. Full reference: [`docs/source/Eng/doc/new_features/v155_features_doc.rst`](docs/source/Eng/doc/new_features/v155_features_doc.rst). - **`match_elements` / `assign_stable_ids`** (`AC_match_elements`, `AC_assign_stable_ids`): `diff_snapshots` keys identity on `(role, name)` — it can't match a renamed-but-stationary control or a moved one, nor give persistent IDs across frames. This matches element boxes by IoU (reusing `element_parse.iou`): `match_elements` returns `{matched, added, removed}`; `assign_stable_ids` carries each element's `id` from a `prior` frame (a moved button keeps its id, a new one gets a fresh id) — so an agent can reliably refer to "element 7" turn-over-turn. Pure-stdlib, headless-testable. -## What's new (2026-06-23) — Portable Agent-Trajectory Trace (Record & Replay) +### Portable Agent-Trajectory Trace (Record & Replay) Log an agent's observation→action steps and replay them. Full reference: [`docs/source/Eng/doc/new_features/v154_features_doc.rst`](docs/source/Eng/doc/new_features/v154_features_doc.rst). - **`record_step` / `to_jsonl` / `from_jsonl` / `replay_trace`** (`AC_replay_trace`): `agent_trace` records OTel spans (observability), `trajectory_eval` only scores, `semantic_recording` replays human macros — none is a replayable obs→action transcript. This is the OmniTool-style `{step, observation, action, result}` JSONL with a deterministic replay driver (injectable `runner`, no live model). The executor command replays each step's AC action through the executor. Pure-stdlib, headless-testable; build regression / training datasets from agent runs. -## What's new (2026-06-23) — Pre-Action Grounding Guard +### Pre-Action Grounding Guard Reject out-of-bounds clicks; snap near-misses onto the real element. Full reference: [`docs/source/Eng/doc/new_features/v153_features_doc.rst`](docs/source/Eng/doc/new_features/v153_features_doc.rst). - **`validate_action` / `snap_to_element` / `in_bounds`** (`AC_validate_action`): `guardrail` scans text and `loop_guard` detects loops — neither validates a coordinate action before dispatch, so a hallucinated `(9999,-5)` click fires into nothing and a 5px-off click misses. This rejects off-screen coordinates and, given `targets`, snaps a near-miss onto the nearest element's centre, returning `{ok, reason, snapped}`. Pure-stdlib geometry over element dicts; the executor `screen` defaults to the live screen. Headless-testable; plugs in front of an agent loop's dispatch. -## What's new (2026-06-23) — Token-Budgeted A11y Text Observation +### Token-Budgeted A11y Text Observation Turn the a11y tree into an indexed text block a VLM can act on. Full reference: [`docs/source/Eng/doc/new_features/v152_features_doc.rst`](docs/source/Eng/doc/new_features/v152_features_doc.rst). - **`serialize_observation` / `observation_index` / `flatten_tree`** (`AC_serialize_observation`, `AC_observation_index`): `describe_screen` gives role *counts* + a flat label list — no stable index, no `[12] button "Submit" @(x,y)` lines, no viewport clip, no token budget. This flattens a (nested) element tree to interactive-only, clips to the viewport, orders reading-style, caps at `max_elements`, assigns a stable `index`, and renders the lines a model acts on ("click [12]"). Pure-stdlib over element dicts; pairs with `fuse_elements`/`set_of_marks`. Headless-testable. -## What's new (2026-06-23) — Canonical Computer-Use Action Schema +### Canonical Computer-Use Action Schema Bridge Anthropic / OpenAI agent actions to AutoControl commands. Full reference: [`docs/source/Eng/doc/new_features/v151_features_doc.rst`](docs/source/Eng/doc/new_features/v151_features_doc.rst). - **`from_anthropic` / `from_openai_cua` / `to_ac_command` / `canonical_action`** (`AC_cua_command`): `tool_use_schema` exports AC_* signatures and `coordinate_space` rescales — neither *normalizes an inbound action payload*. Anthropic emits `{action:"left_click", coordinate:[x,y]}`, OpenAI CUA emits `{type:"click", x, y, button}`; these adapters map both to a canonical action and then to a runnable `[AC_*, params]` (with optional coordinate-space `scale`). Pure-stdlib, headless-testable; the executor command returns `{canonical, command}` for any source. -## What's new (2026-06-23) — Window Client-Area Geometry +### Window Client-Area Geometry Click *inside* a window regardless of its title bar / borders. Full reference: [`docs/source/Eng/doc/new_features/v150_features_doc.rst`](docs/source/Eng/doc/new_features/v150_features_doc.rst). - **`get_client_rect` / `client_point` / `frame_insets` / `client_to_screen`** (`AC_get_client_rect`, `AC_client_point`): `get_window_geometry` returns only the *outer* bbox — there was no client-area rect, frame-inset math, or client→screen mapping. `client_point("App", x, y)` maps a content-relative point to the screen so a click lands inside the window regardless of chrome; `frame_insets` reports border/title-bar thickness. `frame_insets`/`client_to_screen` are pure geometry (headless-testable); `get_client_rect` uses an injectable Win32 reader (`GetClientRect`+`ClientToScreen`). -## What's new (2026-06-23) — Perceptual (YIQ) Image Diff with Anti-Alias Suppression +### Perceptual (YIQ) Image Diff with Anti-Alias Suppression Visual-regression diffing that ignores anti-aliased edges. Full reference: [`docs/source/Eng/doc/new_features/v149_features_doc.rst`](docs/source/Eng/doc/new_features/v149_features_doc.rst). - **`perceptual_diff` / `assert_perceptual`** (`AC_perceptual_diff`): `image_difference` counts raw per-channel deltas and `ssim_compare` is a global score — neither uses a perceptual metric or ignores anti-aliasing, the #1 source of false-positive visual-diff failures. This compares in YIQ space (pixelmatch's colour metric) and, by default, removes thin 1px anti-aliased edge diffs via a morphological open so only solid changes count (`include_aa=True` keeps them). Returns `{diff_pixels, diff_ratio, regions}`; `assert_perceptual` / `max_diff_ratio` gate a regression test. Injectable image pair → headless-testable (a 1px fringe → 0, a solid block → counted). -## What's new (2026-06-23) — Soft Assertions (Aggregate Failures) +### Soft Assertions (Aggregate Failures) Verify many things, report every failure at once. Full reference: [`docs/source/Eng/doc/new_features/v148_features_doc.rst`](docs/source/Eng/doc/new_features/v148_features_doc.rst). - **`SoftAssertions`** (`AC_soft_assert`): `assert_all` takes a pre-built spec list up front — there was no scoped accumulator you sprinkle `check()` calls into that raises everything on block exit (JUnit5 `assertAll` / Playwright `expect.soft`). `with SoftAssertions() as soft: soft.check(...)` records pass/fail (never raising mid-block, returns the bool to branch on), then raises once on exit listing every failure — and never masks an exception already propagating. The executor command aggregates a JSON `checks` list (eq/ne/gt/lt/contains/truthy). Pure-stdlib, headless-testable. -## What's new (2026-06-23) — Window Z-Order (Always-On-Top / Front / Back) +### Window Z-Order (Always-On-Top / Front / Back) Pin a window on top, raise it, or push it behind. Full reference: [`docs/source/Eng/doc/new_features/v147_features_doc.rst`](docs/source/Eng/doc/new_features/v147_features_doc.rst). - **`set_topmost` / `bring_to_front` / `send_to_back` / `plan_zorder`** (`AC_set_topmost`, `AC_bring_to_front`, `AC_send_to_back`): the raw `set_window_position` existed but wasn't in the facade, had no title wrapper and no topmost semantics — the standard RPA "always-on-top" was missing. `plan_zorder` is a pure action→`SetWindowPos` constant lookup (headless-testable); the title-based setters apply it through an injectable driver (the `snap_window` seam pattern), Win32 by default. -## What's new (2026-06-23) — Localized Motion / Activity Detection +### Localized Motion / Activity Detection Find which sub-regions are animating between two frames. Full reference: [`docs/source/Eng/doc/new_features/v146_features_doc.rst`](docs/source/Eng/doc/new_features/v146_features_doc.rst). - **`changed_regions` / `has_motion` / `activity_score`** (`AC_changed_regions`, `AC_has_motion`): `wait_until_screen_stable` is a boolean poll, `ssim_changed_regions` is structural (ignores fast motion), `diff_screenshots` isn't activity blobs. This is the cheap absdiff path — threshold the per-pixel difference, dilate, and return the moved-region boxes (largest first), a boolean, and the fraction of pixels that moved. Pick a quiet area or locate a spinner. Two injectable frames → headless-testable; reuses the shared connected-components helper; `after` defaults to a live screen grab in the executor. -## What's new (2026-06-23) — Colour-Histogram Fingerprint & Change Detection +### Colour-Histogram Fingerprint & Change Detection Tell whether the view is "the same" despite lighting / scale. Full reference: [`docs/source/Eng/doc/new_features/v145_features_doc.rst`](docs/source/Eng/doc/new_features/v145_features_doc.rst). - **`image_histogram` / `compare_histograms` / `histogram_changed`** (`AC_image_histogram`, `AC_histogram_changed`): `image_dedup`'s perceptual hash is spatial (brittle to colour/theme) and `color_stats` is one colour. A normalized colour histogram is the illumination/scale-robust "same view, or palette shifted?" signal (theme switch, reload, rotated banner). `image_histogram` returns a per-channel histogram (`hsv`/`rgb`/`gray`); `compare_histograms` does correlation/chisqr/intersection/bhattacharyya; `histogram_changed` compares a reference vs the live screen. Injectable image → headless-testable; base OpenCV (`cv2.calcHist`/`compareHist`). -## What's new (2026-06-23) — Rich Clipboard (HTML / CF_HTML) +### Rich Clipboard (HTML / CF_HTML) Copy and paste *formatted* HTML into Word / Outlook. Full reference: [`docs/source/Eng/doc/new_features/v144_features_doc.rst`](docs/source/Eng/doc/new_features/v144_features_doc.rst). - **`build_cf_html` / `parse_cf_html` / `set_clipboard_html` / `get_clipboard_html`** (`AC_set_clipboard_html`, `AC_get_clipboard_html`): the base clipboard handles plain text + image only — rich paste needs `CF_HTML`, whose byte-offset header (`StartHTML`/`EndHTML`/`StartFragment`/`EndFragment`) is famously error-prone. `build_cf_html`/`parse_cf_html` compute and recover it in pure Python (round-trip tested, correct across multi-byte UTF-8); `set/get_clipboard_html` wrap them over the Win32 clipboard (with a plain-text fallback). Byte-offset math is headless-testable; only the I/O is Windows. -## What's new (2026-06-23) — Composable / Filtered Candidate Locators +### Composable / Filtered Candidate Locators Refine located elements with a chain: `.within(panel).filter(has_text="Delete").nth(1)`. Full reference: [`docs/source/Eng/doc/new_features/v143_features_doc.rst`](docs/source/Eng/doc/new_features/v143_features_doc.rst). - **`from_boxes` / `Candidates`** (`AC_locate_chain`): `anchor_locator` is a single relation and `grid_locator` is cells — neither supports composable refinement of a candidate set (the Selenium-4 / Playwright chained-locator idiom). This is a pure post-filter over boxes from *any* source (template / OCR / a11y / `fuse_elements`): `within` (region clip), `filter` (`has_text` / `near` / area / predicate), `sort_reading`, `nth` / `first` / `last`, `resolve()` / `center()`. Every method returns a new `Candidates` (no mutation) → fully headless-testable. The executor command applies a JSON `ops` list. -## What's new (2026-06-23) — Retrying Value Assertions (expect.poll) +### Retrying Value Assertions (expect.poll) Retry *any* value until it matches, not just the built-in checks. Full reference: [`docs/source/Eng/doc/new_features/v142_features_doc.rst`](docs/source/Eng/doc/new_features/v142_features_doc.rst). - **`expect_poll` / `assert_poll` + matchers** (`AC_expect_poll`): `assert_eventually` only polls the fixed dict-spec checks (text/image/pixel/…). This polls any zero-arg `getter` against any `matcher` (`to_equal` / `to_contain` / `to_be_greater_than` / `to_match_regex` / `to_be_truthy` / `to_be_stable`) until it passes or times out — an OCR'd total, a row count stabilising, a custom predicate. Injectable `clock`/`sleep` → deterministic, mirrors Playwright's `expect.poll`. The executor command re-runs a nested action until a key of its result matches. -## What's new (2026-06-23) — Line / Grid / Separator Detection (Hough) +### Line / Grid / Separator Detection (Hough) Find table grid lines and UI dividers from raw pixels. Full reference: [`docs/source/Eng/doc/new_features/v141_features_doc.rst`](docs/source/Eng/doc/new_features/v141_features_doc.rst). - **`find_lines` / `find_grid` / `find_separators`** (`AC_find_lines`, `AC_find_grid`, `AC_find_separators`): `grid_locator` clusters *already-found* boxes and `shape_locator` finds closed rectangles — neither finds a table's ruling lines or a divider from pixels. Canny + probabilistic Hough detects straight segments (classified horizontal/vertical/diagonal), `find_grid` recovers `{rows, cols, cells}` so you can address "row 3, col 2", and `find_separators` returns the coordinates of long dividers. Injectable haystack → headless-testable; base OpenCV (`cv2.HoughLinesP`). -## What's new (2026-06-23) — Model-Free Text-Region Detection (MSER) +### Model-Free Text-Region Detection (MSER) Find where text is on screen without running OCR. Full reference: [`docs/source/Eng/doc/new_features/v140_features_doc.rst`](docs/source/Eng/doc/new_features/v140_features_doc.rst). - **`find_text_regions` / `find_text_lines`** (`AC_find_text_regions`, `AC_find_text_lines`): `shape_locator` finds rectangles (not text) and `locate_text` needs an OCR engine *and* the exact string — neither answers "where is *any* text?". MSER finds the glyph/word/line blobs, so a script can crop candidate boxes to feed OCR (faster + more accurate than full-frame) or detect a label appeared with no OCR dependency. `merge` unions MSER's nested per-glyph regions; `find_text_lines` groups glyphs into per-line boxes; a blank screen returns `[]`. Base OpenCV (`cv2.MSER_create`), injectable haystack → headless-testable. -## What's new (2026-06-23) — HSV Colour-Space Segmentation +### HSV Colour-Space Segmentation Find "any shade of red" regardless of lighting. Full reference: [`docs/source/Eng/doc/new_features/v139_features_doc.rst`](docs/source/Eng/doc/new_features/v139_features_doc.rst). - **`dominant_hue_regions` / `segment_hsv` / `color_mask`** (`AC_dominant_hue_regions`, `AC_segment_hsv`): `find_color_region` masks in RGB with a per-channel ± box — it can't match "the same colour at a different brightness" (status lights, highlights, theme tints). HSV separates hue from brightness, so a hue band + saturation/value floor catches every shade across lighting. `dominant_hue_regions(hue=…)` handles red's 0/180 wrap automatically; `segment_hsv` takes an explicit band; both return `{x,y,width,height,area,center}` blobs reusing the shared connected-components helper. Injectable haystack → headless-testable. -## What's new (2026-06-23) — Fuse & Order On-Screen Element Boxes +### Fuse & Order On-Screen Element Boxes Turn raw OCR + icon + a11y boxes into one clean, numbered element list. Full reference: [`docs/source/Eng/doc/new_features/v138_features_doc.rst`](docs/source/Eng/doc/new_features/v138_features_doc.rst). - **`iou` / `merge_boxes` / `fuse_elements` / `reading_order`** (`AC_fuse_elements`, `AC_reading_order`): `set_of_marks` numbers a clean element list but nothing *produced* it — a real screen parse yields three overlapping sources with duplicates and no order. These supply the missing step: drop near-duplicate boxes by IoU, union OCR/icon/a11y keeping the most trustworthy source on overlap (`source_priority` a11y > ocr > icon), and sort top-to-bottom/left-to-right with a stable `index`. Plain `dict` boxes → pure-stdlib, fully headless-testable; pairs directly with `set_of_marks`. -## What's new (2026-06-23) — Actionability Gate (Wait Until Ready Before Acting) +### Actionability Gate (Wait Until Ready Before Acting) Don't click until the target is genuinely ready. Full reference: [`docs/source/Eng/doc/new_features/v137_features_doc.rst`](docs/source/Eng/doc/new_features/v137_features_doc.rst). - **`wait_actionable` / `act_when_ready`** (`AC_wait_actionable`): Playwright/Cypress run an actionability check before every click — present + stopped moving + enabled + not covered — but AutoControl had none (`self_heal_click` clicks immediately; `wait_until_screen_stable` watches the whole frame). This composes the four checks into one gate and returns an `ActionabilityReport` (per-check booleans, target `point`, `reason` = first failing check). Every signal is an injectable callable (`bbox_provider` / `region_sampler` / `enabled_probe` / `hit_tester`) plus an injectable `clock`/`sleep`, so it's fully deterministic and headless-testable. The executor command gates on a template image. -## What's new (2026-06-23) — Multi-Monitor / Virtual-Desktop Geometry +### Multi-Monitor / Virtual-Desktop Geometry Place windows and points correctly across several displays. Full reference: [`docs/source/Eng/doc/new_features/v136_features_doc.rst`](docs/source/Eng/doc/new_features/v136_features_doc.rst). - **`enumerate_monitors` + `Monitor` / `virtual_bounds` / `monitor_at_point` / `monitor_for_window` / `to_local` / `to_virtual` / `remap_point`** (`AC_enumerate_monitors`, `AC_monitor_at_point`): `snap_window` / `arrange_grid` / the layout planner all assumed a single primary `(width, height)` — monitor-blind, unable to tile on a second display or handle a negative-origin virtual desktop. This adds the physical layer: union virtual bounds, which-monitor-owns-this-point/window, virtual↔monitor-local conversion, and equivalent-spot remapping across resolutions/DPI. Pure geometry over `Monitor` dataclasses → fully headless-testable; `enumerate_monitors` has an injectable provider (default `mss`). -## What's new (2026-06-23) — Image Pre-processing for OCR / Template Matching +### Image Pre-processing for OCR / Template Matching Clean up the screen before reading or matching it. Full reference: [`docs/source/Eng/doc/new_features/v135_features_doc.rst`](docs/source/Eng/doc/new_features/v135_features_doc.rst). - **`preprocess_image` + `to_grayscale` / `binarize` / `upscale` / `denoise` / `deskew` / `enhance_contrast`** (`AC_preprocess_image`): `locate_text` and `match_template` fed the *raw* capture to OCR / the matcher — small text, dark themes, low contrast and skew wrecked both, with no preprocessing seam anywhere. This adds the standard pipeline (grayscale → upscale → binarize → deskew → denoise → CLAHE) that multiplies their accuracy. Injectable haystack → ndarray; `detect_skew_angle` measures text rotation; `binarize` does otsu / adaptive. The executor command writes the cleaned image to a path. Headless-testable on synthetic arrays. -## What's new (2026-06-23) — Arrange Multiple Windows (Grid / Cascade) +### Arrange Multiple Windows (Grid / Cascade) Lay out a whole set of windows in one call. Full reference: [`docs/source/Eng/doc/new_features/v134_features_doc.rst`](docs/source/Eng/doc/new_features/v134_features_doc.rst). - **`arrange_grid` / `arrange_cascade`** (`AC_arrange_grid`, `AC_arrange_cascade`): `snap_window` moves *one* window and the layout planner only *computes* rectangles — these close the loop, taking a list of window titles and actually moving every match into a grid (auto near-square shape, or explicit `rows`/`cols` + `gap`) or a diagonal cascade. Build on the layout planner and reuse `snap_window`'s injectable `mover`/`screen_size` seams, so they are fully headless-testable; return the count moved. -## What's new (2026-06-23) — Window Tiling / Layout Geometry Planner +### Window Tiling / Layout Geometry Planner Compute where to place application windows — halves, grids, cascades. Full reference: [`docs/source/Eng/doc/new_features/v133_features_doc.rst`](docs/source/Eng/doc/new_features/v133_features_doc.rst). - **`tile_rect` / `grid_rects` / `cascade_rects`** (`AC_tile_rect`, `AC_grid_rects`, `AC_cascade_rects`): `save/restore_window_layout` replay *exact* saved positions and `snap_window` moves *one* window — nothing *computes* a fresh multi-window layout. This pure-geometry planner returns the target rectangles for halves, quadrants, thirds, an R×C grid and a staggered cascade given a screen work area, so a script can lay out windows deterministically. Returns `WindowRect` (`.as_tuple()` / `.to_dict()`); `gap` insets tiles; cross-platform and fully headless-testable; composes with any window-move backend. -## What's new (2026-06-23) — Locate UI Elements by Edge / Contour (No Template) +### Locate UI Elements by Edge / Contour (No Template) Find the clickable boxes on a screen you have never seen. Full reference: [`docs/source/Eng/doc/new_features/v132_features_doc.rst`](docs/source/Eng/doc/new_features/v132_features_doc.rst). - **`find_shapes` / `find_rectangles`** (`AC_find_shapes`, `AC_find_rectangles`): every other locator needs something to look *for* — a template, a colour, some text. These need nothing: Canny edge detection + contour extraction returns the bounding boxes (`{x,y,width,height,area,center,aspect}`, largest first) of the distinct shapes, so a script can enumerate cards / buttons / input fields structurally and click the Nth one. `find_rectangles` keeps only convex quads and adds an `aspect_range=(min,max)` w/h filter (`(1.5,8)` wide buttons). Injectable haystack → headless-testable. -## What's new (2026-06-23) — ORB Feature Matching (Rotation / Scale / Theme Robust) +### ORB Feature Matching (Rotation / Scale / Theme Robust) Find a target even when it is rotated, rescaled or re-themed. Full reference: [`docs/source/Eng/doc/new_features/v131_features_doc.rst`](docs/source/Eng/doc/new_features/v131_features_doc.rst). - **`feature_match`** (`AC_feature_match`): pixel template matching (`match_template` / `match_masked`) correlates pixels, so it breaks the moment the target is rotated, scaled by an unlisted factor, or re-coloured (light/dark theme, hover). This matches ORB *keypoints* and fits a RANSAC homography, returning the four projected `corners`, the `center`, the `inliers` count and an inlier-fraction `score`. ORB border/patch sizes auto-scale down for icon-sized templates (OpenCV's defaults reject them). Core OpenCV only (no contrib); injectable haystack → headless-testable. -## What's new (2026-06-23) — Structural-Similarity (SSIM) Comparison +### Structural-Similarity (SSIM) Comparison Perceptual screen comparison that tells you *what* changed. Full reference: [`docs/source/Eng/doc/new_features/v130_features_doc.rst`](docs/source/Eng/doc/new_features/v130_features_doc.rst). - **`ssim_compare` / `ssim_changed_regions`** (`AC_ssim_compare`, `AC_ssim_changed_regions`): pixel diff (`diff_screenshots`) fires on a one-pixel shift; a histogram (`detect_drift`) is blind to layout. SSIM is the standard visual-regression metric — tolerant of small illumination changes, sensitive to structural change. `ssim_compare` returns a 0..1 score (1.0 = identical); `ssim_changed_regions` returns boxes of what moved. `ignore=[[x,y,w,h]]` masks live clocks / cursors. Pure NumPy + OpenCV (no scikit-image); injectable image pair → headless-testable. -## What's new (2026-06-23) — Masked Template Matching +### Masked Template Matching Match icons regardless of their background. Full reference: [`docs/source/Eng/doc/new_features/v129_features_doc.rst`](docs/source/Eng/doc/new_features/v129_features_doc.rst). - **`match_masked` / `match_masked_all`** (`AC_match_masked`, `AC_match_masked_all`): plain template matching scores *every* pixel, so an icon clipped from one background fails over a different one. These count only the pixels you mark relevant — an explicit grayscale `mask`, or an RGBA template's alpha channel — so transparent / "don't care" pixels stop dragging the score down. Returns the same `Match` (score/center) as scored template matching; OpenCV masked `TM_CCORR_NORMED`, NaNs zeroed. Injectable haystack → headless-testable. -## What's new (2026-06-23) — Locate On-Screen Regions by Colour +### Locate On-Screen Regions by Colour Find the green status pill / red banner by colour. Full reference: [`docs/source/Eng/doc/new_features/v128_features_doc.rst`](docs/source/Eng/doc/new_features/v128_features_doc.rst). - **`find_color_region` / `find_color_regions`** (`AC_find_color_region`): `color_stats` only describes a region's colour and `assert_pixel` checks one point — neither *locates* a coloured region. This masks pixels within `tolerance` of a target RGB and returns the connected blobs' boxes (`{x,y,width,height,area,center}`, largest first) — for status lights, progress fills, error banners where a template is brittle. Injectable haystack → headless-testable; OpenCV/NumPy via `je_open_cv`. -## What's new (2026-06-23) — Confidence-Returning Template Matching +### Confidence-Returning Template Matching Template matching that returns the score, searches multiple scales, and finds all occurrences. Full reference: [`docs/source/Eng/doc/new_features/v127_features_doc.rst`](docs/source/Eng/doc/new_features/v127_features_doc.rst). - **`match_template` / `match_template_all` / `best_matches` / `TemplateMatch`** (`AC_match_template`, `AC_match_template_all`): the existing matcher (`find_object`) is single-scale and *discards the score*. This returns a `Match` with `score`/`scale`/`center`, searches `scales` for DPI/zoom tolerance, and enumerates every occurrence with non-maximum suppression. Injectable `haystack` (ndarray/path/PIL) → headless-testable on synthetic arrays; OpenCV/NumPy via the `je_open_cv` dependency. -## What's new (2026-06-23) — Wait for Window Title (Regex) +### Wait for Window Title (Regex) Block until a window title matches a regex (or vanishes). Full reference: [`docs/source/Eng/doc/new_features/v126_features_doc.rst`](docs/source/Eng/doc/new_features/v126_features_doc.rst). - **`wait_until_window_title`** (`AC_wait_window_title`): `wait_for_window` matches a title substring and only waits for *appear*; `wait_until_window_closed` is substring vanish. This matches a regular expression by default (`regex=False` for substring) and can wait for the title to vanish (`present=False`) — e.g. wait for a tab to navigate to `r".*— Checkout$"`. Injectable title source, headless-testable. -## What's new (2026-06-23) — Grid / Table Cell Addressing +### Grid / Table Cell Addressing Address a table cell by (row, column) from cell bounding boxes. Full reference: [`docs/source/Eng/doc/new_features/v125_features_doc.rst`](docs/source/Eng/doc/new_features/v125_features_doc.rst). - **`cluster_grid` / `locate_cell`** (`AC_grid_cell`): `anchor_locator` does pairwise relations but nothing addresses a 2-D grid. Given the cell bounding boxes (from `locate_all_image` / `find_text_matches`), this clusters them into rows (by centre-y within `row_tolerance`) and columns (by centre-x) and returns the centre of the 0-based `(row, col)` cell — ready to click. Pure clustering, fully headless-testable. -## What's new (2026-06-23) — Anchor Ordinal & Locate-All +### Anchor Ordinal & Locate-All Pick the Nth anchor-relative match, or enumerate them all. Full reference: [`docs/source/Eng/doc/new_features/v124_features_doc.rst`](docs/source/Eng/doc/new_features/v124_features_doc.rst). - **`anchor_locate(..., ordinal=N)` / `anchor_locate_all`** (`AC_anchor_locate` ordinal, `AC_anchor_locate_all`): `anchor_locate` always returned the single nearest match — no way to grab "the 2nd row below the header" or list every row. Adds a 1-based `ordinal` selector (backward-compatible; `ordinal=1` = nearest) and `anchor_locate_all` returning every match sorted by distance — the building block for table/list-row selection. Pure ranking core, deterministic. -## What's new (2026-06-23) — Held Modifiers Across an Action Group +### Held Modifiers Across an Action Group Hold ctrl/shift down across several actions, released even on error. Full reference: [`docs/source/Eng/doc/new_features/v123_features_doc.rst`](docs/source/Eng/doc/new_features/v123_features_doc.rst). - **`hold_modifiers` / `plan_with_modifiers`** (`AC_with_modifiers`): `hotkey` releases its keys immediately — there was no way to hold a modifier down across several independent actions (shift-click range select, ctrl-click multi-select) with a guaranteed release. `hold_modifiers` is a context manager that presses on enter and releases in reverse on exit (in a `finally`, so nothing leaks); `plan_with_modifiers` is the pure plan. Injectable sink, deterministic. -## What's new (2026-06-23) — Unicode Text Entry (Emoji / CJK) +### Unicode Text Entry (Emoji / CJK) Type any Unicode (emoji / CJK / accented) that `write` can't. Full reference: [`docs/source/Eng/doc/new_features/v122_features_doc.rst`](docs/source/Eng/doc/new_features/v122_features_doc.rst). - **`type_unicode` / `plan_paste` / `unicode_code_units`** (`AC_type_unicode`): `write` types through the virtual-key table and *raises* on emoji/CJK/many accented chars. `type_unicode` enters any text reliably by setting the clipboard and pasting (`modifier` ctrl/command). `unicode_code_units` splits text into UTF-16 code units (surrogate pairs) for KEYEVENTF_UNICODE backends. Pure-planning + injectable sink, deterministic. -## What's new (2026-06-23) — Wait for Region Colour +### Wait for Region Colour Block until a colour fills (or leaves) a screen region. Full reference: [`docs/source/Eng/doc/new_features/v121_features_doc.rst`](docs/source/Eng/doc/new_features/v121_features_doc.rst). - **`wait_until_color`** (`AC_wait_color`): `wait_for_pixel` matches one point exactly and `wait_until_pixel_changes` detects any change at one point — neither waits for "the status light turns green" / "the progress bar fills" / "the red banner is gone". This counts pixels within `tolerance` of `target_rgb` over a region and succeeds when that fraction crosses `min_fraction` (or drops below it, `present=False`). Injectable sampler, headless-testable. Pure-stdlib. -## What's new (2026-06-23) — Relative Mouse Movement +### Relative Mouse Movement Nudge the pointer by a delta from where it is. Full reference: [`docs/source/Eng/doc/new_features/v120_features_doc.rst`](docs/source/Eng/doc/new_features/v120_features_doc.rst). - **`move_mouse_relative` / `relative_target`** (`AC_move_mouse_relative`): the mouse wrapper only had absolute `set_mouse_position` — no `moveRel(dx, dy)` for relative-pointer / canvas / FPS apps and incremental drags. Reads the live position and moves by the delta; `relative_target` is the pure arithmetic, and the getter/setter are injectable for headless tests. Pure-stdlib, deterministic. -## What's new (2026-06-23) — Hold Key / Auto-Repeat +### Hold Key / Auto-Repeat Hold a key for a duration, or auto-repeat it at a fixed rate. Full reference: [`docs/source/Eng/doc/new_features/v119_features_doc.rst`](docs/source/Eng/doc/new_features/v119_features_doc.rst). - **`hold_key` / `plan_key_hold`** (`AC_hold_key`): `type_keyboard` is an instant down+up — there was no "hold this key for N seconds" (game movement, hold-to-scroll) or "send it at R presses/second" (auto-repeat). `plan_key_hold` builds the deterministic op-plan (press/wait/release, or N spaced key events for `rate_hz`); `hold_key` routes waits to an injectable `sleep` and keys to an injectable `sink`. Pure-planning, deterministic. -## What's new (2026-06-23) — Wait Until Gone (Blocking Vanish Waits) +### Wait Until Gone (Blocking Vanish Waits) Block until a spinner / toast / dialog disappears. Full reference: [`docs/source/Eng/doc/new_features/v118_features_doc.rst`](docs/source/Eng/doc/new_features/v118_features_doc.rst). - **`wait_until_gone` / `wait_until_image_gone` / `wait_until_text_gone`** (`AC_wait_image_gone`, `AC_wait_text_gone`): `wait_for_image`/`wait_for_text` only block until something *appears*, and `observer` fires async callbacks on vanish — there was no *blocking* "wait until this image/text disappears then continue" call. The generic `wait_until_gone` takes any predicate (headless-testable); the image/text helpers build it from the locate functions. `gone_for_s` debounces flicker. Returns a `WaitOutcome`. Pure-stdlib. -## What's new (2026-06-23) — Clear-Then-Type Field Entry +### Clear-Then-Type Field Entry Reliably set a text field's value (the Playwright `fill` idiom). Full reference: [`docs/source/Eng/doc/new_features/v117_features_doc.rst`](docs/source/Eng/doc/new_features/v117_features_doc.rst). - **`set_field_text` / `plan_field_set`** (`AC_set_field_text`): there was no single "focus → clear → set value" primitive, and `write` raises on emoji/CJK. This clears the field (select-all + delete) then enters the text — optionally via the clipboard (`paste=True`) which is the Unicode-safe path `write` can't do. `modifier` is the platform command key (`ctrl`/`command`). Pure-planning + injectable sink, deterministic. -## What's new (2026-06-22) — Multi-Waypoint Mouse Gestures +## What's new (2026-06-22) + +### Multi-Waypoint Mouse Gestures Move or drag the pointer through a polyline of waypoints. Full reference: [`docs/source/Eng/doc/new_features/v116_features_doc.rst`](docs/source/Eng/doc/new_features/v116_features_doc.rst). - **`plan_path` / `move_along_path` / `drag_path` / `path_easings`** (`AC_move_along_path`, `AC_drag_path`): `humanize` and `tween_drag` only interpolate a single start→end hop — there was no way to drive an arbitrary chain of waypoints (signatures, marquee selects, multi-stop drags) with the button held across the whole path. `plan_path` is pure eased point math (reusing `tween_drag`'s easings, junctions de-duplicated); the move/drag dispatch through an injectable sink for headless testing. Pure-stdlib, deterministic. -## What's new (2026-06-22) — Check-Digit Algorithms +### Check-Digit Algorithms Compute / verify Luhn, Verhoeff, Damm and ISO 7064 MOD 97-10 check digits. Full reference: [`docs/source/Eng/doc/new_features/v115_features_doc.rst`](docs/source/Eng/doc/new_features/v115_features_doc.rst). - **`luhn_validate` / `luhn_check_digit` / `verhoeff_*` / `damm_*` / `mod97_10_*`** (`AC_checksum_validate`, `AC_checksum_digit`): `pii_text` detects card/IBAN shapes by regex and `data_quality` does regex validation, but nothing computed or verified a *check digit*. This adds the four schemes behind most identifiers (cards/IMEI, national IDs, IBAN) — the shared engine `identifier_validate` builds on. Pure-stdlib, deterministic. -## What's new (2026-06-22) — GNU gettext Catalog I/O (.po / .mo) +### GNU gettext Catalog I/O (.po / .mo) Read/compile the de-facto translation format. Full reference: [`docs/source/Eng/doc/new_features/v114_features_doc.rst`](docs/source/Eng/doc/new_features/v114_features_doc.rst). - **`parse_po` / `read_mo` / `GettextCatalog` / `parse_po_file` / `read_mo_file`** (`AC_gettext_translate`, `AC_gettext_ngettext`): the repo pseudo-localises and renders ICU messages but couldn't read GNU gettext `.po`/`.mo`. This parses `.po` (contexts, plurals, the `Plural-Forms` header via `gettext.c2py`), compiles a standards-compliant `.mo` that Python's own `gettext.GNUTranslations` loads, and exposes `gettext`/`ngettext`/`pgettext`. Pure-stdlib, deterministic. -## What's new (2026-06-22) — ICU-lite MessageFormat (Plural / Select) +### ICU-lite MessageFormat (Plural / Select) Render count-aware localised messages. Full reference: [`docs/source/Eng/doc/new_features/v113_features_doc.rst`](docs/source/Eng/doc/new_features/v113_features_doc.rst). - **`format_message` / `plural_category` / `ordinal_category`** (`AC_format_message`): `i18n_test.check_catalog` only compares placeholder sets and `interpolate` is flat `${var}` — neither renders `"{count, plural, one {# item} other {# items}}"`. This implements the ICU MessageFormat subset most apps use: `select`, `plural`, `selectordinal` with CLDR categories, exact `=N` selectors, the `#` count, `offset:`, nesting and apostrophe quoting. Injectable plural rules. Pure-stdlib, deterministic. -## What's new (2026-06-22) — Locale-Aware List Formatting +### Locale-Aware List Formatting Join items the way a language expects ("A, B, and C"). Full reference: [`docs/source/Eng/doc/new_features/v112_features_doc.rst`](docs/source/Eng/doc/new_features/v112_features_doc.rst). - **`format_list`** (`AC_format_list`): a naive `", ".join` gives "A, B, C" with no "and"/"or" and no localisation. This implements the CLDR list-pattern composition with conjunction / disjunction / unit styles and per-locale conjunction words + serial-comma rule (`en`/`es`/`fr`/`de`/`pt`) — `format_list(["a","b","c"])` → "a, b, and c", `locale="es"` → "a, b y c". Pure-stdlib, deterministic. -## What's new (2026-06-22) — Bidirectional-Text QA (Trojan-Source Scan) +### Bidirectional-Text QA (Trojan-Source Scan) Catch invisible Unicode directional formatting (RTL QA + Trojan-source). Full reference: [`docs/source/Eng/doc/new_features/v111_features_doc.rst`](docs/source/Eng/doc/new_features/v111_features_doc.rst). - **`detect_bidi_issues` / `bidi_controls` / `is_bidi_balanced` / `base_direction` / `is_trojan_source` / `strip_bidi_controls` / `has_bidi_controls`** (`AC_bidi_check`, `AC_bidi_strip`): `confusables` catches lookalike characters, but bidi controls (LRO/RLO/PDF, isolates, marks) can silently reorder rendered text — an RTL-QA gap and the "Trojan Source" attack (CVE-2021-42574). This lists the controls, checks nesting balance, infers base direction, and flags reordering formatting. Pure-stdlib (`unicodedata`), deterministic. -## What's new (2026-06-22) — Readability Scoring +### Readability Scoring Score how hard text is to read; gate generated copy on a reading grade. Full reference: [`docs/source/Eng/doc/new_features/v110_features_doc.rst`](docs/source/Eng/doc/new_features/v110_features_doc.rst). - **`flesch_reading_ease` / `flesch_kincaid_grade` / `gunning_fog` / `smog_index` / `automated_readability_index` / `readability_report` / `readability_stats` / `count_syllables`** (`AC_readability_report`): the text utilities canonicalise, match and rank text but never scored *difficulty*. This adds the classic English readability formulae over a deterministic tokeniser and syllable heuristic, so a test can assert an on-screen message or label stays within a target reading grade. Pure-stdlib (`re`/`math`), deterministic. -## What's new (2026-06-22) — Confusable / Homoglyph Detection +### Confusable / Homoglyph Detection Catch Unicode visual spoofing (IDN-homograph phishing, lookalike labels). Full reference: [`docs/source/Eng/doc/new_features/v109_features_doc.rst`](docs/source/Eng/doc/new_features/v109_features_doc.rst). - **`confusable_skeleton` / `is_confusable` / `detect_homoglyphs` / `is_mixed_script` / `scripts_of`** (`AC_confusable_scan`, `AC_confusable_compare`): a Cyrillic `"а"` is pixel-for-pixel a Latin `"a"`, so `"pаypal"` reads as `"paypal"` yet compares unequal. Following Unicode TR39, this folds confusables to a prototype skeleton (strings match when skeletons match) and flags mixed-script tokens. Pure-stdlib (`unicodedata`), deterministic. -## What's new (2026-06-22) — Locale-Aware String Collation +### Locale-Aware String Collation Sort strings the way a reader of the language expects. Full reference: [`docs/source/Eng/doc/new_features/v108_features_doc.rst`](docs/source/Eng/doc/new_features/v108_features_doc.rst). - **`sort_strings` / `collation_compare` / `collation_key`** (`AC_collation_sort`, `AC_collation_compare`): Python's default `sorted` is codepoint order, so `"Z" < "a"` and `"ä"` lands far from `"a"`. This Unicode-Collation-lite key orders by base letter, then accent (secondary), then case (tertiary), with an optional `tailoring` alphabet so Swedish puts `å ä ö` after `z`. Pure-stdlib (`unicodedata`), deterministic across platforms — unlike `locale.strxfrm`. -## What's new (2026-06-22) — Transactional Outbox +### Transactional Outbox Durably buffer events and drain them at-least-once. Full reference: [`docs/source/Eng/doc/new_features/v107_features_doc.rst`](docs/source/Eng/doc/new_features/v107_features_doc.rst). - **`Outbox`** (`AC_outbox_enqueue`, `AC_outbox_pending`): `events.cloud_events` posts synchronously with no durability — a crash or network blip loses the event. The outbox persists each event first, then `drain`s pending entries through an injected sink with at-least-once delivery: a sink failure leaves the entry pending for retry until `max_attempts`, after which it is dead-lettered. `save` / `load` keep events across restarts. Pure-stdlib, deterministic. -## What's new (2026-06-22) — Optimistic-Concurrency Versioned Store +### Optimistic-Concurrency Versioned Store Update only if the version is unchanged (compare-and-swap / If-Match). Full reference: [`docs/source/Eng/doc/new_features/v106_features_doc.rst`](docs/source/Eng/doc/new_features/v106_features_doc.rst). - **`VersionedStore` / `VersionConflict` / `if_match_header` / `check_if_match`** (`AC_cas_put`, `AC_cas_get`): `http_conditional` used ETag for read caching but never for write concurrency. This local compare-and-swap store `put`s only when `expected_version` matches (raising `VersionConflict` on a stale write), bumps a monotonic version, and bridges to HTTP `If-Match` — the write side of the ETag story. Pure-stdlib, deterministic. -## What's new (2026-06-22) — Per-Stream Sequence-Gap Detection +### Per-Stream Sequence-Gap Detection Detect missing / out-of-order / duplicate messages by sequence number. Full reference: [`docs/source/Eng/doc/new_features/v105_features_doc.rst`](docs/source/Eng/doc/new_features/v105_features_doc.rst). - **`SequenceTracker`** (`AC_sequence_observe`): nothing tracked per-stream monotonic sequence numbers. `observe(stream, seq)` classifies each as `ok` / `duplicate` / `gap` (with the `missing` numbers) / `reorder` (late arrivals fill gaps), and exposes `gaps` and `high_water`. Complements `dedup_window`. Pure-stdlib, deterministic. -## What's new (2026-06-22) — Time-Windowed Deduplication +### Time-Windowed Deduplication Drop duplicate/redelivered messages within a TTL window. Full reference: [`docs/source/Eng/doc/new_features/v104_features_doc.rst`](docs/source/Eng/doc/new_features/v104_features_doc.rst). - **`DedupWindow`** (`AC_dedup_check`): `work_queue` dedups only in-flight references, so a completed reference re-enqueues and redelivered webhooks reprocess. This sliding-window inbox `check_and_mark`s a message id — `True` the first time, `False` for a duplicate within `ttl_s` — converting at-least-once delivery to exactly-once-in-window. Injectable clock, bounded size. Pure-stdlib, deterministic. -## What's new (2026-06-22) — Idempotency-Key Store +### Idempotency-Key Store Run a side effect once, replay its response on retries. Full reference: [`docs/source/Eng/doc/new_features/v103_features_doc.rst`](docs/source/Eng/doc/new_features/v103_features_doc.rst). - **`IdempotencyStore` / `request_fingerprint` / `IdempotencyConflict`** (`AC_idempotency_begin`, `AC_idempotency_complete`): `RetryPolicy` re-executes and `work_queue` dedups only in-flight refs — nothing cached the first result. This Stripe-style store returns `new`/`in_progress`/`completed` for a key, replays the stored response, raises on a fingerprint conflict, and supports injectable-clock TTL + JSON persistence. Pure-stdlib, deterministic. -## What's new (2026-06-22) — Moving-Average Smoothing +### Moving-Average Smoothing Smooth a noisy value series. Full reference: [`docs/source/Eng/doc/new_features/v102_features_doc.rst`](docs/source/Eng/doc/new_features/v102_features_doc.rst). - **`sma` / `wma` / `ewma` / `rolling`** (`AC_sma`, `AC_ewma`): `stats.describe` summarizes a whole sample and `timeseries` rolls counters into rates, but nothing smoothed a noisy signal. This adds trailing simple/weighted/exponentially-weighted moving averages and a generic rolling reducer, all returning a same-length list aligned to the input timeline. Pure-stdlib, deterministic. -## What's new (2026-06-22) — Single-Series Anomaly Detection +### Single-Series Anomaly Detection Flag the spike in one live metric series. Full reference: [`docs/source/Eng/doc/new_features/v101_features_doc.rst`](docs/source/Eng/doc/new_features/v101_features_doc.rst). - **`detect_anomalies` / `mad_anomalies` / `zscore_anomalies` / `ewma_control`** (`AC_detect_anomalies`): `data_drift` is two-batch distribution shift and `slo.burn_alerts` only thresholds budget burn — neither points at *which* value in one series is anomalous. This flags outliers via robust MAD (modified z-score), plain z-score, and an EWMA control chart (with an optional in-control baseline) — `{index, value, score, is_anomaly}` records. Pure-stdlib, deterministic. -## What's new (2026-06-22) — Near-Duplicate Text Detection (SimHash / MinHash) +### Near-Duplicate Text Detection (SimHash / MinHash) Fingerprint text to find near-dups at scale. Full reference: [`docs/source/Eng/doc/new_features/v100_features_doc.rst`](docs/source/Eng/doc/new_features/v100_features_doc.rst). - **`simhash` / `near_duplicates` / `minhash_signature` / `minhash_similarity`** (`AC_simhash`, `AC_near_duplicates`): `fuzzy_dedupe` is O(n²) pairwise with no stable fingerprint and `image_dedup` only hashes pixels. This adds the text analog — SimHash (Hamming-distance near-dup clustering) and MinHash (estimated Jaccard) using a fixed `blake2b` hash for deterministic fingerprints. Pairs with `normalize_text`. Pure-stdlib. -## What's new (2026-06-22) — String-Distance Similarity Metrics +### String-Distance Similarity Metrics Match typos and reordered tokens. Full reference: [`docs/source/Eng/doc/new_features/v99_features_doc.rst`](docs/source/Eng/doc/new_features/v99_features_doc.rst). - **`levenshtein` / `damerau_levenshtein` / `jaro` / `jaro_winkler` / `jaccard` / `dice` / `similarity`** (`AC_text_similarity`): `fuzzy` exposed only difflib's gestalt ratio. This adds the edit-distance and token-set metrics it lacks — Jaro-Winkler (standard for short labels), Damerau (transposition-aware), and char-n-gram Jaccard/Dice — plus a unified `similarity()` that normalizes every metric to `[0, 1]`. Pairs with `normalize_text`. Pure-stdlib, deterministic. -## What's new (2026-06-22) — Time-Series Transforms +### Time-Series Transforms Turn counters into rates; downsample and resample. Full reference: [`docs/source/Eng/doc/new_features/v98_features_doc.rst`](docs/source/Eng/doc/new_features/v98_features_doc.rst). - **`ts_rate` / `ts_irate` / `ts_increase` / `ts_delta` / `ts_downsample` / `ts_resample`** (`AC_ts_rate`, `AC_ts_downsample`): `observability` counters store only the current value (no counter→rate anywhere) and `cost_telemetry` only buckets by day. This adds Prometheus-style reset-aware rate/increase/delta over `(timestamp, value)` series, tumbling-bucket downsampling (avg/sum/min/max/first/last/count), and grid resampling (last/linear/none). No wall clock — deterministic. Pure-stdlib. -## What's new (2026-06-22) — Unicode Text Normalisation & Slugify +### Unicode Text Normalisation & Slugify Canonicalize text before fuzzy/search/OCR matching. Full reference: [`docs/source/Eng/doc/new_features/v97_features_doc.rst`](docs/source/Eng/doc/new_features/v97_features_doc.rst). - **`normalize_text` / `deaccent` / `slugify` / `normalize_quotes` / `fold_whitespace`** (`AC_normalize_text`, `AC_slugify`): `fuzzy` and `search_index.tokenize` only lowercase and OCR matching only `.lower()`+substring, so `"Café"` (NFC) vs `"Café"` (NFD) vs `"cafe"` compare unequal. This adds the missing canonicalization layer (NFKC + casefold + whitespace fold, accent stripping, smart-quote mapping, ASCII slugs). Pure-stdlib (`unicodedata`), deterministic. -## What's new (2026-06-22) — JSON-Schema Compatibility Checking +### JSON-Schema Compatibility Checking Classify schema changes as backward/forward/full. Full reference: [`docs/source/Eng/doc/new_features/v96_features_doc.rst`](docs/source/Eng/doc/new_features/v96_features_doc.rst). - **`check_compatibility` / `diff_schemas` / `is_backward_compatible` / `is_forward_compatible` / `is_full_compatible`** (`AC_check_compatibility`): we could validate against and generate JSON Schemas but couldn't answer "will an old consumer still read new data?". This classifies changes (added-required field, removed field, narrowed/widened type, enum add/remove) under Confluent/Avro backward/forward/full rules over the object subset. Pure-stdlib, deterministic. -## What's new (2026-06-22) — Typed Configuration Schema +### Typed Configuration Schema Validate config into a typed object. Full reference: [`docs/source/Eng/doc/new_features/v95_features_doc.rst`](docs/source/Eng/doc/new_features/v95_features_doc.rst). - **`ConfigSchema` / `ConfigField` / `validate_config` / `coerce`** (`AC_validate_config`): `assets._coerce` coerces one value and `json_schema` validates structure, but nothing bound a resolved config dict into a typed object with required-field enforcement and choice constraints. This coerces types (`str`/`int`/`float`/`bool`), applies defaults, enforces required/choices, and returns `{ok, config, errors}` — a stdlib pydantic-settings analog. Pure-stdlib, deterministic. -## What's new (2026-06-22) — OTLP/JSON Span Export +### OTLP/JSON Span Export Export spans the way a collector ingests them. Full reference: [`docs/source/Eng/doc/new_features/v94_features_doc.rst`](docs/source/Eng/doc/new_features/v94_features_doc.rst). - **`spans_to_otlp` / `attributes_to_otlp` / `write_otlp`** (`AC_spans_to_otlp`): `agent_trace.to_otel` returned flat dicts that aren't valid OTLP/JSON (no resourceSpans/scopeSpans nesting, times not as uint64 strings). This wraps spans in the proper envelope with hex IDs, uint64-string times, and OTLP `KeyValue` attribute encoding — what an OpenTelemetry collector's file exporter reads. Pairs with `trace_context`. Pure-stdlib, deterministic. -## What's new (2026-06-22) — Canonical Log Lines & Structured Logging +### Canonical Log Lines & Structured Logging One wide event per run, with trace correlation. Full reference: [`docs/source/Eng/doc/new_features/v93_features_doc.rst`](docs/source/Eng/doc/new_features/v93_features_doc.rst). - **`CanonicalLogLine` / `JSONLogFormatter` / `bind_trace_context`** (`AC_canonical_log`): `logging_instance` emits a fixed pipe-delimited string with no JSON and no trace/span fields. This adds a Stripe-style canonical log line (field accumulator + `timer` with injectable clock) and a JSON `logging.Formatter` that carries `trace_id`/`span_id` — the log-trace correlation counterpart to `trace_context`. Pure-stdlib, deterministic. -## What's new (2026-06-22) — Conditional HTTP Requests & Cache Validators +### Conditional HTTP Requests & Cache Validators Skip re-downloading unchanged resources (ETag / 304). Full reference: [`docs/source/Eng/doc/new_features/v92_features_doc.rst`](docs/source/Eng/doc/new_features/v92_features_doc.rst). - **`store_validators` / `conditioned_call` / `is_fresh` / `parse_cache_control` / `is_not_modified`** (`AC_parse_cache_control`, `AC_store_validators`): `http_request` never sent `If-None-Match`/`If-Modified-Since` nor read `Cache-Control`, so every poll re-downloaded. This extracts validators, parses `Cache-Control` (max-age/no-store/…), decides freshness by an explicit age, conditions the next request, and detects `304 Not Modified`. Pure-stdlib, deterministic. -## What's new (2026-06-22) — Cookie Jar (HTTP Session Carry) +### Cookie Jar (HTTP Session Carry) Carry a session across HTTP calls. Full reference: [`docs/source/Eng/doc/new_features/v91_features_doc.rst`](docs/source/Eng/doc/new_features/v91_features_doc.rst). - **`CookieJar` / `parse_set_cookie`** (`AC_cookie_header`, `AC_parse_set_cookie`): `http_request` is stateless — no session cookies persisted across calls, so a login-then-call flow couldn't carry a session headlessly. This parses `Set-Cookie` headers into a jar, builds the `Cookie` request header, and saves/loads the jar as JSON (cookies cleared on `Max-Age<=0`/empty). Pure-stdlib, deterministic. -## What's new (2026-06-22) — HTTP Content Negotiation & Decompression +### HTTP Content Negotiation & Decompression Build `Accept` headers and decode gzip/deflate. Full reference: [`docs/source/Eng/doc/new_features/v90_features_doc.rst`](docs/source/Eng/doc/new_features/v90_features_doc.rst). - **`build_accept` / `build_accept_encoding` / `parse_quality_values` / `decode_body` / `negotiated_call`** (`AC_decode_body`, `AC_parse_quality_values`): `urllib`/`http_request` never set `Accept-Encoding` nor decoded `Content-Encoding`, so compressed bodies arrived raw. This adds `Accept`/`Accept-Encoding` builders, a q-value parser (sorted by quality), and gzip/deflate (incl. raw deflate) decoding. Brotli excluded (not stdlib). Pure-stdlib, deterministic. -## What's new (2026-06-22) — multipart/form-data Build & Parse +### multipart/form-data Build & Parse Build file-upload bodies. Full reference: [`docs/source/Eng/doc/new_features/v89_features_doc.rst`](docs/source/Eng/doc/new_features/v89_features_doc.rst). - **`build_multipart` / `parse_multipart` / `MultipartFile`** (`AC_build_multipart`, `AC_parse_multipart`): `http_request` sent only JSON/raw — there was no file upload, and stdlib `cgi` (which parsed multipart) was removed in 3.13. This assembles a `multipart/form-data` body from text fields and files with an injectable boundary (byte-stable), and parses one back into `{fields, files}`. Pure-stdlib, deterministic. -## What's new (2026-06-22) — Secret Redaction for Config & Logs +### Secret Redaction for Config & Logs Mask secrets before logging or exporting. Full reference: [`docs/source/Eng/doc/new_features/v88_features_doc.rst`](docs/source/Eng/doc/new_features/v88_features_doc.rst). - **`redact_config` / `redact_secret_text`** (`AC_redact_config`, `AC_redact_secret_text`): `utils/redaction` only blurs screenshots and `secrets_scan` only *detects* — neither returned a masked copy. This reuses the `secrets_scan` detector (key-name patterns, AWS/bearer formats, high-entropy) to return a redacted deep copy of a config structure, and to mask secret-looking tokens in a free-text log line (preserving surrounding words). Vault refs (`${secrets.*}`) are left intact. Pure-stdlib, deterministic. -## What's new (2026-06-22) — RFC 8288 Link Header & Pagination +### RFC 8288 Link Header & Pagination Parse `Link` headers and follow `rel="next"`. Full reference: [`docs/source/Eng/doc/new_features/v87_features_doc.rst`](docs/source/Eng/doc/new_features/v87_features_doc.rst). - **`parse_link_header` / `next_url` / `links_by_rel` / `paginate`** (`AC_parse_link_header`, `AC_next_url`): paginated REST APIs return `Link: <...>; rel="next"` but nothing parsed it. This parses the header (quoted values with commas, multiple links), indexes by relation, and `paginate` walks `rel="next"` over an injected `fetch` (transport/cassette) up to `max_pages`. Pure-stdlib, deterministic. -## What's new (2026-06-22) — Referential Integrity Checks +### Referential Integrity Checks Foreign-key, unique, accepted-values and row-count checks across tables. Full reference: [`docs/source/Eng/doc/new_features/v86_features_doc.rst`](docs/source/Eng/doc/new_features/v86_features_doc.rst). - **`check_foreign_key` / `check_unique_key` / `check_accepted_values` / `check_row_count`** (`AC_check_foreign_key`, `AC_check_unique_key`, `AC_check_accepted_values`, `AC_check_row_count`): `validate_rows` is intra-row, single-table (its `unique` only dedupes within one batch). This adds dbt-style generic checks — parent/child foreign keys across two tables, single/composite key uniqueness, accepted-values, and row-count bounds — over rows from `load_rows`/`query_sqlite`. Pure-stdlib, deterministic. -## What's new (2026-06-22) — URI-Scheme Value References +### URI-Scheme Value References Store pointers, not secrets, in config. Full reference: [`docs/source/Eng/doc/new_features/v85_features_doc.rst`](docs/source/Eng/doc/new_features/v85_features_doc.rst). - **`resolve_ref` / `resolve_refs_in` / `is_ref` / `RefResolver`** (`AC_resolve_ref`, `AC_resolve_refs`): `interpolate` hardcoded only `${secrets.NAME}` and `AssetStore` refs were vault-name-only — there was no general read-time indirection. This resolves `env://VAR`, `file://path` (with an optional `base_dir` traversal guard), and `secret://name` (injectable resolver or the governance broker), and walks nested structures resolving every reference. Env reader / secret resolver / base dir are injectable. Pure-stdlib, deterministic. -## What's new (2026-06-21) — W3C Baggage Propagation +## What's new (2026-06-21) + +### W3C Baggage Propagation Carry cross-cutting key-value context across HTTP. Full reference: [`docs/source/Eng/doc/new_features/v84_features_doc.rst`](docs/source/Eng/doc/new_features/v84_features_doc.rst). - **`Baggage` / `parse_baggage` / `format_baggage` / `inject_baggage` / `extract_baggage`** (`AC_baggage_parse`, `AC_baggage_format`): `trace_context` carried trace/span identity but nothing propagated cross-cutting context (`run_id`/`tenant`/`experiment`). This implements the W3C Baggage header — a percent-encoded `key=value` list — with an immutable `Baggage` (set/remove return new instances) and case-insensitive inject/extract over a headers dict. Pairs with `trace_context`. Pure-stdlib, deterministic. -## What's new (2026-06-21) — Dataset Diff (Row-Set Change Report) +### Dataset Diff (Row-Set Change Report) Diff two tabular extracts by key. Full reference: [`docs/source/Eng/doc/new_features/v83_features_doc.rst`](docs/source/Eng/doc/new_features/v83_features_doc.rst). - **`diff_rows` / `cell_changes` / `summarize_diff`** (`AC_diff_rows`, `AC_cell_changes`): the framework diffed screens/snapshots but had nothing to diff two **tabular** row-sets by key. This keys both sides and reports `{added, removed, changed, unchanged}` (changed carries `{key, old, new}`), expands per-cell `{key, column, old, new}` changes, and counts each bucket. Supports composite keys; last-write-wins on duplicates. Pure-stdlib, deterministic. -## What's new (2026-06-21) — Distribution Drift Detection +### Distribution Drift Detection Check whether today's data is shaped like the baseline. Full reference: [`docs/source/Eng/doc/new_features/v82_features_doc.rst`](docs/source/Eng/doc/new_features/v82_features_doc.rst). - **`psi` / `ks_two_sample` / `categorical_drift` / `detect_drift`** (`AC_detect_drift`, `AC_categorical_drift`): `stats` had A/B experiment tests but no Population Stability Index and no KS two-sample test for reference-vs-current distributions. This adds PSI (quantile-binned log-ratio), the KS statistic with a Kolmogorov p-value, and a categorical chi-square + total-variation summary — pairing with `data_profile`. `detect_drift` gives a one-call `{psi, drifted, ks}` verdict. Pure-stdlib, deterministic. -## What's new (2026-06-21) — Layered Configuration Resolver +### Layered Configuration Resolver Compose config with `defaults < file < env < CLI` precedence. Full reference: [`docs/source/Eng/doc/new_features/v81_features_doc.rst`](docs/source/Eng/doc/new_features/v81_features_doc.rst). - **`LayeredConfig` / `deep_merge` / `SourceTrace`** (`AC_resolve_config`, `AC_explain_config`): `json_patch.merge_patch` merges two docs, `config_sync` is last-write-wins, `AssetStore` is flat-per-env — none compose an ordered precedence stack with deep merge or report which layer won each key. `add_layer(name, mapping, priority)` then `resolve()` deep-merges (nested dicts recursively, scalars/lists replaced); `explain("db.host")` names the winning layer. Layers are caller-supplied (env passed in, never `os.environ` implicitly). Pure-stdlib, deterministic. -## What's new (2026-06-21) — Server-Sent Events (SSE) Client Parser +### Server-Sent Events (SSE) Client Parser Consume `text/event-stream` responses. Full reference: [`docs/source/Eng/doc/new_features/v80_features_doc.rst`](docs/source/Eng/doc/new_features/v80_features_doc.rst). - **`parse_event_stream` / `SSEParser` / `SSEEvent`** (`AC_parse_sse`): the MCP HTTP transport emits SSE, but nothing consumed it — a streaming LLM/agent/chatops endpoint left `http_request` with a raw blob. This implements the WHATWG event-stream parsing algorithm (`event`/`data`/`id`/`retry`, comments, the leading-space rule, blank-line dispatch) with an incremental `feed` for chunks and a one-shot `parse_event_stream`. Pure-stdlib, fully deterministic. -## What's new (2026-06-21) — Dotenv (.env) Parsing +### Dotenv (.env) Parsing Read 12-factor `.env` files into config. Full reference: [`docs/source/Eng/doc/new_features/v79_features_doc.rst`](docs/source/Eng/doc/new_features/v79_features_doc.rst). - **`parse_dotenv` / `load_dotenv` / `dotenv_values` / `dump_dotenv`** (`AC_parse_dotenv`, `AC_load_dotenv`): `load_vars_from_json` ingested flat JSON but nothing read the de-facto `.env` file. This parses `KEY=VALUE` lines (`export` prefixes, single/double quoting, `\n`/`\t` escapes, inline comments) into a plain dict — no `python-dotenv` dependency. The loader merges into a caller-supplied mapping rather than mutating `os.environ`, so it stays safe and deterministic. Pure-stdlib. -## What's new (2026-06-21) — RFC 9457 Problem Details Parsing +### RFC 9457 Problem Details Parsing Read standardized API errors out of HTTP responses. Full reference: [`docs/source/Eng/doc/new_features/v78_features_doc.rst`](docs/source/Eng/doc/new_features/v78_features_doc.rst). - **`parse_problem` / `is_problem` / `raise_for_problem` / `ProblemDetails`** (`AC_parse_problem`): `http_request` returned a non-2xx body unparsed, so flows and `assert_http` had no structured way to read a standardized API error. This parses the RFC 9457 `application/problem+json` document — registered `type`/`title`/`status`/`detail`/`instance` members plus vendor extensions — returning `None` for non-problem responses or raising `HttpProblemError`. Pure-stdlib, fully deterministic. -## What's new (2026-06-21) — Data Profiling & Schema Inference +### Data Profiling & Schema Inference Survey a row-set and propose a validation schema. Full reference: [`docs/source/Eng/doc/new_features/v77_features_doc.rst`](docs/source/Eng/doc/new_features/v77_features_doc.rst). - **`profile_rows` / `infer_schema`** (`AC_profile_rows`, `AC_infer_schema`): `validate_rows` consumes a hand-written schema and `stats.describe` summarizes one numeric list — nothing surveyed a whole row-set. This profiles each column (null fraction, cardinality, inferred type, top values, numeric min/max/mean) and infers a `validate_rows`-compatible schema (required where non-null, unique where distinct, numeric bounds) — the profiler step that feeds the existing validator. Pure-stdlib, fully deterministic. -## What's new (2026-06-21) — W3C Trace Context Propagation +### W3C Trace Context Propagation Correlate spans and logs across HTTP boundaries. Full reference: [`docs/source/Eng/doc/new_features/v76_features_doc.rst`](docs/source/Eng/doc/new_features/v76_features_doc.rst). - **`SpanContext` / `new_root_context` / `child_context` / `inject_context` / `extract_context`** (`AC_trace_inject`, `AC_trace_extract`): the existing tracer and `agent_trace` spans carried no IDs, so a span on one side of an HTTP call couldn't be correlated with the work it triggered on the other. This implements the W3C Trace Context standard — generate/parse/propagate `traceparent` + `tracestate` headers (version-`00`, rejects malformed/all-zero IDs), with an injectable RNG for deterministic IDs in tests. Pure-stdlib. -## What's new (2026-06-21) — HTTP Record & Replay Cassette +### HTTP Record & Replay Cassette Re-run API flows in CI with no live server. Full reference: [`docs/source/Eng/doc/new_features/v75_features_doc.rst`](docs/source/Eng/doc/new_features/v75_features_doc.rst). - **`Cassette` / `CassetteMissError`** (`AC_http_replay`): the HTTP client hardcoded its `urllib` transport, so a flow driving a real API couldn't be re-run offline. The client now exposes a `build_call` / `urllib_transport` seam, and this adds a VCR-style cassette — `replay` returns a recorded response for a matching request (pure, no network — the CI-valuable half), `recording_transport` is a thin pass-through over the live transport. Match on `method`/`url` (optionally `body`); `save`/`load` JSON cassettes. Pure-stdlib. -## What's new (2026-06-21) — Bulkhead & Rate-Limit Headers +### Bulkhead & Rate-Limit Headers Cap concurrency, honor server back-off. Full reference: [`docs/source/Eng/doc/new_features/v74_features_doc.rst`](docs/source/Eng/doc/new_features/v74_features_doc.rst). - **`Bulkhead` / `next_delay` / `parse_retry_after` / `parse_ratelimit`** (`AC_bulkhead_run`, `AC_retry_after`): `resilience` recovers and `rate_limit` paces, but nothing capped *simultaneous* in-flight calls (a slow dependency could exhaust every worker) and the HTTP client ignored `Retry-After`/`RateLimit-*`. This adds a bulkhead (bounded-concurrency permit that sheds load with `BulkheadFullError` when full) and parsers for the server's advised delay (delta-seconds or HTTP-date). Non-blocking permit counting → deterministic, no threads in tests. Pure-stdlib. -## What's new (2026-06-21) — Streaming Latency Percentiles +### Streaming Latency Percentiles Mergeable p99 for load/soak runs. Full reference: [`docs/source/Eng/doc/new_features/v73_features_doc.rst`](docs/source/Eng/doc/new_features/v73_features_doc.rst). - **`LatencyDigest` / `exact_percentiles`** (`AC_percentiles`): `stats.percentile` needs the full sorted list; this adds a HdrHistogram-style digest with O(1) `record`, bounded memory (significant-figure buckets), and `merge` for cross-shard aggregation — the property you need for a correct aggregate p99 from per-worker results. `exact_percentiles` covers the small-set case (arbitrary quantiles). Pure-stdlib `math`. -## What's new (2026-06-21) — Service-Level Objectives (SLO) +### Service-Level Objectives (SLO) SLI, error budget and burn-rate alerts. Full reference: [`docs/source/Eng/doc/new_features/v72_features_doc.rst`](docs/source/Eng/doc/new_features/v72_features_doc.rst). - **`evaluate_slo` / `burn_rate` / `burn_alerts` / `default_burn_rules`** (`AC_evaluate_slo`, `AC_burn_alerts`): the framework emitted raw signals but had no SLO layer. This computes the SLI over outcome records (`[{timestamp, ok}]`), the error budget against a target, and the **multi-window multi-burn-rate** alerts from the Google SRE workbook (page 14.4×@1h, 6×@6h; ticket 1×@3d — firing only when both windows exceed the threshold). Records are plain data, clock injectable, fully deterministic. Pure-stdlib. -## What's new (2026-06-21) — Chaos Experiments +### Chaos Experiments Inject faults, verify the system holds. Full reference: [`docs/source/Eng/doc/new_features/v71_features_doc.rst`](docs/source/Eng/doc/new_features/v71_features_doc.rst). - **`ChaosExperiment` / `run_experiment` / `Probe` / `latency_fault` / `exception_fault`** (`AC_run_chaos`): `resilience` *recovers* from failures; this *causes* them and checks a steady-state hypothesis still holds (Chaos Toolkit lifecycle — verify before, inject faults, verify after, roll back LIFO). Probes/faults/rollbacks are callables; the clock/RNG/sleep are injectable so experiments run **deterministically** in tests with no real failures or sleeping. `AC_run_chaos` drives an action-list spec. Pure-stdlib. -## What's new (2026-06-21) — JSON Contract & Snapshot Matching +### JSON Contract & Snapshot Matching Match, diff and snapshot JSON payloads. Full reference: [`docs/source/Eng/doc/new_features/v70_features_doc.rst`](docs/source/Eng/doc/new_features/v70_features_doc.rst). - **`match_json` / `diff_json` / `normalize_json` / `snapshot_json`** (`AC_match_json`, `AC_diff_json`): `json_schema` validates against an authored schema and `jsonpath` extracts, but nothing matched two payloads with relaxed rules or diffed them path-by-path. This adds contract/snapshot matching — `partial` (subset), `match_type` (Pact-style `like`), `ignore` volatile paths — returning `{path, kind}` mismatches (`missing`/`extra`/`changed`), plus golden-master `snapshot_json`. Composes with `json_schema` + `json_patch`; pure-stdlib. -## What's new (2026-06-21) — SLSA Build Provenance +### SLSA Build Provenance Attest what was built. Full reference: [`docs/source/Eng/doc/new_features/v69_features_doc.rst`](docs/source/Eng/doc/new_features/v69_features_doc.rst). - **`build_provenance` / `subject_for` / `verify_provenance` / `write_provenance`** (`AC_build_provenance`, `AC_verify_provenance`): the framework signs action files and inventories deps (SBOM) but couldn't attest *what was produced by which build*. This adds an in-toto v1 Statement with a SLSA v1 provenance predicate over file `sha256` digests, and a verifier that re-hashes the artifacts (tamper → mismatch). Complements `action_signing` + `sbom`; pure-stdlib `hashlib`+`json`, fully offline. -## What's new (2026-06-21) — Feature Flags +### Feature Flags Toggle behavior with targeting & rollout. Full reference: [`docs/source/Eng/doc/new_features/v68_features_doc.rst`](docs/source/Eng/doc/new_features/v68_features_doc.rst). - **`FlagStore` / `evaluate_flag` / `is_enabled` / `assign_variant`** (`AC_evaluate_flag`, `AC_flag_enabled`): `decision_table` is one-shot DMN and `ab_locator` is locator A/B — neither is a product flag store with sticky % rollout. This adds an OpenFeature-shaped engine: targeting rules (`eq`/`in`/`semver_*`…), weighted variants, kill switch, and consistent-hash bucketing (`sha256(key.salt.context_key)`) so a subject is **sticky**. Returns `{value, variant, reason}` (`TARGETING_MATCH`/`SPLIT`/`DISABLED`/`ERROR`). Pure-stdlib, deterministic. -## What's new (2026-06-21) — Text Diff, Patch & Three-Way Merge +### Text Diff, Patch & Three-Way Merge Apply and merge text diffs. Full reference: [`docs/source/Eng/doc/new_features/v67_features_doc.rst`](docs/source/Eng/doc/new_features/v67_features_doc.rst). - **`unified_diff` / `apply_unified` / `three_way_merge`** (`AC_unified_diff`, `AC_apply_unified`, `AC_three_way_merge`): `difflib` *generates* a unified diff but the stdlib can't *apply* one, and there was no three-way merge. This adds the missing applier (walks `@@` hunks, verifies context, raises on mismatch) and a line-based three-way merge (non-overlapping edits combine cleanly; overlapping ones emit `<<<<<<<` conflict markers). Complements `json_patch` (structured JSON); pure-stdlib `difflib`. -## What's new (2026-06-21) — Calendar Recurrence Rules (RRULE) +### Calendar Recurrence Rules (RRULE) Schedule "every 2nd Tuesday". Full reference: [`docs/source/Eng/doc/new_features/v66_features_doc.rst`](docs/source/Eng/doc/new_features/v66_features_doc.rst). - **`parse_rrule` / `occurrences` / `next_occurrence`** (`AC_rrule_occurrences`, `AC_rrule_next`): the scheduler's cron is 5-field interval-only — it can't express "every 2nd Tuesday", "the last weekday of the month", or "every weekday for 10 occurrences". This adds an RFC 5545 (iCalendar) RRULE parser + occurrence expander supporting `FREQ`/`INTERVAL`/`COUNT`/`UNTIL`/`BYDAY` (with ordinals like `2MO`/`-1FR`)/`BYMONTHDAY`/`BYMONTH`/`BYSETPOS`/`WKST`. Pure-stdlib `datetime`+`calendar`, injectable clock for deterministic `next_occurrence`. -## What's new (2026-06-21) — Statistics & A/B Significance +### Statistics & A/B Significance Decide whether a difference is real. Full reference: [`docs/source/Eng/doc/new_features/v65_features_doc.rst`](docs/source/Eng/doc/new_features/v65_features_doc.rst). - **`describe` / `percentile` / `two_proportion_z_test` / `welch_t_test` / `cohens_d` / `chi_square_2x2`** (`AC_describe_stats`, `AC_ab_significance`): `ab_locator` ranks by raw success rate and `run_history` stores durations, but nothing computed percentiles or significance. This adds the analysis layer — summary stats + p50/p90/p95/p99, a two-proportion z-test (with CI), Welch's t-test (exact t-distribution p-value via the incomplete beta — no SciPy), Cohen's d, and a 2×2 chi-square. The normal CDF is exact via `math.erf`; validated against textbook values (incl. the chi²=z² identity). Pure-stdlib `math`+`statistics`. -## What's new (2026-06-21) — Full-Text Search (BM25) +### Full-Text Search (BM25) Rank a document corpus by relevance. Full reference: [`docs/source/Eng/doc/new_features/v64_features_doc.rst`](docs/source/Eng/doc/new_features/v64_features_doc.rst). - **`SearchIndex` / `search_documents` / `tokenize`** (`AC_search_documents`, `ac_search_documents`): `fuzzy` is pairwise and `skill_library` matches substrings alphabetically — neither ranks a corpus by relevance. This adds an inverted-index search ranked with Okapi BM25 (`k1=1.5`, `b=0.75`, `IDF = ln(1+(N−df+0.5)/(df+0.5))`) or TF-IDF, so a rare term out-ranks a common one, term frequency saturates, and long docs are normalized down. Incremental `add`/`remove`, optional stop-words, deterministic ranking. Pure-stdlib `math`+`collections`+`re` — no database. -## What's new (2026-06-21) — JSON Pointer, Patch & Merge Patch +### JSON Pointer, Patch & Merge Patch Address, diff and patch JSON. Full reference: [`docs/source/Eng/doc/new_features/v63_features_doc.rst`](docs/source/Eng/doc/new_features/v63_features_doc.rst). - **`resolve_pointer` / `make_patch` / `apply_patch` / `merge_patch` / `make_merge_patch`** (`AC_resolve_pointer`, `AC_apply_json_patch`, `AC_make_json_patch`, `AC_merge_patch`): `jsonpath` is read-only and `approval` compares whole artifacts — nothing could address one location, compute a structured delta, or apply a partial update. This adds the three IETF primitives — JSON Pointer (RFC 6901), JSON Patch (RFC 6902, all six ops, **atomic** apply), and JSON Merge Patch (RFC 7386, `null` deletes) — for config-drift detection, partial updates, HTTP PATCH bodies, and golden-master deltas. Pure-stdlib `json`+`copy`, validated against the RFC test vectors. -## What's new (2026-06-21) — Client-Side Rate Limiting +### Client-Side Rate Limiting Stay under API quotas. Full reference: [`docs/source/Eng/doc/new_features/v62_features_doc.rst`](docs/source/Eng/doc/new_features/v62_features_doc.rst). - **`TokenBucket` / `SlidingWindowLimiter` / `throttle`** (`AC_rate_limit`, `ac_rate_limit`): `RetryPolicy`/`CircuitBreaker` recover from failures but nothing shaped the *rate* of calls. This adds a token bucket (smooth rate + burst), a sliding-window limiter (Cloudflare's O(1) weighted counter), and a leading-edge throttle decorator. Every limiter takes an injectable `clock` (and `acquire` a `sleep`) so it's fully deterministic in CI with no real delays. `AC_rate_limit` gates an action against a named bucket, returning `{acquired, tokens, wait}`. -## What's new (2026-06-21) — JSON Web Tokens (JWT) +### JSON Web Tokens (JWT) Mint and verify bearer tokens for the APIs you automate. Full reference: [`docs/source/Eng/doc/new_features/v61_features_doc.rst`](docs/source/Eng/doc/new_features/v61_features_doc.rst). - **`encode_jwt` / `decode_jwt` / `ClaimsPolicy`** (`AC_jwt_encode`, `AC_jwt_decode`): the framework had HMAC *file* signing and an ACME-bound RS256 JWS, but nothing to mint/verify a compact bearer JWT. This adds a pure-stdlib HS256/384/512 codec with full claim validation (`exp`/`nbf`/`aud`/`iss`, injectable clock) that drops straight into `http_request`'s bearer auth. Safe by default: rejects `alg:none`, enforces an algorithm allowlist (anti-confusion), and compares signatures with `hmac.compare_digest`. `AC_jwt_decode` returns `{ok, claims}` so flows can branch without raising. -## What's new (2026-06-21) — License Policy Gate +### License Policy Gate Flag disallowed dependency licenses. Full reference: [`docs/source/Eng/doc/new_features/v60_features_doc.rst`](docs/source/Eng/doc/new_features/v60_features_doc.rst). - **`evaluate_sbom` / `evaluate_license` / `normalize_spdx` / `license_findings_to_sarif`** (`AC_check_licenses`, `ac_check_licenses`): the SBOM recorded each dependency's license name but never *judged* it. This normalizes license strings to SPDX ids and evaluates them against an allowlist/denylist (with a built-in `DEFAULT_COPYLEFT` set), understanding SPDX expressions (`OR` = choice, `AND` = all), then bridges violations into SARIF (`denied`→error, `unknown`→warning). Pure-stdlib, fully offline — the license-compliance lane beside the OSV vulnerability lane. -## What's new (2026-06-21) — OpenVEX Vulnerability Triage +### OpenVEX Vulnerability Triage Suppress the vulns that don't affect you. Full reference: [`docs/source/Eng/doc/new_features/v59_features_doc.rst`](docs/source/Eng/doc/new_features/v59_features_doc.rst). - **`vex_statement` / `build_vex` / `apply_vex`** (`AC_apply_vex`, `ac_apply_vex`): the OSV scanner surfaces every known CVE forever — there was no way to record "we checked, this one doesn't affect us". This authors [OpenVEX](https://openvex.dev) 0.2.0 statements and applies them to the scanner's findings: `not_affected`/`fixed` **suppress** a finding, `affected`/`under_investigation` **annotate** it. Statements join on the vuln id *or* an alias, optionally product-scoped; `not_affected` requires a justification or impact statement. Pure-stdlib; chains directly after `AC_scan_vulns`. -## What's new (2026-06-21) — Dependency Vulnerability Scanning (OSV) +### Dependency Vulnerability Scanning (OSV) Match the SBOM against known CVEs. Full reference: [`docs/source/Eng/doc/new_features/v58_features_doc.rst`](docs/source/Eng/doc/new_features/v58_features_doc.rst). - **`scan_components` / `match_package` / `is_affected` / `findings_to_sarif`** (`AC_scan_vulns`, `ac_scan_vulns`): `build_sbom` only *inventoried* dependencies and `to_sarif` only *exported* findings — nothing ever **produced** a vulnerability finding. This matches the SBOM's `(ecosystem, name, version)` components against an [OSV](https://osv.dev) advisory database (sweeping `introduced`/`fixed`/`last_affected` ranges, PEP-503 name normalization, severity→SARIF level) and bridges results into the existing SARIF exporter for GitHub/Azure DevOps code scanning. The advisory DB is **injected as data** (offline, deterministic); the live `osv.dev` query is an optional `fetcher` seam. Pure-stdlib `re`. -## What's new (2026-06-21) — JSON Schema Validation +### JSON Schema Validation Validate nested JSON against a real schema. Full reference: [`docs/source/Eng/doc/new_features/v57_features_doc.rst`](docs/source/Eng/doc/new_features/v57_features_doc.rst). - **`validate_json` / `is_valid` / `assert_schema`** (`AC_validate_json`, `ac_validate_json`): the framework only *generated* JSON Schema and `data_quality` is a flat per-column checker — neither could validate a nested API request/response body. This adds the consumer: a JSON Schema (Draft 2020-12 subset) validator that reports **every** violation as `{path, keyword, message}` (e.g. `$.age maximum`). Covers `type` (incl. integral-float `integer`), `enum`/`const`, numeric/string bounds, array & object keywords, `allOf`/`anyOf`/`oneOf`/`not`, boolean schemas and local `$ref`. Pure-stdlib `re`; pairs with `json_query` and the `http_request` helper. -## What's new (2026-06-20) — SARIF 2.1.0 Findings Export +## What's new (2026-06-20) + +### SARIF 2.1.0 Findings Export Unify scanner findings for GitHub code scanning. Full reference: [`docs/source/Eng/doc/new_features/v56_features_doc.rst`](docs/source/Eng/doc/new_features/v56_features_doc.rst). - **`to_sarif` / `write_sarif` / `make_finding` / `from_lint_issues` / `from_audit_findings`** (`AC_export_sarif`, `ac_export_sarif`): the framework's findings producers (action-lint, secrets scan, WCAG audit, guardrail) had no common export. This builds a SARIF 2.1.0 document — with auto rule catalog and stable `partialFingerprints` for cross-run dedupe — that GitHub/Azure DevOps code scanning ingests as line-anchored alerts. Pure-stdlib `json`+`hashlib`; adapters normalize the existing lint/audit shapes. -## What's new (2026-06-20) — Text PII Detection & Redaction +### Text PII Detection & Redaction Mask PII in text before it leaks. Full reference: [`docs/source/Eng/doc/new_features/v55_features_doc.rst`](docs/source/Eng/doc/new_features/v55_features_doc.rst). - **`detect_pii` / `redact_pii_text`** (`AC_detect_pii` / `AC_redact_pii`, `ac_*`): image redaction existed but text (OCR, clipboard, LLM I/O, logs) had no string-level PII handling. This detects emails / phones / SSNs / credit cards / IPv4 / IBANs over plain text and redacts with `label` / `mask` / `partial` / `hash`. Overlapping spans dedupe (a card isn't also a phone); patterns are backtracking-safe. Pure-stdlib `re`+`hashlib`. -## What's new (2026-06-20) — Self-Healing Locator Write-Back +### Self-Healing Locator Write-Back Persist corrected locators so heals aren't forgotten. Full reference: [`docs/source/Eng/doc/new_features/v54_features_doc.rst`](docs/source/Eng/doc/new_features/v54_features_doc.rst). - **`RepairStore` / `repair_from_heal`** (`AC_repair_record` / `AC_repair_resolved` / `AC_repair_pending` / `AC_repair_approve`, `ac_*`): runtime self-healing previously **threw away** the corrected location, so every run re-healed. This records the corrected locator (coords/VLM description/method) from a heal, **auto-applies** it when `confidence >= auto_threshold` (default 0.9) or queues a reviewable suggestion, and `resolved(key)` returns the learned fix for reuse. Closes the heal→durable-fix loop; pure-stdlib, fully testable. -## What's new (2026-06-20) — DMN-Style Decision Tables +### DMN-Style Decision Tables Externalize branching into reviewable rule tables. Full reference: [`docs/source/Eng/doc/new_features/v53_features_doc.rst`](docs/source/Eng/doc/new_features/v53_features_doc.rst). - **`evaluate_table` / `DecisionTable`** (`AC_decision_table`, `ac_decision_table`): replaces nested `AC_if_var` chains with rows of `conditions -> outputs` and a hit policy (`UNIQUE`/`FIRST`/`PRIORITY`/`COLLECT`). Cell conditions are wildcard / literal / `{op, value}` using the executor's standard comparators (reused, not duplicated). Pure-stdlib, fully testable; the DMN way to keep business rules data-driven. -## What's new (2026-06-20) — Saga / Compensating Rollback +### Saga / Compensating Rollback Undo completed steps when a later one fails. Full reference: [`docs/source/Eng/doc/new_features/v52_features_doc.rst`](docs/source/Eng/doc/new_features/v52_features_doc.rst). - **`Saga` / `run_saga`** (`AC_run_saga`, `ac_run_saga`): records a compensating action per step; on any failure runs the completed steps' compensations in **LIFO** order — the durable-transaction primitive `AC_try` (single-block) couldn't provide. Forward actions/compensations are callables (or JSON action lists), so it's fully unit-tested with no side effects; compensation is best-effort (a failing undo is logged, rollback continues). Returns `{ok, completed, compensated, failed_step, error}`. -## What's new (2026-06-20) — JSONPath Querying +### JSONPath Querying Query API/DB JSON with wildcards, recursion, filters. Full reference: [`docs/source/Eng/doc/new_features/v51_features_doc.rst`](docs/source/Eng/doc/new_features/v51_features_doc.rst). - **`json_query` / `json_query_one` / `json_extract`** (`AC_json_query` / `AC_json_extract`, `ac_*`): the executor's path walker only split on `.` and indexed — this adds a JSONPath subset (`$`, `.key`, `[n]`/`[-n]`, `*`/`[*]`, `..` recursive descent, `[?(@.k op v)]` filters) over parsed JSON, so array-bearing API/DB responses are easy to extract from. `json_extract` runs a `{key: path}` mapping into a flat dict. Pure-stdlib `re`; the path engine `AC_http_to_var` and DB-row flows were missing. -## What's new (2026-06-20) — Multi-Channel Webhook Notifications +### Multi-Channel Webhook Notifications Alert Teams/Discord/Slack/webhook. Full reference: [`docs/source/Eng/doc/new_features/v50_features_doc.rst`](docs/source/Eng/doc/new_features/v50_features_doc.rst). - **`notify_webhook` / `WebhookChannel`** (`AC_notify_webhook`, `ac_notify_webhook`): `notify` was desktop-toast only and ChatOps shipped Slack only — this sends to **Slack / Discord / Microsoft Teams / raw** webhooks, building the transport-shaped payload (Slack & Teams MessageCard use `text`, Discord uses `content`) and POSTing via the egress-guarded HTTP client. The `poster` transport is injectable (or `set_default_poster`), so sending is unit-tested with no network. -## What's new (2026-06-20) — Outbound CloudEvents Emitter +### Outbound CloudEvents Emitter Emit run/automation events as CloudEvents. Full reference: [`docs/source/Eng/doc/new_features/v49_features_doc.rst`](docs/source/Eng/doc/new_features/v49_features_doc.rst). - **`to_cloudevent` / `EventEmitter` / `post_cloudevent`** (`AC_emit_event`, `ac_emit_event`): the repo could receive webhooks but not **emit** events — this wraps run-lifecycle/assertion/failure data in a CloudEvents 1.0 (CNCF) envelope and optionally POSTs it over the egress-guarded HTTP client (interop with Knative, Azure Event Grid, iPaaS, generic webhooks). The `sink`/`poster` transport is injectable, so emission is unit-tested with no network. -## What's new (2026-06-20) — Environment-Scoped Typed Asset Store +### Environment-Scoped Typed Asset Store Per-environment typed config + credential refs. Full reference: [`docs/source/Eng/doc/new_features/v48_features_doc.rst`](docs/source/Eng/doc/new_features/v48_features_doc.rst). - **`AssetStore` / `active_environment`** (`AC_set_asset` / `AC_get_asset` / `AC_list_assets`, `ac_*`): the orchestrator "Assets/lockers" pillar — centrally-managed config values that differ by environment (dev/staging/prod) and carry a type (`text`/`int`/`bool`/`credential`). `get` coerces to the declared type and falls back to the default env; `credential` assets hold a secret *reference* that `resolve` turns into the real value via an injected resolver (Python-only, so secrets never enter `get`/executor records). Fills the gap the secret vault (secret-only) and config-sync (whole-blob) left. -## What's new (2026-06-20) — Task / Process Mining (Automation-Candidate Discovery) +### Task / Process Mining (Automation-Candidate Discovery) Discover what to automate from recorded action logs. Full reference: [`docs/source/Eng/doc/new_features/v47_features_doc.rst`](docs/source/Eng/doc/new_features/v47_features_doc.rst). - **`mine_action_log` / `find_repeated_sequences` / `directly_follows` / `rank_automation_candidates`** (`AC_mine_actions`, `ac_mine_actions`): mines a recorded action log for frequent, repeatable command n-grams, builds a directly-follows graph, and ranks automation candidates by `count × length` — the RPA "task mining" pillar AutoControl recorded data for but never analysed. Pure-stdlib; operates on the existing action-list shape; a candidate that recurs and spans several steps is a strong "extract into a skill" signal. -## What's new (2026-06-20) — Stuck-Loop Guard (Agent Loop Progress Detection) +### Stuck-Loop Guard (Agent Loop Progress Detection) Catch agents stuck in no-progress loops. Full reference: [`docs/source/Eng/doc/new_features/v46_features_doc.rst`](docs/source/Eng/doc/new_features/v46_features_doc.rst). - **`LoopGuard` / `digest_result`** (`AC_loop_guard_observe` / `AC_loop_guard_reset`, `ac_*`): the top computer-use failure mode is an agent repeating an action with no effect — and the model can't see its own loop. `LoopGuard` watches the `(tool, args, result)` stream and flags `repeat` (same call N times), `ping_pong` (A-B-A-B), and `no_op` (observation digest unchanged), escalating `ok`→`warn`→`critical` by run length. Complements the step/time budget and offline trajectory eval; pure-stdlib, deterministic. -## What's new (2026-06-20) — Coordinate-Space Mapping (Model Grid ⇄ Physical Pixels) +### Coordinate-Space Mapping (Model Grid ⇄ Physical Pixels) Translate computer-use model clicks to real pixels. Full reference: [`docs/source/Eng/doc/new_features/v45_features_doc.rst`](docs/source/Eng/doc/new_features/v45_features_doc.rst). - **`CoordinateSpace` / `xga_space` / `normalized_space` / `downscale_png`** (`AC_to_physical` / `AC_to_model`, `ac_*`): computer-use/VLA models click in a fixed grid (Anthropic downscales to XGA; Gemini returns a 1000×1000 grid), not physical pixels. This maps both ways (round + clamp), `xga_space` aspect-preserves without upscaling, and `downscale_png` resizes a screenshot to the model's input size (Pillow, already core). Pure-arithmetic mapping — unit-tested without a model/GPU. -## What's new (2026-06-20) — Voice-Command Router +### Voice-Command Router Trigger flows hands-free from recognized speech. Full reference: [`docs/source/Eng/doc/new_features/v44_features_doc.rst`](docs/source/Eng/doc/new_features/v44_features_doc.rst). - **`VoiceRouter`** (`AC_voice_register` / `AC_voice_dispatch` / `AC_voice_list` / `AC_voice_clear`, `ac_*`): map spoken trigger phrases to `AC_*` action lists; feed it recognized text and it runs the closest registered command (phrase matching reuses the fuzzy matcher, so "save the file" fires "save file"). **Speech-to-text is out of scope and injectable** — the router takes text and a `recognizer`/`runner` callable, so routing is fully unit-tested without audio or any speech dependency (a real Vosk/mic recogniser plugs into `listen_once`). -## What's new (2026-06-20) — Locale-Aware Number, Currency & Date Parsing +### Locale-Aware Number, Currency & Date Parsing Parse localized numbers/currency/dates. Full reference: [`docs/source/Eng/doc/new_features/v43_features_doc.rst`](docs/source/Eng/doc/new_features/v43_features_doc.rst). - **`parse_decimal` / `parse_number` / `format_decimal` / `format_currency` / `format_date`** (`AC_parse_decimal` / `AC_parse_number` / `AC_format_decimal` / `AC_format_currency` / `AC_format_date`, `ac_*`): OCR/UI text like `"1.234,56"` (de_DE) parses correctly to `1234.56` via **Babel**'s CLDR data, and values format back per-locale. `babel` is an optional `[locale]` extra, imported lazily; functional tests run under `importorskip` (wiring/facade always verified). -## What's new (2026-06-20) — Perceptual-Hash Image Dedupe +### Perceptual-Hash Image Dedupe Collapse near-identical screenshots. Full reference: [`docs/source/Eng/doc/new_features/v42_features_doc.rst`](docs/source/Eng/doc/new_features/v42_features_doc.rst). - **`average_hash` / `dhash` / `hamming_distance` / `images_similar` / `dedupe_images`** (`AC_image_hash` / `AC_dedupe_images`, `ac_*`): perceptual hashing maps visually similar images to close fingerprints, so near-duplicate frames in a recording or step report cluster by Hamming distance and collapse to one representative. Uses **Pillow** (already core — no extra dep); the dedupe/compare logic is pure Python with an injectable `hasher`, so clustering is unit-tested without any image and the real Pillow path under `importorskip`. -## What's new (2026-06-20) — S3-Compatible Artifact Store +### S3-Compatible Artifact Store Push run artifacts to object storage. Full reference: [`docs/source/Eng/doc/new_features/v41_features_doc.rst`](docs/source/Eng/doc/new_features/v41_features_doc.rst). - **`S3ArtifactStore`** (`AC_s3_upload` / `AC_s3_download` / `AC_s3_list` / `AC_s3_delete`, `ac_*`): upload/download/list/delete reports, screenshots, and recordings against any S3-compatible bucket (AWS S3, MinIO, R2). `boto3` is an **optional** `[s3]` extra and the client is **injectable**, so the store's logic — and the executor path — are fully unit-tested with a fake client (no boto3/network); the live AWS path is honestly noted as CI-unverifiable. The whole API is relative to the store `prefix`. A module-level default store backs the commands. -## What's new (2026-06-20) — Fuzzy String Matching & Dedupe +### Fuzzy String Matching & Dedupe Match noisy OCR/UI text robustly. Full reference: [`docs/source/Eng/doc/new_features/v40_features_doc.rst`](docs/source/Eng/doc/new_features/v40_features_doc.rst). - **`fuzzy_ratio` / `fuzzy_best_match` / `fuzzy_matches` / `fuzzy_dedupe`** (`AC_fuzzy_ratio` / `AC_fuzzy_best_match` / `AC_fuzzy_dedupe`, `ac_*`): score similarity (0..1), pick the closest candidate from a list, or collapse near-duplicates — so a flow can act on "the button that *looks like* Submit" rather than an exact label. The default backend is stdlib `difflib` (**zero extra deps**); the optional `[fuzzy]` extra adds `rapidfuzz` for speed, with scores normalised either way. `ignore_case` and `score_cutoff` supported. -## What's new (2026-06-19) — Video Step-Overlay Report +## What's new (2026-06-19) + +### Video Step-Overlay Report Caption screenshots into a walkthrough video. Full reference: [`docs/source/Eng/doc/new_features/v39_features_doc.rst`](docs/source/Eng/doc/new_features/v39_features_doc.rst). - **`write_step_video`** (`AC_write_step_video`, `ac_write_step_video`): turns per-step screenshots into a shareable video where each frame is held for a few seconds with its caption and a pass/fail colour banner burned in. The assembly logic (`build_overlay_plan` / `render_overlay_frame`) is separated from OpenCV via injectable `loader`/`drawer`/`writer_factory` hooks — unit-testable with fakes and no `cv2`/`numpy` dependency; the real path lazily imports `cv2` only when those hooks are absent. The visual companion to the HTML/JSON reports. -## What's new (2026-06-19) — Agent Observability (GenAI OpenTelemetry Spans) +### Agent Observability (GenAI OpenTelemetry Spans) OTel GenAI-convention spans for LLM runs. Full reference: [`docs/source/Eng/doc/new_features/v38_features_doc.rst`](docs/source/Eng/doc/new_features/v38_features_doc.rst). - **`AgentTrace`** (`AC_trace_record` / `AC_trace_summary` / `AC_trace_export` / `AC_trace_reset`, `ac_*`): records spans whose attributes follow the OpenTelemetry **GenAI semantic conventions** (`gen_ai.operation.name`, `gen_ai.system`, `gen_ai.request.model`, `gen_ai.usage.input_tokens`/`output_tokens`, `gen_ai.tool.name`) and the `"{operation} {model}"` span name. `to_otel()` drops into an OTLP exporter; `summary()` rolls up token cost and latency; an `operation()` context manager times live blocks and marks errors. Pure-stdlib (no `opentelemetry` dep), injectable clock; pairs with trajectory evaluation (record here, score there). -## What's new (2026-06-19) — Compliance Control Report (SOC2 / ISO 27001) +### Compliance Control Report (SOC2 / ISO 27001) Map governance evidence to named controls. Full reference: [`docs/source/Eng/doc/new_features/v37_features_doc.rst`](docs/source/Eng/doc/new_features/v37_features_doc.rst). - **`build_compliance_report`** (`AC_compliance_report`, `ac_compliance_report`): the framework already ships the controls an auditor cares about — egress allowlist, JIT credential leases, maker-checker approval, secrets scanner, audit logging, CycloneDX SBOM. This maps a flat `evidence` mapping to SOC2 (CC6.1/CC6.3/CC6.8/CC7.3/CC8.1) and ISO 27001 (A.5.23/A.8.16/A.8.30) controls, each marked `satisfied`/`gap`/`not_assessed`, and renders JSON or a standalone HTML table. The capstone of the governance set — a reporting aid, not a certification. -## What's new (2026-06-19) — Agent Trajectory Evaluation +### Agent Trajectory Evaluation Score an agent run against a rubric. Full reference: [`docs/source/Eng/doc/new_features/v36_features_doc.rst`](docs/source/Eng/doc/new_features/v36_features_doc.rst). - **`evaluate_trajectory`** (`AC_evaluate_trajectory`, `ac_evaluate_trajectory`): scores a recorded trajectory (ordered `{action, args, observation}` steps) against a declarative rubric — `required_actions` (+`ordered`), `forbidden_actions`, `max_steps`, `success_contains`. Returns `{passed, score, steps, checks}` where `score` is the fraction of applicable checks passed and each `check` pinpoints a violated expectation. A deterministic, dependency-free signal for agent regression testing; the rubric is plain data so it lives in JSON action files and travels over MCP. -## What's new (2026-06-19) — Approval Testing (Golden-Master Baselines) +### Approval Testing (Golden-Master Baselines) Lock outputs against a human-approved baseline. Full reference: [`docs/source/Eng/doc/new_features/v35_features_doc.rst`](docs/source/Eng/doc/new_features/v35_features_doc.rst). - **`verify_artifact` / `approve_artifact`** (`AC_verify_artifact` / `AC_approve_artifact` / `AC_pending_artifacts`, `ac_*`): golden-master / snapshot testing for *any* artifact (text, JSON, OCR output, screenshot bytes). `verify_artifact` compares produced content to `.approved.`; a mismatch or missing baseline writes `.received.` for review and fails, and `approve_artifact` promotes a reviewed received file to the baseline. Complements pixel diffing with a review-gated baseline you commit alongside the test; names are path-traversal-checked. -## What's new (2026-06-19) — Network Egress Allowlist Guard +### Network Egress Allowlist Guard Pin which hosts automation may reach. Full reference: [`docs/source/Eng/doc/new_features/v34_features_doc.rst`](docs/source/Eng/doc/new_features/v34_features_doc.rst). - **`EgressPolicy` / `set_egress_policy`** (`AC_egress_allow` / `AC_egress_check` / `AC_egress_reset`, `ac_*`): an allow list (default-deny) and/or deny list of `fnmatch` host globs (`*.example.com`) consulted by **every** `http_request` (so `AC_http` and all features built on it are covered at once). Blocked hosts raise `EgressBlocked` *before* a socket opens. Starts in allow-all mode — no behavior change until an operator locks egress down. Closes the exfiltration surface for unattended automation. -## What's new (2026-06-19) — Just-In-Time Credential Leases +### Just-In-Time Credential Leases Zero standing privilege for secrets. Full reference: [`docs/source/Eng/doc/new_features/v33_features_doc.rst`](docs/source/Eng/doc/new_features/v33_features_doc.rst). - **`CredentialBroker`** (`AC_lease_secret` / `AC_lease_valid` / `AC_revoke_lease` / `AC_lease_active`, `ac_*`): a consumer takes a short-lived *lease* (token bound to a secret name + expiry); the real value is fetched only at `redeem` time, only while valid, through a pluggable resolver (an unlocked `SecretManager`, env, vault). Secret values never enter executor/MCP records — the executor/MCP/Builder surfaces manage the lease lifecycle only; `redeem` is a deliberate Python-API-only escape hatch. Clock and resolver injectable. -## What's new (2026-06-19) — Maker-Checker Approval Gate +### Maker-Checker Approval Gate Segregation of duties for high-risk steps. Full reference: [`docs/source/Eng/doc/new_features/v32_features_doc.rst`](docs/source/Eng/doc/new_features/v32_features_doc.rst). - **`ApprovalGate`** (`AC_approval_request` / `AC_approval_approve` / `AC_approval_reject` / `AC_approval_status`, `ac_*`): a *maker* files a high-risk action and gets a token; a *checker* — required to be a **different** principal — approves or rejects it; the action proceeds only once `is_approved` is true. State is an optional shared JSON file so the dispatcher and the human approver can run as separate processes. Pure-stdlib, SOC2-style four-eyes control. -## What's new (2026-06-19) — Plugin SDK +### Plugin SDK Third-party `AC_*` commands via entry points. Full reference: [`docs/source/Eng/doc/new_features/v31_features_doc.rst`](docs/source/Eng/doc/new_features/v31_features_doc.rst). - **`discover_plugins` / `load_plugins`** (`AC_list_plugins` / `AC_load_plugins`, `ac_*`): a pip package registers new executor commands declaratively in the `je_auto_control.commands` entry-point group; AutoControl discovers and registers them at runtime (immediately usable from JSON flows, socket server, scheduler, MCP). Broken plugins are skipped; the declarative, namespaced complement to the runtime path loader. -## What's new (2026-06-19) — MCP Structured Output +### MCP Structured Output MCP 2025-06-18 structured tool output. Full reference: [`docs/source/Eng/doc/new_features/v30_features_doc.rst`](docs/source/Eng/doc/new_features/v30_features_doc.rst). - **`MCPTool(output_schema=...)`** — a tool may declare an `outputSchema`; its dict result is returned as `structuredContent` in the `tools/call` response so clients/LLMs consume a typed, schema-validated object instead of re-parsing text. `to_descriptor()` advertises it in `tools/list`; non-dict results and schema-less tools are unchanged. `ac_validate_rows` is the first built-in to adopt it. -## What's new (2026-06-19) — Tweened Drag +### Tweened Drag Deterministic eased drags. Full reference: [`docs/source/Eng/doc/new_features/v29_features_doc.rst`](docs/source/Eng/doc/new_features/v29_features_doc.rst). - **`tween_points` / `tween_drag` / `easing_names`** (`AC_tween_drag`, `ac_tween_drag`): drag from `start` to `end` along an eased curve (linear / ease_in_out_quad / ease_out_cubic / ease_in_cubic) — deterministic, pure-math path, injectable sink for tests; complements the humanized jitter. -## What's new (2026-06-19) — Process-Doc (SOP) Generator +### Process-Doc (SOP) Generator Turn an action list into a step-by-step SOP. Full reference: [`docs/source/Eng/doc/new_features/v28_features_doc.rst`](docs/source/Eng/doc/new_features/v28_features_doc.rst). - **`generate_sop` / `write_sop`** (`AC_generate_sop`, `ac_generate_sop`): map a recorded/authored action list to numbered, human-readable steps + an HTML document (UiPath Task-Capture deliverable); content HTML-escaped, unknown commands degrade gracefully. -## What's new (2026-06-19) — Heal Analytics & Secret Scan +### Heal Analytics & Secret Scan Two pure-stdlib audit/analysis tools. Full reference: [`docs/source/Eng/doc/new_features/v27_features_doc.rst`](docs/source/Eng/doc/new_features/v27_features_doc.rst). - **Self-heal analytics** — `analyze_heal_log` / `heal_stats` (`AC_heal_stats`, `ac_heal_stats`): aggregate the self-heal log into heal-rate, strategy mix, fallback-rate, avg latency and the most-brittle locators — catch decaying selectors before they fail. - **Secret scan** — `scan_secrets(data)` (`AC_scan_secrets`, `ac_scan_secrets`): flag hardcoded secrets in action JSON (by key name, value pattern, or high entropy) that should use `${secrets.*}`; vault refs ignored, previews masked. -## What's new (2026-06-19) — CI Annotations & Clipboard History +### CI Annotations & Clipboard History Two pure-stdlib utilities. Full reference: [`docs/source/Eng/doc/new_features/v26_features_doc.rst`](docs/source/Eng/doc/new_features/v26_features_doc.rst). - **CI annotations** — `emit_annotations(results)` (`AC_ci_annotations`, `ac_ci_annotations`): turn result dicts into GitHub Actions workflow commands (`::error file=...,line=...::msg`) so failures show inline in a PR, no reporter action needed. - **Clipboard history** — `ClipboardHistory` / `default_clipboard_history` (`AC_clip_history_capture`/`list`/`search`/`start`/`stop`, `ac_clip_history_*`): a capped, searchable, newest-first ring buffer of copied text with an optional background poller. -## What's new (2026-06-19) — Resilience Primitives +### Resilience Primitives Reusable retry + circuit-breaker primitives. Full reference: [`docs/source/Eng/doc/new_features/v25_features_doc.rst`](docs/source/Eng/doc/new_features/v25_features_doc.rst). - **RetryPolicy** — `RetryPolicy(...).run(fn)` / `retry_call(fn)`: retry on configured exceptions with exponential backoff (injectable sleep). (The existing `AC_retry` flow command already retries an action body; this is the reusable callable wrapper.) - **CircuitBreaker** — `CircuitBreaker` / `CircuitOpenError` (`AC_circuit_call`, `ac_circuit_call`): open after N consecutive failures, short-circuit until a reset timeout, then half-open — stops a retry storm hammering a downed dependency. Injectable clock; `AC_circuit_call` runs an action list through a named breaker. -## What's new (2026-06-19) — Timed Input Macros +### Timed Input Macros Replay input with timing fidelity + a press-hold-release DSL, full stack. Full reference: [`docs/source/Eng/doc/new_features/v24_features_doc.rst`](docs/source/Eng/doc/new_features/v24_features_doc.rst). - **Timed timeline replay** — `replay_timeline(events, speed=...)` (`AC_replay_timeline`, `ac_replay_timeline`): replay events honoring each `delta_ms` gap, scaled by `speed` and clampable; ops = move/click/scroll/press/release/key. - **Input-sequence DSL** — `run_sequence(steps)` (`AC_input_sequence`, `ac_input_sequence`): declarative press/hold/release chords + `repeat`/`wait`. Both inject sink+sleep for deterministic tests. -## What's new (2026-06-19) — Semantic Screen State +### Semantic Screen State The semantic companion to the pixel diff, full stack. Full reference: [`docs/source/Eng/doc/new_features/v23_features_doc.rst`](docs/source/Eng/doc/new_features/v23_features_doc.rst). - **Snapshot & diff** — `snapshot` / `diff_snapshots` / `snapshot_screen` / `screen_changed` (`AC_screen_snapshot` / `AC_screen_diff` / `AC_screen_changed`, `ac_*`): normalize the a11y tree to `{role, name, bbox}` and report what **appeared / vanished / moved** with a human-readable summary — the feedback signal an agent needs to verify a step ("Save dialog appeared"). - **Describe the screen** — `describe_screen` (`AC_describe_screen`, `ac_describe_screen`): a compact "where am I" — role counts + interactive control labels. -## What's new (2026-06-19) — Set-of-Marks Overlay +### Set-of-Marks Overlay The standard VLM-grounding format, full stack. Full reference: [`docs/source/Eng/doc/new_features/v22_features_doc.rst`](docs/source/Eng/doc/new_features/v22_features_doc.rst). - **Number elements** — `mark_elements` / `render_marks` / `resolve_mark` (pure + Pillow): assign `1..N` to interactable elements (with centre/role/text), draw numbered red boxes on a screenshot, and map a chosen number back to its element — so a VLM picks a *number* instead of guessing pixels (directly strengthens the existing VLM locator). - **Mark-then-click loop** — `mark_screen(render_path=...)` / `mark_click(n)` (`AC_mark_screen` / `AC_mark_click`, `ac_*`): number the live a11y tree (+ optional overlay screenshot), feed marks+image to a model, then click mark `n`. -## What's new (2026-06-19) — Checkpoint & Resume +### Checkpoint & Resume Durable execution for long flows + a `py.typed` marker, full stack. Full reference: [`docs/source/Eng/doc/new_features/v21_features_doc.rst`](docs/source/Eng/doc/new_features/v21_features_doc.rst). - **Flow checkpoint & resume** — `run_resumable(actions, run_id=..., store=...)` / `CheckpointStore` (`AC_run_resumable` / `AC_checkpoint_status` / `AC_checkpoint_clear`, `ac_*`): persist step-index + variables after each step; on re-run with the same `run_id`, fast-forward past completed steps and rehydrate variables — a flow that crashes at step 400 resumes at 400, not 0. Pluggable (SQLite default), cleared on completion. - **`py.typed` marker** — ships the PEP 561 marker so Mypy/Pyright/Pylance honor AutoControl's inline type hints in downstream code (the repo's typed API was previously invisible to type checkers). -## What's new (2026-06-19) — i18n / l10n Testing +### i18n / l10n Testing Three pure-stdlib internationalization/localization testing helpers that compound, full stack. Full reference: [`docs/source/Eng/doc/new_features/v20_features_doc.rst`](docs/source/Eng/doc/new_features/v20_features_doc.rst). @@ -1107,7 +1139,7 @@ Three pure-stdlib internationalization/localization testing helpers that compoun - **Text-overflow detection** — `check_overflow(elements)` (`AC_check_overflow`, `ac_check_overflow`): flag text whose estimated width exceeds its widget bounds (the #1 l10n bug), computed from the a11y bounds AutoControl already reads. - **Catalog completeness** — `check_catalog(base, target)` (`AC_check_catalog`, `ac_check_catalog`): diff a translation catalog for missing / orphaned / empty keys and placeholder mismatches — a CI gate against blank UI. -## What's new (2026-06-19) — Data Quality +### Data Quality Three pure-stdlib data-quality helpers (the gate between `load_rows`/OCR and downstream entry), full stack. Full reference: [`docs/source/Eng/doc/new_features/v19_features_doc.rst`](docs/source/Eng/doc/new_features/v19_features_doc.rst). @@ -1115,35 +1147,35 @@ Three pure-stdlib data-quality helpers (the gate between `load_rows`/OCR and dow - **Field extraction** — `extract_fields(text, fields, patterns)` (`AC_extract_fields`, `ac_extract_fields`): named regex presets (email/url/ipv4/phone/date_iso/amount/hashtag) + custom patterns over free text / OCR blobs. - **Row masking** — `mask_rows(rows, rules)` (`AC_mask_rows`, `ac_mask_rows`): mask columns before export — `redact` / `hash` (SHA-256) / `partial` (keep last 4); complements the screenshot-only redaction. -## What's new (2026-06-19) — SBOM & Suite Sharding +### SBOM & Suite Sharding Two pure-stdlib ops tools (security + scale research angles), full stack. Full reference: [`docs/source/Eng/doc/new_features/v18_features_doc.rst`](docs/source/Eng/doc/new_features/v18_features_doc.rst). - **CycloneDX SBOM** — `build_sbom` / `write_sbom` (`AC_generate_sbom`, `ac_generate_sbom`): emit a CycloneDX 1.6 dependency SBOM (name/version/purl/license) for supply-chain compliance (EU CRA / EO 14028); `root` limits to a package's closure, `extra_components` inventories action files. No third-party dependency. - **Duration-aware suite sharding** — `shard_flows` / `merge_results` (`AC_shard_suite` / `AC_merge_results`): bin-pack flows into N shards balanced by historical per-flow duration (so the slowest worker, not test count, defines runtime), then merge per-shard reports into one rollup. -## What's new (2026-06-19) — Reactive Observer +### Reactive Observer A non-blocking screen observer (SikuliX `observe` model), full stack (facade, `AC_*`, MCP, Script Builder). Full reference: [`docs/source/Eng/doc/new_features/v17_features_doc.rst`](docs/source/Eng/doc/new_features/v17_features_doc.rst). - **`ScreenObserver`** (`AC_observe_add` / `AC_observe_remove` / `AC_observe_list` / `AC_observe_poll` / `AC_observe_start` / `AC_observe_stop`, `ac_observe_*`): register watches that fire on **appear** / **vanish** / **change** of an image/text/pixel and run a callback or action list — react to dialogs/progress/status while the main flow continues. - **Testable by design** — detection is an injectable `predicate`; transition logic is unit-tested via `poll_once()` with synthetic values. Built-in `image_predicate` / `text_predicate` / `pixel_predicate` wrap the existing locate/OCR/pixel helpers. -## What's new (2026-06-19) — WCAG 2.2 Audit +### WCAG 2.2 Audit The accessibility audit gains a WCAG 2.2 / EN 301 549 success-criterion layer, full stack (facade, `AC_*`, MCP, Script Builder). Full reference: [`docs/source/Eng/doc/new_features/v16_features_doc.rst`](docs/source/Eng/doc/new_features/v16_features_doc.rst). - **WCAG-tagged conformance audit** — `wcag_audit(level="AA")` (`AC_wcag_audit`, `ac_wcag_audit`): tags every defect with its WCAG success-criterion id/level/impact (4.1.2, 1.4.3, 1.4.10) and returns a conformance report with `by_criterion`/`by_impact` counts, filtered to A/AA/AAA — mappable to EN 301 549 for EAA compliance evidence. - **Target Size (SC 2.5.8)** — `audit_target_size(elements, min_px=24)`: new WCAG 2.2 rule flagging interactive targets smaller than 24×24 px, computed from element bounds; `tag_issue` adds SC tagging to any existing audit issue. -## What's new (2026-06-19) — Memory & Determinism +### Memory & Determinism Two pure-stdlib tools from the agent/QA research round, full stack (facade, `AC_*`, MCP, Script Builder). Full reference: [`docs/source/Eng/doc/new_features/v15_features_doc.rst`](docs/source/Eng/doc/new_features/v15_features_doc.rst). - **Agent episodic memory** — `AgentMemory` (`AC_memory_remember` / `AC_memory_recall` / `AC_memory_recent` / `AC_memory_forget` / `AC_memory_stats`, `ac_memory_*`): SQLite store of `(goal → trajectory → outcome)` episodes with keyword recall to inject past experience into the planner's context — cross-run learning, no embedding dependency. - **Deterministic run** — `DeterministicRun` / `seed_everything` (`AC_seed_everything`, `ac_seed_everything`): pin the RNG seed and freeze `time.time` for a `with` block (recording the choices for replay) to kill time/randomness flakiness; `time.monotonic` left intact so timeouts still work. -## What's new (2026-06-19) — Office I/O +### Office I/O Headless read/write for Excel/Word/PowerPoint, full stack (facade, `AC_*`, MCP, Script Builder). Optional extra: `pip install je_auto_control[office]`. Full reference: [`docs/source/Eng/doc/new_features/v14_features_doc.rst`](docs/source/Eng/doc/new_features/v14_features_doc.rst). @@ -1153,7 +1185,7 @@ Headless read/write for Excel/Word/PowerPoint, full stack (facade, `AC_*`, MCP, The backing libraries (`openpyxl`/`python-docx`/`python-pptx`) are optional — each call raises a clear error if missing, and `import je_auto_control` pulls none of them. -## What's new (2026-06-19) — Agent Toolkit +### Agent Toolkit Three pure-stdlib tools for LLM/agent-driven automation, full stack (facade, `AC_*`, MCP, Script Builder). Full reference: [`docs/source/Eng/doc/new_features/v13_features_doc.rst`](docs/source/Eng/doc/new_features/v13_features_doc.rst). @@ -1161,14 +1193,14 @@ Three pure-stdlib tools for LLM/agent-driven automation, full stack (facade, `AC - **Prompt-injection guardrail** — `assess_text` / `scan_text` / `redact_text` (`AC_guard_text`, `ac_guard_text`): scan untrusted screen/OCR text for injection patterns (instruction-override, system-prompt exfiltration, jailbreak/chat-template markers …) before feeding it to an LLM; returns `{suspicious, score, findings, redacted}`. - **A2A agent card** — `build_agent_card` / `write_agent_card` (`AC_agent_card`, `ac_agent_card`): publish an A2A agent card so other agents can discover and call AutoControl as a GUI-automation peer. -## What's new (2026-06-19) — Authoring & Debugging +### Authoring & Debugging Two pure-stdlib authoring-time tools, full stack (facade, `AC_*`, MCP, Script Builder). Full reference: [`docs/source/Eng/doc/new_features/v12_features_doc.rst`](docs/source/Eng/doc/new_features/v12_features_doc.rst). - **Element repository** — `ElementRepository` (`AC_element_save` / `AC_element_find` / `AC_element_click` / `AC_element_remove` / `AC_element_list`, `ac_element_*`): save native-UI locators under friendly names (object repository) and reuse them — `repo.click("login.submit")` instead of repeating name/role everywhere; a UI change is fixed in one place. - **Step debugger / tracer** — `FlowDebugger` (breakpoints, `step`/`continue_`/`run_to_end`, live `variables()`) and `trace_actions` (`AC_debug_trace`, `ac_debug_trace`): step through an action list one command at a time with variables persisting across steps, or get a per-step `{index, command, result}` trace (with `dry_run` to plan without running). -## What's new (2026-06-19) — Test & Tooling Batch +### Test & Tooling Batch Three pure-stdlib quality-of-life tools, full stack (facade, `AC_*`, MCP, Script Builder). Full reference: [`docs/source/Eng/doc/new_features/v11_features_doc.rst`](docs/source/Eng/doc/new_features/v11_features_doc.rst). @@ -1176,14 +1208,14 @@ Three pure-stdlib quality-of-life tools, full stack (facade, `AC_*`, MCP, Script - **MCP registry manifest** — `write_server_manifest("server.json", include_tools=True)` (`AC_mcp_manifest`, `ac_mcp_manifest`): publish a registry-valid `server.json` so MCP agents/IDEs can discover this server. - **Risk-based test selection** — `rank_flows` / `select_flows` (`AC_rank_tests` / `AC_select_tests`): rank flows by recent failures, flakiness, staleness and never-run from run history; run the riskiest first or only the top-k. -## What's new (2026-06-19) — Transactional Queue +### Transactional Queue Turn AutoControl from "run a script" into "run a robot." A SQLite-backed work queue implements the production-RPA dispatcher/performer pattern: enqueue items, process one at a time with per-item status, dedup and retry, so a run of thousands is **resumable after a crash** and parallelizable. Pure stdlib, full stack. Full reference: [`docs/source/Eng/doc/new_features/v10_features_doc.rst`](docs/source/Eng/doc/new_features/v10_features_doc.rst). - **Dispatcher/performer** — `WorkQueue.add()` enqueues (dedupes by reference); `get_next()` atomically claims the oldest item; `complete()` / `fail()` record the outcome. `AC_queue_add` / `AC_queue_next` / `AC_queue_complete` / `AC_queue_fail` / `AC_queue_stats`. - **Failure semantics** — application errors retry up to `max_retries`; **business** errors (`BusinessError` / `kind="business"`) never retry. `stats()` gives per-status counts for dashboards. -## What's new (2026-06-19) — Unattended Reliability +### Unattended Reliability Three practitioner-pain fixes for unattended / login automation, all headless and full-stack. Full reference: [`docs/source/Eng/doc/new_features/v9_features_doc.rst`](docs/source/Eng/doc/new_features/v9_features_doc.rst). @@ -1191,14 +1223,14 @@ Three practitioner-pain fixes for unattended / login automation, all headless an - **Native file dialogs** — `handle_file_dialog` (`AC_handle_file_dialog`): wait for the OS Open/Save/folder dialog, type the path, confirm — in one call, with an injectable driver. - **Locked-session guard** — `ensure_interactive_session` / `is_session_locked` (`AC_assert_session_active`): fail clearly when the workstation is locked / disconnected instead of emitting phantom clicks. -## What's new (2026-06-19) — Popup Watchdog +### Popup Watchdog The #1 cause of unattended-automation failure is an unexpected dialog the script never coded for (UAC, "session expiring", Windows Update, a modal). The popup watchdog runs a concurrent guard thread that watches for registered patterns and dismisses them independently of the main flow. Surfaced by the practitioner pain-point research as the top unattended failure cause; full stack (facade, `AC_*`, MCP, Script Builder), fully headless. Full reference: [`docs/source/Eng/doc/new_features/v8_features_doc.rst`](docs/source/Eng/doc/new_features/v8_features_doc.rst). - **Auto-dismiss popups** — `default_popup_watchdog.add_window_rule(title, action="close")` then `.start()` (`AC_watchdog_add` / `AC_watchdog_start` / `AC_watchdog_stop` / `AC_watchdog_list`): closes a matching window or presses a key (`enter`/`esc`) when it appears. - **Custom rules** — `PopupWatchdog` / `WatchdogRule` pair any detector (image/a11y/text) with a dismisser; a failing rule is logged and skipped, never killing the guard loop. -## What's new (2026-06-19) — Native UI Control +### Native UI Control Object-level desktop automation: read and drive native controls through the OS accessibility API (by name / role / app / **AutomationId**) instead of clicking pixels or OCR-ing text — far more reliable for native apps. The accessibility layer previously only listed/found/clicked; it now also acts. Ships through the full stack (facade, `AC_*`, MCP, Script Builder) with a Windows UIAutomation backend; unsupported backends raise a clear error. Full reference: [`docs/source/Eng/doc/new_features/v7_features_doc.rst`](docs/source/Eng/doc/new_features/v7_features_doc.rst). @@ -1207,7 +1239,7 @@ Object-level desktop automation: read and drive native controls through the OS a - **Read a table/grid** — `read_control_table` (`AC_read_table`): scrape a grid/list/table control into rows of cell strings — desktop data extraction without OCR. - Targets a control by `name` / `role` / `app_name` / `automation_id` (the stable Windows identifier), so it survives layout/localization changes. -## What's new (2026-06-19) +### Additional updates Two headless cores that shipped without the rest of their stack are now first-class. Both gain a facade re-export, an `AC_*` executor command, an diff --git a/docs/source/Eng/doc/new_features/v203_features_doc.rst b/docs/source/Eng/doc/new_features/v203_features_doc.rst new file mode 100644 index 00000000..d83263a0 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v203_features_doc.rst @@ -0,0 +1,49 @@ +Open Files / URLs with the Default App +====================================== + +The framework could launch a literal executable (``start_exe`` / ``shell_process``), +but not the single most common "hand off to another app" RPA step: open +``report.pdf`` with whatever app is registered for it, ``print`` a document, or +open a URL in the default browser. ``shell_open`` adds that, routed per-OS to +``os.startfile`` / ``open`` / ``xdg-open`` / ``webbrowser``. + +* :func:`plan_open` — pure planner: classify the target (URL vs file path), + validate it (URL scheme allow-list; ``realpath`` for files) and return the + dispatch descriptor, +* :func:`open_path` — run the plan through an injectable ``opener`` sink (the real + OS call by default). + +Pure stdlib; the dispatch logic is unit-testable without launching anything via +the injectable ``opener``. Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import open_path, plan_open + + open_path("report.pdf") # default PDF viewer + open_path("invoice.pdf", verb="print") # print it + open_path("https://example.com") # default browser + + plan_open("https://example.com") + # {"kind": "url", "scheme": "https", "target": "...", "backend": "webbrowser", + # "verb": "open"} + plan_open("report.pdf") + # {"kind": "file", "target": "", "backend": "startfile", ...} + +A ``scheme://`` target (or ``mailto:`` / ``tel:``) is opened as a URL — only the +allow-listed schemes (``http`` / ``https`` / ``ftp`` / ``file`` / ``mailto`` / +``tel``) are accepted, anything else raises ``ValueError``. Everything else is a +file path (a Windows drive like ``C:\\…`` is correctly treated as a path, not a +scheme) and is ``realpath``-resolved. ``verb`` (``open`` / ``print`` / ``edit``) +applies to files on Windows. + +Executor commands +----------------- + +``AC_open_path`` (``target`` / ``verb`` → ``{opened}``) and ``AC_plan_open`` +(``target`` / ``verb`` → the plan). They are exposed as the matching ``ac_*`` MCP +tools (``open_path`` side-effect-only, ``plan_open`` read-only) and as Script +Builder commands under **Shell**. diff --git a/docs/source/Eng/doc/new_features/v204_features_doc.rst b/docs/source/Eng/doc/new_features/v204_features_doc.rst new file mode 100644 index 00000000..ba9de3f6 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v204_features_doc.rst @@ -0,0 +1,59 @@ +Idle Detection + Keep the Machine Awake +======================================= + +Long unattended automation runs get derailed two ways: the screensaver / power +policy sleeps the box mid-run, or the run should hold while a human is actively +using the machine. The framework had neither signal. ``idle_keepawake`` adds +both, behind injectable seams so all logic is testable without touching the OS. + +* :func:`idle_seconds` / :func:`is_idle` — seconds since the last user keyboard / + mouse input (``GetLastInputInfo`` on Windows), through an injectable ``probe``. +* :func:`plan_keep_awake` — pure planner describing which wake flags a request + maps to. +* :func:`keep_awake` — scoped context manager that keeps the machine awake for + the duration of a ``with`` block, restoring the prior state on exit. +* :func:`keep_awake_on` / :func:`allow_sleep` — a process-global on / off pair + for JSON action flows. + +All three keep-awake entry points apply the plan through an injectable ``driver`` +(``SetThreadExecutionState`` on Windows, ``caffeinate`` on macOS, +``systemd-inhibit`` on Linux by default). Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import ( + idle_seconds, is_idle, keep_awake, keep_awake_on, allow_sleep, + ) + + idle_seconds() # e.g. 3.4 — seconds since last input + is_idle(300) # True once nobody has touched the machine for 5 min + + # Scoped: keep awake only while a long step runs + with keep_awake(): + run_long_batch() + + # Flow-style: on at the start, off at the end + keep_awake_on(display=True, system=True) + try: + run_long_batch() + finally: + allow_sleep() + +:func:`is_idle` is the gate for "only run when the user has stepped away"; +:func:`keep_awake` / :func:`keep_awake_on` stop the display and system sleeping +so an overnight run is not interrupted. ``display=False`` keeps the system awake +but lets the screen blank (battery-friendly for headless boxes). + +Executor commands +----------------- + +``AC_idle_seconds`` (→ ``{idle_seconds}``), ``AC_is_idle`` (``threshold`` → +``{idle, idle_seconds}``), ``AC_plan_keep_awake`` (``display`` / ``system`` → the +plan), ``AC_keep_awake_on`` (``display`` / ``system`` → the active plan) and +``AC_allow_sleep`` (→ ``{released}``). They are exposed as the matching ``ac_*`` +MCP tools (reads read-only, keep-awake on/off side-effect-only) and as Script +Builder commands under **Shell**. The :func:`keep_awake` context manager is the +Python-API surface for scoped use. diff --git a/docs/source/Eng/doc/new_features/v205_features_doc.rst b/docs/source/Eng/doc/new_features/v205_features_doc.rst new file mode 100644 index 00000000..13176912 --- /dev/null +++ b/docs/source/Eng/doc/new_features/v205_features_doc.rst @@ -0,0 +1,44 @@ +Resolve the App Registered for a File Type +========================================== + +:func:`open_path` (``shell_open``) opens a file with whatever app is registered +for it; ``file_assoc`` answers the inverse, read-only question — *which* app is +that? Given ``report.pdf`` (or a bare ``.pdf`` / ``pdf``) it returns the +registered executable, the friendly app name, the open command line and the MIME +content type, via the Windows ``AssocQueryStringW`` shell API. + +* :func:`normalize_ext` — pure helper turning a path / ``.ext`` / bare ``ext`` + into a lowercased ``.ext``, +* :func:`file_association` — run the lookup through an injectable ``resolver`` + seam (the real shell API by default). + +The assembly logic is unit-testable without Windows via the injectable +``resolver``. Imports no ``PySide6``. + +Headless API +------------ + +.. code-block:: python + + from je_auto_control import file_association, normalize_ext + + normalize_ext("report.PDF") # ".pdf" + normalize_ext("archive.tar.gz") # ".gz" + + file_association("report.pdf") + # {"ext": ".pdf", "command": "...AcroRd32.exe \"%1\"", + # "exe": "...AcroRd32.exe", "friendly": "Adobe Acrobat", + # "content_type": "application/pdf"} + +The app fields are ``None`` when nothing is registered for the type. This is the +natural companion to :func:`open_path`: ``file_association`` tells you *what* +would open a file (assert "PDFs open in Acrobat, not the browser"), and +``open_path`` actually opens it. The live lookup uses the Windows shell API; on +other platforms pass your own ``resolver``. + +Executor commands +----------------- + +``AC_normalize_ext`` (``target`` → ``{ext}``, pure) and ``AC_file_association`` +(``target`` → the association dict). They are exposed as the matching ``ac_*`` +MCP tools (both read-only) and as Script Builder commands under **Shell**. diff --git a/docs/source/Zh/doc/new_features/v203_features_doc.rst b/docs/source/Zh/doc/new_features/v203_features_doc.rst new file mode 100644 index 00000000..6978a170 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v203_features_doc.rst @@ -0,0 +1,43 @@ +以預設程式開啟檔案 / URL +======================== + +框架原本能啟動字面執行檔(``start_exe`` / ``shell_process``),卻無法做最常見的「交接給另一個應用程式」 +RPA 步驟:用註冊的應用程式開啟 ``report.pdf``、``print`` 一份文件,或在預設瀏覽器開啟 URL。 +``shell_open`` 補上這點,依作業系統路由到 ``os.startfile`` / ``open`` / ``xdg-open`` / +``webbrowser``。 + +* :func:`plan_open` ——純 planner:分類目標(URL 或檔案路徑)、驗證(URL scheme 白名單;檔案用 + ``realpath``)並回傳分派描述子, +* :func:`open_path` ——透過可注入的 ``opener`` 接縫執行計畫(預設為真正的 OS 呼叫)。 + +純標準庫;透過可注入的 ``opener``,分派邏輯可在不真正開啟任何東西的情況下單元測試。不匯入 +``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import open_path, plan_open + + open_path("report.pdf") # 預設 PDF 檢視器 + open_path("invoice.pdf", verb="print") # 列印 + open_path("https://example.com") # 預設瀏覽器 + + plan_open("https://example.com") + # {"kind": "url", "scheme": "https", "target": "...", "backend": "webbrowser", + # "verb": "open"} + plan_open("report.pdf") + # {"kind": "file", "target": "", "backend": "startfile", ...} + +``scheme://`` 目標(或 ``mailto:`` / ``tel:``)會以 URL 開啟——只接受白名單 scheme +(``http`` / ``https`` / ``ftp`` / ``file`` / ``mailto`` / ``tel``),其他則拋出 ``ValueError``。 +其餘皆視為檔案路徑(Windows 磁碟代號如 ``C:\\…`` 會正確視為路徑而非 scheme)並以 ``realpath`` +解析。``verb``(``open`` / ``print`` / ``edit``)在 Windows 上套用於檔案。 + +執行器指令 +---------- + +``AC_open_path``(``target`` / ``verb`` → ``{opened}``)與 ``AC_plan_open``(``target`` / +``verb`` → 計畫)。皆以對應的 ``ac_*`` MCP 工具(``open_path`` 為僅副作用、``plan_open`` 為唯讀) +及 Script Builder 指令(位於 **Shell** 分類下)形式提供。 diff --git a/docs/source/Zh/doc/new_features/v204_features_doc.rst b/docs/source/Zh/doc/new_features/v204_features_doc.rst new file mode 100644 index 00000000..676c9555 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v204_features_doc.rst @@ -0,0 +1,53 @@ +閒置偵測 + 保持機器清醒 +======================= + +長時間無人值守的自動化執行常因兩種情況中斷:螢幕保護 / 電源原則在執行中途讓機器睡眠,或是當有人正在 +使用機器時執行應該暫停。框架原本兩種訊號都沒有。``idle_keepawake`` 補上這兩者,並以可注入接縫實作, +所有邏輯都能在不碰作業系統的情況下測試。 + +* :func:`idle_seconds` / :func:`is_idle` ——距離使用者上次鍵盤 / 滑鼠輸入的秒數(Windows 上用 + ``GetLastInputInfo``),透過可注入的 ``probe`` 取得。 +* :func:`plan_keep_awake` ——純 planner,描述請求對應到哪些清醒旗標。 +* :func:`keep_awake` ——具範圍的 context manager,在 ``with`` 區塊期間保持機器清醒,離開時還原先前狀態。 +* :func:`keep_awake_on` / :func:`allow_sleep` ——供 JSON 動作流程使用的行程全域開 / 關配對。 + +三個 keep-awake 入口皆透過可注入的 ``driver`` 套用計畫(預設 Windows 用 +``SetThreadExecutionState``、macOS 用 ``caffeinate``、Linux 用 ``systemd-inhibit``)。不匯入 +``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import ( + idle_seconds, is_idle, keep_awake, keep_awake_on, allow_sleep, + ) + + idle_seconds() # 例如 3.4 ——距離上次輸入的秒數 + is_idle(300) # 沒人碰機器滿 5 分鐘後回傳 True + + # 具範圍:只在長步驟執行時保持清醒 + with keep_awake(): + run_long_batch() + + # 流程式:開始時開、結束時關 + keep_awake_on(display=True, system=True) + try: + run_long_batch() + finally: + allow_sleep() + +:func:`is_idle` 是「只在使用者離開時才執行」的判斷閘;:func:`keep_awake` / +:func:`keep_awake_on` 阻止螢幕與系統睡眠,讓整夜執行不被打斷。``display=False`` 會保持系統清醒但允許 +螢幕變黑(對無頭機器較省電)。 + +執行器指令 +---------- + +``AC_idle_seconds``(→ ``{idle_seconds}``)、``AC_is_idle``(``threshold`` → +``{idle, idle_seconds}``)、``AC_plan_keep_awake``(``display`` / ``system`` → 計畫)、 +``AC_keep_awake_on``(``display`` / ``system`` → 生效中的計畫)與 ``AC_allow_sleep`` +(→ ``{released}``)。皆以對應的 ``ac_*`` MCP 工具(讀取為唯讀、keep-awake 開 / 關為僅副作用) +及 Script Builder 指令(位於 **Shell** 分類下)形式提供。:func:`keep_awake` context manager +則是具範圍使用的 Python API 介面。 diff --git a/docs/source/Zh/doc/new_features/v205_features_doc.rst b/docs/source/Zh/doc/new_features/v205_features_doc.rst new file mode 100644 index 00000000..2911bec4 --- /dev/null +++ b/docs/source/Zh/doc/new_features/v205_features_doc.rst @@ -0,0 +1,37 @@ +解析檔案類型已註冊的應用程式 +============================ + +:func:`open_path`(``shell_open``)用註冊的應用程式開啟檔案;``file_assoc`` 回答相反的唯讀問題—— +那個應用程式是「哪一個」?給定 ``report.pdf``(或裸的 ``.pdf`` / ``pdf``),它會透過 Windows +``AssocQueryStringW`` shell API 回傳已註冊的執行檔、友善應用程式名稱、開啟命令列與 MIME 內容類型。 + +* :func:`normalize_ext` ——純輔助函式,把路徑 / ``.ext`` / 裸 ``ext`` 轉成小寫的 ``.ext``, +* :func:`file_association` ——透過可注入的 ``resolver`` 接縫執行查詢(預設為真正的 shell API)。 + +組裝邏輯可透過可注入的 ``resolver`` 在非 Windows 上單元測試。不匯入 ``PySide6``。 + +無頭 API +-------- + +.. code-block:: python + + from je_auto_control import file_association, normalize_ext + + normalize_ext("report.PDF") # ".pdf" + normalize_ext("archive.tar.gz") # ".gz" + + file_association("report.pdf") + # {"ext": ".pdf", "command": "...AcroRd32.exe \"%1\"", + # "exe": "...AcroRd32.exe", "friendly": "Adobe Acrobat", + # "content_type": "application/pdf"} + +當該類型未註冊任何應用程式時,應用程式欄位為 ``None``。這是 :func:`open_path` 的自然搭檔: +``file_association`` 告訴你「什麼」會開啟檔案(可斷言「PDF 用 Acrobat 開,不是瀏覽器」),而 +``open_path`` 實際開啟它。即時查詢使用 Windows shell API;其他平台請傳入自己的 ``resolver``。 + +執行器指令 +---------- + +``AC_normalize_ext``(``target`` → ``{ext}``,純)與 ``AC_file_association`` +(``target`` → 關聯 dict)。皆以對應的 ``ac_*`` MCP 工具(皆唯讀)及 Script Builder 指令 +(位於 **Shell** 分類下)形式提供。 diff --git a/je_auto_control/__init__.py b/je_auto_control/__init__.py index 6b8fcbc8..c360611f 100644 --- a/je_auto_control/__init__.py +++ b/je_auto_control/__init__.py @@ -91,6 +91,15 @@ ) # Reactive UIA event waits (focus-changed) from je_auto_control.utils.ax_events import wait_for_focus_change +# Open a file with its default app / a URL in the default browser +from je_auto_control.utils.shell_open import open_path, plan_open +# Detect user-idle time and keep the machine awake during unattended runs +from je_auto_control.utils.idle_keepawake import ( + allow_sleep, idle_seconds, is_idle, keep_awake, keep_awake_on, + plan_keep_awake, +) +# Resolve which application is registered to open a given file type +from je_auto_control.utils.file_assoc import file_association, normalize_ext # Rich clipboard formats — RTF + CSV/TSV codecs and Windows get / set from je_auto_control.utils.clipboard_rich_formats import ( build_rtf, csv_to_rows, get_clipboard_csv, get_clipboard_rtf, rows_to_csv, @@ -1700,6 +1709,10 @@ def start_autocontrol_gui(*args, **kwargs): "legacy_info", "legacy_default_action", "get_selection", "list_views", "set_view", "wait_for_focus_change", + "plan_open", "open_path", + "idle_seconds", "is_idle", "plan_keep_awake", + "keep_awake", "keep_awake_on", "allow_sleep", + "normalize_ext", "file_association", "build_rtf", "rtf_to_text", "rows_to_csv", "csv_to_rows", "set_clipboard_rtf", "get_clipboard_rtf", "set_clipboard_csv", "get_clipboard_csv", diff --git a/je_auto_control/gui/script_builder/command_schema.py b/je_auto_control/gui/script_builder/command_schema.py index f5a8811a..ba1809b2 100644 --- a/je_auto_control/gui/script_builder/command_schema.py +++ b/je_auto_control/gui/script_builder/command_schema.py @@ -4250,6 +4250,74 @@ def _add_work_queue_specs(specs: List[CommandSpec]) -> None: "AC_shell_command", "Shell", "Shell Command", fields=(FieldSpec("shell_command", FieldType.STRING),), )) + specs.append(CommandSpec( + "AC_open_path", "Shell", "Open File / URL (default app)", + fields=( + FieldSpec("target", FieldType.STRING, + placeholder="report.pdf or https://example.com"), + FieldSpec("verb", FieldType.STRING, optional=True, default="open", + placeholder="open / print / edit"), + ), + description="Open a file with its default app, or a URL in the browser.", + )) + specs.append(CommandSpec( + "AC_plan_open", "Shell", "Plan Open (classify)", + fields=( + FieldSpec("target", FieldType.STRING), + FieldSpec("verb", FieldType.STRING, optional=True, default="open"), + ), + description="Classify how a file/URL would be opened (pure, no launch).", + )) + specs.append(CommandSpec( + "AC_idle_seconds", "Shell", "Idle Seconds", + fields=(), + description="Seconds since the last user keyboard / mouse input.", + )) + specs.append(CommandSpec( + "AC_is_idle", "Shell", "Is User Idle", + fields=( + FieldSpec("threshold", FieldType.FLOAT, default=300.0, + placeholder="idle seconds threshold"), + ), + description="True if the user has been idle for >= threshold seconds.", + )) + specs.append(CommandSpec( + "AC_plan_keep_awake", "Shell", "Plan Keep Awake", + fields=( + FieldSpec("display", FieldType.BOOL, optional=True, default=True), + FieldSpec("system", FieldType.BOOL, optional=True, default=True), + ), + description="Describe a keep-awake request (pure, no OS call).", + )) + specs.append(CommandSpec( + "AC_keep_awake_on", "Shell", "Keep Machine Awake", + fields=( + FieldSpec("display", FieldType.BOOL, optional=True, default=True), + FieldSpec("system", FieldType.BOOL, optional=True, default=True), + ), + description="Keep the machine awake until Allow Sleep is run.", + )) + specs.append(CommandSpec( + "AC_allow_sleep", "Shell", "Allow Machine to Sleep", + fields=(), + description="Release a previously-started keep-awake.", + )) + specs.append(CommandSpec( + "AC_normalize_ext", "Shell", "Normalize Extension", + fields=( + FieldSpec("target", FieldType.STRING, + placeholder="report.pdf or .pdf or pdf"), + ), + description="Lowercased file extension (with dot) of a path / ext.", + )) + specs.append(CommandSpec( + "AC_file_association", "Shell", "File Association (default app)", + fields=( + FieldSpec("target", FieldType.STRING, + placeholder="report.pdf or .pdf"), + ), + description="Which app is registered to open a file type (Windows).", + )) specs.append(CommandSpec( "AC_take_golden", "Report", "Capture Golden Image", fields=(FieldSpec("path", FieldType.FILE_PATH),), diff --git a/je_auto_control/utils/executor/action_executor.py b/je_auto_control/utils/executor/action_executor.py index 59dd56f7..9f8cf9b3 100644 --- a/je_auto_control/utils/executor/action_executor.py +++ b/je_auto_control/utils/executor/action_executor.py @@ -2609,6 +2609,61 @@ def _wait_for_focus_change(timeout: Any = 5.0) -> Dict[str, Any]: return {"changed": element is not None, "element": element} +def _plan_open(target: str, verb: str = "open") -> Dict[str, Any]: + """Adapter: classify how a file path / URL would be opened (pure).""" + from je_auto_control.utils.shell_open import plan_open + return plan_open(str(target), verb=str(verb)) + + +def _open_path(target: str, verb: str = "open") -> Dict[str, Any]: + """Adapter: open a file with its default app / a URL in the browser.""" + from je_auto_control.utils.shell_open import open_path + return {"opened": bool(open_path(str(target), verb=str(verb)))} + + +def _idle_seconds() -> Dict[str, Any]: + """Adapter: seconds since the last user input.""" + from je_auto_control.utils.idle_keepawake import idle_seconds + return {"idle_seconds": float(idle_seconds())} + + +def _is_idle(threshold: Any) -> Dict[str, Any]: + """Adapter: whether the user has been idle for >= ``threshold`` seconds.""" + from je_auto_control.utils.idle_keepawake import idle_seconds, is_idle + seconds = float(threshold) + return {"idle": bool(is_idle(seconds)), "idle_seconds": idle_seconds()} + + +def _plan_keep_awake(display: Any = True, system: Any = True) -> Dict[str, Any]: + """Adapter: describe a keep-awake request (pure, no OS call).""" + from je_auto_control.utils.idle_keepawake import plan_keep_awake + return plan_keep_awake(display=bool(display), system=bool(system)) + + +def _keep_awake_on(display: Any = True, system: Any = True) -> Dict[str, Any]: + """Adapter: keep the machine awake until ``AC_allow_sleep``.""" + from je_auto_control.utils.idle_keepawake import keep_awake_on + return keep_awake_on(display=bool(display), system=bool(system)) + + +def _allow_sleep() -> Dict[str, Any]: + """Adapter: release a previously-started keep-awake.""" + from je_auto_control.utils.idle_keepawake import allow_sleep + return {"released": bool(allow_sleep())} + + +def _normalize_ext(target: str) -> Dict[str, Any]: + """Adapter: the lowercased extension of a path / bare ext (pure).""" + from je_auto_control.utils.file_assoc import normalize_ext + return {"ext": normalize_ext(str(target))} + + +def _file_association(target: str) -> Dict[str, Any]: + """Adapter: the app registered to open ``target``'s file type.""" + from je_auto_control.utils.file_assoc import file_association + return file_association(str(target)) + + def _get_control_text(name: Optional[str] = None, role: Optional[str] = None, app_name: Optional[str] = None, automation_id: Optional[str] = None) -> Dict[str, Any]: @@ -6599,6 +6654,15 @@ def __init__(self): "AC_list_views": _list_views, "AC_set_view": _set_view, "AC_wait_for_focus_change": _wait_for_focus_change, + "AC_plan_open": _plan_open, + "AC_open_path": _open_path, + "AC_idle_seconds": _idle_seconds, + "AC_is_idle": _is_idle, + "AC_plan_keep_awake": _plan_keep_awake, + "AC_keep_awake_on": _keep_awake_on, + "AC_allow_sleep": _allow_sleep, + "AC_normalize_ext": _normalize_ext, + "AC_file_association": _file_association, "AC_get_control_text": _get_control_text, "AC_find_control_text": _find_control_text, "AC_select_control_text": _select_control_text, diff --git a/je_auto_control/utils/file_assoc/__init__.py b/je_auto_control/utils/file_assoc/__init__.py new file mode 100644 index 00000000..1b8241f5 --- /dev/null +++ b/je_auto_control/utils/file_assoc/__init__.py @@ -0,0 +1,6 @@ +"""Resolve which application is registered to open a given file type.""" +from je_auto_control.utils.file_assoc.file_assoc import ( + file_association, normalize_ext, +) + +__all__ = ["normalize_ext", "file_association"] diff --git a/je_auto_control/utils/file_assoc/file_assoc.py b/je_auto_control/utils/file_assoc/file_assoc.py new file mode 100644 index 00000000..245827d3 --- /dev/null +++ b/je_auto_control/utils/file_assoc/file_assoc.py @@ -0,0 +1,86 @@ +"""Resolve which application is registered to open a given file type. + +:func:`shell_open` opens a file with whatever app is registered for it; +``file_assoc`` answers the inverse, read-only question — *which* app is that? +Given ``report.pdf`` (or a bare ``.pdf`` / ``pdf``) it returns the registered +executable, the friendly app name, the open command line and the MIME content +type, via the Windows ``AssocQueryStringW`` shell API. + +:func:`normalize_ext` is a pure helper (path / bare-extension -> ``.ext``), +fully unit-testable. :func:`file_association` runs the lookup through an +injectable ``resolver`` seam (the real shell API by default), so the assembly +logic is testable without Windows. Imports no ``PySide6``. +""" +import ctypes +import os +import sys +from typing import Any, Callable, Dict, Optional + +# ASSOCSTR query ids (shlwapi AssocQueryStringW) -> result-dict keys. +_ASSOC_FIELDS = { + "command": 1, # ASSOCSTR_COMMAND + "exe": 2, # ASSOCSTR_EXECUTABLE + "friendly": 4, # ASSOCSTR_FRIENDLYAPPNAME + "content_type": 12, # ASSOCSTR_CONTENTTYPE +} + +# A resolver: ext (".pdf") -> {command, exe, friendly, content_type}. +AssocResolver = Callable[[str], Dict[str, Any]] + + +def normalize_ext(target: str) -> str: + """Return the lowercased extension (with leading dot) of a path or ext. + + Accepts ``report.pdf``, ``C:\\x\\y.PDF``, ``.pdf`` or bare ``pdf``. Raises + ``ValueError`` for an empty target or one with no resolvable extension. + """ + text = str(target).strip() + if not text: + raise ValueError("target is empty") + _, ext = os.path.splitext(os.path.basename(text)) + if not ext: + ext = text if text.startswith(".") else "." + text + ext = ext.lower() + if len(ext) < 2 or any(char in ext for char in "/\\ \t"): + raise ValueError(f"no file extension in target: {target!r}") + return ext + + +def _assoc_query(ext: str, assoc_str: int) -> Optional[str]: + """Run one AssocQueryStringW lookup; return the string or None.""" + shlwapi = ctypes.windll.shlwapi + size = ctypes.c_ulong(0) + shlwapi.AssocQueryStringW(0, assoc_str, ext, None, None, ctypes.byref(size)) + if size.value == 0: + return None + buf = ctypes.create_unicode_buffer(size.value) + result = shlwapi.AssocQueryStringW(0, assoc_str, ext, None, buf, + ctypes.byref(size)) + return buf.value if result == 0 else None + + +def _default_resolver(ext: str) -> Dict[str, Any]: + """Resolve ``ext``'s registered app via the Windows shell API.""" + if not sys.platform.startswith("win"): + raise RuntimeError( + "file association lookup is Windows-only; pass resolver=") + return {key: _assoc_query(ext, assoc_str) + for key, assoc_str in _ASSOC_FIELDS.items()} + + +def file_association(target: str, *, + resolver: Optional[AssocResolver] = None) -> Dict[str, Any]: + """Return the app registered to open ``target``'s file type. + + Returns ``{ext, command, exe, friendly, content_type}`` (the app fields are + None when nothing is registered). Pass ``resolver`` (``ext -> dict``) to + intercept the lookup in tests; the default uses the Windows shell API. + """ + ext = normalize_ext(target) + resolve = resolver if resolver is not None else _default_resolver + info = resolve(ext) + return {"ext": ext, + "command": info.get("command"), + "exe": info.get("exe"), + "friendly": info.get("friendly"), + "content_type": info.get("content_type")} diff --git a/je_auto_control/utils/idle_keepawake/__init__.py b/je_auto_control/utils/idle_keepawake/__init__.py new file mode 100644 index 00000000..2f860ec2 --- /dev/null +++ b/je_auto_control/utils/idle_keepawake/__init__.py @@ -0,0 +1,10 @@ +"""Detect user-idle time and keep the machine awake during unattended runs.""" +from je_auto_control.utils.idle_keepawake.idle_keepawake import ( + allow_sleep, idle_seconds, is_idle, keep_awake, keep_awake_on, + plan_keep_awake, +) + +__all__ = [ + "idle_seconds", "is_idle", "plan_keep_awake", + "keep_awake", "keep_awake_on", "allow_sleep", +] diff --git a/je_auto_control/utils/idle_keepawake/idle_keepawake.py b/je_auto_control/utils/idle_keepawake/idle_keepawake.py new file mode 100644 index 00000000..9c5b0700 --- /dev/null +++ b/je_auto_control/utils/idle_keepawake/idle_keepawake.py @@ -0,0 +1,202 @@ +"""Detect how long the user has been idle, and keep the machine awake. + +Long unattended automation runs get derailed two ways: the screensaver / power +policy sleeps the box mid-run, or the run should hold while a human is actively +using the machine. The framework had neither signal. ``idle_keepawake`` adds +both, behind injectable seams so all logic is testable without touching the OS: + +* :func:`idle_seconds` / :func:`is_idle` report seconds since the last user + keyboard / mouse input (``GetLastInputInfo`` on Windows) through an injectable + ``probe``. +* :func:`plan_keep_awake` is a pure planner describing which wake flags a request + maps to. :func:`keep_awake` is a scoped context manager, and + :func:`keep_awake_on` / :func:`allow_sleep` are a process-global on / off pair + for JSON action flows — both apply the plan through an injectable ``driver`` + (``SetThreadExecutionState`` / ``caffeinate`` / ``systemd-inhibit`` by + default) and restore the prior state on release. + +Imports no ``PySide6``. +""" +import ctypes +import sys +import threading +from contextlib import contextmanager +from typing import Any, Callable, Dict, Iterator, List, Optional + +# Windows SetThreadExecutionState flags. +_ES_CONTINUOUS = 0x80000000 +_ES_SYSTEM_REQUIRED = 0x00000001 +_ES_DISPLAY_REQUIRED = 0x00000002 + +# A probe: returns seconds since the last user input. +IdleProbe = Callable[[], float] +# A driver: applies a keep-awake plan and returns a zero-arg release callable. +KeepAwakeDriver = Callable[[Dict[str, Any]], Callable[[], None]] + +# Process-global keep-awake state for the on / off serializable surface: holds +# the active release callable (0 or 1 entry), swapped atomically under the lock. +_LOCK = threading.Lock() +_ACTIVE: List[Callable[[], None]] = [] + + +class _LastInputInfo(ctypes.Structure): + """Win32 ``LASTINPUTINFO`` — tick of the last input via GetLastInputInfo.""" + + _fields_ = [("cbSize", ctypes.c_uint), ("dwTime", ctypes.c_uint)] + + +def _default_probe() -> float: + """Return seconds since last input on Windows; raise elsewhere.""" + if not sys.platform.startswith("win"): + raise RuntimeError( + "idle detection has no OS probe on this platform; pass probe=") + info = _LastInputInfo(ctypes.sizeof(_LastInputInfo), 0) + user32 = ctypes.windll.user32 + kernel32 = ctypes.windll.kernel32 + if not user32.GetLastInputInfo(ctypes.byref(info)): + raise RuntimeError("GetLastInputInfo failed") + millis = (kernel32.GetTickCount() - info.dwTime) & 0xFFFFFFFF + return max(0.0, millis / 1000.0) + + +def idle_seconds(*, probe: Optional[IdleProbe] = None) -> float: + """Return seconds since the last user keyboard / mouse input. + + Pass ``probe`` (a ``() -> float``) to supply the reading in tests; the + default queries the OS (Windows only). Never negative. + """ + source = probe if probe is not None else _default_probe + value = float(source()) + return value if value >= 0.0 else 0.0 + + +def is_idle(threshold_seconds: float, *, + probe: Optional[IdleProbe] = None) -> bool: + """Return True if the user has been idle for at least ``threshold_seconds``. + + Raises ``ValueError`` for a negative threshold. + """ + if threshold_seconds < 0: + raise ValueError("threshold_seconds must be non-negative") + return idle_seconds(probe=probe) >= float(threshold_seconds) + + +def _keepawake_backend() -> str: + if sys.platform.startswith("win"): + return "SetThreadExecutionState" + if sys.platform == "darwin": + return "caffeinate" + return "systemd-inhibit" + + +def plan_keep_awake(*, display: bool = True, + system: bool = True) -> Dict[str, Any]: + """Describe a keep-awake request (pure, no OS call). + + Returns ``{display, system, backend, flags}`` where ``flags`` is the Windows + ``SetThreadExecutionState`` bitmask the request maps to. Raises + ``ValueError`` if neither the system nor the display is to be kept awake. + """ + if not display and not system: + raise ValueError("keep_awake must keep the system or display awake") + flags = _ES_CONTINUOUS + if system: + flags |= _ES_SYSTEM_REQUIRED + if display: + flags |= _ES_DISPLAY_REQUIRED + return {"display": bool(display), "system": bool(system), + "backend": _keepawake_backend(), "flags": flags} + + +def _win_keep_awake(flags: int) -> Callable[[], None]: + kernel32 = ctypes.windll.kernel32 + kernel32.SetThreadExecutionState(ctypes.c_uint(flags)) + + def _release() -> None: + kernel32.SetThreadExecutionState(ctypes.c_uint(_ES_CONTINUOUS)) + + return _release + + +def _proc_keep_awake(argv: List[str]) -> Callable[[], None]: + import subprocess # nosec B404 # reason: fixed argv, no shell + # fixed argv list, no shell — injection-safe + proc = subprocess.Popen(argv) # nosec B603 # nosemgrep + + def _release() -> None: + proc.terminate() + + return _release + + +def _caffeinate_argv(plan: Dict[str, Any]) -> List[str]: + argv = ["caffeinate", "-i"] + if plan["system"]: + argv.append("-s") + if plan["display"]: + argv.append("-d") + return argv + + +def _systemd_argv(plan: Dict[str, Any]) -> List[str]: + what = "idle:sleep" if plan["display"] else "sleep" + return ["systemd-inhibit", f"--what={what}", + "--why=AutoControl unattended run", "sleep", "infinity"] + + +def _default_driver(plan: Dict[str, Any]) -> Callable[[], None]: + builders = { + "SetThreadExecutionState": lambda: _win_keep_awake(plan["flags"]), + "caffeinate": lambda: _proc_keep_awake(_caffeinate_argv(plan)), + "systemd-inhibit": lambda: _proc_keep_awake(_systemd_argv(plan)), + } + return builders[plan["backend"]]() + + +@contextmanager +def keep_awake(*, display: bool = True, system: bool = True, + driver: Optional[KeepAwakeDriver] = None + ) -> Iterator[Dict[str, Any]]: + """Scoped context manager keeping the machine (and display) awake. + + Inside the ``with`` block the system will not sleep / screensave; the prior + state is restored on exit. Pass ``driver`` (``plan -> release_callable``) to + intercept the OS call in tests. Yields the active plan. + """ + plan = plan_keep_awake(display=display, system=system) + acquire = driver if driver is not None else _default_driver + release = acquire(plan) + try: + yield plan + finally: + if callable(release): + release() + + +def keep_awake_on(*, display: bool = True, system: bool = True, + driver: Optional[KeepAwakeDriver] = None) -> Dict[str, Any]: + """Start keeping the machine awake until :func:`allow_sleep` is called. + + Process-global and lock-guarded; a second call replaces the prior request. + Pass ``driver`` to intercept the OS call in tests. Returns the active plan. + """ + plan = plan_keep_awake(display=display, system=system) + acquire = driver if driver is not None else _default_driver + release = acquire(plan) + with _LOCK: + previous = _ACTIVE[:] + _ACTIVE.clear() + _ACTIVE.append(release) + for old in previous: + old() + return plan + + +def allow_sleep() -> bool: + """Release a previously-started keep-awake. True if one was active.""" + with _LOCK: + pending = _ACTIVE[:] + _ACTIVE.clear() + for release in pending: + release() + return bool(pending) diff --git a/je_auto_control/utils/mcp_server/tools/_factories.py b/je_auto_control/utils/mcp_server/tools/_factories.py index de0c8f7c..7a9b95cd 100644 --- a/je_auto_control/utils/mcp_server/tools/_factories.py +++ b/je_auto_control/utils/mcp_server/tools/_factories.py @@ -2084,6 +2084,90 @@ def process_and_shell_tools() -> List[MCPTool]: handler=h.shell_command, annotations=DESTRUCTIVE, ), + MCPTool( + name="ac_open_path", + description=("Open a file with its OS-registered default app (or a " + "'verb' like print), or a URL in the default browser. " + "'target' is a path or URL. Returns {opened}."), + input_schema=schema({"target": {"type": "string"}, + "verb": {"type": "string"}}, + required=["target"]), + handler=h.open_path, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_plan_open", + description=("Classify how a file path / URL would be opened without " + "opening it (pure): {kind, target, backend, verb} " + "(+scheme for URLs). Rejects non-allow-listed schemes."), + input_schema=schema({"target": {"type": "string"}, + "verb": {"type": "string"}}, + required=["target"]), + handler=h.plan_open, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_idle_seconds", + description=("Seconds since the last user keyboard / mouse input " + "(GetLastInputInfo on Windows). Returns {idle_seconds}."), + input_schema=schema({}), + handler=h.idle_seconds, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_is_idle", + description=("Whether the user has been idle for at least " + "'threshold' seconds. Returns {idle, idle_seconds}."), + input_schema=schema({"threshold": {"type": "number"}}, + required=["threshold"]), + handler=h.is_idle, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_plan_keep_awake", + description=("Describe a keep-awake request without applying it " + "(pure): {display, system, backend, flags}."), + input_schema=schema({"display": {"type": "boolean"}, + "system": {"type": "boolean"}}), + handler=h.plan_keep_awake, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_keep_awake_on", + description=("Keep the machine (and 'display') awake until " + "ac_allow_sleep is called. Returns the active plan."), + input_schema=schema({"display": {"type": "boolean"}, + "system": {"type": "boolean"}}), + handler=h.keep_awake_on, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_allow_sleep", + description=("Release a previously-started keep-awake so the machine " + "can sleep again. Returns {released}."), + input_schema=schema({}), + handler=h.allow_sleep, + annotations=SIDE_EFFECT_ONLY, + ), + MCPTool( + name="ac_normalize_ext", + description=("Return the lowercased file extension (with leading " + "dot) of a path or bare extension (pure): {ext}."), + input_schema=schema({"target": {"type": "string"}}, + required=["target"]), + handler=h.normalize_ext, + annotations=READ_ONLY, + ), + MCPTool( + name="ac_file_association", + description=("Which application is registered to open a file type. " + "'target' is a path / .ext / bare ext. Returns {ext, " + "command, exe, friendly, content_type} (Windows)."), + input_schema=schema({"target": {"type": "string"}}, + required=["target"]), + handler=h.file_association, + annotations=READ_ONLY, + ), ] diff --git a/je_auto_control/utils/mcp_server/tools/_handlers.py b/je_auto_control/utils/mcp_server/tools/_handlers.py index 26f98959..8069a557 100644 --- a/je_auto_control/utils/mcp_server/tools/_handlers.py +++ b/je_auto_control/utils/mcp_server/tools/_handlers.py @@ -558,6 +558,51 @@ def shell_command(command: str, timeout: float = 30.0 } +def open_path(target, verb="open"): + from je_auto_control.utils.executor.action_executor import _open_path + return _open_path(target, verb) + + +def plan_open(target, verb="open"): + from je_auto_control.utils.executor.action_executor import _plan_open + return _plan_open(target, verb) + + +def idle_seconds(): + from je_auto_control.utils.executor.action_executor import _idle_seconds + return _idle_seconds() + + +def is_idle(threshold): + from je_auto_control.utils.executor.action_executor import _is_idle + return _is_idle(threshold) + + +def plan_keep_awake(display=True, system=True): + from je_auto_control.utils.executor.action_executor import _plan_keep_awake + return _plan_keep_awake(display, system) + + +def keep_awake_on(display=True, system=True): + from je_auto_control.utils.executor.action_executor import _keep_awake_on + return _keep_awake_on(display, system) + + +def allow_sleep(): + from je_auto_control.utils.executor.action_executor import _allow_sleep + return _allow_sleep() + + +def normalize_ext(target): + from je_auto_control.utils.executor.action_executor import _normalize_ext + return _normalize_ext(target) + + +def file_association(target): + from je_auto_control.utils.executor.action_executor import _file_association + return _file_association(target) + + def get_clipboard() -> str: from je_auto_control.utils.clipboard.clipboard import get_clipboard as _get return _get() diff --git a/je_auto_control/utils/shell_open/__init__.py b/je_auto_control/utils/shell_open/__init__.py new file mode 100644 index 00000000..d6c58fc6 --- /dev/null +++ b/je_auto_control/utils/shell_open/__init__.py @@ -0,0 +1,4 @@ +"""Open a file with its default app, or a URL in the default browser (per-OS).""" +from je_auto_control.utils.shell_open.shell_open import open_path, plan_open + +__all__ = ["plan_open", "open_path"] diff --git a/je_auto_control/utils/shell_open/shell_open.py b/je_auto_control/utils/shell_open/shell_open.py new file mode 100644 index 00000000..cbaf6697 --- /dev/null +++ b/je_auto_control/utils/shell_open/shell_open.py @@ -0,0 +1,93 @@ +"""Open a file with its default app, or a URL in the default browser. + +The framework can launch a literal executable (``start_exe`` / ``shell_process``), +but not the single most common "hand off to another app" RPA step: open ``report.pdf`` +with whatever app is registered for it, ``print`` a document, or open a URL in the +default browser. ``shell_open`` adds that, routed per-OS to ``os.startfile`` / +``open`` / ``xdg-open`` / ``webbrowser``. + +:func:`plan_open` is a pure planner — it classifies the target (URL vs file path), +validates it (URL scheme allowlist; ``realpath`` for files) and returns the +dispatch descriptor, fully unit-testable. :func:`open_path` runs the plan through +an injectable ``opener`` sink (the real OS call by default), so the dispatch logic +is testable without launching anything. Imports no ``PySide6``. +""" +import os +import re +import sys +from typing import Any, Callable, Dict, Optional + +# URL schemes we will hand to the browser / OS — the safety allowlist. +_URL_SCHEMES = frozenset({"http", "https", "ftp", "file", "mailto", "tel"}) +_SCHEME_AUTHORITY = re.compile(r"([a-zA-Z][a-zA-Z0-9+.\-]+)://") +_SCHEME_OPAQUE = re.compile(r"(mailto|tel):", re.IGNORECASE) + +# A plan dispatcher: maps a plan dict to the real open call → bool. +Opener = Callable[[Dict[str, Any]], bool] + + +def _scheme(target: str) -> Optional[str]: + """Return the URL scheme of ``target`` (e.g. ``https``), or None for a path. + + Requires ``scheme://`` (so a Windows drive ``C:\\`` is not a scheme) or a known + opaque scheme (``mailto:`` / ``tel:``). + """ + match = _SCHEME_AUTHORITY.match(target) or _SCHEME_OPAQUE.match(target) + return match.group(1).lower() if match else None + + +def _file_backend() -> str: + if sys.platform.startswith("win"): + return "startfile" + if sys.platform == "darwin": + return "open" + return "xdg-open" + + +def plan_open(target: str, *, verb: str = "open") -> Dict[str, Any]: + """Classify ``target`` and return how to open it (pure, no side effect). + + Returns ``{kind, target, backend, verb}`` (plus ``scheme`` for URLs). Raises + ``ValueError`` for an empty target or a URL whose scheme isn't allow-listed. + """ + text = str(target).strip() + if not text: + raise ValueError("target is empty") + scheme = _scheme(text) + if scheme is not None: + if scheme not in _URL_SCHEMES: + raise ValueError(f"unsupported URL scheme: {scheme!r}") + return {"kind": "url", "scheme": scheme, "target": text, + "backend": "webbrowser", "verb": verb} + path = os.path.realpath(os.path.expanduser(text)) + return {"kind": "file", "target": path, "backend": _file_backend(), + "verb": verb} + + +def _default_opener(plan: Dict[str, Any]) -> bool: + backend, target = plan["backend"], plan["target"] + if backend == "webbrowser": + import webbrowser + return bool(webbrowser.open(target)) + if backend == "startfile": + if not sys.platform.startswith("win"): + raise RuntimeError("startfile is only supported on Windows") + # file path is from the allow-listed plan; os.startfile is not a shell + os.startfile(target, plan.get("verb") or "open") # noqa: S606 # nosec B606 # nosemgrep + return True + import subprocess # nosec B404 # reason: argv list, no shell + # fixed backend + argv list, no shell — injection-safe + subprocess.Popen([backend, target]) # nosec B603 # nosemgrep + return True + + +def open_path(target: str, *, verb: str = "open", + opener: Optional[Opener] = None) -> bool: + """Open ``target`` (file → default app / verb; URL → default browser). + + Pass an ``opener`` ``plan -> bool`` to intercept the dispatch (e.g. in tests); + the default runs the real OS call. Returns True on success. + """ + plan = plan_open(target, verb=verb) + dispatch = opener if opener is not None else _default_opener + return bool(dispatch(plan)) diff --git a/test/unit_test/headless/test_file_assoc_batch.py b/test/unit_test/headless/test_file_assoc_batch.py new file mode 100644 index 00000000..8be1c928 --- /dev/null +++ b/test/unit_test/headless/test_file_assoc_batch.py @@ -0,0 +1,79 @@ +"""Headless tests for file-association lookup (pure normalize + injected resolver).""" +import pytest + +import je_auto_control as ac +from je_auto_control.utils.file_assoc import file_association, normalize_ext + + +def test_normalize_ext_from_path(): + assert normalize_ext("report.pdf") == ".pdf" + assert normalize_ext(r"C:\Users\me\report.PDF") == ".pdf" + + +def test_normalize_ext_from_bare_and_dotted(): + assert normalize_ext("pdf") == ".pdf" + assert normalize_ext(".PDF") == ".pdf" + + +def test_normalize_ext_takes_last_extension(): + assert normalize_ext("archive.tar.gz") == ".gz" + + +def test_normalize_ext_rejects_empty_and_extensionless(): + for bad in ("", " ", "folder/", "no_dot_here/"): + with pytest.raises(ValueError): + normalize_ext(bad) + + +def test_file_association_uses_injected_resolver(): + def fake_resolver(ext): + assert ext == ".pdf" + return {"command": "acro.exe \"%1\"", "exe": "acro.exe", + "friendly": "Acrobat", "content_type": "application/pdf"} + + info = file_association("report.pdf", resolver=fake_resolver) + assert info["ext"] == ".pdf" + assert info["exe"] == "acro.exe" + assert info["friendly"] == "Acrobat" + assert info["content_type"] == "application/pdf" + + +def test_file_association_missing_fields_default_to_none(): + info = file_association(".xyz", resolver=lambda ext: {}) + assert info["ext"] == ".xyz" + assert info["exe"] is None and info["friendly"] is None + assert info["command"] is None and info["content_type"] is None + + +def test_file_association_normalizes_before_resolving(): + seen = {} + + def fake_resolver(ext): + seen["ext"] = ext + return {} + + file_association("DOC", resolver=fake_resolver) + assert seen["ext"] == ".doc" + + +# --- wiring --------------------------------------------------------------- + +def test_executor_pure_normalize_path(): + from je_auto_control.utils.executor.action_executor import _normalize_ext + assert _normalize_ext("report.pdf") == {"ext": ".pdf"} + + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_normalize_ext", "AC_file_association"} <= known + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + names = {t.name for t in build_default_tool_registry()} + assert {"ac_normalize_ext", "ac_file_association"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_normalize_ext", "AC_file_association"} <= specs + + +def test_facade_exports(): + for name in ("normalize_ext", "file_association"): + assert hasattr(ac, name) and name in ac.__all__ diff --git a/test/unit_test/headless/test_idle_keepawake_batch.py b/test/unit_test/headless/test_idle_keepawake_batch.py new file mode 100644 index 00000000..048b0aef --- /dev/null +++ b/test/unit_test/headless/test_idle_keepawake_batch.py @@ -0,0 +1,112 @@ +"""Headless tests for idle detection + keep-awake (injected probe / driver).""" +import pytest + +import je_auto_control as ac +from je_auto_control.utils.idle_keepawake import ( + allow_sleep, idle_seconds, is_idle, keep_awake, keep_awake_on, + plan_keep_awake, +) +from je_auto_control.utils.idle_keepawake.idle_keepawake import ( + _ES_CONTINUOUS, _ES_DISPLAY_REQUIRED, _ES_SYSTEM_REQUIRED, +) + + +def test_idle_seconds_uses_probe(): + assert idle_seconds(probe=lambda: 12.5) == pytest.approx(12.5) + + +def test_idle_seconds_clamps_negative(): + assert idle_seconds(probe=lambda: -3.0) == pytest.approx(0.0) + + +def test_is_idle_threshold(): + assert is_idle(5, probe=lambda: 9.0) is True + assert is_idle(15, probe=lambda: 9.0) is False + + +def test_is_idle_rejects_negative_threshold(): + with pytest.raises(ValueError): + is_idle(-1, probe=lambda: 0.0) + + +def test_plan_keep_awake_flags(): + plan = plan_keep_awake(display=True, system=True) + assert plan["flags"] == (_ES_CONTINUOUS | _ES_SYSTEM_REQUIRED + | _ES_DISPLAY_REQUIRED) + assert plan["display"] is True and plan["system"] is True + assert plan["backend"] in ("SetThreadExecutionState", "caffeinate", + "systemd-inhibit") + + +def test_plan_keep_awake_system_only_omits_display_flag(): + plan = plan_keep_awake(display=False, system=True) + assert plan["flags"] & _ES_DISPLAY_REQUIRED == 0 + assert plan["flags"] & _ES_SYSTEM_REQUIRED == _ES_SYSTEM_REQUIRED + + +def test_plan_keep_awake_rejects_all_false(): + with pytest.raises(ValueError): + plan_keep_awake(display=False, system=False) + + +def test_keep_awake_context_acquires_and_releases(): + events = [] + + def fake_driver(plan): + events.append(("acquire", plan["flags"])) + return lambda: events.append(("release", None)) + + with keep_awake(driver=fake_driver) as plan: + assert plan["backend"] is not None + assert events == [("acquire", plan["flags"])] + assert events[-1] == ("release", None) + + +def test_keep_awake_on_then_allow_sleep(): + released = [] + plan = keep_awake_on(driver=lambda p: lambda: released.append(True)) + assert plan["system"] is True + assert allow_sleep() is True + assert released == [True] + + +def test_allow_sleep_when_nothing_active(): + allow_sleep() # drain any prior state + assert allow_sleep() is False + + +def test_keep_awake_on_replaces_prior_request(): + released = [] + keep_awake_on(driver=lambda p: lambda: released.append("first")) + keep_awake_on(driver=lambda p: lambda: released.append("second")) + assert released == ["first"] # prior request released on replace + assert allow_sleep() is True + assert released == ["first", "second"] + + +# --- wiring --------------------------------------------------------------- + +def test_executor_pure_plan_path(): + from je_auto_control.utils.executor.action_executor import _plan_keep_awake + plan = _plan_keep_awake(display=True, system=False) + assert plan["flags"] & _ES_DISPLAY_REQUIRED == _ES_DISPLAY_REQUIRED + + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_idle_seconds", "AC_is_idle", "AC_plan_keep_awake", + "AC_keep_awake_on", "AC_allow_sleep"} <= known + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + names = {t.name for t in build_default_tool_registry()} + assert {"ac_idle_seconds", "ac_is_idle", "ac_plan_keep_awake", + "ac_keep_awake_on", "ac_allow_sleep"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_idle_seconds", "AC_is_idle", "AC_plan_keep_awake", + "AC_keep_awake_on", "AC_allow_sleep"} <= specs + + +def test_facade_exports(): + for name in ("idle_seconds", "is_idle", "plan_keep_awake", + "keep_awake", "keep_awake_on", "allow_sleep"): + assert hasattr(ac, name) and name in ac.__all__ diff --git a/test/unit_test/headless/test_shell_open_batch.py b/test/unit_test/headless/test_shell_open_batch.py new file mode 100644 index 00000000..2f3ad14c --- /dev/null +++ b/test/unit_test/headless/test_shell_open_batch.py @@ -0,0 +1,81 @@ +"""Headless tests for default-app / URL opening (pure planner + injected opener).""" +import os + +import pytest + +import je_auto_control as ac +from je_auto_control.utils.shell_open import open_path, plan_open + + +def test_plan_url_goes_to_browser(): + plan = plan_open("https://example.com/x") + assert plan["kind"] == "url" + assert plan["scheme"] == "https" + assert plan["backend"] == "webbrowser" + + +def test_plan_mailto_and_tel(): + assert plan_open("mailto:a@b.com")["scheme"] == "mailto" + assert plan_open("tel:+123")["scheme"] == "tel" + + +def test_plan_file_path_is_realpathed(): + plan = plan_open("report.pdf") + assert plan["kind"] == "file" + assert plan["target"] == os.path.realpath("report.pdf") + assert plan["backend"] in ("startfile", "open", "xdg-open") + + +def test_windows_drive_is_a_file_not_a_scheme(): + assert plan_open(r"C:\Users\me\report.txt")["kind"] == "file" + + +def test_verb_is_carried(): + assert plan_open("report.pdf", verb="print")["verb"] == "print" + + +def test_rejects_bad_scheme_and_empty(): + with pytest.raises(ValueError): + plan_open("javascript://alert(1)") + with pytest.raises(ValueError): + plan_open(" ") + + +def test_open_path_dispatches_plan_to_injected_opener(): + captured = {} + + def fake_opener(plan): + captured.update(plan) + return True + + assert open_path("https://x.io", opener=fake_opener) is True + assert captured["backend"] == "webbrowser" + assert captured["target"] == "https://x.io" + + +def test_open_path_returns_opener_result(): + assert open_path("report.pdf", opener=lambda plan: False) is False + + +# --- wiring --------------------------------------------------------------- + +def test_executor_pure_plan_path(): + from je_auto_control.utils.executor.action_executor import _plan_open + plan = _plan_open("https://example.com") + assert plan["backend"] == "webbrowser" and plan["kind"] == "url" + + +def test_wiring(): + known = set(ac.executor.known_commands()) + assert {"AC_open_path", "AC_plan_open"} <= known + from je_auto_control.utils.mcp_server.tools import build_default_tool_registry + names = {t.name for t in build_default_tool_registry()} + assert {"ac_open_path", "ac_plan_open"} <= names + from je_auto_control.gui.script_builder.command_schema import _build_specs + specs = {s.command for s in _build_specs()} + assert {"AC_open_path", "AC_plan_open"} <= specs + + +def test_facade_exports(): + for name in ("plan_open", "open_path"): + assert hasattr(ac, name) and name in ac.__all__