agent-bios 0.19.1 → 0.19.2

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 (85) hide show
  1. package/DEPENDENCIES.md +27 -27
  2. package/INSTALL.md +4 -4
  3. package/README.md +53 -26
  4. package/claude/CLAUDE.md +1 -1
  5. package/claude/guides/claude-prompting.md +1 -1
  6. package/claude/guides/cli-multi-model-workflow.md +3 -3
  7. package/claude/guides/documentation-hygiene.md +3 -0
  8. package/claude/guides/gpt-prompting.md +1 -1
  9. package/claude/guides/korean-writing.md +153 -0
  10. package/claude/guides/learning-flow.md +4 -4
  11. package/claude/guides/session-distill-workflow.md +8 -8
  12. package/claude/guides/slide-writing/RUNBOOK.md +5 -5
  13. package/claude/skills/repo-charter/SKILL.md +3 -3
  14. package/claude/skills/understand/SKILL.md +5 -5
  15. package/codex/AGENTS.md +1 -1
  16. package/codex/guides/claude-prompting.md +1 -1
  17. package/codex/guides/cli-multi-model-workflow.md +3 -3
  18. package/codex/guides/documentation-hygiene.md +3 -0
  19. package/codex/guides/gpt-prompting.md +1 -1
  20. package/codex/guides/korean-writing.md +153 -0
  21. package/codex/guides/learning-flow.md +4 -4
  22. package/codex/guides/session-distill-workflow.md +8 -8
  23. package/codex/guides/slide-writing/RUNBOOK.md +5 -5
  24. package/compose/app_bridge/SKILL.md +12 -12
  25. package/compose/app_bridge/scripts/bridge.py +15 -7
  26. package/compose/assemble.py +5 -5
  27. package/compose/bootstrap/SKILL.md +18 -18
  28. package/compose/canary.sh +4 -4
  29. package/compose/check-domains.py +6 -6
  30. package/compose/corpus-state.py +16 -1168
  31. package/compose/corpus.py +13 -402
  32. package/compose/corpus_app.py +14 -450
  33. package/compose/corpus_catalog.py +15 -926
  34. package/compose/corpus_import.py +14 -523
  35. package/compose/corpus_install.py +14 -1847
  36. package/compose/corpus_session.py +16 -848
  37. package/compose/corpus_setup.py +16 -672
  38. package/compose/corpus_setup_cli.py +15 -580
  39. package/compose/corpus_setup_i18n.py +20 -324
  40. package/compose/corpus_setup_ui.py +18 -645
  41. package/compose/corpus_store.py +16 -1664
  42. package/compose/corpus_transaction.py +15 -284
  43. package/compose/corpus_ui.py +17 -972
  44. package/compose/corpus_ui_runtime.py +16 -274
  45. package/compose/corpus_understand.py +13 -671
  46. package/compose/domains.json +2 -1
  47. package/compose/instructions-state.py +1175 -0
  48. package/compose/instructions.py +409 -0
  49. package/compose/instructions_app.py +464 -0
  50. package/compose/instructions_catalog.py +931 -0
  51. package/compose/instructions_import.py +529 -0
  52. package/compose/instructions_install.py +1866 -0
  53. package/compose/instructions_session.py +852 -0
  54. package/compose/instructions_setup.py +676 -0
  55. package/compose/instructions_setup_cli.py +586 -0
  56. package/compose/instructions_setup_i18n.py +324 -0
  57. package/compose/instructions_setup_ui.py +647 -0
  58. package/compose/instructions_store.py +1668 -0
  59. package/compose/instructions_transaction.py +306 -0
  60. package/compose/instructions_ui.py +975 -0
  61. package/compose/instructions_ui_runtime.py +278 -0
  62. package/compose/instructions_understand.py +678 -0
  63. package/compose/register-hooks.py +1 -1
  64. package/compose/setup/START.md +11 -11
  65. package/docs/advanced-launch.md +11 -11
  66. package/docs/instructions-compatibility.md +86 -0
  67. package/docs/{corpus.md → instructions.md} +35 -8
  68. package/docs/recovery.md +10 -10
  69. package/docs/releases/0.19.2.md +38 -0
  70. package/docs/session-model.md +23 -20
  71. package/docs/setup.md +27 -26
  72. package/docs/understand.md +6 -6
  73. package/install.sh +70 -69
  74. package/launch/agent-launch.py +298 -290
  75. package/launch/agent-launch.toml +2 -2
  76. package/launch/i18n/en.toml +55 -55
  77. package/launch/i18n/ja.toml +56 -56
  78. package/launch/i18n/ko.toml +56 -56
  79. package/launch/shell_integration.py +4 -4
  80. package/learn/collect-learning.py +10 -10
  81. package/learn/learning.schema.json +1 -1
  82. package/learn/migrate-learnings.py +51 -51
  83. package/package.json +27 -11
  84. package/provenance.json +1 -1
  85. /package/docs/assets/{corpus-studio.svg → instructions-studio.svg} +0 -0
