agent-bios 0.14.0 → 0.16.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.
Files changed (60) hide show
  1. package/DEPENDENCIES.md +35 -12
  2. package/README.md +346 -31
  3. package/claude/CLAUDE.md +2 -2
  4. package/claude/agents/frontier.md +1 -1
  5. package/claude/agents/sweep.md +3 -3
  6. package/claude/agents/workhorse.md +2 -2
  7. package/claude/guides/claude-prompting.md +119 -34
  8. package/claude/guides/cli-multi-model-workflow.md +33 -15
  9. package/claude/guides/gpt-prompting.md +148 -28
  10. package/claude/guides/review-request.md +27 -0
  11. package/claude/guides/session-distill-workflow.md +54 -2
  12. package/claude/guides/slide-writing/RUNBOOK.md +137 -0
  13. package/claude/guides/slide-writing/scripts/pair.py +979 -0
  14. package/claude/guides/slide-writing/scripts/render.mjs +82 -0
  15. package/claude/guides/slide-writing.md +195 -0
  16. package/claude/guides/svg-visualization-guide.md +9 -0
  17. package/claude/guides/verification-discipline.md +5 -1
  18. package/claude/hooks/tooling-gotchas-hook.py +7 -5
  19. package/codex/AGENTS.md +2 -2
  20. package/codex/agents/frontier.toml +2 -1
  21. package/codex/agents/reviewer.toml +1 -1
  22. package/codex/agents/sweep.toml +3 -3
  23. package/codex/agents/workhorse.toml +1 -1
  24. package/codex/config-additions.toml +1 -1
  25. package/codex/guides/claude-prompting.md +119 -34
  26. package/codex/guides/cli-multi-model-workflow.md +33 -15
  27. package/codex/guides/gpt-prompting.md +148 -28
  28. package/codex/guides/review-request.md +27 -0
  29. package/codex/guides/session-distill-workflow.md +54 -2
  30. package/codex/guides/slide-writing/RUNBOOK.md +137 -0
  31. package/codex/guides/slide-writing/scripts/pair.py +979 -0
  32. package/codex/guides/slide-writing/scripts/render.mjs +82 -0
  33. package/codex/guides/slide-writing.md +195 -0
  34. package/codex/guides/svg-visualization-guide.md +9 -0
  35. package/codex/guides/verification-discipline.md +5 -1
  36. package/compose/assemble.py +290 -14
  37. package/compose/bootstrap/SKILL.md +119 -0
  38. package/compose/check-domains.py +102 -9
  39. package/compose/corpus-state.py +1174 -0
  40. package/compose/corpus.py +387 -0
  41. package/compose/corpus_catalog.py +882 -0
  42. package/compose/corpus_install.py +1617 -0
  43. package/compose/corpus_session.py +726 -0
  44. package/compose/corpus_store.py +1414 -0
  45. package/compose/corpus_transaction.py +236 -0
  46. package/compose/corpus_ui.py +644 -0
  47. package/compose/domains.json +101 -100
  48. package/compose/write-update-cache.py +53 -0
  49. package/install.sh +174 -24
  50. package/launch/agent-launch.py +1327 -184
  51. package/launch/agent-launch.toml +12 -16
  52. package/launch/i18n/en.toml +113 -7
  53. package/launch/i18n/ja.toml +113 -7
  54. package/launch/i18n/ko.toml +113 -7
  55. package/learn/collect-learning.py +46 -19
  56. package/learn/migrate-learnings.py +10 -1
  57. package/package.json +13 -3
  58. package/provenance.json +1 -1
  59. package/session-cost.py +22 -2
  60. package/wrappers/codex-helm.sh +3 -3
