branchkit 0.1.0__tar.gz → 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {branchkit-0.1.0 → branchkit-0.2.0}/PKG-INFO +1 -1
  2. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/__init__.py +3 -0
  3. branchkit-0.2.0/branchkit/actor.py +56 -0
  4. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/closed_vocab_gen.py +9 -7
  5. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/collection.py +80 -1
  6. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/collection_log.py +1 -1
  7. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/commands.py +10 -6
  8. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/contracts_gen.py +11 -2
  9. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/debug.py +1 -1
  10. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/effects.py +2 -2
  11. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/harness.py +7 -2
  12. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/listen.py +1 -1
  13. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/log_events_gen.py +6 -2
  14. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/methods_gen.py +82 -16
  15. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/mirror.py +6 -2
  16. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/plugin.py +124 -8
  17. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/proxy.py +1 -1
  18. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/settings.py +70 -7
  19. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/types_gen.py +122 -37
  20. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/ui.py +13 -2
  21. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit.egg-info/PKG-INFO +1 -1
  22. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit.egg-info/SOURCES.txt +4 -1
  23. {branchkit-0.1.0 → branchkit-0.2.0}/pyproject.toml +1 -1
  24. branchkit-0.2.0/tests/test_collection.py +109 -0
  25. {branchkit-0.1.0 → branchkit-0.2.0}/tests/test_kernel.py +103 -3
  26. branchkit-0.2.0/tests/test_settings_tabs.py +76 -0
  27. {branchkit-0.1.0 → branchkit-0.2.0}/LICENSE +0 -0
  28. {branchkit-0.1.0 → branchkit-0.2.0}/README.md +0 -0
  29. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/correlation.py +0 -0
  30. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/hud.py +0 -0
  31. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/log.py +0 -0
  32. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/settings_route.py +0 -0
  33. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit/upstream.py +0 -0
  34. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit.egg-info/dependency_links.txt +0 -0
  35. {branchkit-0.1.0 → branchkit-0.2.0}/branchkit.egg-info/top_level.txt +0 -0
  36. {branchkit-0.1.0 → branchkit-0.2.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: branchkit
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: BranchKit plugin SDK for Python
5
5
  Author: BranchKit
6
6
  License-Expression: MIT
@@ -23,6 +23,7 @@ from . import proxy as _proxy
23
23
  _proxy.install_proxy_from_env()
24
24
 
25
25
  from .plugin import (
26
+ DetachedError,
26
27
  PluginCore,
27
28
  RecordingDisabledError,
28
29
  RpcCallError,
@@ -32,6 +33,7 @@ from .plugin import (
32
33
  plugin_data_dir,
33
34
  plugin_dir,
34
35
  )
36
+ from .actor import acting_for, get_current_actor
35
37
  from .collection import CollectionMixin, list_opts, scope_collection, scope_group
36
38
  from .collection_log import CollectionLogMixin, log_list_opts
37
39
  from .debug import DebugMixin
@@ -58,6 +60,7 @@ from .settings_route import method_post, method_url
58
60
  from .ui import (
59
61
  Expr,
60
62
  args,
63
+ js,
61
64
  confirm_button,
62
65
  expr,
63
66
  input_value,
@@ -0,0 +1,56 @@
1
+ # The actor label — who this plugin is acting *on behalf of*.
2
+ #
3
+ # A host-shaped plugin runs things the platform does not model: a scripting
4
+ # host runs script files, the browser plugin fronts an extension, an
5
+ # ambassador fronts an external app. Every platform call it makes is its own,
6
+ # over its own session, so audit rows, collection records, and events can name
7
+ # only the plugin. `acting_for` stamps the finer-grained label on the outbound
8
+ # envelope, and the platform carries it into what it writes.
9
+ #
10
+ # Observability only, by construction. The platform never consults the label
11
+ # for any decision — it cannot, because the host supplies it from inside its
12
+ # own process, so a lying host would only lie about a label it already had the
13
+ # grant to act under. Setting it widens nothing and narrows nothing; it makes
14
+ # the trail readable. Per-hosted-thing ENFORCEMENT would need real delegated
15
+ # identities, which is a different (unbuilt) feature.
16
+ #
17
+ # Scoped with a `contextvars.ContextVar`, exactly like the ambient inbound
18
+ # correlation: hosted things interleave across tasks, and a module global
19
+ # would let one hosted thing's label ride another's calls.
20
+
21
+ from contextlib import contextmanager
22
+ from contextvars import ContextVar
23
+
24
+ _current: ContextVar[str] = ContextVar("branchkit_actor", default="")
25
+
26
+
27
+ def set_actor(actor: str | None):
28
+ """Make `actor` ambient for the current context. Returns a token for
29
+ `reset_actor`. A falsy actor sets the empty ambient — "no label"."""
30
+ return _current.set(actor or "")
31
+
32
+
33
+ def reset_actor(token) -> None:
34
+ _current.reset(token)
35
+
36
+
37
+ def get_current_actor() -> str:
38
+ """The actor label outbound calls currently carry, or "" if none."""
39
+ return _current.get()
40
+
41
+
42
+ @contextmanager
43
+ def acting_for(actor: str | None):
44
+ """Stamp `actor` on every RPC made inside the block.
45
+
46
+ with acting_for("headphones.py"):
47
+ await plugin.dispatch(action)
48
+
49
+ Nested blocks restore the outer label on exit, so one hosted thing
50
+ invoking another leaves the trail intact.
51
+ """
52
+ token = set_actor(actor)
53
+ try:
54
+ yield
55
+ finally:
56
+ reset_actor(token)
@@ -9,16 +9,16 @@ from typing import NotRequired, TypedDict
9
9
  INPUT_DIRECTIVE_LIFT_KEYS_ON_DISPATCH = "lift_keys_on_dispatch"
10
10
 
11
11
  # EFFECT_* are the registered effect names plugins may declare in
12
- # `provides.effects`. Source of truth:
12
+ # `consumes.effects`. Source of truth:
13
13
  # `actuator/src/effects.rs::EffectDef` inventory entries.
14
14
  EFFECT_DISABLE_SCREEN_DIM = "disable_screen_dim"
15
- EFFECT_MUTE_AUDIO_TO_OTHER_APPS = "mute_audio_to_other_apps"
16
- EFFECT_PAUSE_MICROPHONE_INDICATOR = "pause_microphone_indicator"
17
- EFFECT_PREVENT_FOCUS_STEAL = "prevent_focus_steal"
15
+ EFFECT_PREVENT_SYSTEM_SLEEP = "prevent_system_sleep"
16
+ EFFECT_SIGNAL_CLIPBOARD_IN_USE = "signal_clipboard_in_use"
18
17
  EFFECT_SIGNAL_RECORDING_ACTIVE = "signal_recording_active"
19
18
  EFFECT_SUPPRESS_HUDS = "suppress_huds"
20
19
  EFFECT_SUPPRESS_KEYBINDS = "suppress_keybinds"
21
20
  EFFECT_SUPPRESS_NOTIFICATIONS = "suppress_notifications"
21
+ EFFECT_SUPPRESS_VOICE_COMMANDS = "suppress_voice_commands"
22
22
 
23
23
  # The full closed-vocabulary sets.
24
24
  KNOWN_INPUT_DIRECTIVES = (
@@ -27,13 +27,13 @@ KNOWN_INPUT_DIRECTIVES = (
27
27
 
28
28
  KNOWN_EFFECTS = (
29
29
  "disable_screen_dim",
30
- "mute_audio_to_other_apps",
31
- "pause_microphone_indicator",
32
- "prevent_focus_steal",
30
+ "prevent_system_sleep",
31
+ "signal_clipboard_in_use",
33
32
  "signal_recording_active",
34
33
  "suppress_huds",
35
34
  "suppress_keybinds",
36
35
  "suppress_notifications",
36
+ "suppress_voice_commands",
37
37
  )
38
38
 
39
39
  # ERROR_KIND_* are the closed-vocabulary `kind` values the actuator puts
@@ -89,7 +89,9 @@ KNOWN_ERROR_KINDS = (
89
89
  FaultData = TypedDict("FaultData", {
90
90
  "kind": str,
91
91
  "collection": NotRequired[str],
92
+ "consent": NotRequired[str],
92
93
  "detail": NotRequired[str],
93
94
  "id": NotRequired[str],
94
95
  "op": NotRequired[str],
96
+ "privilege": NotRequired[str],
95
97
  })
@@ -70,9 +70,88 @@ class CollectionMixin:
70
70
  return rec if isinstance(rec, dict) else None
71
71
 
72
72
  async def list(self, name: str, opts: dict | None = None) -> list[dict]:
73
+ """Read ONE PAGE of records.
74
+
75
+ Omitting `opts` does NOT mean "every record": the platform applies a
76
+ default limit when the caller supplies none, so a large collection
77
+ comes back truncated, and this method discards `total` so it cannot
78
+ tell you that happened.
79
+
80
+ Choose deliberately: some records -> `list` with an explicit `limit`;
81
+ every record -> `list_all`; a page plus the real count -> `list_page`.
82
+ """
73
83
  res = await self.collection_list(name, opts)
74
84
  return (res or {}).get("records") or []
75
85
 
86
+ async def list_all(self, name: str) -> list[dict]:
87
+ """Read EVERY record, defeating the platform's default list limit.
88
+
89
+ Use this when the read has to be exhaustive - clearing a collection,
90
+ reconciling against it, counting it. `list` returns one page and
91
+ discards `total`, so a caller using it cannot tell a complete read
92
+ from a capped one; that is a quiet correctness bug wherever
93
+ completeness was assumed.
94
+
95
+ Prefer `list` with an explicit `limit` when you only need some
96
+ records: this one is deliberately unbounded.
97
+ """
98
+ return await self._list_exhaustive(name, compacted=False)
99
+
100
+ async def list_all_compacted(self, name: str) -> list[dict]:
101
+ """`list_all` over the compacted-changelog projection - every folded
102
+ record, one per key. A keyed log with more live keys than the cap
103
+ otherwise folds to a view its reader believes is whole."""
104
+ return await self._list_exhaustive(name, compacted=True)
105
+
106
+ async def _list_exhaustive(self, name: str, compacted: bool) -> list[dict]:
107
+ """Reads a collection completely, re-reading while it grows.
108
+
109
+ Normally two round trips, not a cursor walk: `total` comes back with
110
+ the first page, so the second read is bounded exactly. Cursor paging
111
+ would be wrong anyway on contribution-keyed storage, where `cursor` is
112
+ a no-op.
113
+
114
+ The probe passes an EXPLICIT limit rather than omitting one. Reading
115
+ with no limit to discover `total` would trip the platform's
116
+ default-limit warning on every call - this helper would manufacture
117
+ the exact noise that warning exists to surface, burying real
118
+ occurrences underneath it.
119
+
120
+ `total` is the FOLDED count when compacted, so the short-circuit is a
121
+ real one on both projections rather than a guaranteed miss.
122
+ """
123
+ first_page = 1000
124
+
125
+ async def page(limit: int) -> tuple[list[dict], int]:
126
+ opts: dict = {"limit": limit}
127
+ if compacted:
128
+ opts["compacted"] = True
129
+ return await self.list_page(name, opts)
130
+
131
+ # Re-reads until the page covers `total`, because `total` is
132
+ # observed on the read that returns it and the collection can grow
133
+ # between reads. Taking the first `total` on faith would hand back a
134
+ # short result reported as complete - the same "mirror declares
135
+ # itself Ready over a truncated read" this helper exists to prevent,
136
+ # just with a narrower window: one write landing mid-refresh is
137
+ # enough.
138
+ #
139
+ # Bounded rather than unbounded: a collection written faster than it
140
+ # can be read is not a condition to spin on, and a caller waiting on
141
+ # a mirror refresh should get an answer. Practically it settles on
142
+ # the second read.
143
+ max_passes = 5
144
+ limit = first_page
145
+ for _ in range(max_passes):
146
+ records, total = await page(limit)
147
+ if len(records) >= total:
148
+ return records
149
+ limit = total
150
+ raise RuntimeError(
151
+ f"collection {name!r} kept growing across {max_passes} exhaustive "
152
+ "reads; read it with an explicit limit and page with cursor instead"
153
+ )
154
+
76
155
  async def list_compacted(self, name: str, opts: dict | None = None) -> list[dict]:
77
156
  """The compacted-changelog projection of a keyed log — one folded
78
157
  record per key instead of the raw append history."""
@@ -120,7 +199,7 @@ class CollectionMixin:
120
199
  ``{"put": n, "deleted": n, "skipped": n}``.
121
200
 
122
201
  Scope is required and never inferred — pass `scope_collection()` or
123
- `scope_group(...)`. See notes/DESIGN_COLLECTION_REPLACE.md."""
202
+ `scope_group(...)`. See docs/design/DESIGN_COLLECTION_REPLACE.md."""
124
203
  # Refused locally rather than sent: guessing between "everything I
125
204
  # own here" and "the subset under this key space" is how a refresh
126
205
  # silently becomes a wipe.
@@ -62,7 +62,7 @@ class CollectionLogMixin:
62
62
  """Annotate a keyed log (`log` preset, `id_strategy: by_field`):
63
63
  appends `payload` with `key` stamped into the key field, as a fresh
64
64
  append; same-key appends fold. Read the merged view with
65
- `list_compacted`. See notes/DESIGN_LOG_ANNOTATION_PROJECTION.md."""
65
+ `list_compacted`. See docs/design/DESIGN_LOG_ANNOTATION_PROJECTION.md."""
66
66
  await self.collection_append_keyed(key, name, payload)
67
67
 
68
68
  async def list_log(self, name: str, opts: dict | None = None) -> list[dict]:
@@ -69,9 +69,10 @@ class CommandBuilder:
69
69
 
70
70
  def action(self, type: str, params: dict | None = None) -> "CommandBuilder":
71
71
  """Set the action fired on match. `type` is the action's type (a
72
- built-in like "key" or a dotted plugin action); `params` are merged
73
- into the action object."""
74
- self._spec["action"] = {"type": type, **(params or {})}
72
+ built-in like "key" or a dotted plugin action); `params` nest under
73
+ the action object's `params` key — one dialect, see
74
+ DESIGN_ONE_PARAMS_DIALECT.md."""
75
+ self._spec["action"] = {"type": type, "params": params} if params else {"type": type}
75
76
  return self
76
77
 
77
78
  def requires_tags(self, *tags: str) -> "CommandBuilder":
@@ -102,9 +103,12 @@ class CommandBuilder:
102
103
  return self
103
104
 
104
105
  def discovery(self, mode: str) -> "CommandBuilder":
105
- """Declare the command's prefix-discovery affordance ("prefix" or
106
- "exclusive"). Valid only on a literal-prefix + single-tail-capture
107
- pattern. See notes/DESIGN_DISCOVERABLE_PREFIX.md."""
106
+ """Declare the command's prefix-discovery affordance ("prefix",
107
+ "exclusive", or "select" — select points at entries via
108
+ platform-assigned alphabet codewords instead of their names). Valid
109
+ only on a literal-prefix + single-tail-capture pattern. See
110
+ docs/design/DESIGN_DISCOVERABLE_PREFIX.md and
111
+ DESIGN_SELECTION_PRIMITIVE.md."""
108
112
  self._spec["discovery"] = mode
109
113
  return self
110
114
 
@@ -59,6 +59,7 @@ METHOD_INPUT_DOUBLE_CLICK = "input.double_click" # since 0.1.0
59
59
  METHOD_INPUT_DRAG = "input.drag" # since 0.1.0
60
60
  METHOD_INPUT_LIST_INPUT_SOURCES = "input.list_input_sources" # since 0.1.0
61
61
  METHOD_INPUT_MOUSE_BUTTON = "input.mouse_button" # since 0.1.0
62
+ METHOD_INPUT_PARSE_KEY_EVENT = "input.parse_key_event" # since 0.1.0
62
63
  METHOD_INPUT_PRESS_KEY = "input.press_key" # since 0.1.0
63
64
  METHOD_INPUT_RAW_KEY = "input.raw_key" # since 0.1.0
64
65
  METHOD_INPUT_RIGHT_CLICK = "input.right_click" # since 0.1.0
@@ -67,7 +68,6 @@ METHOD_INPUT_SELECT_ALL = "input.select_all" # since 0.1.0
67
68
  METHOD_INPUT_SWITCH_INPUT_SOURCE = "input.switch_input_source" # since 0.1.0
68
69
  METHOD_INPUT_TRIPLE_CLICK = "input.triple_click" # since 0.1.0
69
70
  METHOD_INPUT_TYPE_TEXT = "input.type_text" # since 0.1.0
70
- METHOD_KEY_NAMES_SET = "key_names.set" # since 0.1.0
71
71
  METHOD_KEYBINDS_REGISTER = "keybinds.register" # since 0.1.0
72
72
  METHOD_MODEL_DELETE = "model.delete" # since 0.1.0
73
73
  METHOD_NATIVE_ACCENT_COLOR = "native.accent_color" # since 0.1.0
@@ -587,9 +587,15 @@ METHOD_PIPELINES_STOP = "pipelines.stop" # since 0.1.0
587
587
  METHOD_PIPELINES_WARM = "pipelines.warm" # since 0.1.0
588
588
  METHOD_PLUGIN_DATA_EXPORT = "plugin.data.export" # since 0.1.0
589
589
  METHOD_PLUGIN_DEBUG = "plugin.debug" # since 0.1.0
590
+ METHOD_PLUGIN_REPORT_HEALTH = "plugin.report_health" # since 0.1.0
590
591
  METHOD_PRIVACY_GET_RECORDING = "privacy.get_recording" # stable, since 0.1.0
591
592
  METHOD_PRIVACY_SET_RECORDING = "privacy.set_recording" # stable, since 0.1.0
593
+ METHOD_PRIVILEGES_LIST = "privileges.list" # since 0.1.0
594
+ METHOD_PRIVILEGES_RELINQUISH = "privileges.relinquish" # since 0.1.0
595
+ METHOD_PRIVILEGES_REQUEST = "privileges.request" # since 0.1.0
592
596
  METHOD_RECOGNITION_BIAS_APPLY = "recognition.bias.apply" # since 0.1.0
597
+ METHOD_RECOGNITION_BIAS_GET = "recognition.bias.get" # since 0.1.0
598
+ METHOD_RECOGNITION_BIAS_SET = "recognition.bias.set" # since 0.1.0
593
599
  METHOD_RECOGNITION_REDECODE = "recognition.redecode" # since 0.1.0
594
600
  METHOD_SELECTION_PICK = "selection.pick" # since 0.1.0
595
601
  METHOD_SELECTION_SET = "selection.set" # since 0.1.0
@@ -609,11 +615,12 @@ METHOD_TRIAL_ENTER_CONTEXT = "trial.enter_context" # since 0.1.0
609
615
  METHOD_TRIAL_REGISTER_FIXTURE = "trial.register_fixture" # since 0.1.0
610
616
  METHOD_TRIAL_RESOLVE_SAMPLES = "trial.resolve_samples" # since 0.1.0
611
617
  METHOD_VOCABULARY_COMMIT = "vocabulary.commit" # since 0.1.0
618
+ METHOD_WIRING_DESCRIBE = "wiring.describe" # since 0.1.0
612
619
 
613
620
  # RPC method names: actuator → plugin (use with plugin.handle()).
614
621
  HOOK_ON_ACTION = "on_action" # since 0.1.0
615
622
  HOOK_ON_COMMANDS_CHANGED = "on_commands_changed" # since 0.1.0
616
- HOOK_RENDER_HUD = "render_hud" # since 0.1.0
623
+ HOOK_ON_TRANSCRIPT = "on_transcript" # since 0.1.0
617
624
  HOOK_RENDER_SETTINGS = "render_settings" # since 0.1.0
618
625
  HOOK_TRIAL_APPLY_FIXTURE = "trial_apply_fixture" # since 0.1.0
619
626
  HOOK_TRIAL_RELEASE_FIXTURE = "trial_release_fixture" # since 0.1.0
@@ -630,6 +637,7 @@ EVENT_COLLECTION_UPDATED = "_platform.collection.updated"
630
637
  EVENT_DISCOVERY_CLOSED = "_platform.discovery.closed"
631
638
  EVENT_DISPLAY_CHANGED = "_platform.display.changed"
632
639
  EVENT_EFFECT_DISPLACED = "_platform.effect.displaced"
640
+ EVENT_EFFECT_OWNERSHIP_CHANGED = "_platform.effect.ownership_changed"
633
641
  EVENT_HID_CONNECTED = "_platform.hid.connected"
634
642
  EVENT_HID_DISCONNECTED = "_platform.hid.disconnected"
635
643
  EVENT_HID_INPUT = "_platform.hid.input"
@@ -649,6 +657,7 @@ EVENT_PLUGIN_DEGRADED = "_platform.plugin.degraded"
649
657
  EVENT_PLUGIN_DISABLED = "_platform.plugin.disabled"
650
658
  EVENT_PLUGIN_ENABLED = "_platform.plugin.enabled"
651
659
  EVENT_POWER_CHANGED = "_platform.power.changed"
660
+ EVENT_PRIVILEGE_GRANTED = "_platform.privilege.granted"
652
661
  EVENT_SELECTION_PICKED = "_platform.selection.picked"
653
662
  EVENT_SYSTEM_DID_WAKE = "_platform.system.did_wake"
654
663
  EVENT_SYSTEM_WILL_SLEEP = "_platform.system.will_sleep"
@@ -1,7 +1,7 @@
1
1
  """Per-plugin leveled log façade over `plugin.debug` (the RPC method).
2
2
  Python twin of debug.{go,ts}. Lines land in
3
3
  `<app_support>/plugin-logs/<pluginID>.log`; warn/error cross-post to
4
- actuator.log. See notes/DESIGN_PLUGIN_LOG_LEVELS.md."""
4
+ actuator.log. See docs/design/DESIGN_PLUGIN_LOG_LEVELS.md."""
5
5
 
6
6
  from __future__ import annotations
7
7
 
@@ -1,5 +1,5 @@
1
1
  """Capability-mechanism helpers (effects). Python twin of effects.{go,ts}.
2
- See notes/DESIGN_CAPABILITY_MECHANISM.md."""
2
+ See docs/design/DESIGN_CAPABILITY_MECHANISM.md."""
3
3
 
4
4
  from __future__ import annotations
5
5
 
@@ -15,7 +15,7 @@ def _optional_str(v) -> str | None:
15
15
  class EffectsMixin:
16
16
  async def assert_effect(self, name: str) -> dict:
17
17
  """Declare this plugin is asserting `name` (which must be declared
18
- in the manifest's `provides.effects[*].asserts`). Returns
18
+ in the manifest's `consumes.effects[*].asserts`). Returns
19
19
  ``{"granted", "already_held", "displaced", "enforced"}``."""
20
20
  res = await self.effects_assert(name)
21
21
  return {
@@ -275,7 +275,11 @@ def find_harness_binary() -> str:
275
275
  env = os.environ.get("BRANCHKIT_TEST_HARNESS")
276
276
  if env:
277
277
  return env
278
+ # The installed app ships the harness in its Resources; the cargo
279
+ # target paths serve app-repo checkouts.
278
280
  candidates = [
281
+ "/Applications/BranchKit.app/Contents/Resources/branchkit-test-harness",
282
+ os.path.expanduser("~/Applications/BranchKit.app/Contents/Resources/branchkit-test-harness"),
279
283
  "target/debug/branchkit-test-harness",
280
284
  "target/release/branchkit-test-harness",
281
285
  "../target/debug/branchkit-test-harness",
@@ -291,6 +295,7 @@ def find_harness_binary() -> str:
291
295
  if found:
292
296
  return found
293
297
  raise OSError(
294
- "harness: cannot find branchkit-test-harness binary. "
295
- "Set BRANCHKIT_TEST_HARNESS or run 'cargo build -p branchkit-test-harness'"
298
+ "harness: cannot find branchkit-test-harness binary. It ships inside "
299
+ "BranchKit.app (Contents/Resources); install the app, or set "
300
+ "BRANCHKIT_TEST_HARNESS to a harness binary."
296
301
  )
@@ -9,7 +9,7 @@ network namespace, where a self-bound 127.0.0.1 is a private dead
9
9
  loopback — the inherited host-loopback listener is the only reachable
10
10
  surface. CPython serves an inherited fd natively
11
11
  (`socket.socket(fileno=...)`), so unlike TS there is no two-runtime
12
- dance. See the actuator's notes/DESIGN_SANDBOX_LOOPBACK_FDPASS.md."""
12
+ dance. See the actuator's docs/design/DESIGN_SANDBOX_LOOPBACK_FDPASS.md."""
13
13
 
14
14
  from __future__ import annotations
15
15
 
@@ -2,7 +2,7 @@
2
2
  # Run: just contracts
3
3
  #
4
4
  # BranchKit observability bus — closed log-event vocabulary.
5
- # See notes/DESIGN_OBSERVABILITY_BUS.md for the architectural contract.
5
+ # See docs/design/DESIGN_OBSERVABILITY_BUS.md for the architectural contract.
6
6
 
7
7
  from typing import TypedDict
8
8
 
@@ -14,6 +14,8 @@ LOG_EVENT_CAPTURE_PROGRESS = "capture.progress"
14
14
  LOG_EVENT_COMMAND_MATCH_DECISION = "command.match_decision"
15
15
  LOG_EVENT_COMMAND_MATCHED = "command.matched"
16
16
  LOG_EVENT_COMMAND_NO_MATCH = "command.no_match"
17
+ LOG_EVENT_CONSENT_DECISION = "consent.decision"
18
+ LOG_EVENT_CONSENT_PRIVILEGED_OP = "consent.privileged_op"
17
19
  LOG_EVENT_DIAGNOSTIC_MESSAGE = "diagnostic.message"
18
20
  LOG_EVENT_DISPATCH_ACTION_DISPATCHED = "dispatch.action_dispatched"
19
21
  LOG_EVENT_DISPATCH_ACTION_PANICKED = "dispatch.action_panicked"
@@ -84,6 +86,8 @@ LOG_EVENT_REGISTRY: dict[str, LogEventMeta] = {
84
86
  "command.match_decision": {"name": "command.match_decision", "summary": "Diagnostic snapshot of the matcher's tiebreaker choice — which category won, which gates were active, whether a gated Partial was suppressed.", "since": "0.1.0", "source": "dispatch", "severity": "debug", "redaction": "none"},
85
87
  "command.matched": {"name": "command.matched", "summary": "Speech recognition produced a match against a registered command.", "since": "0.1.0", "source": "dispatch", "severity": "info", "redaction": "none"},
86
88
  "command.no_match": {"name": "command.no_match", "summary": "Speech input did not match any registered command.", "since": "0.1.0", "source": "dispatch", "severity": "debug", "redaction": "none"},
89
+ "consent.decision": {"name": "consent.decision", "summary": "A consent decision on any axis (privilege, dispatch, event subscription, collection read/write, effect, trusted author): granted (always/once), denied, dismissed, revoked, or reconciled by an update — with the surface that produced it. Audit-eligible.", "since": "0.1.0", "source": "plugins", "severity": "info", "redaction": "none"},
90
+ "consent.privileged_op": {"name": "consent.privileged_op", "summary": "A plugin invoked (or was refused) an operation gated on a dangerous privilege. Audit-eligible.", "since": "0.1.0", "source": "plugins", "severity": "info", "redaction": "none"},
87
91
  "diagnostic.message": {"name": "diagnostic.message", "summary": "Catch-all for ad-hoc operational text that doesn't fit a typed variant.", "since": "0.1.0", "source": "generic", "severity": "debug", "redaction": "none"},
88
92
  "dispatch.action_dispatched": {"name": "dispatch.action_dispatched", "summary": "An action entered the dispatch pipeline.", "since": "0.1.0", "source": "dispatch", "severity": "debug", "redaction": "none"},
89
93
  "dispatch.action_panicked": {"name": "dispatch.action_panicked", "summary": "A dispatch handler panicked. Process likely degraded; investigate.", "since": "0.1.0", "source": "dispatch", "severity": "error", "redaction": "none"},
@@ -104,7 +108,7 @@ LOG_EVENT_REGISTRY: dict[str, LogEventMeta] = {
104
108
  "native.window_skipped": {"name": "native.window_skipped", "summary": "World poller skipped a non-standard window (debug-level).", "since": "0.1.0", "source": "native", "severity": "debug", "redaction": "none"},
105
109
  "native.world_poller_event": {"name": "native.world_poller_event", "summary": "World poller lifecycle event (started, seeded, batch processed, idle).", "since": "0.1.0", "source": "native", "severity": "debug", "redaction": "none"},
106
110
  "operation.completed": {"name": "operation.completed", "summary": "An operation handler completed execution.", "since": "0.1.0", "source": "dispatch", "severity": "debug", "redaction": "none"},
107
- "plugin.degraded": {"name": "plugin.degraded", "summary": "A managed plugin's RPC channel hit the consecutive-timeout threshold and was marked degraded.", "since": "0.1.0", "source": "plugins", "severity": "warn", "redaction": "none"},
111
+ "plugin.degraded": {"name": "plugin.degraded", "summary": "A plugin is running but not fully working — either its RPC channel hit the consecutive-timeout threshold (reason absent), or the plugin reported the fault itself via plugin.report_health (reason present).", "since": "0.1.0", "source": "plugins", "severity": "warn", "redaction": "none"},
108
112
  "plugin.diagnostic": {"name": "plugin.diagnostic", "summary": "A plugin emitted a warn- or error-level diagnostic via plugin.debug; cross-posted to actuator.log so plugin-level failures interleave with the actuator's view of dispatch / coordination.", "since": "0.2.0", "source": "plugins", "severity": "warn", "redaction": "none"},
109
113
  "plugin.exited": {"name": "plugin.exited", "summary": "A managed plugin process exited (clean or crashed).", "since": "0.1.0", "source": "plugins", "severity": "info", "redaction": "none"},
110
114
  "plugin.lifecycle_op": {"name": "plugin.lifecycle_op", "summary": "A plugin lifecycle operation (install, uninstall, update, reload, hot-reload, enable, disable).", "since": "0.1.0", "source": "plugins", "severity": "info", "redaction": "none"},
@@ -60,6 +60,7 @@ from .contracts_gen import (
60
60
  METHOD_INPUT_DRAG,
61
61
  METHOD_INPUT_LIST_INPUT_SOURCES,
62
62
  METHOD_INPUT_MOUSE_BUTTON,
63
+ METHOD_INPUT_PARSE_KEY_EVENT,
63
64
  METHOD_INPUT_PRESS_KEY,
64
65
  METHOD_INPUT_RAW_KEY,
65
66
  METHOD_INPUT_RIGHT_CLICK,
@@ -69,7 +70,6 @@ from .contracts_gen import (
69
70
  METHOD_INPUT_TRIPLE_CLICK,
70
71
  METHOD_INPUT_TYPE_TEXT,
71
72
  METHOD_KEYBINDS_REGISTER,
72
- METHOD_KEY_NAMES_SET,
73
73
  METHOD_MODEL_DELETE,
74
74
  METHOD_NATIVE_ACCENT_COLOR,
75
75
  METHOD_NATIVE_ACCESSIBILITY_DISPLAY_INVERT,
@@ -588,9 +588,15 @@ from .contracts_gen import (
588
588
  METHOD_PIPELINES_WARM,
589
589
  METHOD_PLUGIN_DATA_EXPORT,
590
590
  METHOD_PLUGIN_DEBUG,
591
+ METHOD_PLUGIN_REPORT_HEALTH,
591
592
  METHOD_PRIVACY_GET_RECORDING,
592
593
  METHOD_PRIVACY_SET_RECORDING,
594
+ METHOD_PRIVILEGES_LIST,
595
+ METHOD_PRIVILEGES_RELINQUISH,
596
+ METHOD_PRIVILEGES_REQUEST,
593
597
  METHOD_RECOGNITION_BIAS_APPLY,
598
+ METHOD_RECOGNITION_BIAS_GET,
599
+ METHOD_RECOGNITION_BIAS_SET,
594
600
  METHOD_RECOGNITION_REDECODE,
595
601
  METHOD_SELECTION_PICK,
596
602
  METHOD_SELECTION_SET,
@@ -610,6 +616,7 @@ from .contracts_gen import (
610
616
  METHOD_TRIAL_REGISTER_FIXTURE,
611
617
  METHOD_TRIAL_RESOLVE_SAMPLES,
612
618
  METHOD_VOCABULARY_COMMIT,
619
+ METHOD_WIRING_DESCRIBE,
613
620
  )
614
621
 
615
622
  if TYPE_CHECKING:
@@ -662,9 +669,9 @@ if TYPE_CHECKING:
662
669
  HidElementEntry,
663
670
  InputClipboardReadFormatResponse,
664
671
  InputClipboardReadResponse,
672
+ InputParseKeyEventResponse,
665
673
  InputSource,
666
674
  InstalledApp,
667
- KeyNamesSetResponse,
668
675
  KeybindsRegisterResponse,
669
676
  ListOpts,
670
677
  ListeningPort,
@@ -952,8 +959,13 @@ if TYPE_CHECKING:
952
959
  PluginDataExportResponse,
953
960
  PrinterInfo,
954
961
  PrivacyGetRecordingResponse,
962
+ PrivilegeStatusEntry,
963
+ PrivilegesRelinquishResponse,
964
+ PrivilegesRequestResponse,
955
965
  ProcessInfo,
956
966
  RecognitionBiasApplyResponse,
967
+ RecognitionBiasGetResponse,
968
+ RecognitionBiasSetResponse,
957
969
  RecognitionRedecodeResponse,
958
970
  RedecodeItem,
959
971
  ReminderItem,
@@ -974,6 +986,7 @@ if TYPE_CHECKING:
974
986
  UsbDevice,
975
987
  WindowDetail,
976
988
  WindowFrame,
989
+ WiringCollection,
977
990
  WorldModel,
978
991
  )
979
992
 
@@ -1226,14 +1239,10 @@ class MethodsMixin:
1226
1239
  async def discovery_closed(self) -> None:
1227
1240
  await self.call(METHOD_DISCOVERY_CLOSED)
1228
1241
 
1229
- async def dispatch(self, action: Any, session_id: str | None = None, trace_id: str | None = None) -> DispatchResponse:
1242
+ async def dispatch(self, action: Any) -> DispatchResponse:
1230
1243
  params: dict[str, Any] = {
1231
1244
  "action": action,
1232
1245
  }
1233
- if session_id is not None:
1234
- params["session_id"] = session_id
1235
- if trace_id is not None:
1236
- params["trace_id"] = trace_id
1237
1246
  result = await self.call(METHOD_DISPATCH, params)
1238
1247
  return result
1239
1248
 
@@ -1420,6 +1429,24 @@ class MethodsMixin:
1420
1429
  params["button"] = button
1421
1430
  await self.call(METHOD_INPUT_MOUSE_BUTTON, params)
1422
1431
 
1432
+ async def input_parse_key_event(self, alt: bool | None = None, code: str | None = None, ctrl: bool | None = None, key: str | None = None, meta: bool | None = None, shift: bool | None = None) -> InputParseKeyEventResponse:
1433
+ params: dict[str, Any] = {
1434
+ }
1435
+ if alt is not None:
1436
+ params["alt"] = alt
1437
+ if code is not None:
1438
+ params["code"] = code
1439
+ if ctrl is not None:
1440
+ params["ctrl"] = ctrl
1441
+ if key is not None:
1442
+ params["key"] = key
1443
+ if meta is not None:
1444
+ params["meta"] = meta
1445
+ if shift is not None:
1446
+ params["shift"] = shift
1447
+ result = await self.call(METHOD_INPUT_PARSE_KEY_EVENT, params)
1448
+ return result
1449
+
1423
1450
  async def input_press_key(self, code: int | None = None, modifiers: list[str] | None = None, name: str | None = None) -> None:
1424
1451
  params: dict[str, Any] = {
1425
1452
  }
@@ -1482,14 +1509,6 @@ class MethodsMixin:
1482
1509
  }
1483
1510
  await self.call(METHOD_INPUT_TYPE_TEXT, params)
1484
1511
 
1485
- async def key_names_set(self, names: dict[str, int] | None = None) -> KeyNamesSetResponse:
1486
- params: dict[str, Any] = {
1487
- }
1488
- if names is not None:
1489
- params["names"] = names
1490
- result = await self.call(METHOD_KEY_NAMES_SET, params)
1491
- return result
1492
-
1493
1512
  async def keybinds_register(self, snapshot: Any) -> KeybindsRegisterResponse:
1494
1513
  params: dict[str, Any] = {
1495
1514
  "snapshot": snapshot,
@@ -4184,10 +4203,11 @@ class MethodsMixin:
4184
4203
  result = await self.call(METHOD_PIPELINES_GRAMMAR, params)
4185
4204
  return result
4186
4205
 
4187
- async def pipelines_inject(self, event_type: str, name: str, data: Any | None = None) -> PipelinesInjectResponse:
4206
+ async def pipelines_inject(self, event_type: str, name: str, stage: str, data: Any | None = None) -> PipelinesInjectResponse:
4188
4207
  params: dict[str, Any] = {
4189
4208
  "event_type": event_type,
4190
4209
  "name": name,
4210
+ "stage": stage,
4191
4211
  }
4192
4212
  if data is not None:
4193
4213
  params["data"] = data
@@ -4247,6 +4267,14 @@ class MethodsMixin:
4247
4267
  params["tag"] = tag
4248
4268
  await self.call(METHOD_PLUGIN_DEBUG, params)
4249
4269
 
4270
+ async def plugin_report_health(self, degraded: bool, reason: str | None = None) -> None:
4271
+ params: dict[str, Any] = {
4272
+ "degraded": degraded,
4273
+ }
4274
+ if reason is not None:
4275
+ params["reason"] = reason
4276
+ await self.call(METHOD_PLUGIN_REPORT_HEALTH, params)
4277
+
4250
4278
  async def privacy_get_recording(self, name: str) -> PrivacyGetRecordingResponse:
4251
4279
  params: dict[str, Any] = {
4252
4280
  "name": name,
@@ -4261,6 +4289,26 @@ class MethodsMixin:
4261
4289
  }
4262
4290
  await self.call(METHOD_PRIVACY_SET_RECORDING, params)
4263
4291
 
4292
+ async def privileges_list(self) -> list[PrivilegeStatusEntry]:
4293
+ result = await self.call(METHOD_PRIVILEGES_LIST)
4294
+ return (result or {}).get("privileges") or []
4295
+
4296
+ async def privileges_relinquish(self, privilege: str) -> PrivilegesRelinquishResponse:
4297
+ params: dict[str, Any] = {
4298
+ "privilege": privilege,
4299
+ }
4300
+ result = await self.call(METHOD_PRIVILEGES_RELINQUISH, params)
4301
+ return result
4302
+
4303
+ async def privileges_request(self, privilege: str, reason: str | None = None) -> PrivilegesRequestResponse:
4304
+ params: dict[str, Any] = {
4305
+ "privilege": privilege,
4306
+ }
4307
+ if reason is not None:
4308
+ params["reason"] = reason
4309
+ result = await self.call(METHOD_PRIVILEGES_REQUEST, params)
4310
+ return result
4311
+
4264
4312
  async def recognition_bias_apply(self, strength: float, force: bool | None = None) -> RecognitionBiasApplyResponse:
4265
4313
  params: dict[str, Any] = {
4266
4314
  "strength": strength,
@@ -4270,6 +4318,20 @@ class MethodsMixin:
4270
4318
  result = await self.call(METHOD_RECOGNITION_BIAS_APPLY, params)
4271
4319
  return result
4272
4320
 
4321
+ async def recognition_bias_get(self) -> RecognitionBiasGetResponse:
4322
+ result = await self.call(METHOD_RECOGNITION_BIAS_GET)
4323
+ return result
4324
+
4325
+ async def recognition_bias_set(self, enabled: bool | None = None, strength: float | None = None) -> RecognitionBiasSetResponse:
4326
+ params: dict[str, Any] = {
4327
+ }
4328
+ if enabled is not None:
4329
+ params["enabled"] = enabled
4330
+ if strength is not None:
4331
+ params["strength"] = strength
4332
+ result = await self.call(METHOD_RECOGNITION_BIAS_SET, params)
4333
+ return result
4334
+
4273
4335
  async def recognition_redecode(self, items: list["RedecodeItem"], model: str, stage: str, max_active: int | None = None) -> RecognitionRedecodeResponse:
4274
4336
  params: dict[str, Any] = {
4275
4337
  "items": items,
@@ -4429,3 +4491,7 @@ class MethodsMixin:
4429
4491
 
4430
4492
  async def vocabulary_commit(self) -> None:
4431
4493
  await self.call(METHOD_VOCABULARY_COMMIT)
4494
+
4495
+ async def wiring_describe(self) -> list[WiringCollection]:
4496
+ result = await self.call(METHOD_WIRING_DESCRIBE)
4497
+ return (result or {}).get("collections") or []