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.
- package/DEPENDENCIES.md +35 -12
- package/README.md +346 -31
- package/claude/CLAUDE.md +2 -2
- package/claude/agents/frontier.md +1 -1
- package/claude/agents/sweep.md +3 -3
- package/claude/agents/workhorse.md +2 -2
- package/claude/guides/claude-prompting.md +119 -34
- package/claude/guides/cli-multi-model-workflow.md +33 -15
- package/claude/guides/gpt-prompting.md +148 -28
- package/claude/guides/review-request.md +27 -0
- package/claude/guides/session-distill-workflow.md +54 -2
- package/claude/guides/slide-writing/RUNBOOK.md +137 -0
- package/claude/guides/slide-writing/scripts/pair.py +979 -0
- package/claude/guides/slide-writing/scripts/render.mjs +82 -0
- package/claude/guides/slide-writing.md +195 -0
- package/claude/guides/svg-visualization-guide.md +9 -0
- package/claude/guides/verification-discipline.md +5 -1
- package/claude/hooks/tooling-gotchas-hook.py +7 -5
- package/codex/AGENTS.md +2 -2
- package/codex/agents/frontier.toml +2 -1
- package/codex/agents/reviewer.toml +1 -1
- package/codex/agents/sweep.toml +3 -3
- package/codex/agents/workhorse.toml +1 -1
- package/codex/config-additions.toml +1 -1
- package/codex/guides/claude-prompting.md +119 -34
- package/codex/guides/cli-multi-model-workflow.md +33 -15
- package/codex/guides/gpt-prompting.md +148 -28
- package/codex/guides/review-request.md +27 -0
- package/codex/guides/session-distill-workflow.md +54 -2
- package/codex/guides/slide-writing/RUNBOOK.md +137 -0
- package/codex/guides/slide-writing/scripts/pair.py +979 -0
- package/codex/guides/slide-writing/scripts/render.mjs +82 -0
- package/codex/guides/slide-writing.md +195 -0
- package/codex/guides/svg-visualization-guide.md +9 -0
- package/codex/guides/verification-discipline.md +5 -1
- package/compose/assemble.py +290 -14
- package/compose/bootstrap/SKILL.md +119 -0
- package/compose/check-domains.py +102 -9
- package/compose/corpus-state.py +1174 -0
- package/compose/corpus.py +387 -0
- package/compose/corpus_catalog.py +882 -0
- package/compose/corpus_install.py +1617 -0
- package/compose/corpus_session.py +726 -0
- package/compose/corpus_store.py +1414 -0
- package/compose/corpus_transaction.py +236 -0
- package/compose/corpus_ui.py +644 -0
- package/compose/domains.json +101 -100
- package/compose/write-update-cache.py +53 -0
- package/install.sh +174 -24
- package/launch/agent-launch.py +1327 -184
- package/launch/agent-launch.toml +12 -16
- package/launch/i18n/en.toml +113 -7
- package/launch/i18n/ja.toml +113 -7
- package/launch/i18n/ko.toml +113 -7
- package/learn/collect-learning.py +46 -19
- package/learn/migrate-learnings.py +10 -1
- package/package.json +13 -3
- package/provenance.json +1 -1
- package/session-cost.py +22 -2
- 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()
|