@@ -0,0 +1,644 @@
1
+ #!/usr/bin/env python3
2
+ """Textual Corpus Studio.
3
+
4
+ Imported only for an interactive launch. The module contains no persistence
5
+ logic: every read, plan, and apply goes through the supplied ``CorpusStore``.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import difflib
10
+ import json
11
+ import re
12
+ from typing import Any
13
+ from urllib.parse import unquote
14
+
15
+ from textual.app import App, ComposeResult
16
+ from textual.binding import Binding
17
+ from textual.containers import Horizontal, Vertical
18
+ from textual.screen import ModalScreen
19
+ from textual.widgets import (
20
+ Button,
21
+ Footer,
22
+ Header,
23
+ Input,
24
+ Label,
25
+ Markdown,
26
+ MarkdownViewer,
27
+ Select,
28
+ Static,
29
+ TextArea,
30
+ Tree,
31
+ )
32
+
33
+ try:
34
+ from corpus_catalog import CLAUDE_HOOK_EVENTS
35
+ except ImportError: # pragma: no cover - package import from repository root
36
+ from .corpus_catalog import CLAUDE_HOOK_EVENTS
37
+
38
+
39
+ SURFACES = ("always", "relevant", "requested", "event", "delegated")
40
+ VIEWS = ("effective", "installed", "change", "diff", "history")
41
+
42
+
43
+ class Confirmation(ModalScreen[bool]):
44
+ """One bounded confirmation; the caller decides what the answer means."""
45
+
46
+ DEFAULT_CSS = """
47
+ Confirmation { align: center middle; }
48
+ Confirmation > Vertical { width: 62; height: auto; border: round $warning;
49
+ padding: 1 2; background: $surface; }
50
+ Confirmation Horizontal { height: auto; align-horizontal: right; }
51
+ Confirmation Button { margin-left: 1; }
52
+ """
53
+
54
+ def __init__(self, message: str) -> None:
55
+ super().__init__()
56
+ self.message = message
57
+
58
+ def compose(self) -> ComposeResult:
59
+ with Vertical():
60
+ yield Label(self.message, id="confirm-message")
61
+ with Horizontal():
62
+ yield Button("Keep editing", id="confirm-no")
63
+ yield Button("Discard", id="confirm-yes", variant="warning")
64
+
65
+ def on_button_pressed(self, event: Button.Pressed) -> None:
66
+ self.dismiss(event.button.id == "confirm-yes")
67
+
68
+
69
+ class CorpusMarkdownViewer(MarkdownViewer):
70
+ """Keep all links inside Studio; never hand a URL to the operating system."""
71
+
72
+ async def _on_markdown_link_clicked(self, message: Markdown.LinkClicked) -> None:
73
+ message.stop()
74
+ if message.href.startswith("corpus://"):
75
+ ref = unquote(message.href.removeprefix("corpus://"))
76
+ await self.app.select_ref(ref) # type: ignore[attr-defined]
77
+ else:
78
+ self.app.notify("External links are disabled in Corpus Studio.", severity="warning")
79
+
80
+
81
+ class CorpusStudio(App[None]):
82
+ """Searchable corpus library, editor, and revision-bound Preview → Apply flow."""
83
+
84
+ TITLE = "Corpus Studio"
85
+ SUB_TITLE = "Private authoring for future activated sessions"
86
+ BINDINGS = [
87
+ Binding("ctrl+f", "focus_search", "Search"),
88
+ Binding("ctrl+l", "focus_library", "Library"),
89
+ Binding("v", "cycle_view", "View"),
90
+ Binding("c", "create", "Create"),
91
+ Binding("e", "edit", "Edit"),
92
+ Binding("delete", "remove", "Remove"),
93
+ Binding("r", "restore", "Restore"),
94
+ Binding("shift+r", "recover", "Recover"),
95
+ Binding("f2", "focus_surface", "Surface"),
96
+ Binding("escape", "cancel", "Back"),
97
+ Binding("q", "request_quit", "Quit"),
98
+ ]
99
+ CSS = """
100
+ Screen { layout: vertical; }
101
+ #toolbar { height: 6; padding: 0 1; }
102
+ #search-row, #action-row { height: 3; }
103
+ #search { width: 1fr; }
104
+ #view-select { width: 18; margin-left: 1; }
105
+ #action-row Button { width: 1fr; min-width: 8; }
106
+ #workspace { height: 1fr; }
107
+ #library { width: 34; min-width: 24; border-right: solid $primary; }
108
+ #detail { width: 1fr; padding: 0 1; }
109
+ #member-select { display: none; height: 3; }
110
+ #wiki { height: 1fr; }
111
+ #editor-panel, #preview-panel { display: none; height: 1fr; }
112
+ #editor-title { height: 3; }
113
+ #editor-surface { height: 3; width: 32; }
114
+ #primary-member-row { display: none; height: 3; }
115
+ #primary-member-label { width: 20; }
116
+ #editor-primary-member { width: 1fr; }
117
+ #hook-binding-row { display: none; height: 3; }
118
+ #editor-hook-event { width: 26; }
119
+ #editor-hook-matcher { width: 1fr; margin-left: 1; }
120
+ #editor-body { height: 1fr; border: round $primary; }
121
+ #preview-text { height: 1fr; overflow: auto; border: round $warning;
122
+ padding: 1; }
123
+ .actions { height: 3; align-horizontal: right; }
124
+ .actions Button { margin-left: 1; }
125
+ #status-line { height: 1; padding: 0 1; color: $text-muted; }
126
+ """
127
+
128
+ def __init__(self, store: Any) -> None:
129
+ super().__init__()
130
+ self.store = store
131
+ self.items: list[dict[str, Any]] = []
132
+ self.current_ref: str | None = None
133
+ self.editor_ref: str | None = None
134
+ self.editor_item: dict[str, Any] | None = None
135
+ self.editor_initial = ("", "", "requested")
136
+ self.current_member: str | None = None
137
+ self.view_item: dict[str, Any] | None = None
138
+ self.member_drafts: dict[str, str] = {}
139
+ self.pending_plan: dict[str, Any] | None = None
140
+ self.mode = "view"
141
+
142
+ def compose(self) -> ComposeResult:
143
+ yield Header()
144
+ with Vertical(id="toolbar"):
145
+ with Horizontal(id="search-row"):
146
+ yield Input(placeholder="Search title, content, ref, package, domain, or state", id="search")
147
+ yield Select([(view.title(), view) for view in VIEWS], value="effective",
148
+ allow_blank=False, id="view-select")
149
+ with Horizontal(id="action-row"):
150
+ yield Button("Create", id="create", compact=True)
151
+ yield Button("Edit", id="edit", compact=True)
152
+ yield Button("Remove", id="remove", variant="warning", compact=True)
153
+ yield Button("Restore", id="restore", compact=True)
154
+ yield Button("Recover", id="recover", compact=True)
155
+ yield Button("Reset", id="reset", variant="error", compact=True)
156
+ with Horizontal(id="workspace"):
157
+ yield Tree("Corpus", id="library")
158
+ with Vertical(id="detail"):
159
+ yield Select([], prompt="Bundle document", allow_blank=True, id="member-select")
160
+ yield CorpusMarkdownViewer(
161
+ "# Corpus Studio\n\nSelect an item from the library.",
162
+ show_table_of_contents=False,
163
+ open_links=False,
164
+ id="wiki",
165
+ )
166
+ with Vertical(id="editor-panel"):
167
+ yield Input(placeholder="Title", id="editor-title")
168
+ yield Select(
169
+ [
170
+ ("Always in activated sessions", "always"),
171
+ ("When relevant", "relevant"),
172
+ ("When requested", "requested"),
173
+ ("On a matching event (adapter verification required)", "event"),
174
+ ("When delegated (adapter verification required)", "delegated"),
175
+ ],
176
+ value="requested", allow_blank=False, id="editor-surface",
177
+ )
178
+ with Horizontal(id="primary-member-row"):
179
+ yield Label("Reconcile primary", id="primary-member-label")
180
+ yield Select([], prompt="Choose member", allow_blank=True,
181
+ id="editor-primary-member")
182
+ with Horizontal(id="hook-binding-row"):
183
+ yield Select(
184
+ [(event, event) for event in sorted(CLAUDE_HOOK_EVENTS)],
185
+ value=sorted(CLAUDE_HOOK_EVENTS)[0], allow_blank=False, id="editor-hook-event",
186
+ )
187
+ yield Input(placeholder="Hook matcher", id="editor-hook-matcher")
188
+ yield TextArea(language="markdown", id="editor-body")
189
+ with Horizontal(classes="actions"):
190
+ yield Button("Cancel", id="editor-cancel")
191
+ yield Button("Preview", id="preview", variant="primary")
192
+ with Vertical(id="preview-panel"):
193
+ yield Static("", id="preview-text", markup=False)
194
+ with Horizontal(classes="actions"):
195
+ yield Button("Cancel plan", id="plan-cancel")
196
+ yield Button("Apply", id="apply", variant="success")
197
+ yield Static("Stored privately; no host session is activated by this screen.", id="status-line")
198
+ yield Footer()
199
+
200
+ async def on_mount(self) -> None:
201
+ await self.refresh_library()
202
+ self.query_one("#search", Input).focus()
203
+
204
+ def _set_mode(self, mode: str) -> None:
205
+ self.mode = mode
206
+ self.query_one("#member-select", Select).disabled = mode == "preview"
207
+ # Read navigation cannot change the target of an open draft or prepared plan.
208
+ for selector in ("#search", "#view-select", "#library", "#create", "#edit",
209
+ "#remove", "#restore", "#recover", "#reset"):
210
+ self.query_one(selector).disabled = mode != "view"
211
+ self.query_one("#wiki").display = mode == "view"
212
+ self.query_one("#editor-panel").display = mode == "editor"
213
+ self.query_one("#preview-panel").display = mode == "preview"
214
+
215
+ async def refresh_library(self, query: str = "") -> None:
216
+ if self.mode != "view":
217
+ return
218
+ tree = self.query_one("#library", Tree)
219
+ tree.reset("Corpus")
220
+ try:
221
+ self.items = self.store.list_items(include_removed=True)
222
+ except Exception as exc:
223
+ self.items = []
224
+ tree.root.add_leaf(f"Unavailable: {exc}")
225
+ tree.root.expand()
226
+ self.query_one("#status-line", Static).update(str(exc))
227
+ return
228
+ visible = [item for item in self.items if self._matches(item, query)]
229
+ groups: dict[tuple[str, str], Any] = {}
230
+ state_nodes: dict[str, Any] = {}
231
+ for item in visible:
232
+ state = str(item.get("state", "active"))
233
+ package = str(item.get("package_id", "unknown"))
234
+ if state not in state_nodes:
235
+ state_nodes[state] = tree.root.add(state.title())
236
+ state_nodes[state].expand()
237
+ key = (state, package)
238
+ if key not in groups:
239
+ groups[key] = state_nodes[state].add(package)
240
+ groups[key].expand()
241
+ groups[key].add_leaf(
242
+ f"{item.get('title', item.get('ref'))} · {item.get('surface', '?')}",
243
+ data=item.get("ref"),
244
+ )
245
+ tree.root.expand()
246
+ status = self.store.status()
247
+ self.query_one("#status-line", Static).update(
248
+ f"{len(visible)}/{len(self.items)} items · authoring revision {status.get('revision', '?')[:12]} · "
249
+ "changes affect future activated sessions only"
250
+ )
251
+ if visible and (self.current_ref is None or not any(i.get("ref") == self.current_ref for i in visible)):
252
+ await self.select_ref(str(visible[0]["ref"]))
253
+
254
+ @staticmethod
255
+ def _matches(item: dict[str, Any], query: str) -> bool:
256
+ if not query:
257
+ return True
258
+ fields = [item.get(key, "") for key in (
259
+ "ref", "title", "body", "surface", "tier", "kind", "state", "package_id"
260
+ )]
261
+ fields.extend(item.get("domains", []))
262
+ return query.casefold() in "\n".join(str(value) for value in fields).casefold()
263
+
264
+ def _row(self, ref: str | None = None) -> dict[str, Any] | None:
265
+ wanted = ref or self.current_ref
266
+ return next((item for item in self.items if item.get("ref") == wanted), None)
267
+
268
+ @staticmethod
269
+ def _recoverable(row: dict[str, Any]) -> bool:
270
+ return row.get("state") == "removed" and (
271
+ row.get("package_id") == "@local/personal"
272
+ or row.get("learning_source") is True
273
+ )
274
+
275
+ async def select_ref(self, ref: str) -> None:
276
+ if self.mode != "view":
277
+ return
278
+ row = self._row(ref)
279
+ if row is None:
280
+ self.notify(f"Unknown CorpusRef: {ref}", severity="error")
281
+ return
282
+ self.current_ref = ref
283
+ self.current_member = None
284
+ view_value = self.query_one("#view-select", Select).value
285
+ view = view_value if isinstance(view_value, str) else "effective"
286
+ try:
287
+ result = self.store.show(ref, view=view)
288
+ except Exception as exc:
289
+ self.notify(str(exc), severity="error")
290
+ return
291
+ self.view_item = result.get("item") if view == "effective" else None
292
+ self._select_members(self.view_item)
293
+ markdown = self._render_item(row, result, view)
294
+ await self.query_one("#wiki", CorpusMarkdownViewer).document.update(markdown)
295
+ self.query_one("#edit", Button).disabled = row.get("state") == "removed"
296
+ self.query_one("#remove", Button).disabled = row.get("state") == "removed"
297
+ self.query_one("#restore", Button).disabled = not bool(row.get("baseline_ref"))
298
+ self.query_one("#recover", Button).disabled = not self._recoverable(row)
299
+
300
+ def _select_members(self, item: dict[str, Any] | None, preferred: str | None = None) -> None:
301
+ selector = self.query_one("#member-select", Select)
302
+ members = item.get("members", {}) if item else {}
303
+ primary = item.get("primary_member") if item else None
304
+ usable = isinstance(members, dict) and primary in members
305
+ self.current_member = preferred if usable and preferred in members else primary if usable else None
306
+ selector.set_options([(name + (" (primary)" if name == primary else ""), name)
307
+ for name in members] if usable else [])
308
+ selector.value = self.current_member if self.current_member is not None else Select.NULL
309
+ selector.styles.display = "block" if usable and len(members) > 1 else "none"
310
+
311
+ def _editor_members(self) -> dict[str, str]:
312
+ members = dict(self.member_drafts)
313
+ if self.current_member in members:
314
+ members[self.current_member] = self.query_one("#editor-body", TextArea).text
315
+ return members
316
+
317
+ @staticmethod
318
+ def _render_item(row: dict[str, Any], result: dict[str, Any], view: str) -> str:
319
+ if view == "effective" and isinstance(result.get("item"), dict):
320
+ item = result["item"]
321
+ links = "\n".join(
322
+ f"- [{ref}](corpus://{ref})" for ref in item.get("dependencies", [])
323
+ ) or "None"
324
+ conflict = item.get("content_conflict")
325
+ reconciliation = ""
326
+ if isinstance(conflict, dict):
327
+ members = ", ".join(str(path) for path in conflict.get("members", []))
328
+ reconciliation = (
329
+ "\n> ⚠ Legacy content needs reconciliation before activation. "
330
+ "Edit the body to the intended member text, or choose a primary member through the API."
331
+ + (f" Available members: {members}." if members else "") + "\n"
332
+ )
333
+ return (
334
+ f"# {item.get('title', item.get('ref'))}\n\n"
335
+ f"`{item.get('ref')}` · **{row.get('state', 'active')}** · "
336
+ f"{item.get('surface')} · {item.get('kind')}\n\n"
337
+ f"{reconciliation}\n{item.get('body', '')}\n\n## Dependencies\n\n{links}\n"
338
+ + ("\n## Native consumption\n\nRequires explicit `agent-launch --corpus-native`.\n"
339
+ if item.get("kind") == "hook" else "")
340
+ )
341
+ payload = json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)
342
+ return f"# {row.get('title', row.get('ref'))}\n\n## {view.title()}\n\n```json\n{payload}\n```\n"
343
+
344
+ async def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
345
+ if isinstance(event.node.data, str):
346
+ await self.select_ref(event.node.data)
347
+
348
+ async def on_markdown_link_clicked(self, message: Markdown.LinkClicked) -> None:
349
+ # CorpusMarkdownViewer normally handles this before it bubbles. Keeping
350
+ # the app handler makes direct Markdown messages safe in tests and future layouts.
351
+ if message.href.startswith("corpus://"):
352
+ message.stop()
353
+ await self.select_ref(unquote(message.href.removeprefix("corpus://")))
354
+
355
+ async def on_input_changed(self, event: Input.Changed) -> None:
356
+ if event.input.id == "search":
357
+ await self.refresh_library(event.value)
358
+
359
+ async def on_select_changed(self, event: Select.Changed) -> None:
360
+ if event.select.id == "view-select" and self.mode == "view" and isinstance(event.value, str) and self.current_ref:
361
+ await self.select_ref(self.current_ref)
362
+ elif event.select.id == "member-select" and isinstance(event.value, str):
363
+ item = self.editor_item if self.mode == "editor" else self.view_item
364
+ if not item or event.value not in item.get("members", {}) or event.value == self.current_member:
365
+ return
366
+ if self.mode == "editor":
367
+ self.member_drafts = self._editor_members()
368
+ self.current_member = event.value
369
+ self.query_one("#editor-body", TextArea).text = self.member_drafts[event.value]
370
+ elif self.mode == "view":
371
+ self.current_member = event.value
372
+ primary = item.get("primary_member")
373
+ body = item["members"][event.value]
374
+ if event.value == primary:
375
+ markdown = self._render_item(self._row() or item, {"item": item}, "effective")
376
+ elif event.value.endswith(".md"):
377
+ markdown = f"# {event.value}\n\n{body}"
378
+ else:
379
+ fence = "`" * (max(2, max((len(run) for run in re.findall(r'`+', body)), default=0)) + 1)
380
+ markdown = f"# {event.value}\n\n{fence}\n{body}\n{fence}\n"
381
+ await self.query_one("#wiki", CorpusMarkdownViewer).document.update(markdown)
382
+
383
+ def editor_dirty(self) -> bool:
384
+ if self.mode != "editor":
385
+ return False
386
+ current = (
387
+ self.query_one("#editor-title", Input).value,
388
+ self._editor_members().get(self.editor_item.get("primary_member"), self.query_one("#editor-body", TextArea).text)
389
+ if self.editor_item else self.query_one("#editor-body", TextArea).text,
390
+ self.query_one("#editor-surface", Select).value,
391
+ self.query_one("#editor-primary-member", Select).value
392
+ if self.editor_item and isinstance(self.editor_item.get("content_conflict"), dict) else "",
393
+ self.query_one("#editor-hook-event", Select).value if self.editor_item and self.editor_item.get("kind") == "hook" else "",
394
+ self.query_one("#editor-hook-matcher", Input).value if self.editor_item and self.editor_item.get("kind") == "hook" else "",
395
+ )
396
+ return current != self.editor_initial or bool(self.editor_item and self.member_drafts
397
+ and self._editor_members() != self.editor_item.get("members", {}))
398
+
399
+ def _open_editor(self, item: dict[str, Any] | None) -> None:
400
+ if self.mode != "view":
401
+ return
402
+ preferred = self.current_member if item and item.get("ref") == self.current_ref else None
403
+ self.editor_item = item
404
+ self.editor_ref = str(item["ref"]) if item else None
405
+ title = str(item.get("title", "")) if item else ""
406
+ body = str(item.get("body", "")) if item else ""
407
+ surface = str(item.get("surface", "requested")) if item else "requested"
408
+ hook = item.get("hook") if item else None
409
+ is_hook = bool(item and item.get("kind") == "hook")
410
+ conflict = item.get("content_conflict") if item else None
411
+ self.member_drafts = dict(item.get("members", {})) if item and not conflict else {}
412
+ self._select_members(item if not conflict else None, preferred)
413
+ conflict_members = conflict.get("members", []) if isinstance(conflict, dict) else []
414
+ primary_select = self.query_one("#editor-primary-member", Select)
415
+ primary_select.set_options([(str(path), str(path)) for path in conflict_members])
416
+ primary_select.clear()
417
+ self.query_one("#primary-member-row").styles.display = "block" if conflict_members else "none"
418
+ self.query_one("#editor-title", Input).value = title
419
+ self.query_one("#editor-body", TextArea).text = self.member_drafts.get(self.current_member, body)
420
+ self.query_one("#editor-surface", Select).value = surface
421
+ self.query_one("#hook-binding-row").styles.display = "block" if is_hook else "none"
422
+ if is_hook:
423
+ event = hook.get("event") if isinstance(hook, dict) else sorted(CLAUDE_HOOK_EVENTS)[0]
424
+ matcher = hook.get("matcher") if isinstance(hook, dict) else ""
425
+ self.query_one("#editor-hook-event", Select).value = event
426
+ self.query_one("#editor-hook-matcher", Input).value = matcher
427
+ else:
428
+ self.query_one("#editor-hook-matcher", Input).value = ""
429
+ self.editor_initial = (title, body, surface, primary_select.value if conflict_members else "",
430
+ event if is_hook else "", matcher if is_hook else "")
431
+ self._set_mode("editor")
432
+ self.query_one("#editor-title", Input).focus()
433
+
434
+ def action_create(self) -> None:
435
+ self._open_editor(None)
436
+
437
+ def action_edit(self) -> None:
438
+ row = self._row()
439
+ if row is None or row.get("state") == "removed":
440
+ self.notify("Select an active item first.", severity="warning")
441
+ return
442
+ self._open_editor(row)
443
+
444
+ def action_focus_search(self) -> None:
445
+ self.query_one("#search", Input).focus()
446
+
447
+ def action_focus_library(self) -> None:
448
+ self.query_one("#library", Tree).focus()
449
+
450
+ def action_focus_surface(self) -> None:
451
+ if self.mode == "editor":
452
+ self.query_one("#editor-surface", Select).focus()
453
+
454
+ def action_cycle_view(self) -> None:
455
+ view = self.query_one("#view-select", Select)
456
+ current = view.value if isinstance(view.value, str) else VIEWS[0]
457
+ view.value = VIEWS[(VIEWS.index(current) + 1) % len(VIEWS)]
458
+
459
+ def _stage_current_operation(self, operation: str) -> None:
460
+ if self.mode != "view":
461
+ return
462
+ row = self._row()
463
+ if row is None:
464
+ self.notify("Select an item first.", severity="warning")
465
+ return
466
+ if operation == "remove" and row.get("state") == "removed":
467
+ self.notify("The selected item is already removed.", severity="warning")
468
+ return
469
+ if operation == "restore" and not row.get("baseline_ref"):
470
+ self.notify("Restore is available only for an installed item.", severity="warning")
471
+ return
472
+ if operation == "recover" and not self._recoverable(row):
473
+ self.notify(
474
+ "Recover is available only for a removed personal item or learning.",
475
+ severity="warning",
476
+ )
477
+ return
478
+ payload: dict[str, Any] = {"operation": operation, "ref": row["ref"]}
479
+ if operation == "remove":
480
+ payload["item_digest"] = row.get("digest")
481
+ self._stage_plan(payload, f"{operation.title()} {row['ref']} for future snapshots.")
482
+
483
+ def action_remove(self) -> None:
484
+ self._stage_current_operation("remove")
485
+
486
+ def action_restore(self) -> None:
487
+ self._stage_current_operation("restore")
488
+
489
+ def action_recover(self) -> None:
490
+ self._stage_current_operation("recover")
491
+
492
+ async def action_cancel(self) -> None:
493
+ if self.mode == "preview":
494
+ self.pending_plan = None
495
+ self._set_mode("view")
496
+ if self.current_ref:
497
+ await self.select_ref(self.current_ref)
498
+ elif self.mode == "editor":
499
+ await self._cancel_editor()
500
+ else:
501
+ await self.action_request_quit()
502
+
503
+ async def _cancel_editor(self) -> None:
504
+ if not self.editor_dirty():
505
+ self._set_mode("view")
506
+ if self.current_ref:
507
+ await self.select_ref(self.current_ref)
508
+ return
509
+ self.push_screen(Confirmation("Discard the unsaved draft?"), self._discard_editor)
510
+
511
+ async def _discard_editor(self, discard: bool | None) -> None:
512
+ if discard:
513
+ self._set_mode("view")
514
+ if self.current_ref:
515
+ await self.select_ref(self.current_ref)
516
+
517
+ async def action_request_quit(self) -> None:
518
+ if self.editor_dirty():
519
+ self.push_screen(Confirmation("Discard the unsaved draft and exit Studio?"), self._discard_and_exit)
520
+ else:
521
+ self.exit()
522
+
523
+ def _discard_and_exit(self, discard: bool | None) -> None:
524
+ if discard:
525
+ self.exit()
526
+
527
+ def _editor_payload(self) -> tuple[dict[str, Any], str]:
528
+ title = self.query_one("#editor-title", Input).value.strip()
529
+ body = self.query_one("#editor-body", TextArea).text
530
+ members = self._editor_members()
531
+ if self.editor_item and self.member_drafts:
532
+ body = members[self.editor_item["primary_member"]]
533
+ value = self.query_one("#editor-surface", Select).value
534
+ surface = value if isinstance(value, str) else "requested"
535
+ if not title:
536
+ raise ValueError("Title is required.")
537
+ if self.editor_item is None:
538
+ item = {
539
+ "title": title, "body": body, "surface": surface,
540
+ "tier": "env-personal", "domains": ["personal"], "kind": "rule",
541
+ "members": {"content.md": body},
542
+ }
543
+ return {"operation": "create", "item": item}, f"Create {title!r} on {surface}."
544
+ old = str(self.editor_item.get("body", ""))
545
+ patch = {
546
+ "title": title, "body": body, "surface": surface,
547
+ }
548
+ if self.member_drafts and members != self.editor_item.get("members", {}):
549
+ patch["members"] = members
550
+ if isinstance(self.editor_item.get("content_conflict"), dict):
551
+ primary = self.query_one("#editor-primary-member", Select).value
552
+ if not isinstance(primary, str):
553
+ raise ValueError("Choose the primary member to reconcile legacy content.")
554
+ patch["primary_member"] = primary
555
+ if self.editor_item.get("kind") == "hook":
556
+ event = self.query_one("#editor-hook-event", Select).value
557
+ matcher = self.query_one("#editor-hook-matcher", Input).value
558
+ if not isinstance(event, str) or event not in CLAUDE_HOOK_EVENTS:
559
+ raise ValueError("Choose a supported Claude hook event.")
560
+ if not matcher or "\n" in matcher or "\r" in matcher:
561
+ raise ValueError("Hook matcher must be one non-empty line.")
562
+ patch["hook"] = {"event": event, "matcher": matcher}
563
+ diff = "".join(difflib.unified_diff(
564
+ old.splitlines(True), body.splitlines(True), fromfile="current", tofile="planned",
565
+ )) or "(content unchanged; metadata will change)"
566
+ if "members" in patch:
567
+ diff = "\n".join("".join(difflib.unified_diff(
568
+ self.editor_item["members"][name].splitlines(True), value.splitlines(True),
569
+ fromfile=f"current/{name}", tofile=f"planned/{name}",
570
+ )) for name, value in members.items() if value != self.editor_item["members"][name])
571
+ hook_summary = ""
572
+ if "hook" in patch:
573
+ hook_summary = f"\nHook binding: {patch['hook']['event']} / {patch['hook']['matcher']}"
574
+ return {
575
+ "operation": "update", "ref": self.editor_item["ref"],
576
+ "item_digest": self.editor_item["digest"], "patch": patch,
577
+ }, diff + hook_summary
578
+
579
+ def _stage_plan(self, payload: dict[str, Any], summary: str) -> None:
580
+ try:
581
+ plan = self.store.plan(payload)
582
+ except Exception as exc:
583
+ self.notify(str(exc), severity="error")
584
+ return
585
+ self.pending_plan = plan
586
+ preview = (
587
+ f"{summary}\n\nPlan: {plan['plan_id']}\n"
588
+ f"Expected revision: {plan['expected_revision']}\n"
589
+ f"Result revision: {plan['result_revision']}\n\n"
590
+ "Pinned/running sessions will not change. Apply affects future activated sessions only.\n\n"
591
+ f"Details:\n{json.dumps(plan.get('details', {}), ensure_ascii=False, indent=2, sort_keys=True)}"
592
+ )
593
+ self.query_one("#preview-text", Static).update(preview)
594
+ self._set_mode("preview")
595
+
596
+ async def _apply_pending(self) -> None:
597
+ if self.pending_plan is None:
598
+ return
599
+ try:
600
+ result = self.store.apply(
601
+ self.pending_plan["plan_id"],
602
+ expected_revision=self.pending_plan["expected_revision"],
603
+ )
604
+ except Exception as exc:
605
+ self.notify(str(exc), severity="error")
606
+ return
607
+ ref = result.get("details", {}).get("ref")
608
+ self.pending_plan = None
609
+ self._set_mode("view")
610
+ await self.refresh_library(self.query_one("#search", Input).value)
611
+ if isinstance(ref, str) and self._row(ref):
612
+ await self.select_ref(ref)
613
+ self.notify("Applied. Future activated sessions use the new revision.")
614
+
615
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
616
+ button = event.button.id
617
+ if button == "create":
618
+ self.action_create()
619
+ elif button == "edit":
620
+ self.action_edit()
621
+ elif button == "editor-cancel":
622
+ await self._cancel_editor()
623
+ elif button == "preview":
624
+ try:
625
+ payload, summary = self._editor_payload()
626
+ except ValueError as exc:
627
+ self.notify(str(exc), severity="error")
628
+ else:
629
+ self._stage_plan(payload, summary)
630
+ elif button == "plan-cancel":
631
+ await self.action_cancel()
632
+ elif button == "apply":
633
+ await self._apply_pending()
634
+ elif button in {"remove", "restore", "recover"}:
635
+ self._stage_current_operation(button)
636
+ elif button == "reset":
637
+ self._stage_plan(
638
+ {"operation": "reset"},
639
+ "Reset future authoring to the last successful install tuple; preserve sources, history, and pins.",
640
+ )
641
+
642
+
643
+ def run(store: Any) -> None:
644
+ CorpusStudio(store).run()