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