agent-bios 0.17.0 → 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
@@ -283,6 +283,52 @@ arbitrary package authoring/import, automatic semantic conflict resolution, or v
283
283
  native skill-menu registration; those design stages must not be inferred from the
284
284
  library UI.
285
285
 
286
+ Studio marks rules containing explicit `guides/*.md` references with **→ GUIDE**
287
+ and the guide names in the library. Their **Guide pointer** section links to exact
288
+ guide members in the same package and shows each target's current consumption surface
289
+ and state. Missing or ambiguous targets are disclosed rather than guessed. These
290
+ are display-only references, not new dependencies or proof of loading: rule bodies,
291
+ IDs, ordering, selection, and delivery remain unchanged. Personal body edits are
292
+ reflected when the library refreshes; ordinary rules are not classified by meaning.
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
+
286
332
  New personal identities are allocated once in the creation plan and remain stable
287
333
  on retry; reset and history rollback do not make retired identities reusable.
288
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
@@ -11,11 +11,14 @@ import json
11
11
  import re
12
12
  from pathlib import PurePosixPath
13
13
  from typing import Any
14
- from urllib.parse import unquote
14
+ from urllib.parse import quote, unquote
15
+
16
+ from rich.text import Text
15
17
 
16
18
  from textual.app import App, ComposeResult
17
19
  from textual.binding import Binding
18
20
  from textual.containers import Horizontal, Vertical
21
+ from textual.events import Key
19
22
  from textual.screen import ModalScreen
20
23
  from textual.widgets import (
21
24
  Button,
@@ -41,6 +44,40 @@ SURFACES = ("always", "relevant", "requested", "event", "delegated")
41
44
  VIEWS = ("effective", "installed", "change", "diff", "history")
42
45
 
43
46
 
47
+ def availability_label(state: str) -> str:
48
+ return "available" if state == "active" else state
49
+
50
+
51
+ def guide_pointers(item: dict[str, Any], inventory: list[dict[str, Any]]) -> list[dict[str, Any]]:
52
+ """Display literal guide references, not inferred dependencies or new routing."""
53
+ if item.get("kind") != "rule":
54
+ return []
55
+ paths = dict.fromkeys(re.findall(r"\bguides/[A-Za-z0-9][A-Za-z0-9_./-]*\.md\b", item.get("body", "")))
56
+ result = []
57
+ for path in paths:
58
+ if ".." in PurePosixPath(path).parts:
59
+ continue
60
+ matches = [candidate for candidate in inventory
61
+ if candidate.get("package_id") == item.get("package_id")
62
+ and candidate.get("kind") == "guide" and path in candidate.get("members", {})]
63
+ result.append({"path": path, "target": matches[0] if len(matches) == 1 else None,
64
+ "problem": "Ambiguous guide reference" if matches else "Not found in this package"})
65
+ return result
66
+
67
+
68
+ def library_label(item: dict[str, Any], inventory: list[dict[str, Any]]) -> Text:
69
+ pointers = guide_pointers(item, inventory)
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 " "))
73
+ if pointers:
74
+ label.append("→ GUIDE ", style="bold cyan")
75
+ label.append(", ".join(PurePosixPath(pointer["path"]).stem for pointer in pointers))
76
+ label.append(" · ")
77
+ label.append(f"{item.get('title', item.get('ref'))} · {item.get('surface', '?')}")
78
+ return label
79
+
80
+
44
81
  def render_member_body(body: str, member: str | None, kind: str | None = None) -> str:
45
82
  """Render prose as Markdown and preserve source files as literal code."""
46
83
  suffix = PurePosixPath(member).suffix.lower() if member else ""
@@ -82,7 +119,35 @@ class Confirmation(ModalScreen[bool]):
82
119
  class CorpusMarkdownViewer(MarkdownViewer):
83
120
  """Keep all links inside Studio; never hand a URL to the operating system."""
84
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
+
85
147
  async def _on_markdown_link_clicked(self, message: Markdown.LinkClicked) -> None:
148
+ # stop() prevents bubbling, not the inherited MarkdownViewer handler.
149
+ # Its default go() would try to load corpus:// as a filesystem path.
150
+ message.prevent_default()
86
151
  message.stop()
87
152
  if message.href.startswith("corpus://"):
88
153
  ref = unquote(message.href.removeprefix("corpus://"))
@@ -91,6 +156,57 @@ class CorpusMarkdownViewer(MarkdownViewer):
91
156
  self.app.notify("External links are disabled in Corpus Studio.", severity="warning")
92
157
 
93
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
+
94
210
  class CorpusStudio(App[None]):
95
211
  """Searchable corpus library, editor, and revision-bound Preview → Apply flow."""
96
212
 
@@ -136,6 +252,9 @@ class CorpusStudio(App[None]):
136
252
  .actions { height: 3; align-horizontal: right; }
