agent-bios 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -291,6 +291,44 @@ are display-only references, not new dependencies or proof of loading: rule bodi
291
291
  IDs, ordering, selection, and delivery remain unchanged. Personal body edits are
292
292
  reflected when the library refreshes; ordinary rules are not classified by meaning.
293
293
 
294
+ Moving the library cursor to an item immediately displays its document; Enter is
295
+ not required. Group rows show group guidance and disable item-specific actions.
296
+ Cursor events cannot retarget an open editor or a prepared change preview.
297
+
298
+ Arrow keys also navigate the reading controls: **Search ↓ buttons ↓ library →
299
+ document**, with **←/→** between neighboring buttons and **↑** back toward search.
300
+ At the end of search text, **→** focuses the view selector; within text, the caret
301
+ moves normally. **←** from a closed view selector returns to search. Open menus
302
+ keep their native arrow selection. The library keeps normal **↑/↓** item movement;
303
+ at its top, **↑** returns to controls. In the document, arrows scroll until an
304
+ edge: **←** returns to the library, **↑** reaches the controls/member selector,
305
+ and **↓** at the bottom reaches pending on/off buttons when present. Disabled or
306
+ hidden buttons are skipped. TextArea editing, modal boundaries, and Tab remain intact.
307
+
308
+ In the library, **Space** toggles an item's use in future activated sessions:
309
+ `[x]` is on, `[ ]` is off, `[-]` is removed, and `*` marks an unapplied change.
310
+ The Available group means retained authoring items, not that every item is on.
311
+ Stage several choices, then use **Preview on/off → Apply**, or **Discard on/off**.
312
+ Text inputs keep normal spaces; Space on a group still expands or collapses it.
313
+ Unapplied choices require discard confirmation on exit and cannot be mixed with
314
+ content edits. A stale authoring revision refuses the preview or apply.
315
+
316
+ Per-item choices override domain and explicit launch selections, including core
317
+ and infrastructure defaults. Turning an item off does not delete its body, edits,
318
+ or identity, and old snapshots and session pins remain intact. Choices survive
319
+ updates and content restoration; full reset returns to installed defaults.
320
+ Enabling a removed item requires Restore/Recover first. A guide switched off is
321
+ marked in its referring rule, without automatically changing that rule. Native
322
+ hook/agent opt-in, trust, host support, and promotion rules still apply: a checked
323
+ item is a projection choice, not proof of execution. This does not block host
324
+ global/project instructions or a tool from opening a file independently.
325
+
326
+ The same revision-checked manager accepts `{"operation":"enable","items":{"@agent-bios/core:rule-003":false}}`
327
+ through `corpus plan`; `true` forces inclusion, `false` excludes, and `null`
328
+ removes that override so the normal selection applies. `list` reports `enabled`,
329
+ `enabled_override`, and the captured authoring `revision`; pass that revision as
330
+ `expected_revision` when planning a batch from the displayed inventory.
331
+
294
332
  New personal identities are allocated once in the creation plan and remain stable
295
333
  on retry; reset and history rollback do not make retired identities reusable.
296
334
  Member files are the content authority: `primary_member` identifies the main file,
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: corpus
3
- description: Inspect or change the private agent-bios corpus used by activated sessions, including item creation, edits, consumption placement, removal, restore, recovery, selection, reset, rollback, history, and current-vs-pinned explanation. Use for requests about the user's agent-bios instructions or personal learnings; it does not alter the running session or native global files.
3
+ description: Inspect or change the private agent-bios corpus used by activated sessions, including item creation, edits, consumption placement, enablement, removal, restore, recovery, selection, reset, rollback, history, and current-vs-pinned explanation. Use for requests about the user's agent-bios instructions or personal learnings; it does not alter the running session or native global files.
4
4
  ---
5
5
 
6
6
  # Corpus management
@@ -64,11 +64,21 @@ Supported payloads:
64
64
  {"operation":"restore","ref":"@scope/name:item-id"}
65
65
  {"operation":"recover","ref":"@local/personal:item-id"}
66
66
  {"operation":"select","selection":["@scope/name/domain"]}
67
+ {"operation":"enable","items":{"@scope/name:item-id":false},"expected_revision":"<revision from list>"}
67
68
  {"operation":"reset"}
68
69
  {"operation":"rollback","baseline_ref":"<baseline_ref>"}
69
70
  {"operation":"rollback","history_id":"<history_id>"}