@@ -1,975 +1,20 @@
1
1
  #!/usr/bin/env python3
2
- """Textual Corpus Studio.
2
+ """Compatibility entrypoint for corpus_ui.py; canonical implementation: instructions_ui.py.
3
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``.
4
+ Retained for installed bridges, saved commands and third-party imports. Remove
5
+ only after the documented compatibility window and historical consumers close.
6
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 quote, unquote
15
-
16
- from rich.text import Text
17
-
18
- from textual.app import App, ComposeResult
19
- from textual.binding import Binding
20
- from textual.containers import Horizontal, Vertical
21
- from textual.events import Key
22
- from textual.screen import ModalScreen
23
- from textual.widgets import (
24
- Button,
25
- Footer,
26
- Header,
27
- Input,
28
- Label,
29
- Markdown,
30
- MarkdownViewer,
31
- Select,
32
- Static,
33
- TextArea,
34
- Tree,
35
- )
36
-
37
- try:
38
- from corpus_catalog import HOOK_EVENTS
39
- except ImportError: # pragma: no cover - package import from repository root
40
- from .corpus_catalog import HOOK_EVENTS
41
-
42
-
43
- SURFACES = ("always", "relevant", "requested", "event", "delegated")
44
- VIEWS = ("effective", "installed", "change", "diff", "history")
45
-
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
-
81
- def render_member_body(body: str, member: str | None, kind: str | None = None) -> str:
82
- """Render prose as Markdown and preserve source files as literal code."""
83
- suffix = PurePosixPath(member).suffix.lower() if member else ""
84
- if suffix in {".md", ".markdown"} or (not member and kind != "hook"):
85
- return body
86
- language = {".py": "python", ".json": "json", ".toml": "toml",
87
- ".sh": "bash", ".zsh": "bash", ".yaml": "yaml", ".yml": "yaml",
88
- ".js": "javascript", ".ts": "typescript"}.get(suffix, "")
89
- fence = "`" * max(3, 1 + max((len(run) for run in re.findall(r"`+", body)), default=0))
90
- return f"{fence}{language}\n{body}\n{fence}"
91
-
92
-
93
- class Confirmation(ModalScreen[bool]):
94
- """One bounded confirmation; the caller decides what the answer means."""
95
-
96
- DEFAULT_CSS = """
97
- Confirmation { align: center middle; }
98
- Confirmation > Vertical { width: 62; height: auto; border: round $warning;
99
- padding: 1 2; background: $surface; }
100
- Confirmation Horizontal { height: auto; align-horizontal: right; }
101
- Confirmation Button { margin-left: 1; }
102
- """
103
-
104
- def __init__(self, message: str) -> None:
105
- super().__init__()
106
- self.message = message
107
-
108
- def compose(self) -> ComposeResult:
109
- with Vertical():
110
- yield Label(self.message, id="confirm-message")
111
- with Horizontal():
112
- yield Button("Keep editing", id="confirm-no")
113
- yield Button("Discard", id="confirm-yes", variant="warning")
114
-
115
- def on_button_pressed(self, event: Button.Pressed) -> None:
116
- self.dismiss(event.button.id == "confirm-yes")
117
-
118
-
119
- class CorpusMarkdownViewer(MarkdownViewer):
120
- """Keep all links inside Studio; never hand a URL to the operating system."""
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
-
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()
151
- message.stop()
152
- if message.href.startswith("corpus://"):
153
- ref = unquote(message.href.removeprefix("corpus://"))
154
- await self.app.select_ref(ref) # type: ignore[attr-defined]
155
- else:
156
- self.app.notify("External links are disabled in Corpus Studio.", severity="warning")
157
-
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
-
210
- class CorpusStudio(App[None]):
211
- """Searchable corpus library, editor, and revision-bound Preview → Apply flow."""
212
-
213
- TITLE = "Corpus Studio"
214
- SUB_TITLE = "Private authoring for future activated sessions"
215
- BINDINGS = [
216
- Binding("ctrl+f", "focus_search", "Search"),
217
- Binding("ctrl+l", "focus_library", "Library"),
218
- Binding("v", "cycle_view", "View"),
219
- Binding("c", "create", "Create"),
220
- Binding("e", "edit", "Edit"),
221
- Binding("delete", "remove", "Remove"),
222
- Binding("r", "restore", "Restore"),
223
- Binding("shift+r", "recover", "Recover"),
224
- Binding("f2", "focus_surface", "Surface"),
225
- Binding("escape", "cancel", "Back"),
226
- Binding("q", "request_quit", "Quit"),
227
- ]
228
- CSS = """
229
- Screen { layout: vertical; }
230
- #toolbar { height: 6; padding: 0 1; }
231
- #search-row, #action-row { height: 3; }
232
- #search { width: 1fr; }
233
- #view-select { width: 18; margin-left: 1; }
234
- #action-row Button { width: 1fr; min-width: 8; }
235
- #workspace { height: 1fr; }
236
- #library { width: 34; min-width: 24; border-right: solid $primary; }
237
- #detail { width: 1fr; padding: 0 1; }
238
- #member-select { display: none; height: 3; }
239
- #wiki { height: 1fr; }
240
- #editor-panel, #preview-panel { display: none; height: 1fr; }
241
- #editor-title { height: 3; }
242
- #editor-surface { height: 3; width: 32; }
243
- #primary-member-row { display: none; height: 3; }
244
- #primary-member-label { width: 20; }
245
- #editor-primary-member { width: 1fr; }
246
- #hook-binding-row { display: none; height: 3; }
247
- #editor-hook-event { width: 26; }
248
- #editor-hook-matcher { width: 1fr; margin-left: 1; }
249
- #editor-body { height: 1fr; border: round $primary; }
250
- #preview-text { height: 1fr; overflow: auto; border: round $warning;
251
- padding: 1; }
252
- .actions { height: 3; align-horizontal: right; }
253
- .actions Button { margin-left: 1; }
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; }
258
- """
259
-
260
- def __init__(self, store: Any) -> None:
261
- super().__init__()
262
- self.store = store
263
- self.items: list[dict[str, Any]] = []
264
- self.current_ref: str | None = None
265
- self.editor_ref: str | None = None
266
- self.editor_item: dict[str, Any] | None = None
267
- self.editor_initial = ("", "", "requested")
268
- self.current_member: str | None = None
269
- self.view_item: dict[str, Any] | None = None
270
- self.member_drafts: dict[str, str] = {}
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
275
- self.mode = "view"
276
-
277
- def compose(self) -> ComposeResult:
278
- yield Header()
279
- with Vertical(id="toolbar"):
280
- with Horizontal(id="search-row"):
281
- yield CorpusSearch(placeholder="Search title, content, ref, package, domain, or state (↓ controls)", id="search")
282
- yield Select([(view.title(), view) for view in VIEWS], value="effective",
283
- allow_blank=False, id="view-select")
284
- with Horizontal(id="action-row"):
285
- yield Button("Create", id="create", compact=True)
286
- yield Button("Edit", id="edit", compact=True)
287
- yield Button("Remove", id="remove", variant="warning", compact=True)
288
- yield Button("Restore", id="restore", compact=True)
289
- yield Button("Recover", id="recover", compact=True)
290
- yield Button("Reset", id="reset", variant="error", compact=True)
291
- with Horizontal(id="workspace"):
292
- yield CorpusTree("Corpus", id="library")
293
- with Vertical(id="detail"):
294
- yield Select([], prompt="Bundle document", allow_blank=True, id="member-select")
295
- yield CorpusMarkdownViewer(
296
- "# Corpus Studio\n\nSelect an item from the library.",
297
- show_table_of_contents=False,
298
- open_links=False,
299
- id="wiki",
300
- )
301
- with Vertical(id="editor-panel"):
302
- yield Input(placeholder="Title", id="editor-title")
303
- yield Select(
304
- [
305
- ("Always in activated sessions", "always"),
306
- ("When relevant", "relevant"),
307
- ("When requested", "requested"),
308
- ("On a matching event (adapter verification required)", "event"),
309
- ("When delegated (adapter verification required)", "delegated"),
310
- ],
311
- value="requested", allow_blank=False, id="editor-surface",
312
- )
313
- with Horizontal(id="primary-member-row"):
314
- yield Label("Reconcile primary", id="primary-member-label")
315
- yield Select([], prompt="Choose member", allow_blank=True,
316
- id="editor-primary-member")
317
- with Horizontal(id="hook-binding-row"):
318
- yield Select(
319
- [(event, event) for event in sorted(HOOK_EVENTS)],
320
- value=sorted(HOOK_EVENTS)[0], allow_blank=False, id="editor-hook-event",
321
- )
322
- yield Input(placeholder="Hook matcher", id="editor-hook-matcher")
323
- yield TextArea(language="markdown", id="editor-body")
324
- with Horizontal(classes="actions"):
325
- yield Button("Cancel", id="editor-cancel")
326
- yield Button("Preview", id="preview", variant="primary")
327
- with Vertical(id="preview-panel"):
328
- yield Static("", id="preview-text", markup=False)
329
- with Horizontal(classes="actions"):
330
- yield Button("Cancel plan", id="plan-cancel")
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")
336
- yield Static("Stored privately; no host session is activated by this screen.", id="status-line")
337
- yield Footer()
338
-
339
- async def on_mount(self) -> None:
340
- await self.refresh_library()
341
- self.query_one("#search", Input).focus()
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
-
400
- def _set_mode(self, mode: str) -> None:
401
- self.mode = mode
402
- self.query_one("#member-select", Select).disabled = mode == "preview"
403
- # Read navigation cannot change the target of an open draft or prepared plan.
404
- for selector in ("#search", "#view-select", "#library", "#create", "#edit",
405
- "#remove", "#restore", "#recover", "#reset"):
406
- self.query_one(selector).disabled = mode != "view"
407
- self.query_one("#wiki").display = mode == "view"
408
- self.query_one("#editor-panel").display = mode == "editor"
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)
487
-
488
- async def refresh_library(self, query: str = "") -> None:
489
- if self.mode != "view":
490
- return
491
- tree = self.query_one("#library", Tree)
492
- tree.reset("Corpus")
493
- try:
494
- self.items = self.store.list_items(include_removed=True)
495
- except Exception as exc:
496
- self.items = []
497
- tree.root.add_leaf(f"Unavailable: {exc}")
498
- tree.root.expand()
499
- self.query_one("#status-line", Static).update(str(exc))
500
- return
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}
504
- groups: dict[tuple[str, str], Any] = {}
505
- state_nodes: dict[str, Any] = {}
506
- for item in visible:
507
- state = str(item.get("state", "active"))
508
- package = str(item.get("package_id", "unknown"))
509
- if state not in state_nodes:
510
- state_nodes[state] = tree.root.add(availability_label(state).title())
511
- state_nodes[state].expand()
512
- key = (state, package)
513
- if key not in groups:
514
- groups[key] = state_nodes[state].add(package)
515
- groups[key].expand()
516
- groups[key].add_leaf(
517
- library_label(display_by_ref[item["ref"]], displayed),
518
- data=item.get("ref"),
519
- )
520
- tree.root.expand()
521
- status = self.store.status()
522
- self.query_one("#status-line", Static).update(
523
- f"{len(visible)}/{len(self.items)} items · authoring revision {status.get('revision', '?')[:12]} · "
524
- "changes affect future activated sessions only"
525
- )
526
- if visible and (self.current_ref is None or not any(i.get("ref") == self.current_ref for i in visible)):
527
- await self.select_ref(str(visible[0]["ref"]))
528
- self._toggle_controls()
529
-
530
- @staticmethod
531
- def _matches(item: dict[str, Any], query: str) -> bool:
532
- if not query:
533
- return True
534
- fields = [item.get(key, "") for key in (
535
- "ref", "title", "body", "surface", "tier", "kind", "state", "package_id"
536
- )]
537
- fields.extend(item.get("domains", []))
538
- return query.casefold() in "\n".join(str(value) for value in fields).casefold()
539
-
540
- def _row(self, ref: str | None = None) -> dict[str, Any] | None:
541
- wanted = ref or self.current_ref
542
- return next((item for item in self.items if item.get("ref") == wanted), None)
543
-
544
- @staticmethod
545
- def _recoverable(row: dict[str, Any]) -> bool:
546
- return row.get("state") == "removed" and (
547
- row.get("package_id") == "@local/personal"
548
- or row.get("learning_source") is True
549
- )
550
-
551
- async def select_ref(self, ref: str) -> None:
552
- if self.mode != "view":
553
- return
554
- row = self._row(ref)
555
- if row is None:
556
- self.notify(f"Unknown CorpusRef: {ref}", severity="error")
557
- return
558
- self.current_ref = ref
559
- self.current_member = None
560
- view_value = self.query_one("#view-select", Select).value
561
- view = view_value if isinstance(view_value, str) else "effective"
562
- try:
563
- result = self.store.show(ref, view=view)
564
- except Exception as exc:
565
- self.notify(str(exc), severity="error")
566
- return
567
- self.view_item = result.get("item") if view == "effective" else None
568
- self._select_members(self.view_item)
569
- markdown = self._render_item(row, result, view)
570
- await self.query_one("#wiki", CorpusMarkdownViewer).document.update(markdown)
571
- self.query_one("#edit", Button).disabled = row.get("state") == "removed"
572
- self.query_one("#remove", Button).disabled = row.get("state") == "removed"
573
- self.query_one("#restore", Button).disabled = not bool(row.get("baseline_ref"))
574
- self.query_one("#recover", Button).disabled = not self._recoverable(row)
575
- self._toggle_controls()
576
-
577
- def _select_members(self, item: dict[str, Any] | None, preferred: str | None = None) -> None:
578
- selector = self.query_one("#member-select", Select)
579
- members = item.get("members", {}) if item else {}
580
- primary = item.get("primary_member") if item else None
581
- usable = isinstance(members, dict) and primary in members
582
- self.current_member = preferred if usable and preferred in members else primary if usable else None
583
- selector.set_options([(name + (" (primary)" if name == primary else ""), name)
584
- for name in members] if usable else [])
585
- selector.value = self.current_member if self.current_member is not None else Select.NULL
586
- selector.styles.display = "block" if usable and len(members) > 1 else "none"
587
-
588
- def _editor_members(self) -> dict[str, str]:
589
- members = dict(self.member_drafts)
590
- if self.current_member in members:
591
- members[self.current_member] = self.query_one("#editor-body", TextArea).text
592
- return members
593
-
594
- def _render_item(self, row: dict[str, Any], result: dict[str, Any], view: str) -> str:
595
- if view == "effective" and isinstance(result.get("item"), dict):
596
- item = result["item"]
597
- links = "\n".join(
598
- f"- [{ref}](corpus://{ref})" for ref in item.get("dependencies", [])
599
- ) or "None"
600
- conflict = item.get("content_conflict")
601
- reconciliation = ""
602
- if isinstance(conflict, dict):
603
- members = ", ".join(str(path) for path in conflict.get("members", []))
604
- reconciliation = (
605
- "\n> ⚠ Legacy content needs reconciliation before activation. "
606
- "Edit the body to the intended member text, or choose a primary member through the API."
607
- + (f" Available members: {members}." if members else "") + "\n"
608
- )
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
- )
630
- return (
631
- f"# {item.get('title', item.get('ref'))}\n\n"
632
- f"`{item.get('ref')}` · **{availability_label(row.get('state', 'active'))}** · "
633
- f"{item.get('surface')} · {item.get('kind')}\n\n"
634
- f"Use in future sessions: **{'ON' if display_row.get('enabled', True) else 'OFF'}**"
635
- + (" (pending; not applied)" if display_row.get("enabled_pending") else " (saved selection)")
636
- + ". Inclusion is not proof of loading or permission to execute.\n\n"
637
- f"{guide_links}{reconciliation}\n{body}\n\n## Dependencies\n\n{links}\n"
638
- + ("\n## Native consumption\n\nRequires explicit `agent-launch --corpus-native`.\n"
639
- if item.get("kind") == "hook" else "")
640
- )
641
- payload = json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True)
642
- return f"# {row.get('title', row.get('ref'))}\n\n## {view.title()}\n\n```json\n{payload}\n```\n"
643
-
644
- async def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
645
- if isinstance(event.node.data, str):
646
- await self.select_ref(event.node.data)
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
-
668
- async def on_markdown_link_clicked(self, message: Markdown.LinkClicked) -> None:
669
- # CorpusMarkdownViewer normally handles this before it bubbles. Keeping
670
- # the app handler makes direct Markdown messages safe in tests and future layouts.
671
- if message.href.startswith("corpus://"):
672
- message.stop()
673
- await self.select_ref(unquote(message.href.removeprefix("corpus://")))
674
-
675
- async def on_input_changed(self, event: Input.Changed) -> None:
676
- if event.input.id == "search":
677
- await self.refresh_library(event.value)
678
-
679
- async def on_select_changed(self, event: Select.Changed) -> None:
680
- if event.select.id == "view-select" and self.mode == "view" and isinstance(event.value, str) and self.current_ref:
681
- await self.select_ref(self.current_ref)
682
- elif event.select.id == "member-select" and isinstance(event.value, str):
683
- item = self.editor_item if self.mode == "editor" else self.view_item
684
- if not item or event.value not in item.get("members", {}) or event.value == self.current_member:
685
- return
686
- if self.mode == "editor":
687
- self.member_drafts = self._editor_members()
688
- self.current_member = event.value
689
- self.query_one("#editor-body", TextArea).text = self.member_drafts[event.value]
690
- elif self.mode == "view":
691
- self.current_member = event.value
692
- primary = item.get("primary_member")
693
- body = item["members"][event.value]
694
- if event.value == primary:
695
- markdown = self._render_item(self._row() or item, {"item": item}, "effective")
696
- else:
697
- markdown = f"# {event.value}\n\n{render_member_body(body, event.value)}\n"
698
- await self.query_one("#wiki", CorpusMarkdownViewer).document.update(markdown)
699
-
700
- def editor_dirty(self) -> bool:
701
- if self.mode != "editor":
702
- return False
703
- current = (
704
- self.query_one("#editor-title", Input).value,
705
- self._editor_members().get(self.editor_item.get("primary_member"), self.query_one("#editor-body", TextArea).text)
706
- if self.editor_item else self.query_one("#editor-body", TextArea).text,
707
- self.query_one("#editor-surface", Select).value,
708
- self.query_one("#editor-primary-member", Select).value
709
- if self.editor_item and isinstance(self.editor_item.get("content_conflict"), dict) else "",
710
- self.query_one("#editor-hook-event", Select).value if self.editor_item and self.editor_item.get("kind") == "hook" else "",
711
- self.query_one("#editor-hook-matcher", Input).value if self.editor_item and self.editor_item.get("kind") == "hook" else "",
712
- )
713
- return current != self.editor_initial or bool(self.editor_item and self.member_drafts
714
- and self._editor_members() != self.editor_item.get("members", {}))
715
-
716
- def _open_editor(self, item: dict[str, Any] | None) -> None:
717
- if self.mode != "view":
718
- return
719
- if self.toggle_drafts:
720
- self.notify("Apply or discard on/off changes first.", severity="warning")
721
- return
722
- preferred = self.current_member if item and item.get("ref") == self.current_ref else None
723
- self.editor_item = item
724
- self.editor_ref = str(item["ref"]) if item else None
725
- title = str(item.get("title", "")) if item else ""
726
- body = str(item.get("body", "")) if item else ""
727
- surface = str(item.get("surface", "requested")) if item else "requested"
728
- hook = item.get("hook") if item else None
729
- is_hook = bool(item and item.get("kind") == "hook")
730
- conflict = item.get("content_conflict") if item else None
731
- self.member_drafts = dict(item.get("members", {})) if item and not conflict else {}
732
- self._select_members(item if not conflict else None, preferred)
733
- conflict_members = conflict.get("members", []) if isinstance(conflict, dict) else []
734
- primary_select = self.query_one("#editor-primary-member", Select)
735
- primary_select.set_options([(str(path), str(path)) for path in conflict_members])
736
- primary_select.clear()
737
- self.query_one("#primary-member-row").styles.display = "block" if conflict_members else "none"
738
- self.query_one("#editor-title", Input).value = title
739
- self.query_one("#editor-body", TextArea).text = self.member_drafts.get(self.current_member, body)
740
- self.query_one("#editor-surface", Select).value = surface
741
- self.query_one("#hook-binding-row").styles.display = "block" if is_hook else "none"
742
- if is_hook:
743
- event = hook.get("event") if isinstance(hook, dict) else sorted(HOOK_EVENTS)[0]
744
- matcher = hook.get("matcher") if isinstance(hook, dict) else ""
745
- self.query_one("#editor-hook-event", Select).value = event
746
- self.query_one("#editor-hook-matcher", Input).value = matcher
747
- else:
748
- self.query_one("#editor-hook-matcher", Input).value = ""
749
- self.editor_initial = (title, body, surface, primary_select.value if conflict_members else "",
750
- event if is_hook else "", matcher if is_hook else "")
751
- self._set_mode("editor")
752
- self.query_one("#editor-title", Input).focus()
753
-
754
- def action_create(self) -> None:
755
- self._open_editor(None)
756
-
757
- def action_edit(self) -> None:
758
- row = self._row()
759
- if row is None or row.get("state") == "removed":
760
- self.notify("Select an active item first.", severity="warning")
761
- return
762
- self._open_editor(row)
763
-
764
- def action_focus_search(self) -> None:
765
- self.query_one("#search", Input).focus()
766
-
767
- def action_focus_library(self) -> None:
768
- self.query_one("#library", Tree).focus()
769
-
770
- def action_focus_surface(self) -> None:
771
- if self.mode == "editor":
772
- self.query_one("#editor-surface", Select).focus()
773
-
774
- def action_cycle_view(self) -> None:
775
- view = self.query_one("#view-select", Select)
776
- current = view.value if isinstance(view.value, str) else VIEWS[0]
777
- view.value = VIEWS[(VIEWS.index(current) + 1) % len(VIEWS)]
778
-
779
- def _stage_current_operation(self, operation: str) -> None:
780
- if self.mode != "view":
781
- return
782
- row = self._row()
783
- if row is None:
784
- self.notify("Select an item first.", severity="warning")
785
- return
786
- if operation == "remove" and row.get("state") == "removed":
787
- self.notify("The selected item is already removed.", severity="warning")
788
- return
789
- if operation == "restore" and not row.get("baseline_ref"):
790
- self.notify("Restore is available only for an installed item.", severity="warning")
791
- return
792
- if operation == "recover" and not self._recoverable(row):
793
- self.notify(
794
- "Recover is available only for a removed personal item or learning.",
795
- severity="warning",
796
- )
797
- return
798
- payload: dict[str, Any] = {"operation": operation, "ref": row["ref"]}
799
- if operation == "remove":
800
- payload["item_digest"] = row.get("digest")
801
- self._stage_plan(payload, f"{operation.title()} {row['ref']} for future snapshots.")
802
-
803
- def action_remove(self) -> None:
804
- self._stage_current_operation("remove")
805
-
806
- def action_restore(self) -> None:
807
- self._stage_current_operation("restore")
808
-
809
- def action_recover(self) -> None:
810
- self._stage_current_operation("recover")
811
-
812
- async def action_cancel(self) -> None:
813
- if self.mode == "preview":
814
- self.pending_plan = None
815
- self._set_mode("view")
816
- if self.current_ref:
817
- await self.select_ref(self.current_ref)
818
- elif self.mode == "editor":
819
- await self._cancel_editor()
820
- else:
821
- await self.action_request_quit()
822
-
823
- async def _cancel_editor(self) -> None:
824
- if not self.editor_dirty():
825
- self._set_mode("view")
826
- if self.current_ref:
827
- await self.select_ref(self.current_ref)
828
- return
829
- self.push_screen(Confirmation("Discard the unsaved draft?"), self._discard_editor)
830
-
831
- async def _discard_editor(self, discard: bool | None) -> None:
832
- if discard:
833
- self._set_mode("view")
834
- if self.current_ref:
835
- await self.select_ref(self.current_ref)
836
-
837
- async def action_request_quit(self) -> None:
838
- if self.editor_dirty():
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)
842
- else:
843
- self.exit()
844
-
845
- def _discard_and_exit(self, discard: bool | None) -> None:
846
- if discard:
847
- self.exit()
848
-
849
- def _editor_payload(self) -> tuple[dict[str, Any], str]:
850
- title = self.query_one("#editor-title", Input).value.strip()
851
- body = self.query_one("#editor-body", TextArea).text
852
- members = self._editor_members()
853
- if self.editor_item and self.member_drafts:
854
- body = members[self.editor_item["primary_member"]]
855
- value = self.query_one("#editor-surface", Select).value
856
- surface = value if isinstance(value, str) else "requested"
857
- if not title:
858
- raise ValueError("Title is required.")
859
- if self.editor_item is None:
860
- item = {
861
- "title": title, "body": body, "surface": surface,
862
- "tier": "env-personal", "domains": ["personal"], "kind": "rule",
863
- "members": {"content.md": body},
864
- }
865
- return {"operation": "create", "item": item}, f"Create {title!r} on {surface}."
866
- old = str(self.editor_item.get("body", ""))
867
- patch = {
868
- "title": title, "body": body, "surface": surface,
869
- }
870
- if self.member_drafts and members != self.editor_item.get("members", {}):
871
- patch["members"] = members
872
- if isinstance(self.editor_item.get("content_conflict"), dict):
873
- primary = self.query_one("#editor-primary-member", Select).value
874
- if not isinstance(primary, str):
875
- raise ValueError("Choose the primary member to reconcile legacy content.")
876
- patch["primary_member"] = primary
877
- if self.editor_item.get("kind") == "hook":
878
- event = self.query_one("#editor-hook-event", Select).value
879
- matcher = self.query_one("#editor-hook-matcher", Input).value
880
- if not isinstance(event, str) or event not in HOOK_EVENTS:
881
- raise ValueError("Choose a supported hook event.")
882
- if not matcher or "\n" in matcher or "\r" in matcher:
883
- raise ValueError("Hook matcher must be one non-empty line.")
884
- patch["hook"] = {"event": event, "matcher": matcher}
885
- diff = "".join(difflib.unified_diff(
886
- old.splitlines(True), body.splitlines(True), fromfile="current", tofile="planned",
887
- )) or "(content unchanged; metadata will change)"
888
- if "members" in patch:
889
- diff = "\n".join("".join(difflib.unified_diff(
890
- self.editor_item["members"][name].splitlines(True), value.splitlines(True),
891
- fromfile=f"current/{name}", tofile=f"planned/{name}",
892
- )) for name, value in members.items() if value != self.editor_item["members"][name])
893
- hook_summary = ""
894
- if "hook" in patch:
895
- hook_summary = f"\nHook binding: {patch['hook']['event']} / {patch['hook']['matcher']}"
896
- return {
897
- "operation": "update", "ref": self.editor_item["ref"],
898
- "item_digest": self.editor_item["digest"], "patch": patch,
899
- }, diff + hook_summary
900
-
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
905
- try:
906
- plan = self.store.plan(payload)
907
- except Exception as exc:
908
- self.notify(str(exc), severity="error")
909
- return
910
- self.pending_plan = plan
911
- preview = (
912
- f"{summary}\n\nPlan: {plan['plan_id']}\n"
913
- f"Expected revision: {plan['expected_revision']}\n"
914
- f"Result revision: {plan['result_revision']}\n\n"
915
- "Pinned/running sessions will not change. Apply affects future activated sessions only.\n\n"
916
- f"Details:\n{json.dumps(plan.get('details', {}), ensure_ascii=False, indent=2, sort_keys=True)}"
917
- )
918
- self.query_one("#preview-text", Static).update(preview)
919
- self._set_mode("preview")
920
-
921
- async def _apply_pending(self) -> None:
922
- if self.pending_plan is None:
923
- return
924
- try:
925
- result = self.store.apply(
926
- self.pending_plan["plan_id"],
927
- expected_revision=self.pending_plan["expected_revision"],
928
- )
929
- except Exception as exc:
930
- self.notify(str(exc), severity="error")
931
- return
932
- ref = result.get("details", {}).get("ref")
933
- if result.get("details", {}).get("operation") == "enable":
934
- self._clear_toggle_drafts()
935
- self.pending_plan = None
936
- self._set_mode("view")
937
- await self.refresh_library(self.query_one("#search", Input).value)
938
- if isinstance(ref, str) and self._row(ref):
939
- await self.select_ref(ref)
940
- self.notify("Applied. Future activated sessions use the new revision.")
941
-
942
- async def on_button_pressed(self, event: Button.Pressed) -> None:
943
- button = event.button.id
944
- if button == "enablement-preview":
945
- self._preview_toggles()
946
- elif button == "enablement-discard":
947
- await self._discard_toggles()
948
- elif button == "create":
949
- self.action_create()
950
- elif button == "edit":
951
- self.action_edit()
952
- elif button == "editor-cancel":
953
- await self._cancel_editor()
954
- elif button == "preview":
955
- try:
956
- payload, summary = self._editor_payload()
957
- except ValueError as exc:
958
- self.notify(str(exc), severity="error")
959
- else:
960
- self._stage_plan(payload, summary)
961
- elif button == "plan-cancel":
962
- await self.action_cancel()
963
- elif button == "apply":
964
- await self._apply_pending()
965
- elif button in {"remove", "restore", "recover"}:
966
- self._stage_current_operation(button)
967
- elif button == "reset":
968
- self._stage_plan(
969
- {"operation": "reset"},
970
- "Reset future authoring to the last successful install tuple; preserve sources, history, and pins.",
971
- )
972
-
973
-
974
- def run(store: Any) -> None:
975
- CorpusStudio(store).run()
7
+ from pathlib import Path
8
+ import importlib
9
+ import runpy
10
+ import sys
11
+
12
+ if __name__ == "__main__":
13
+ runpy.run_path(str(Path(__file__).with_name('instructions_ui.py')), run_name="__main__")
14
+ else:
15
+ _name = 'instructions_ui'
16
+ _module = importlib.import_module("." + _name, __package__) if __package__ else importlib.import_module(_name)
17
+ for _symbol in tuple(vars(_module)):
18
+ if "Instructions" in _symbol:
19
+ setattr(_module, _symbol.replace("Instructions", "Corpus"), getattr(_module, _symbol))
20
+ sys.modules[__name__] = _module