137
253
  .actions Button { margin-left: 1; }
138
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; }
139
258
  """
140
259
 
141
260
  def __init__(self, store: Any) -> None:
@@ -150,13 +269,16 @@ class CorpusStudio(App[None]):
150
269
  self.view_item: dict[str, Any] | None = None
151
270
  self.member_drafts: dict[str, str] = {}
152
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
153
275
  self.mode = "view"
154
276
 
155
277
  def compose(self) -> ComposeResult:
156
278
  yield Header()
157
279
  with Vertical(id="toolbar"):
158
280
  with Horizontal(id="search-row"):
159
- 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")
160
282
  yield Select([(view.title(), view) for view in VIEWS], value="effective",
161
283
  allow_blank=False, id="view-select")
162
284
  with Horizontal(id="action-row"):
@@ -167,7 +289,7 @@ class CorpusStudio(App[None]):
167
289
  yield Button("Recover", id="recover", compact=True)
168
290
  yield Button("Reset", id="reset", variant="error", compact=True)
169
291
  with Horizontal(id="workspace"):
170
- yield Tree("Corpus", id="library")
292
+ yield CorpusTree("Corpus", id="library")
171
293
  with Vertical(id="detail"):
172
294
  yield Select([], prompt="Bundle document", allow_blank=True, id="member-select")
173
295
  yield CorpusMarkdownViewer(
@@ -207,6 +329,10 @@ class CorpusStudio(App[None]):
207
329
  with Horizontal(classes="actions"):
208
330
  yield Button("Cancel plan", id="plan-cancel")
209
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")
210
336
  yield Static("Stored privately; no host session is activated by this screen.", id="status-line")
211
337
  yield Footer()
212
338
 
@@ -214,6 +340,63 @@ class CorpusStudio(App[None]):
214
340
  await self.refresh_library()
215
341
  self.query_one("#search", Input).focus()
216
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
+
217
400
  def _set_mode(self, mode: str) -> None:
218
401
  self.mode = mode
219
402
  self.query_one("#member-select", Select).disabled = mode == "preview"
@@ -224,6 +407,83 @@ class CorpusStudio(App[None]):
224
407
  self.query_one("#wiki").display = mode == "view"
225
408
  self.query_one("#editor-panel").display = mode == "editor"
226
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)
227
487
 
228
488
  async def refresh_library(self, query: str = "") -> None:
229
489
  if self.mode != "view":
@@ -239,20 +499,22 @@ class CorpusStudio(App[None]):
239
499
  self.query_one("#status-line", Static).update(str(exc))
240
500
  return
241
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}
242
504
  groups: dict[tuple[str, str], Any] = {}
243
505
  state_nodes: dict[str, Any] = {}
244
506
  for item in visible:
245
507
  state = str(item.get("state", "active"))
246
508
  package = str(item.get("package_id", "unknown"))
247
509
  if state not in state_nodes:
248
- state_nodes[state] = tree.root.add(state.title())
510
+ state_nodes[state] = tree.root.add(availability_label(state).title())
249
511
  state_nodes[state].expand()
250
512
  key = (state, package)
251
513
  if key not in groups:
252
514
  groups[key] = state_nodes[state].add(package)
253
515
  groups[key].expand()
254
516
  groups[key].add_leaf(
255
- f"{item.get('title', item.get('ref'))} · {item.get('surface', '?')}",
517
+ library_label(display_by_ref[item["ref"]], displayed),
256
518
  data=item.get("ref"),
257
519
  )
258
520
  tree.root.expand()
@@ -263,6 +525,7 @@ class CorpusStudio(App[None]):
263
525
  )
264
526
  if visible and (self.current_ref is None or not any(i.get("ref") == self.current_ref for i in visible)):
265
527
  await self.select_ref(str(visible[0]["ref"]))
528
+ self._toggle_controls()
266
529
 
267
530
  @staticmethod
268
531
  def _matches(item: dict[str, Any], query: str) -> bool:
@@ -309,6 +572,7 @@ class CorpusStudio(App[None]):
309
572
  self.query_one("#remove", Button).disabled = row.get("state") == "removed"
310
573
  self.query_one("#restore", Button).disabled = not bool(row.get("baseline_ref"))
311
574
  self.query_one("#recover", Button).disabled = not self._recoverable(row)
575
+ self._toggle_controls()
312
576
 
313
577
  def _select_members(self, item: dict[str, Any] | None, preferred: str | None = None) -> None:
314
578
  selector = self.query_one("#member-select", Select)
@@ -327,8 +591,7 @@ class CorpusStudio(App[None]):
327
591
  members[self.current_member] = self.query_one("#editor-body", TextArea).text
328
592
  return members
329
593
 
330
- @staticmethod
331
- def _render_item(row: dict[str, Any], result: dict[str, Any], view: str) -> str:
594
+ def _render_item(self, row: dict[str, Any], result: dict[str, Any], view: str) -> str:
332
595
  if view == "effective" and isinstance(result.get("item"), dict):
333
596
  item = result["item"]
334
597
  links = "\n".join(
@@ -344,11 +607,34 @@ class CorpusStudio(App[None]):
344
607
  + (f" Available members: {members}." if members else "") + "\n"
345
608
  )
346
609
  body = render_member_body(item.get("body", ""), item.get("primary_member"), item.get("kind"))
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)
613
+ guide_links = ""
614
+ if pointers:
615
+ lines = []
616
+ for pointer in pointers:
617
+ target = pointer["target"]
618
+ if target is None:
619
+ lines.append(f"- `{pointer['path']}` — {pointer['problem']}")
620
+ else:
621
+ destination = quote(target["ref"], safe="@/:")
622
+ use = "ON" if target.get("enabled", True) else "OFF — linked guide disabled"
623
+ lines.append(f"- [{pointer['path']}](corpus://{destination}) — "
624
+ f"**{target['surface']}** · {availability_label(target.get('state', 'active'))} · {use}")
625
+ guide_links = (
626
+ "## Guide pointer\n\nThis rule explicitly references the following guide(s). "
627
+ "The rule keeps its own consumption surface; these links do not change "
628
+ "delivery or prove that a guide was loaded.\n\n" + "\n".join(lines) + "\n\n## Rule\n\n"
629
+ )
347
630
  return (
348
631
  f"# {item.get('title', item.get('ref'))}\n\n"
349
- f"`{item.get('ref')}` · **{row.get('state', 'active')}** · "
632
+ f"`{item.get('ref')}` · **{availability_label(row.get('state', 'active'))}** · "
350
633
  f"{item.get('surface')} · {item.get('kind')}\n\n"
351
- f"{reconciliation}\n{body}\n\n## Dependencies\n\n{links}\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"
637
+ f"{guide_links}{reconciliation}\n{body}\n\n## Dependencies\n\n{links}\n"
352
638
  + ("\n## Native consumption\n\nRequires explicit `agent-launch --corpus-native`.\n"
353
639
  if item.get("kind") == "hook" else "")
354
640
  )
@@ -359,6 +645,26 @@ class CorpusStudio(App[None]):
359
645
  if isinstance(event.node.data, str):
360
646
  await self.select_ref(event.node.data)
361
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
+
362
668
  async def on_markdown_link_clicked(self, message: Markdown.LinkClicked) -> None:
363
669
  # CorpusMarkdownViewer normally handles this before it bubbles. Keeping
364
670
  # the app handler makes direct Markdown messages safe in tests and future layouts.
@@ -410,6 +716,9 @@ class CorpusStudio(App[None]):
410
716
  def _open_editor(self, item: dict[str, Any] | None) -> None:
411
717
  if self.mode != "view":
412
718
  return
719
+ if self.toggle_drafts:
720
+ self.notify("Apply or discard on/off changes first.", severity="warning")
721
+ return
413
722
  preferred = self.current_member if item and item.get("ref") == self.current_ref else None
414
723
  self.editor_item = item
415
724
  self.editor_ref = str(item["ref"]) if item else None
@@ -528,6 +837,8 @@ class CorpusStudio(App[None]):
528
837
  async def action_request_quit(self) -> None:
529
838
  if self.editor_dirty():
530
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)
531
842
  else:
532
843
  self.exit()
533
844
 
@@ -588,6 +899,9 @@ class CorpusStudio(App[None]):
588
899
  }, diff + hook_summary
589
900
 
590
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
591
905
  try:
592
906
  plan = self.store.plan(payload)
593
907
  except Exception as exc:
@@ -616,6 +930,8 @@ class CorpusStudio(App[None]):
616
930
  self.notify(str(exc), severity="error")
617
931
  return
618
932
  ref = result.get("details", {}).get("ref")
933
+ if result.get("details", {}).get("operation") == "enable":
934
+ self._clear_toggle_drafts()
619
935
  self.pending_plan = None
620
936
  self._set_mode("view")
621
937
  await self.refresh_library(self.query_one("#search", Input).value)
@@ -625,7 +941,11 @@ class CorpusStudio(App[None]):
625
941
 
626
942
  async def on_button_pressed(self, event: Button.Pressed) -> None:
627
943
  button = event.button.id
628
- 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":
629
949
  self.action_create()
630
950
  elif button == "edit":
631
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.0",
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":"7b616f4ae5366b1395560b3a88556287ac36122d","committedAt":"2026-09-11T15:52:49+09:00","dirty":false}
1
+ {"commit":"588260e5b60f52f0f58b47e8d2ef7ae567b382a6","committedAt":"2026-09-11T21:09:27+09:00","dirty":false}