70
71
  ```
71
72
 
73
+ For turning items on or off without losing edits, use `enable`, not remove/restore.
74
+ Its `items` mapping supports batches: `true` forces inclusion, `false` excludes,
75
+ and `null` returns to normal selection. Read `enabled`, `enabled_override`, and
76
+ `revision` from `list`; the captured revision binds the whole batch. Overrides
77
+ take precedence over domain/core/infra and per-launch selections, survive updates
78
+ and content restoration, and clear on full reset. They do not bypass native
79
+ opt-in, trust, host support, or promotion rules, or prevent independent file reads.
80
+ Removed items require Restore/Recover first. Existing session pins remain unchanged.
81
+
72
82
  For a body edit, submit `body`; the manager updates the item's `primary_member`
73
83
  and derives every content view from that file. For a multi-file edit, submit
74
84
  `members` retaining unchanged files. A conflicting simultaneous body/member edit
@@ -37,6 +37,7 @@ ITEM_FIELDS = {
37
37
  RUNTIME_FIELDS = {
38
38
  "digest", "revision", "content_ref", "path", "state", "created_at",
39
39
  "updated_at", "baseline_ref", "plan_id", "history_id",
40
+ "enabled", "enabled_override",
40
41
  }
41
42
  LEARNING_FIELDS = {"schema_version", "learning_id", "lesson", "domain", "created", "supporting_sessions",
42
43
  "criteria", "classification", "proposed_domain", "context"}
@@ -307,8 +308,20 @@ class CorpusStore:
307
308
  for field, typ in (("items", dict), ("overrides", dict), ("tombstones", dict), ("learning_suppressions", dict)):
308
309
  if not isinstance(state.get(field), typ):
309
310
  raise CorpusStoreError(f"personal state has invalid {field}")
311
+ # Optional, so reading an older state does not change its revision or the
312
+ # exact before/after documents used by prepared-transaction recovery.
313
+ self._enabled_overrides(state)
310
314
  return state
311
315
 
316
+ @staticmethod
317
+ def _enabled_overrides(user: dict[str, Any]) -> dict[str, bool]:
318
+ values = user.get("enabled_overrides", {})
319
+ if (not isinstance(values, dict) or any(
320
+ not isinstance(ref, str) or ":" not in ref or not isinstance(value, bool)
321
+ for ref, value in values.items())):
322
+ raise CorpusStoreError("personal state has invalid enabled_overrides")
323
+ return values
324
+
312
325
  def _write_transaction(self, tx_id: str, record: dict[str, Any]) -> None:
313
326
  _atomic_write(self.runtime / "transactions" / tx_id / "journal.json", record)
314
327
 
@@ -351,7 +364,7 @@ class CorpusStore:
351
364
  journal["history_id"] = history_id
352
365
  _atomic_write(journal_path, journal)
353
366
  history = {"runtime": prior["runtime"], "user": prior["user"],
354
- "revision": plan.get("expected_revision")}
367
+ "revision": plan.get("expected_revision"), "details": plan.get("details", {})}
355
368
  history_path = self.user_root / "history" / history_id / "state.json"
356
369
  if not history_path.exists():
357
370
  _atomic_write(history_path, history)
@@ -701,7 +714,7 @@ class CorpusStore:
701
714
  active = [item for item in items if item.get("active", True) is not False]
702
715
  selection = self._effective_selection(user, defaults, None)
703
716
  self._validate_snapshot_selection(selection, _inventory, active)
704
- selected = self._selected_items(active, selection)
717
+ selected = self._selected_items(active, selection, self._enabled_overrides(user))
705
718
  self._require_resolved(selected)
706
719
  catalog.compile_items(_copy_json(selected), Path(temp) / host, host)
707
720
  except Exception as exc:
@@ -896,18 +909,24 @@ class CorpusStore:
896
909
  "revision": revision, "baseline_count": len(refs),
897
910
  "personal_items": len(user["items"]), "overrides": len(user["overrides"]),
898
911
  "tombstones": len(user["tombstones"]), "selection": self._effective_selection(user, defaults, None),
912
+ "enabled_overrides": _copy_json(self._enabled_overrides(user)),
899
913
  }
900
914
 
901
915
  def list_items(self, include_removed: bool = True) -> list[dict[str, Any]]:
902
916
  with self._lock():
903
917
  self._recover_locked()
904
918
  runtime, user = self._runtime_state(), self._user_state()
905
- selected_ref, inventory, _defaults = self._selected_baseline(runtime)
919
+ selected_ref, inventory, defaults = self._selected_baseline(runtime)
906
920
  all_items, _, _, _ = self._effective_items(runtime, user)
907
921
  effective = {item["ref"]: item for item in all_items}
908
922
  for host in ("claude", "codex"):
909
923
  effective.update({item["ref"]: item for item in self._effective_items(runtime, user, host=host, include_suppressed=True)[0]})
910
924
  source = {item["ref"]: self._validate_item(item, allow_origin=True) for item in inventory["items"]}
925
+ overrides = self._enabled_overrides(user)
926
+ selection = self._effective_selection(user, defaults, None)
927
+ enabled = {item["ref"] for item in self._selected_items(
928
+ [item for item in effective.values() if item.get("active", True) is not False], selection, overrides)}
929
+ revision = self._authoring_revision(runtime, user)
911
930
  rows: list[dict[str, Any]] = []
912
931
  for ref in sorted(set(source) | set(user["items"]) | set(effective)):
913
932
  base = source.get(ref)
@@ -919,6 +938,9 @@ class CorpusStore:
919
938
  row["state"] = "removed" if removed else ("conflict" if item and (item.get("conflict") or item.get("content_conflict")) else "active")
920
939
  row["digest"] = _digest(item or base or user["items"][ref])
921
940
  row["baseline_ref"] = selected_ref if base else None
941
+ row["enabled"] = not removed and ref in enabled
942
+ row["enabled_override"] = overrides.get(ref)
943
+ row["revision"] = revision
922
944
  rows.append(row)
923
945
  return rows
924
946
 
@@ -944,7 +966,11 @@ class CorpusStore:
944
966
  return {"ref": ref, "history": self.history(ref)}
945
967
  removed = ref in user["tombstones"] or (effective is not None and effective.get("active", True) is False)
946
968
  state = "removed" if removed else ("conflict" if effective and (effective.get("conflict") or effective.get("content_conflict")) else "active")
947
- return {"ref": ref, "item": effective, "digest": _digest(effective) if effective else None, "state": state}
969
+ overrides = self._enabled_overrides(user)
970
+ enabled = bool(effective and not removed and self._selected_items(
971
+ [effective], self._effective_selection(user, _defaults, None), overrides))
972
+ return {"ref": ref, "item": effective, "digest": _digest(effective) if effective else None, "state": state,
973
+ "enabled": enabled, "enabled_override": overrides.get(ref)}
948
974
 
949
975
  # ---- plans --------------------------------------------------------------
950
976
 
@@ -971,7 +997,7 @@ class CorpusStore:
971
997
  if not isinstance(payload, dict):
972
998
  raise ValidationError("plan payload must be an object")
973
999
  op = payload.get("operation", payload.get("op"))
974
- if op not in {"create", "update", "remove", "restore", "recover", "reset", "rollback", "select"}:
1000
+ if op not in {"create", "update", "remove", "restore", "recover", "reset", "rollback", "select", "enable"}:
975
1001
  raise ValidationError("unknown corpus operation")
976
1002
  allowed = {
977
1003
  "create": {"operation", "op", "item", "package_id", "expected_revision"},
@@ -982,6 +1008,7 @@ class CorpusStore:
982
1008
  "reset": {"operation", "op", "expected_revision"},
983
1009
  "rollback": {"operation", "op", "baseline_ref", "history_id", "expected_revision"},
984
1010
  "select": {"operation", "op", "selection", "expected_revision"},
1011
+ "enable": {"operation", "op", "items", "expected_revision"},
985
1012
  }[op]
986
1013
  unknown = set(payload) - allowed
987
1014
  if unknown:
@@ -1114,6 +1141,31 @@ class CorpusStore:
1114
1141
  else:
1115
1142
  next_user["items"][ref].pop("active", None)
1116
1143
  details["ref"] = ref
1144
+ elif op == "enable":
1145
+ choices = payload.get("items")
1146
+ if not isinstance(choices, dict) or not choices:
1147
+ raise ValidationError("enable needs a non-empty mapping of refs to booleans or null")
1148
+ available = dict(effective)
1149
+ for host in ("claude", "codex"):
1150
+ available.update({item["ref"]: item for item in self._effective_items(runtime, user, host=host)[0]})
1151
+ overrides = dict(self._enabled_overrides(user))
1152
+ known = set(available) | set(overrides) | set(user["items"]) | {item["ref"] for item in inventory["items"]}
1153
+ for ref, value in choices.items():
1154
+ if not isinstance(ref, str) or ref not in known:
1155
+ raise ValidationError(f"unknown corpus enablement ref: {ref}")
1156
+ if value is None:
1157
+ overrides.pop(ref, None)
1158
+ elif not isinstance(value, bool):
1159
+ raise ValidationError("enable values must be booleans or null")
1160
+ elif ref not in available or available[ref].get("active", True) is False:
1161
+ raise ValidationError("recover or restore a removed item before enabling it")
1162
+ else:
1163
+ overrides[ref] = value
1164
+ if overrides:
1165
+ next_user["enabled_overrides"] = overrides
1166
+ else:
1167
+ next_user.pop("enabled_overrides", None)
1168
+ details["items"] = _copy_json(choices)
1117
1169
  elif op == "select":
1118
1170
  selection = self._validate_selection(payload.get("selection"), inventory)
1119
1171
  next_user["selection"] = selection
@@ -1208,7 +1260,8 @@ class CorpusStore:
1208
1260
  history_id = f"{prepared['prepared_at'].replace(':', '').replace('+00:00', 'Z')}-{plan_id[:12]}"
1209
1261
  prepared["history_id"] = history_id
1210
1262
  self._write_transaction(plan_id, prepared)
1211
- _atomic_write(self.user_root / "history" / history_id / "state.json", {"runtime": runtime, "user": user, "revision": current})
1263
+ _atomic_write(self.user_root / "history" / history_id / "state.json",
1264
+ {"runtime": runtime, "user": user, "revision": current, "details": details})
1212
1265
  if details.get("operation") == "reset":
1213
1266
  _atomic_write(self.user_root / "trash" / history_id / "state.json", {"runtime": runtime, "user": user, "revision": current})
1214
1267
  _atomic_write(self._user_state_path, next_user)
@@ -1225,11 +1278,18 @@ class CorpusStore:
1225
1278
 
1226
1279
  # ---- immutable snapshots and history -----------------------------------
1227
1280
 
1228
- def _selected_items(self, items: list[dict[str, Any]], selection: list[str] | None) -> list[dict[str, Any]]:
1229
- if selection and "all" in selection:
1230
- return items
1281
+ def _selected_items(self, items: list[dict[str, Any]], selection: list[str] | None,
1282
+ overrides: dict[str, bool] | None = None) -> list[dict[str, Any]]:
1283
+ overrides = overrides or {}
1231
1284
  selected: list[dict[str, Any]] = []
1232
1285
  for item in items:
1286
+ if item["ref"] in overrides:
1287
+ if overrides[item["ref"]]:
1288
+ selected.append(item)
1289
+ continue
1290
+ if selection and "all" in selection:
1291
+ selected.append(item)
1292
+ continue
1233
1293
  if item.get("tier") in {"core", "infra"}:
1234
1294
  selected.append(item)
1235
1295
  continue
@@ -1268,7 +1328,7 @@ class CorpusStore:
1268
1328
  active = [item for item in items if item.get("active", True) is not False]
1269
1329
  effective_selection = self._effective_selection(user, defaults, selection)
1270
1330
  self._validate_snapshot_selection(effective_selection, inventory, active)
1271
- selected = self._selected_items(active, effective_selection)
1331
+ selected = self._selected_items(active, effective_selection, self._enabled_overrides(user))
1272
1332
  self._require_resolved(selected)
1273
1333
  selected, promotion_warnings = self._resolve_promotions(selected, user, host, baseline_ref)
1274
1334
  bootstrap_path = self.repo / "compose" / "bootstrap" / "SKILL.md"
@@ -1372,7 +1432,11 @@ class CorpusStore:
1372
1432
  continue
1373
1433
  if ref is not None:
1374
1434
  user = record.get("user", {})
1375
- if ref not in user.get("items", {}) and ref not in user.get("overrides", {}) and ref not in user.get("tombstones", {}):
1435
+ details = record.get("details", {})
1436
+ changed = details.get("items", {}) if details.get("operation") == "enable" else {}
1437
+ if (ref not in user.get("items", {}) and ref not in user.get("overrides", {})
1438
+ and ref not in user.get("tombstones", {}) and ref not in user.get("enabled_overrides", {})
1439
+ and ref not in changed):
1376
1440
  continue
1377
1441
  rows.append({"history_id": path.parent.name, "revision": record.get("revision"), "path": str(path.parent)})
1378
1442
  return rows
@@ -18,6 +18,7 @@ from rich.text import Text
18
18
  from textual.app import App, ComposeResult
19
19
  from textual.binding import Binding
20
20
  from textual.containers import Horizontal, Vertical
21
+ from textual.events import Key
21
22
  from textual.screen import ModalScreen
22
23
  from textual.widgets import (
23
24
  Button,
@@ -43,6 +44,10 @@ SURFACES = ("always", "relevant", "requested", "event", "delegated")
43
44
  VIEWS = ("effective", "installed", "change", "diff", "history")
44
45
 
45
46
 
47
+ def availability_label(state: str) -> str:
48
+ return "available" if state == "active" else state
49
+
50
+
46
51
  def guide_pointers(item: dict[str, Any], inventory: list[dict[str, Any]]) -> list[dict[str, Any]]:
47
52
  """Display literal guide references, not inferred dependencies or new routing."""
48
53
  if item.get("kind") != "rule":
@@ -63,6 +68,8 @@ def guide_pointers(item: dict[str, Any], inventory: list[dict[str, Any]]) -> lis
63
68
  def library_label(item: dict[str, Any], inventory: list[dict[str, Any]]) -> Text:
64
69
  pointers = guide_pointers(item, inventory)
65
70
  label = Text()
71
+ mark = "-" if item.get("state") == "removed" else "x" if item.get("enabled", True) else " "
72
+ label.append(f"[{mark}]" + ("* " if item.get("enabled_pending") else " "))
66
73
  if pointers:
67
74
  label.append("→ GUIDE ", style="bold cyan")
68
75
  label.append(", ".join(PurePosixPath(pointer["path"]).stem for pointer in pointers))
@@ -112,6 +119,31 @@ class Confirmation(ModalScreen[bool]):
112
119
  class CorpusMarkdownViewer(MarkdownViewer):
113
120
  """Keep all links inside Studio; never hand a URL to the operating system."""
114
121
 
122
+ BINDINGS = [
123
+ Binding("left", "left_or_library", "Library", show=False),
124
+ Binding("up", "up_or_controls", "Controls", show=False),
125
+ Binding("down", "down_or_pending", "Pending choices", show=False),
126
+ ]
127
+
128
+ def action_left_or_library(self) -> None:
129
+ if self.scroll_x <= 0:
130
+ self.app.action_focus_library()
131
+ else:
132
+ self.action_scroll_left()
133
+
134
+ def action_up_or_controls(self) -> None:
135
+ if self.scroll_y <= 0:
136
+ if not self.app._focus_id("member-select"):
137
+ self.app._focus_controls()
138
+ else:
139
+ self.action_scroll_up()
140
+
141
+ def action_down_or_pending(self) -> None:
142
+ if self.scroll_y >= self.max_scroll_y:
143
+ self.app._focus_id("enablement-preview")
144
+ else:
145
+ self.action_scroll_down()
146
+
115
147
  async def _on_markdown_link_clicked(self, message: Markdown.LinkClicked) -> None:
116
148
  # stop() prevents bubbling, not the inherited MarkdownViewer handler.
117
149
  # Its default go() would try to load corpus:// as a filesystem path.
@@ -124,6 +156,57 @@ class CorpusMarkdownViewer(MarkdownViewer):
124
156
  self.app.notify("External links are disabled in Corpus Studio.", severity="warning")
125
157
 
126
158
 
159
+ class CorpusSearch(Input):
160
+ """Leave search with arrows without taking editing keys away from text."""
161
+
162
+ BINDINGS = [
163
+ Binding("down", "focus_controls", "Controls", show=False),
164
+ Binding("right", "right_or_view", "View", show=False),
165
+ ]
166
+
167
+ def action_focus_controls(self) -> None:
168
+ self.app._focus_controls()
169
+
170
+ def action_right_or_view(self) -> None:
171
+ if self.selection.start == self.selection.end == len(self.value):
172
+ self.app._focus_id("view-select")
173
+ else:
174
+ self.action_cursor_right()
175
+
176
+
177
+ class CorpusTree(Tree):
178
+ """Space is local to the library, never intercepted in text inputs."""
179
+
180
+ BINDINGS = [
181
+ Binding("space", "toggle_item", "On/off"),
182
+ Binding("up", "up_or_controls", "Controls", show=False),
183
+ Binding("down", "down_or_pending", "Pending choices", show=False),
184
+ Binding("right", "focus_document", "Document", show=False),
185
+ ]
186
+
187
+ def action_up_or_controls(self) -> None:
188
+ if self.cursor_line <= 0:
189
+ self.app._focus_controls()
190
+ else:
191
+ self.action_cursor_up()
192
+
193
+ def action_down_or_pending(self) -> None:
194
+ before = self.cursor_node
195
+ self.action_cursor_down()
196
+ if self.cursor_node is before:
197
+ self.app._focus_id("enablement-preview")
198
+
199
+ def action_focus_document(self) -> None:
200
+ self.app._focus_document()
201
+
202
+ async def action_toggle_item(self) -> None:
203
+ node = self.cursor_node
204
+ if node is not None and isinstance(node.data, str):
205
+ await self.app.toggle_enabled(node.data)
206
+ else:
207
+ self.action_toggle_node()
208
+
209
+
127
210
  class CorpusStudio(App[None]):
128
211
  """Searchable corpus library, editor, and revision-bound Preview → Apply flow."""
129
212
 
@@ -169,6 +252,9 @@ class CorpusStudio(App[None]):
169
252
  .actions { height: 3; align-horizontal: right; }
170
253
  .actions Button { margin-left: 1; }
171
254
  #status-line { height: 1; padding: 0 1; color: $text-muted; }
255
+ #enablement-bar { height: 3; display: none; }
256
+ #enablement-summary { width: 1fr; padding: 1; }
257
+ #enablement-bar Button { margin-right: 1; }
172
258
  """
173
259
 
174
260
  def __init__(self, store: Any) -> None:
@@ -183,13 +269,16 @@ class CorpusStudio(App[None]):
183
269
  self.view_item: dict[str, Any] | None = None
184
270
  self.member_drafts: dict[str, str] = {}
185
271
  self.pending_plan: dict[str, Any] | None = None
272
+ self.toggle_drafts: dict[str, bool] = {}
273
+ self.toggle_original: dict[str, bool] = {}
274
+ self.toggle_revision: str | None = None
186
275
  self.mode = "view"
187
276
 
188
277
  def compose(self) -> ComposeResult:
189
278
  yield Header()
190
279
  with Vertical(id="toolbar"):
191
280
  with Horizontal(id="search-row"):
192
- yield Input(placeholder="Search title, content, ref, package, domain, or state", id="search")
281
+ yield CorpusSearch(placeholder="Search title, content, ref, package, domain, or state (↓ controls)", id="search")
193
282
  yield Select([(view.title(), view) for view in VIEWS], value="effective",
194
283
  allow_blank=False, id="view-select")
195
284
  with Horizontal(id="action-row"):
@@ -200,7 +289,7 @@ class CorpusStudio(App[None]):
200
289
  yield Button("Recover", id="recover", compact=True)
201
290
  yield Button("Reset", id="reset", variant="error", compact=True)
202
291
  with Horizontal(id="workspace"):
203
- yield Tree("Corpus", id="library")
292
+ yield CorpusTree("Corpus", id="library")
204
293
  with Vertical(id="detail"):
205
294
  yield Select([], prompt="Bundle document", allow_blank=True, id="member-select")
206
295
  yield CorpusMarkdownViewer(
@@ -240,6 +329,10 @@ class CorpusStudio(App[None]):
240
329
  with Horizontal(classes="actions"):
241
330
  yield Button("Cancel plan", id="plan-cancel")
242
331
  yield Button("Apply", id="apply", variant="success")
332
+ with Horizontal(id="enablement-bar"):
333
+ yield Static("", id="enablement-summary", markup=False)
334
+ yield Button("Preview on/off", id="enablement-preview", variant="primary")
335
+ yield Button("Discard on/off", id="enablement-discard")
243
336
  yield Static("Stored privately; no host session is activated by this screen.", id="status-line")
244
337
  yield Footer()
245
338
 
@@ -247,6 +340,63 @@ class CorpusStudio(App[None]):
247
340
  await self.refresh_library()
248
341
  self.query_one("#search", Input).focus()
249
342
 
343
+ @staticmethod
344
+ def _focusable(widget: Any) -> bool:
345
+ return widget.focusable and all(getattr(node, "display", True) for node in [widget, *widget.ancestors])
346
+
347
+ def _focus_id(self, *ids: str) -> bool:
348
+ # Query the current screen only: arrows must never escape a modal.
349
+ for ident in ids:
350
+ for widget in self.screen.query(f"#{ident}"):
351
+ if self._focusable(widget):
352
+ widget.focus()
353
+ return True
354
+ return False
355
+
356
+ def _focus_controls(self) -> None:
357
+ for button in self.screen.query("#action-row Button"):
358
+ if self._focusable(button):
359
+ button.focus()
360
+ return
361
+ self._focus_id("library" if getattr(self.focused, "id", None) == "search" else "search")
362
+
363
+ def _focus_document(self) -> None:
364
+ if self.mode != "view" or self._focus_id("member-select"):
365
+ return
366
+ for viewer in self.screen.query("#wiki"):
367
+ if self._focusable(viewer.document):
368
+ viewer.document.focus()
369
+
370
+ def on_key(self, event: Key) -> None:
371
+ focused = self.focused
372
+ moved = False
373
+ if isinstance(focused, Button) and focused.parent is not None:
374
+ siblings = [button for button in focused.parent.query(Button)
375
+ if button.parent is focused.parent and self._focusable(button)]
376
+ if event.key in {"left", "right"} and focused in siblings:
377
+ index = siblings.index(focused) + (-1 if event.key == "left" else 1)
378
+ if 0 <= index < len(siblings):
379
+ siblings[index].focus()
380
+ moved = True
381
+ elif event.key in {"up", "down"} and len(self.screen_stack) == 1:
382
+ if focused.parent.id == "action-row":
383
+ moved = self._focus_id("search" if event.key == "up" else "library")
384
+ elif focused.parent.id == "enablement-bar":
385
+ moved = self._focus_id("library")
386
+ elif self.mode == "editor" and event.key == "up":
387
+ moved = self._focus_id("editor-body")
388
+ elif isinstance(focused, Select) and not focused.expanded and self.mode == "view":
389
+ if event.key == "left" and focused.id in {"view-select", "member-select"}:
390
+ moved = self._focus_id("search" if focused.id == "view-select" else "library")
391
+ elif event.key == "right" and focused.id == "member-select":
392
+ for viewer in self.screen.query("#wiki"):
393
+ if self._focusable(viewer.document):
394
+ viewer.document.focus()
395
+ moved = True
396
+ if moved:
397
+ event.prevent_default()
398
+ event.stop()
399
+
250
400
  def _set_mode(self, mode: str) -> None:
251
401
  self.mode = mode
252
402
  self.query_one("#member-select", Select).disabled = mode == "preview"
@@ -257,6 +407,83 @@ class CorpusStudio(App[None]):
257
407
  self.query_one("#wiki").display = mode == "view"
258
408
  self.query_one("#editor-panel").display = mode == "editor"
259
409
  self.query_one("#preview-panel").display = mode == "preview"
410
+ self._toggle_controls()
411
+ # Hiding a panel does not reliably release its focused child. Choose a
412
+ # visible, non-destructive entry point for the next screen state.
413
+ if mode == "preview":
414
+ self._focus_id("plan-cancel")
415
+ elif mode == "view":
416
+ self._focus_id("enablement-preview", "library")
417
+
418
+ def _display_items(self) -> list[dict[str, Any]]:
419
+ return [dict(row, enabled=self.toggle_drafts[row["ref"]], enabled_pending=True)
420
+ if row.get("ref") in self.toggle_drafts else row for row in self.items]
421
+
422
+ def _toggle_controls(self) -> None:
423
+ pending = bool(self.toggle_drafts)
424
+ self.query_one("#enablement-bar").display = pending and self.mode == "view"
425
+ self.query_one("#enablement-summary", Static).update(
426
+ f"{len(self.toggle_drafts)} unapplied on/off change(s) · * = pending")
427
+ if self.mode == "view":
428
+ for selector in ("#create", "#reset"):
429
+ self.query_one(selector).disabled = pending
430
+ if pending:
431
+ for selector in ("#edit", "#remove", "#restore", "#recover"):
432
+ self.query_one(selector).disabled = True
433
+
434
+ async def _repaint_enablement(self) -> None:
435
+ displayed = self._display_items()
436
+ by_ref = {row["ref"]: row for row in displayed}
437
+ nodes = [self.query_one("#library", Tree).root]
438
+ while nodes:
439
+ node = nodes.pop()
440
+ if isinstance(node.data, str) and node.data in by_ref:
441
+ node.set_label(library_label(by_ref[node.data], displayed))
442
+ nodes.extend(node.children)
443
+ self._toggle_controls()
444
+ if self.current_ref:
445
+ await self.select_ref(self.current_ref)
446
+
447
+ async def toggle_enabled(self, ref: str) -> None:
448
+ if self.mode != "view":
449
+ return
450
+ row = self._row(ref)
451
+ if row is None or row.get("state") == "removed":
452
+ self.notify("Recover or restore this item before changing its use.", severity="warning")
453
+ return
454
+ if not self.toggle_drafts:
455
+ self.toggle_revision = row["revision"]
456
+ original = self.toggle_original.setdefault(ref, row["enabled"])
457
+ value = not self.toggle_drafts.get(ref, row["enabled"])
458
+ if value == original:
459
+ self.toggle_drafts.pop(ref, None)
460
+ self.toggle_original.pop(ref, None)
461
+ else:
462
+ self.toggle_drafts[ref] = value
463
+ if not self.toggle_drafts:
464
+ self.toggle_revision = None
465
+ await self._repaint_enablement()
466
+
467
+ def _clear_toggle_drafts(self) -> None:
468
+ self.toggle_drafts.clear()
469
+ self.toggle_original.clear()
470
+ self.toggle_revision = None
471
+
472
+ async def _discard_toggles(self) -> None:
473
+ self._clear_toggle_drafts()
474
+ self._toggle_controls()
475
+ await self.refresh_library(self.query_one("#search", Input).value)
476
+ if self.current_ref:
477
+ await self.select_ref(self.current_ref)
478
+ self._focus_id("library")
479
+
480
+ def _preview_toggles(self) -> None:
481
+ if not self.toggle_drafts or self.mode != "view":
482
+ return
483
+ summary = "Use in future sessions (no content is deleted):\n" + "\n".join(
484
+ f"{'ON' if enabled else 'OFF'} {ref}" for ref, enabled in self.toggle_drafts.items())
485
+ self._stage_plan({"operation": "enable", "items": dict(self.toggle_drafts),
486
+ "expected_revision": self.toggle_revision}, summary)
260
487
 
261
488
  async def refresh_library(self, query: str = "") -> None:
262
489
  if self.mode != "view":
@@ -272,20 +499,22 @@ class CorpusStudio(App[None]):
272
499
  self.query_one("#status-line", Static).update(str(exc))
273
500
  return
274
501
  visible = [item for item in self.items if self._matches(item, query)]
502
+ displayed = self._display_items()
503
+ display_by_ref = {item["ref"]: item for item in displayed}
275
504
  groups: dict[tuple[str, str], Any] = {}
276
505
  state_nodes: dict[str, Any] = {}
277
506
  for item in visible:
278
507
  state = str(item.get("state", "active"))
279
508
  package = str(item.get("package_id", "unknown"))
280
509
  if state not in state_nodes:
281
- state_nodes[state] = tree.root.add(state.title())
510
+ state_nodes[state] = tree.root.add(availability_label(state).title())
282
511
  state_nodes[state].expand()
283
512
  key = (state, package)
284
513
  if key not in groups:
285
514
  groups[key] = state_nodes[state].add(package)
286
515
  groups[key].expand()
287
516
  groups[key].add_leaf(
288
- library_label(item, self.items),
517
+ library_label(display_by_ref[item["ref"]], displayed),
289
518
  data=item.get("ref"),
290
519
  )
291
520
  tree.root.expand()
@@ -296,6 +525,7 @@ class CorpusStudio(App[None]):
296
525
  )
297
526
  if visible and (self.current_ref is None or not any(i.get("ref") == self.current_ref for i in visible)):
298
527
  await self.select_ref(str(visible[0]["ref"]))
528
+ self._toggle_controls()
299
529
 
300
530
  @staticmethod
301
531
  def _matches(item: dict[str, Any], query: str) -> bool:
@@ -342,6 +572,7 @@ class CorpusStudio(App[None]):
342
572
  self.query_one("#remove", Button).disabled = row.get("state") == "removed"
343
573
  self.query_one("#restore", Button).disabled = not bool(row.get("baseline_ref"))
344
574
  self.query_one("#recover", Button).disabled = not self._recoverable(row)
575
+ self._toggle_controls()
345
576
 
346
577
  def _select_members(self, item: dict[str, Any] | None, preferred: str | None = None) -> None:
347
578
  selector = self.query_one("#member-select", Select)
@@ -376,7 +607,9 @@ class CorpusStudio(App[None]):
376
607
  + (f" Available members: {members}." if members else "") + "\n"
377
608
  )
378
609
  body = render_member_body(item.get("body", ""), item.get("primary_member"), item.get("kind"))
379
- pointers = guide_pointers(item, self.items)
610
+ displayed = self._display_items()
611
+ display_row = next((entry for entry in displayed if entry.get("ref") == item.get("ref")), row)
612
+ pointers = guide_pointers(item, displayed)
380
613
  guide_links = ""
381
614
  if pointers:
382
615
  lines = []
@@ -386,8 +619,9 @@ class CorpusStudio(App[None]):
386
619
  lines.append(f"- `{pointer['path']}` — {pointer['problem']}")
387
620
  else:
388
621
  destination = quote(target["ref"], safe="@/:")
622
+ use = "ON" if target.get("enabled", True) else "OFF — linked guide disabled"
389
623
  lines.append(f"- [{pointer['path']}](corpus://{destination}) — "
390
- f"**{target['surface']}** · {target.get('state', 'active')}")
624
+ f"**{target['surface']}** · {availability_label(target.get('state', 'active'))} · {use}")
391
625
  guide_links = (
392
626
  "## Guide pointer\n\nThis rule explicitly references the following guide(s). "
393
627
  "The rule keeps its own consumption surface; these links do not change "
@@ -395,8 +629,11 @@ class CorpusStudio(App[None]):
395
629
  )
396
630
  return (
397
631
  f"# {item.get('title', item.get('ref'))}\n\n"
398
- f"`{item.get('ref')}` · **{row.get('state', 'active')}** · "
632
+ f"`{item.get('ref')}` · **{availability_label(row.get('state', 'active'))}** · "
399
633
  f"{item.get('surface')} · {item.get('kind')}\n\n"
634
+ f"Use in future sessions: **{'ON' if display_row.get('enabled', True) else 'OFF'}**"
635
+ + (" (pending; not applied)" if display_row.get("enabled_pending") else " (saved selection)")
636
+ + ". Inclusion is not proof of loading or permission to execute.\n\n"
400
637
  f"{guide_links}{reconciliation}\n{body}\n\n## Dependencies\n\n{links}\n"
401
638
  + ("\n## Native consumption\n\nRequires explicit `agent-launch --corpus-native`.\n"
402
639
  if item.get("kind") == "hook" else "")
@@ -408,6 +645,26 @@ class CorpusStudio(App[None]):
408
645
  if isinstance(event.node.data, str):
409
646
  await self.select_ref(event.node.data)
410
647
 
648
+ async def on_tree_node_highlighted(self, event: Tree.NodeHighlighted) -> None:
649
+ tree = self.query_one("#library", Tree)
650
+ # Ignore queued highlights superseded by another cursor move or tree rebuild.
651
+ if self.mode != "view" or event.node is not tree.cursor_node:
652
+ return
653
+ if isinstance(event.node.data, str):
654
+ await self.select_ref(event.node.data)
655
+ return
656
+ # A group is not the previously displayed item: never leave its edit/delete
657
+ # actions live while the cursor points at a different kind of node.
658
+ self.current_ref = None
659
+ self.view_item = None
660
+ self._select_members(None)
661
+ for selector in ("#edit", "#remove", "#restore", "#recover"):
662
+ self.query_one(selector, Button).disabled = True
663
+ label = str(event.node.label.plain)
664
+ await self.query_one("#wiki", CorpusMarkdownViewer).document.update(
665
+ f"# Corpus group\n\n{label}\n\nMove the cursor to a corpus item to read it."
666
+ )
667
+
411
668
  async def on_markdown_link_clicked(self, message: Markdown.LinkClicked) -> None:
412
669
  # CorpusMarkdownViewer normally handles this before it bubbles. Keeping
413
670
  # the app handler makes direct Markdown messages safe in tests and future layouts.
@@ -459,6 +716,9 @@ class CorpusStudio(App[None]):
459
716
  def _open_editor(self, item: dict[str, Any] | None) -> None:
460
717
  if self.mode != "view":
461
718
  return
719
+ if self.toggle_drafts:
720
+ self.notify("Apply or discard on/off changes first.", severity="warning")
721
+ return
462
722
  preferred = self.current_member if item and item.get("ref") == self.current_ref else None
463
723
  self.editor_item = item
464
724
  self.editor_ref = str(item["ref"]) if item else None
@@ -577,6 +837,8 @@ class CorpusStudio(App[None]):
577
837
  async def action_request_quit(self) -> None:
578
838
  if self.editor_dirty():
579
839
  self.push_screen(Confirmation("Discard the unsaved draft and exit Studio?"), self._discard_and_exit)
840
+ elif self.toggle_drafts:
841
+ self.push_screen(Confirmation("Discard unapplied on/off changes and exit Studio?"), self._discard_and_exit)
580
842
  else:
581
843
  self.exit()
582
844
 
@@ -637,6 +899,9 @@ class CorpusStudio(App[None]):
637
899
  }, diff + hook_summary
638
900
 
639
901
  def _stage_plan(self, payload: dict[str, Any], summary: str) -> None:
902
+ if self.toggle_drafts and payload.get("operation") != "enable":
903
+ self.notify("Apply or discard on/off changes first.", severity="warning")
904
+ return
640
905
  try:
641
906
  plan = self.store.plan(payload)
642
907
  except Exception as exc:
@@ -665,6 +930,8 @@ class CorpusStudio(App[None]):
665
930
  self.notify(str(exc), severity="error")
666
931
  return
667
932
  ref = result.get("details", {}).get("ref")
933
+ if result.get("details", {}).get("operation") == "enable":
934
+ self._clear_toggle_drafts()
668
935
  self.pending_plan = None
669
936
  self._set_mode("view")
670
937
  await self.refresh_library(self.query_one("#search", Input).value)
@@ -674,7 +941,11 @@ class CorpusStudio(App[None]):
674
941
 
675
942
  async def on_button_pressed(self, event: Button.Pressed) -> None:
676
943
  button = event.button.id
677
- if button == "create":
944
+ if button == "enablement-preview":
945
+ self._preview_toggles()
946
+ elif button == "enablement-discard":
947
+ await self._discard_toggles()
948
+ elif button == "create":
678
949
  self.action_create()
679
950
  elif button == "edit":
680
951
  self.action_edit()
@@ -144,7 +144,12 @@ class CorpusUnderstand:
144
144
  return value
145
145
 
146
146
  def _bundles(self) -> list[dict]:
147
- rows = [x for x in self.store.list_items(include_removed=False) if x.get("state") == "active"]
147
+ # Projection preferences and the inventory read revision are not learning
148
+ # content. Disabled items remain readable and learnable without repinning
149
+ # every bundle when an unrelated activation preference changes.
150
+ rows = [{key: value for key, value in x.items()
151
+ if key not in {"enabled", "enabled_override", "revision"}}
152
+ for x in self.store.list_items(include_removed=False) if x.get("state") == "active"]
148
153
  bundles, assigned = [], set()
149
154
  for ident, title, purpose, numbers in CORE_BUNDLES:
150
155
  wanted = {f"rule-{n:03}" for n in numbers}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-bios",
3
- "version": "0.17.1",
3
+ "version": "0.18.0",
4
4
  "releaseDate": "2026-09-11",
5
5
  "description": "A thin, low-level instruction layer for LLM CLI agents: one set of principles and behavior whichever model you run. Stores a private, editable corpus for explicitly activated sessions.",
6
6
  "bin": {
package/provenance.json CHANGED
@@ -1 +1 @@
1
- {"commit":"ef9c746993ca2908f76ea41e2bfb7e097a18047f","committedAt":"2026-09-11T16:37:10+09:00","dirty":false}
1
+ {"commit":"588260e5b60f52f0f58b47e8d2ef7ae567b382a6","committedAt":"2026-09-11T21:09:27+09:00","dirty":false}