mishkan-harness 0.2.0 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -172
- package/bin/mishkan.js +677 -16
- package/docs/design/MISHKAN_decisions.md +450 -8
- package/docs/design/MISHKAN_harness_design.md +1 -33
- package/docs/design/MISHKAN_observability.md +13 -9
- package/docs/engineer/profile-readable.md +201 -0
- package/docs/engineer/profile.md +754 -0
- package/docs/research/graphify-token-saving-poc.md +108 -0
- package/docs/usage/09-workflows.md +74 -4
- package/docs/usage/10-observability.md +37 -13
- package/docs/usage/11-graphify.md +264 -0
- package/docs/usage/12-skill-discovery.md +294 -0
- package/docs/usage/README.md +5 -3
- package/package.json +1 -1
- package/payload/install/settings.hooks.json +24 -0
- package/payload/mishkan/AGENT_SPEC.md +25 -7
- package/payload/mishkan/agents/ahikam.md +4 -2
- package/payload/mishkan/agents/aholiab.md +4 -2
- package/payload/mishkan/agents/asaph.md +5 -2
- package/payload/mishkan/agents/baruch.md +19 -2
- package/payload/mishkan/agents/benaiah.md +4 -2
- package/payload/mishkan/agents/bezalel.md +4 -2
- package/payload/mishkan/agents/caleb.md +4 -2
- package/payload/mishkan/agents/deborah.md +4 -2
- package/payload/mishkan/agents/elasah.md +4 -2
- package/payload/mishkan/agents/eliashib.md +4 -2
- package/payload/mishkan/agents/ezra.md +4 -2
- package/payload/mishkan/agents/hanun.md +5 -2
- package/payload/mishkan/agents/hiram.md +5 -2
- package/payload/mishkan/agents/hizkiah.md +5 -2
- package/payload/mishkan/agents/huldah.md +4 -2
- package/payload/mishkan/agents/huram.md +4 -2
- package/payload/mishkan/agents/hushai.md +5 -2
- package/payload/mishkan/agents/igal.md +4 -2
- package/payload/mishkan/agents/ira.md +5 -2
- package/payload/mishkan/agents/jahaziel.md +5 -2
- package/payload/mishkan/agents/jakin.md +4 -2
- package/payload/mishkan/agents/jehonathan.md +5 -2
- package/payload/mishkan/agents/jehoshaphat.md +4 -2
- package/payload/mishkan/agents/joab.md +5 -2
- package/payload/mishkan/agents/joah.md +5 -2
- package/payload/mishkan/agents/maaseiah.md +4 -2
- package/payload/mishkan/agents/meremoth.md +5 -2
- package/payload/mishkan/agents/meshullam.md +5 -2
- package/payload/mishkan/agents/nathan.md +5 -2
- package/payload/mishkan/agents/nehemiah.md +4 -2
- package/payload/mishkan/agents/obed.md +4 -2
- package/payload/mishkan/agents/oholiab.md +5 -2
- package/payload/mishkan/agents/palal.md +5 -2
- package/payload/mishkan/agents/phinehas.md +4 -2
- package/payload/mishkan/agents/rehum.md +4 -2
- package/payload/mishkan/agents/salma.md +5 -2
- package/payload/mishkan/agents/seraiah.md +4 -2
- package/payload/mishkan/agents/shallum.md +5 -2
- package/payload/mishkan/agents/shaphan.md +4 -2
- package/payload/mishkan/agents/shemaiah.md +4 -2
- package/payload/mishkan/agents/shevna.md +5 -2
- package/payload/mishkan/agents/uriah.md +5 -2
- package/payload/mishkan/agents/zaccur.md +4 -2
- package/payload/mishkan/agents/zadok.md +5 -2
- package/payload/mishkan/agents/zerubbabel.md +4 -2
- package/payload/mishkan/commands/code-graph.md +35 -0
- package/payload/mishkan/commands/eval-baruch.md +57 -0
- package/payload/mishkan/commands/mishkan-org-reference.md +58 -0
- package/payload/mishkan/commands/mishkan-resume.md +19 -5
- package/payload/mishkan/commands/mishkan-skills-misses.md +47 -0
- package/payload/mishkan/commands/mishkan-skills-reindex.md +33 -0
- package/payload/mishkan/commands/skills.md +35 -0
- package/payload/mishkan/commands/sprint-close.md +3 -1
- package/payload/mishkan/hooks/pre-tool-knowledge-route.sh +231 -0
- package/payload/mishkan/hooks/pre-tool-task-skill-route.sh +122 -0
- package/payload/mishkan/hooks/session-start-skill-index.sh +35 -0
- package/payload/mishkan/observability/schema.json +1 -1
- package/payload/mishkan/observability/watch/src/mishkan_watch/__main__.py +84 -2
- package/payload/mishkan/observability/watch/src/mishkan_watch/app.py +64 -3
- package/payload/mishkan/observability/watch/src/mishkan_watch/org_data.py +79 -0
- package/payload/mishkan/observability/watch/src/mishkan_watch/skills_data.py +267 -0
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/agents.py +33 -1
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/knowledge.py +118 -9
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/live.py +102 -2
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/org.py +139 -0
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/skills.py +187 -0
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/usage.py +377 -0
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/workflows.py +199 -27
- package/payload/mishkan/observability/watch/src/mishkan_watch/theme.tcss +78 -4
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/__main__.py +36 -6
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/bus_tail.py +16 -3
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/graphify_tail.py +220 -0
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/worktree_poll.py +13 -2
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/state.py +157 -3
- package/payload/mishkan/observability/watchd/tests/test_state.py +6 -2
- package/payload/mishkan/org/org.json +141 -0
- package/payload/mishkan/scripts/pre-commit-eval-gate.sh +50 -0
- package/payload/mishkan/scripts/skill-discovery-indexer.py +462 -0
- package/payload/mishkan/scripts/skill-discovery-misses.py +173 -0
- package/payload/mishkan/scripts/skill-discovery-router.py +457 -0
- package/payload/mishkan/scripts/skill-discovery-smoke.sh +156 -0
- package/payload/mishkan/skills/graphify-query-craft/SKILL.md +162 -0
- package/payload/mishkan/skills/mishkan-init/SKILL.md +15 -0
- package/payload/mishkan/skills/skill-discovery/SKILL.md +120 -0
- package/payload/mishkan/templates/case-node.schema.json +1 -0
- package/payload/mishkan/templates/observability-log.schema.json +1 -0
- package/payload/mishkan/workflows/README.md +78 -13
- package/payload/mishkan/workflows/chosheb-feature-ship.js +75 -0
- package/payload/mishkan/workflows/migdal-dr-drill.js +94 -0
- package/payload/mishkan/workflows/migdal-infra-change.js +67 -0
- package/payload/mishkan/workflows/mishkan-blast-radius.js +144 -0
- package/payload/mishkan/workflows/mishkan-init.js +58 -0
- package/payload/mishkan/workflows/mishkan-knowledge-gap-discovery.js +121 -0
- package/payload/mishkan/workflows/mishkan-standards-rollout.js +153 -0
- package/payload/mishkan/workflows/mishmar-security-gate.js +83 -0
- package/payload/mishkan/workflows/panim-ds-rollout.js +83 -0
- package/payload/mishkan/workflows/sefer-release-notes.js +85 -0
- package/payload/mishkan/workflows/yasad-data-migration-wave.js +78 -0
- package/payload/mishkan/workflows/yasad-schema-evolution.js +76 -0
- package/payload/mishkan/templates/user-CLAUDE.md +0 -62
|
@@ -1,24 +1,106 @@
|
|
|
1
|
-
"""mishkan-watch CLI entry point.
|
|
1
|
+
"""mishkan-watch CLI entry point.
|
|
2
|
+
|
|
3
|
+
UX contract: ``mishkan-watch`` is a single command. If the daemon isn't
|
|
4
|
+
already running we fork it as a child process, wait briefly for its
|
|
5
|
+
socket to appear, then launch the TUI. On TUI exit we leave the daemon
|
|
6
|
+
alive so other clients (a second ``mishkan-watch`` window, the TUI on
|
|
7
|
+
another tmux pane) can use it. ``mishkan-watchd stop`` remains the
|
|
8
|
+
explicit shutdown.
|
|
9
|
+
|
|
10
|
+
The dual-terminal flow (one for ``mishkan-watchd start``, one for the
|
|
11
|
+
TUI) still works for power users — pass ``--no-autostart`` and the
|
|
12
|
+
client refuses to manage the daemon.
|
|
13
|
+
"""
|
|
2
14
|
from __future__ import annotations
|
|
3
15
|
|
|
4
16
|
import argparse
|
|
5
17
|
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
6
20
|
import sys
|
|
21
|
+
import time
|
|
7
22
|
from pathlib import Path
|
|
8
23
|
|
|
9
24
|
|
|
10
25
|
DEFAULT_SOCKET = Path(os.path.expanduser("~/.claude/mishkan/run/watch.sock"))
|
|
26
|
+
WATCHD_BOOT_TIMEOUT_S = 8.0
|
|
27
|
+
WATCHD_BOOT_POLL_MS = 100
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _ensure_daemon(socket_path: Path, *, allow_autostart: bool) -> int:
|
|
31
|
+
"""Return 0 on success, 1 on failure.
|
|
32
|
+
|
|
33
|
+
If the socket already exists we assume the daemon is up — this is
|
|
34
|
+
the common case (daemon already running across sessions). Otherwise,
|
|
35
|
+
when autostart is allowed and ``mishkan-watchd`` is on PATH, we
|
|
36
|
+
fork it and poll for the socket up to WATCHD_BOOT_TIMEOUT_S.
|
|
37
|
+
"""
|
|
38
|
+
if socket_path.exists():
|
|
39
|
+
return 0
|
|
40
|
+
if not allow_autostart:
|
|
41
|
+
print(
|
|
42
|
+
"mishkan-watch: daemon socket not found and --no-autostart was "
|
|
43
|
+
"passed.\n Start the daemon manually: mishkan-watchd start",
|
|
44
|
+
file=sys.stderr,
|
|
45
|
+
)
|
|
46
|
+
return 1
|
|
47
|
+
watchd = shutil.which("mishkan-watchd")
|
|
48
|
+
if not watchd:
|
|
49
|
+
print(
|
|
50
|
+
"mishkan-watch: daemon socket not found and `mishkan-watchd` is "
|
|
51
|
+
"not on PATH.\n Install the observability stack:\n"
|
|
52
|
+
" npx mishkan-harness observability",
|
|
53
|
+
file=sys.stderr,
|
|
54
|
+
)
|
|
55
|
+
return 1
|
|
56
|
+
print("mishkan-watch: starting daemon …", file=sys.stderr)
|
|
57
|
+
# Detached child — survives this process. stdout/stderr go to /dev/null
|
|
58
|
+
# so the TUI doesn't get polluted by daemon log lines.
|
|
59
|
+
try:
|
|
60
|
+
subprocess.Popen(
|
|
61
|
+
[watchd, "start"],
|
|
62
|
+
stdout=subprocess.DEVNULL,
|
|
63
|
+
stderr=subprocess.DEVNULL,
|
|
64
|
+
stdin=subprocess.DEVNULL,
|
|
65
|
+
start_new_session=True,
|
|
66
|
+
close_fds=True,
|
|
67
|
+
)
|
|
68
|
+
except OSError as e:
|
|
69
|
+
print(f"mishkan-watch: failed to fork daemon: {e}", file=sys.stderr)
|
|
70
|
+
return 1
|
|
71
|
+
# Poll for socket. Bounded; the daemon binds the socket within the
|
|
72
|
+
# first ~200 ms in normal conditions, but uv tool first runs and
|
|
73
|
+
# cold imports can push it higher on slow disks.
|
|
74
|
+
deadline = time.monotonic() + WATCHD_BOOT_TIMEOUT_S
|
|
75
|
+
while time.monotonic() < deadline:
|
|
76
|
+
if socket_path.exists():
|
|
77
|
+
return 0
|
|
78
|
+
time.sleep(WATCHD_BOOT_POLL_MS / 1000)
|
|
79
|
+
print(
|
|
80
|
+
f"mishkan-watch: daemon socket {socket_path} did not appear within "
|
|
81
|
+
f"{WATCHD_BOOT_TIMEOUT_S:.0f} s.\n Check daemon logs: "
|
|
82
|
+
"mishkan-watchd status",
|
|
83
|
+
file=sys.stderr,
|
|
84
|
+
)
|
|
85
|
+
return 1
|
|
11
86
|
|
|
12
87
|
|
|
13
88
|
def cli(argv: list[str] | None = None) -> int:
|
|
14
89
|
p = argparse.ArgumentParser(
|
|
15
90
|
prog="mishkan-watch",
|
|
16
|
-
description="MISHKAN observability TUI.
|
|
91
|
+
description="MISHKAN observability TUI. Auto-starts the daemon "
|
|
92
|
+
"if it isn't running.",
|
|
17
93
|
)
|
|
18
94
|
p.add_argument("--socket", type=Path, default=DEFAULT_SOCKET,
|
|
19
95
|
help=f"daemon UNIX socket (default: {DEFAULT_SOCKET})")
|
|
96
|
+
p.add_argument("--no-autostart", action="store_true",
|
|
97
|
+
help="don't fork the daemon; refuse if socket missing")
|
|
20
98
|
args = p.parse_args(argv)
|
|
21
99
|
|
|
100
|
+
rc = _ensure_daemon(args.socket, allow_autostart=not args.no_autostart)
|
|
101
|
+
if rc != 0:
|
|
102
|
+
return rc
|
|
103
|
+
|
|
22
104
|
# Importing the app lazily keeps CLI startup fast even when Textual is
|
|
23
105
|
# heavy to import.
|
|
24
106
|
from .app import run
|
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
import os
|
|
11
11
|
import time
|
|
12
12
|
from pathlib import Path
|
|
13
|
+
|
|
13
14
|
from typing import Any
|
|
14
15
|
|
|
15
16
|
from textual import work
|
|
@@ -24,6 +25,9 @@ from .tabs.activity import ActivityTab
|
|
|
24
25
|
from .tabs.agents import AgentsTab
|
|
25
26
|
from .tabs.knowledge import KnowledgeTab
|
|
26
27
|
from .tabs.live import LiveTab
|
|
28
|
+
from .tabs.org import OrgTab
|
|
29
|
+
from .tabs.skills import SkillsTab
|
|
30
|
+
from .tabs.usage import UsageTab
|
|
27
31
|
from .tabs.workflows import WorkflowsTab
|
|
28
32
|
|
|
29
33
|
|
|
@@ -40,6 +44,10 @@ class MishkanWatch(App):
|
|
|
40
44
|
Binding("3", "switch_tab('workflows')", "Workflows"),
|
|
41
45
|
Binding("4", "switch_tab('knowledge')", "Knowledge"),
|
|
42
46
|
Binding("5", "switch_tab('activity')", "Activity"),
|
|
47
|
+
Binding("6", "switch_tab('org-ref')", "Org-Ref"),
|
|
48
|
+
Binding("7", "switch_tab('usage')", "Usage"),
|
|
49
|
+
Binding("8", "switch_tab('skills')", "Skills"),
|
|
50
|
+
Binding("p", "toggle_project_filter", "Project/All"),
|
|
43
51
|
Binding("question_mark", "show_help", "help", show=False),
|
|
44
52
|
]
|
|
45
53
|
|
|
@@ -48,6 +56,17 @@ class MishkanWatch(App):
|
|
|
48
56
|
self._socket_path = socket_path or DEFAULT_SOCKET
|
|
49
57
|
self._client: DaemonClient | None = None
|
|
50
58
|
self._started_at = time.time()
|
|
59
|
+
# Project filter — at startup, prefer CLAUDE_PROJECT_DIR (set by
|
|
60
|
+
# Claude Code in subprocess hooks), fall back to cwd. None means
|
|
61
|
+
# "show all projects" (toggle via 'p' key).
|
|
62
|
+
self._current_project: Path = Path(
|
|
63
|
+
os.environ.get("CLAUDE_PROJECT_DIR") or os.getcwd()
|
|
64
|
+
).resolve()
|
|
65
|
+
# Default OFF — the filter is opt-in via 'p'. Defaulting ON broke
|
|
66
|
+
# rendering for users whose sess.project carries the Claude Code
|
|
67
|
+
# encoded form (e.g. -home-ogu-theY4NN-harness) instead of an
|
|
68
|
+
# absolute path; the comparator below tolerates both forms.
|
|
69
|
+
self._filter_to_current: bool = False
|
|
51
70
|
self._totals = {
|
|
52
71
|
"tokens_in": 0,
|
|
53
72
|
"tokens_out": 0,
|
|
@@ -72,8 +91,26 @@ class MishkanWatch(App):
|
|
|
72
91
|
yield KnowledgeTab(id="tab-knowledge")
|
|
73
92
|
with TabPane("Activity", id="activity"):
|
|
74
93
|
yield ActivityTab(id="tab-activity")
|
|
75
|
-
|
|
94
|
+
with TabPane("Org-Ref", id="org-ref"):
|
|
95
|
+
yield OrgTab(id="tab-org-ref")
|
|
96
|
+
with TabPane("Usage", id="usage"):
|
|
97
|
+
yield UsageTab(id="tab-usage")
|
|
98
|
+
with TabPane("Skills", id="skills"):
|
|
99
|
+
yield SkillsTab(id="tab-skills")
|
|
100
|
+
# Footer docked first so it lands at the very bottom; status-bar
|
|
101
|
+
# yielded after so it sits just above. Otherwise both compete for
|
|
102
|
+
# the same bottom slot and Footer wins (status-bar invisible).
|
|
76
103
|
yield Footer()
|
|
104
|
+
yield Static("", id="status-bar")
|
|
105
|
+
|
|
106
|
+
def on_mount_initial_filter(self) -> None:
|
|
107
|
+
"""Propagate the initial project filter to tabs that support it."""
|
|
108
|
+
for tab in self._all_tabs():
|
|
109
|
+
try:
|
|
110
|
+
if hasattr(tab, "set_project_filter") and self._filter_to_current:
|
|
111
|
+
tab.set_project_filter(self._current_project)
|
|
112
|
+
except Exception:
|
|
113
|
+
continue
|
|
77
114
|
|
|
78
115
|
async def on_mount(self) -> None:
|
|
79
116
|
# Status bar refresh on a fixed cadence rather than on every event.
|
|
@@ -81,6 +118,7 @@ class MishkanWatch(App):
|
|
|
81
118
|
# on each was a major source of UI latency. 500 ms ticks feel
|
|
82
119
|
# live without saturating the loop.
|
|
83
120
|
self.set_interval(0.5, self._refresh_status_bar)
|
|
121
|
+
self.on_mount_initial_filter()
|
|
84
122
|
self._client = DaemonClient(self._socket_path)
|
|
85
123
|
await self._client.start(self._on_frame, self._on_status)
|
|
86
124
|
|
|
@@ -129,7 +167,7 @@ class MishkanWatch(App):
|
|
|
129
167
|
|
|
130
168
|
def _all_tabs(self) -> list[Any]:
|
|
131
169
|
out = []
|
|
132
|
-
for sel in ("#tab-live", "#tab-agents", "#tab-workflows", "#tab-knowledge", "#tab-activity"):
|
|
170
|
+
for sel in ("#tab-live", "#tab-agents", "#tab-workflows", "#tab-knowledge", "#tab-activity", "#tab-org-ref", "#tab-usage", "#tab-skills"):
|
|
133
171
|
try:
|
|
134
172
|
out.append(self.query_one(sel))
|
|
135
173
|
except Exception:
|
|
@@ -222,12 +260,35 @@ class MishkanWatch(App):
|
|
|
222
260
|
# Minimal help; expand into a modal in a follow-up.
|
|
223
261
|
try:
|
|
224
262
|
bar = self.query_one("#status-bar", Static)
|
|
225
|
-
help_text = Text("1-
|
|
263
|
+
help_text = Text("1-8 tabs · p project filter · / filter (Activity) · q quit",
|
|
226
264
|
style="bold")
|
|
227
265
|
bar.update(help_text)
|
|
228
266
|
except Exception:
|
|
229
267
|
pass
|
|
230
268
|
|
|
269
|
+
def action_toggle_project_filter(self) -> None:
|
|
270
|
+
"""Toggle between 'current project only' and 'all projects' filter.
|
|
271
|
+
|
|
272
|
+
Live tab reads `app.filter_project` to decide what to show. When
|
|
273
|
+
the filter is on, only sessions/worktrees whose project path
|
|
274
|
+
matches `self._current_project` are rendered.
|
|
275
|
+
"""
|
|
276
|
+
self._filter_to_current = not self._filter_to_current
|
|
277
|
+
# Force a re-render of the structural panels via a fake snapshot pass.
|
|
278
|
+
for tab in self._all_tabs():
|
|
279
|
+
try:
|
|
280
|
+
if hasattr(tab, "set_project_filter"):
|
|
281
|
+
tab.set_project_filter(
|
|
282
|
+
self._current_project if self._filter_to_current else None
|
|
283
|
+
)
|
|
284
|
+
except Exception:
|
|
285
|
+
continue
|
|
286
|
+
self._refresh_status_bar()
|
|
287
|
+
|
|
288
|
+
@property
|
|
289
|
+
def filter_project(self) -> Path | None:
|
|
290
|
+
return self._current_project if self._filter_to_current else None
|
|
291
|
+
|
|
231
292
|
|
|
232
293
|
def run(socket_path: Path | None = None) -> int:
|
|
233
294
|
app = MishkanWatch(socket_path=socket_path)
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"""Shared loader for the MISHKAN org.json reference.
|
|
2
|
+
|
|
3
|
+
Used by:
|
|
4
|
+
- tabs/org.py : full org tree + details panel
|
|
5
|
+
- tabs/agents.py : inline role annotation next to alias
|
|
6
|
+
- tabs/live.py : same
|
|
7
|
+
|
|
8
|
+
Loads once at import; refreshes are cheap (re-call _load_org()).
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _candidate_paths() -> list[Path]:
|
|
19
|
+
"""Where org.json may live, first hit wins.
|
|
20
|
+
|
|
21
|
+
Order matters: the installed (~/.claude) copy is canonical because
|
|
22
|
+
it tracks the harness version actually in use. Repo fallbacks are
|
|
23
|
+
for dev mode (running mishkan-watch out of the source checkout).
|
|
24
|
+
"""
|
|
25
|
+
paths: list[Path] = [
|
|
26
|
+
Path(os.path.expanduser("~/.claude/mishkan/org/org.json")),
|
|
27
|
+
]
|
|
28
|
+
# Walk up from this file looking for `payload/mishkan/org/org.json`
|
|
29
|
+
# in case we're running from the repo checkout (dev mode).
|
|
30
|
+
here = Path(__file__).resolve()
|
|
31
|
+
for parent in here.parents:
|
|
32
|
+
candidate = parent / "payload" / "mishkan" / "org" / "org.json"
|
|
33
|
+
if candidate.is_file():
|
|
34
|
+
paths.append(candidate)
|
|
35
|
+
break
|
|
36
|
+
if parent == parent.parent:
|
|
37
|
+
break
|
|
38
|
+
return paths
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load_org() -> dict[str, Any]:
|
|
42
|
+
for p in _candidate_paths():
|
|
43
|
+
try:
|
|
44
|
+
if p.is_file():
|
|
45
|
+
return json.loads(p.read_text())
|
|
46
|
+
except Exception:
|
|
47
|
+
continue
|
|
48
|
+
return {"groups": []}
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def role_for(alias: str, org: dict[str, Any] | None = None) -> str | None:
|
|
52
|
+
"""Return the snake_case role for a given alias, or None if unknown.
|
|
53
|
+
|
|
54
|
+
alias matching is case-insensitive. Returns the `short` field if
|
|
55
|
+
present (e.g. "PM"), else the full `role` (e.g. "project_manager").
|
|
56
|
+
The caller chooses which form to render.
|
|
57
|
+
"""
|
|
58
|
+
if not alias:
|
|
59
|
+
return None
|
|
60
|
+
org = org or load_org()
|
|
61
|
+
key = alias.lower()
|
|
62
|
+
for grp in org.get("groups", []):
|
|
63
|
+
for ag in grp.get("agents", []):
|
|
64
|
+
if ag.get("alias", "").lower() == key:
|
|
65
|
+
return ag.get("short") or ag.get("role")
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def full_role_for(alias: str, org: dict[str, Any] | None = None) -> str | None:
|
|
70
|
+
"""Same as role_for but returns the full snake_case role, never the short alias."""
|
|
71
|
+
if not alias:
|
|
72
|
+
return None
|
|
73
|
+
org = org or load_org()
|
|
74
|
+
key = alias.lower()
|
|
75
|
+
for grp in org.get("groups", []):
|
|
76
|
+
for ag in grp.get("agents", []):
|
|
77
|
+
if ag.get("alias", "").lower() == key:
|
|
78
|
+
return ag.get("role")
|
|
79
|
+
return None
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""Shared loader for installed skills and their cross-references to ADRs.
|
|
2
|
+
|
|
3
|
+
Used by:
|
|
4
|
+
- tabs/skills.py — full Skills browser tab
|
|
5
|
+
|
|
6
|
+
Independent of the skill-discovery indexer being built by Bezalel — the TUI
|
|
7
|
+
needs a self-contained read-only view that works whether or not the
|
|
8
|
+
indexer has run. If Bezalel's index.json exists, we prefer it; otherwise
|
|
9
|
+
we fall back to scanning the canonical paths directly.
|
|
10
|
+
|
|
11
|
+
What we expose
|
|
12
|
+
--------------
|
|
13
|
+
- load_skills() : list of skill entries with metadata
|
|
14
|
+
- load_adr_index() : map adr id -> list of skill names mentioned there
|
|
15
|
+
|
|
16
|
+
A skill entry:
|
|
17
|
+
{
|
|
18
|
+
"name": str,
|
|
19
|
+
"origin": "mishkan" | "user" | "plugin" | "project" | "builtin",
|
|
20
|
+
"source_path": str,
|
|
21
|
+
"description": str,
|
|
22
|
+
"category": str,
|
|
23
|
+
"frontmatter": dict,
|
|
24
|
+
"adrs": list[str], # ADR ids that mention this skill, e.g. ["D-008"]
|
|
25
|
+
}
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import re
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# Search order matters for name-collision resolution. First-hit wins.
|
|
37
|
+
_SKILL_SOURCES = [
|
|
38
|
+
("mishkan", Path.home() / ".claude" / "mishkan" / "skills"),
|
|
39
|
+
("user", Path.home() / ".claude" / "skills"),
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _plugin_skill_dirs() -> list[tuple[str, Path]]:
|
|
44
|
+
"""Enumerate `~/.claude/plugins/*/skills/` lazily — order is filesystem order."""
|
|
45
|
+
out: list[tuple[str, Path]] = []
|
|
46
|
+
plugins = Path.home() / ".claude" / "plugins"
|
|
47
|
+
if not plugins.is_dir():
|
|
48
|
+
return out
|
|
49
|
+
try:
|
|
50
|
+
for child in plugins.iterdir():
|
|
51
|
+
sd = child / "skills"
|
|
52
|
+
if sd.is_dir():
|
|
53
|
+
out.append(("plugin", sd))
|
|
54
|
+
except OSError:
|
|
55
|
+
pass
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _all_sources() -> list[tuple[str, Path]]:
|
|
60
|
+
return list(_SKILL_SOURCES) + _plugin_skill_dirs()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
_FM_BLOCK_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
|
64
|
+
_FM_KV_RE = re.compile(r"^([A-Za-z_-]+):\s*(.*)$")
|
|
65
|
+
_USE_WHEN_RE = re.compile(r"(?:Use when|use when|Use this skill when)\s+([^.\n]+)\.?", re.IGNORECASE)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _parse_skill_md(path: Path) -> dict[str, Any]:
|
|
69
|
+
"""Parse a SKILL.md file. Returns frontmatter + body-derived hints.
|
|
70
|
+
|
|
71
|
+
Frontmatter is YAML-ish but we keep parsing simple — single-line
|
|
72
|
+
key: value pairs (no nested structures, no multiline values). Skills
|
|
73
|
+
that need more get represented as the raw frontmatter dict; the TUI
|
|
74
|
+
just shows what's there.
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
78
|
+
except OSError:
|
|
79
|
+
return {}
|
|
80
|
+
fm: dict[str, Any] = {}
|
|
81
|
+
body = text
|
|
82
|
+
m = _FM_BLOCK_RE.match(text)
|
|
83
|
+
if m:
|
|
84
|
+
block = m.group(1)
|
|
85
|
+
body = text[m.end():]
|
|
86
|
+
for raw in block.splitlines():
|
|
87
|
+
line = raw.strip()
|
|
88
|
+
if not line or line.startswith("#"):
|
|
89
|
+
continue
|
|
90
|
+
kv = _FM_KV_RE.match(line)
|
|
91
|
+
if kv:
|
|
92
|
+
fm[kv.group(1)] = kv.group(2).strip().strip('"').strip("'")
|
|
93
|
+
triggers: list[str] = []
|
|
94
|
+
for um in _USE_WHEN_RE.finditer(body):
|
|
95
|
+
triggers.append(um.group(1).strip())
|
|
96
|
+
return {"frontmatter": fm, "triggers": triggers, "body_chars": len(body)}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _category_for(path: Path, fm: dict[str, Any]) -> str:
|
|
100
|
+
"""Heuristic category — first segment of the path under skills/, falling
|
|
101
|
+
back to a keyword scan of the description."""
|
|
102
|
+
try:
|
|
103
|
+
parts = path.parent.name
|
|
104
|
+
except Exception:
|
|
105
|
+
parts = ""
|
|
106
|
+
cat = parts
|
|
107
|
+
desc = (fm.get("description") or "").lower()
|
|
108
|
+
for keyword, label in (
|
|
109
|
+
("security", "security"), ("frontend", "frontend"),
|
|
110
|
+
("backend", "backend"), ("python", "python"),
|
|
111
|
+
("kubernetes", "infra"), ("k8s", "infra"),
|
|
112
|
+
("docker", "infra"), ("test", "testing"),
|
|
113
|
+
("docs", "docs"), ("design", "design"),
|
|
114
|
+
):
|
|
115
|
+
if keyword in desc:
|
|
116
|
+
cat = label
|
|
117
|
+
break
|
|
118
|
+
return cat or "uncategorised"
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _load_indexer_output() -> list[dict[str, Any]] | None:
|
|
122
|
+
"""If Bezalel's indexer has run, prefer its output."""
|
|
123
|
+
p = Path.home() / ".claude" / "mishkan" / "skill-discovery" / "index.json"
|
|
124
|
+
try:
|
|
125
|
+
if p.is_file():
|
|
126
|
+
data = json.loads(p.read_text())
|
|
127
|
+
if isinstance(data, dict) and "entries" in data:
|
|
128
|
+
return data["entries"]
|
|
129
|
+
if isinstance(data, list):
|
|
130
|
+
return data
|
|
131
|
+
except Exception:
|
|
132
|
+
return None
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def load_skills() -> list[dict[str, Any]]:
|
|
137
|
+
"""Return the full set of installed skills.
|
|
138
|
+
|
|
139
|
+
If `~/.claude/mishkan/skill-discovery/index.json` exists, use it;
|
|
140
|
+
otherwise scan the canonical paths directly. Entries are deduplicated
|
|
141
|
+
by skill name (first hit by source precedence wins).
|
|
142
|
+
"""
|
|
143
|
+
indexed = _load_indexer_output()
|
|
144
|
+
if indexed:
|
|
145
|
+
adr_map = load_adr_index()
|
|
146
|
+
for e in indexed:
|
|
147
|
+
name = e.get("name") or ""
|
|
148
|
+
e["adrs"] = adr_map.get(name, [])
|
|
149
|
+
return indexed
|
|
150
|
+
out: list[dict[str, Any]] = []
|
|
151
|
+
seen: set[str] = set()
|
|
152
|
+
for origin, root in _all_sources():
|
|
153
|
+
if not root.is_dir():
|
|
154
|
+
continue
|
|
155
|
+
try:
|
|
156
|
+
# Two layouts: skills/<name>.md, or skills/<name>/SKILL.md.
|
|
157
|
+
for entry in sorted(root.iterdir()):
|
|
158
|
+
skill_md: Path | None = None
|
|
159
|
+
name: str | None = None
|
|
160
|
+
if entry.is_file() and entry.suffix == ".md":
|
|
161
|
+
skill_md = entry
|
|
162
|
+
name = entry.stem
|
|
163
|
+
elif entry.is_dir():
|
|
164
|
+
cand = entry / "SKILL.md"
|
|
165
|
+
if cand.is_file():
|
|
166
|
+
skill_md = cand
|
|
167
|
+
name = entry.name
|
|
168
|
+
if not skill_md or not name or name in seen:
|
|
169
|
+
continue
|
|
170
|
+
seen.add(name)
|
|
171
|
+
parsed = _parse_skill_md(skill_md)
|
|
172
|
+
fm = parsed.get("frontmatter") or {}
|
|
173
|
+
out.append({
|
|
174
|
+
"name": fm.get("name") or name,
|
|
175
|
+
"origin": origin,
|
|
176
|
+
"source_path": str(skill_md),
|
|
177
|
+
"description": fm.get("description") or "",
|
|
178
|
+
"category": _category_for(skill_md, fm),
|
|
179
|
+
"frontmatter": fm,
|
|
180
|
+
"triggers": parsed.get("triggers") or [],
|
|
181
|
+
"adrs": [],
|
|
182
|
+
})
|
|
183
|
+
except OSError:
|
|
184
|
+
continue
|
|
185
|
+
adr_map = load_adr_index()
|
|
186
|
+
for e in out:
|
|
187
|
+
e["adrs"] = adr_map.get(e["name"], [])
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
_ADR_HEADER_RE = re.compile(r"^## (D-\d{3})\b", re.MULTILINE)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def load_adr_index() -> dict[str, list[str]]:
|
|
195
|
+
"""Map skill name -> list of ADR ids that mention it.
|
|
196
|
+
|
|
197
|
+
Parses `docs/design/MISHKAN_decisions.md`. Each ADR section is delimited
|
|
198
|
+
by `## D-NNN`; within each section we scan for any installed-skill name
|
|
199
|
+
that appears verbatim. This is conservative — a skill named `pdf` could
|
|
200
|
+
match noise, but we accept that for now (the alternative is a strict
|
|
201
|
+
whitelist that drifts).
|
|
202
|
+
"""
|
|
203
|
+
decisions_path = _find_decisions_md()
|
|
204
|
+
if not decisions_path:
|
|
205
|
+
return {}
|
|
206
|
+
try:
|
|
207
|
+
text = decisions_path.read_text()
|
|
208
|
+
except OSError:
|
|
209
|
+
return {}
|
|
210
|
+
sections: list[tuple[str, str]] = []
|
|
211
|
+
cur_id: str | None = None
|
|
212
|
+
cur_buf: list[str] = []
|
|
213
|
+
for line in text.splitlines():
|
|
214
|
+
m = _ADR_HEADER_RE.match(line)
|
|
215
|
+
if m:
|
|
216
|
+
if cur_id is not None:
|
|
217
|
+
sections.append((cur_id, "\n".join(cur_buf)))
|
|
218
|
+
cur_id = m.group(1)
|
|
219
|
+
cur_buf = []
|
|
220
|
+
elif cur_id is not None:
|
|
221
|
+
cur_buf.append(line)
|
|
222
|
+
if cur_id is not None:
|
|
223
|
+
sections.append((cur_id, "\n".join(cur_buf)))
|
|
224
|
+
# Cross-ref against known skills — we need the name list, but at this
|
|
225
|
+
# point load_skills hasn't returned yet (would be circular). Instead
|
|
226
|
+
# scan all skill dir entries by name and look those up.
|
|
227
|
+
skill_names: set[str] = set()
|
|
228
|
+
for _, root in _all_sources():
|
|
229
|
+
if not root.is_dir():
|
|
230
|
+
continue
|
|
231
|
+
try:
|
|
232
|
+
for entry in root.iterdir():
|
|
233
|
+
if entry.is_file() and entry.suffix == ".md":
|
|
234
|
+
skill_names.add(entry.stem)
|
|
235
|
+
elif entry.is_dir() and (entry / "SKILL.md").is_file():
|
|
236
|
+
skill_names.add(entry.name)
|
|
237
|
+
except OSError:
|
|
238
|
+
continue
|
|
239
|
+
rev: dict[str, list[str]] = {}
|
|
240
|
+
for adr_id, body in sections:
|
|
241
|
+
for name in skill_names:
|
|
242
|
+
if not name:
|
|
243
|
+
continue
|
|
244
|
+
# Whole-word match — guard against substring noise.
|
|
245
|
+
if re.search(rf"\b{re.escape(name)}\b", body):
|
|
246
|
+
rev.setdefault(name, []).append(adr_id)
|
|
247
|
+
return rev
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _find_decisions_md() -> Path | None:
|
|
251
|
+
"""Locate MISHKAN_decisions.md. Prefer ~/.claude install path, fall back
|
|
252
|
+
to a repo-mode walk-up."""
|
|
253
|
+
candidates: list[Path] = [
|
|
254
|
+
Path.home() / ".claude" / "mishkan" / "design" / "MISHKAN_decisions.md",
|
|
255
|
+
]
|
|
256
|
+
here = Path(__file__).resolve()
|
|
257
|
+
for parent in here.parents:
|
|
258
|
+
c = parent / "docs" / "design" / "MISHKAN_decisions.md"
|
|
259
|
+
if c.is_file():
|
|
260
|
+
candidates.append(c)
|
|
261
|
+
break
|
|
262
|
+
if parent == parent.parent:
|
|
263
|
+
break
|
|
264
|
+
for c in candidates:
|
|
265
|
+
if c.is_file():
|
|
266
|
+
return c
|
|
267
|
+
return None
|
|
@@ -189,6 +189,11 @@ class AgentsTab(Container):
|
|
|
189
189
|
root = tree.root
|
|
190
190
|
root.expand()
|
|
191
191
|
sessions = (self._state.get("sessions") or {})
|
|
192
|
+
# Daemon-side confirmed-alive gate (state.py) is the source of
|
|
193
|
+
# truth for what counts as a real session. Any session reaching
|
|
194
|
+
# us here is confirmed by session_discover. A residual project=""
|
|
195
|
+
# entry would be a daemon bug — surface it visually as "?" so we
|
|
196
|
+
# notice rather than silently hiding it.
|
|
192
197
|
for sid, sess in sessions.items():
|
|
193
198
|
label = Text()
|
|
194
199
|
label.append(sid[:8] + "… ", style="cyan")
|
|
@@ -204,6 +209,9 @@ class AgentsTab(Container):
|
|
|
204
209
|
al = Text()
|
|
205
210
|
al.append("● ", style="#00D4AA")
|
|
206
211
|
al.append(name, style="bold")
|
|
212
|
+
role = _role_for(name)
|
|
213
|
+
if role:
|
|
214
|
+
al.append(f" · {role}", style="dim")
|
|
207
215
|
sess_node.add_leaf(al, data={"kind": "agent", "sid": sid, "agent": name})
|
|
208
216
|
|
|
209
217
|
def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
|
|
@@ -271,8 +279,32 @@ class AgentsTab(Container):
|
|
|
271
279
|
p = ev.get("payload") or {}
|
|
272
280
|
ts = (ev.get("ts") or "")[11:19]
|
|
273
281
|
agent = ev.get("agent") or "(main)"
|
|
282
|
+
role = _role_for(agent) if agent != "(main)" else None
|
|
274
283
|
msg = (p.get("message") or "")[:60]
|
|
275
|
-
|
|
284
|
+
label = f"{agent} · {role}" if role else agent
|
|
285
|
+
text.append(f"● {label} {ts}\n", style="#FC8181 bold")
|
|
276
286
|
text.append(f" {msg}\n", style="white")
|
|
277
287
|
text.append("─────────\n", style="dim")
|
|
278
288
|
panel.update(text)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
# Module-level cache for the alias→role lookup (org.json is static).
|
|
292
|
+
_ROLE_CACHE: dict[str, str | None] | None = None
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _role_for(alias: str) -> str | None:
|
|
296
|
+
global _ROLE_CACHE
|
|
297
|
+
if _ROLE_CACHE is None:
|
|
298
|
+
try:
|
|
299
|
+
from ..org_data import load_org
|
|
300
|
+
org = load_org()
|
|
301
|
+
cache: dict[str, str | None] = {}
|
|
302
|
+
for grp in org.get("groups", []):
|
|
303
|
+
for ag in grp.get("agents", []):
|
|
304
|
+
a = (ag.get("alias") or "").lower()
|
|
305
|
+
if a:
|
|
306
|
+
cache[a] = ag.get("short") or ag.get("role")
|
|
307
|
+
_ROLE_CACHE = cache
|
|
308
|
+
except Exception:
|
|
309
|
+
_ROLE_CACHE = {}
|
|
310
|
+
return _ROLE_CACHE.get((alias or "").lower())
|