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.
Files changed (116) hide show
  1. package/README.md +108 -172
  2. package/bin/mishkan.js +677 -16
  3. package/docs/design/MISHKAN_decisions.md +450 -8
  4. package/docs/design/MISHKAN_harness_design.md +1 -33
  5. package/docs/design/MISHKAN_observability.md +13 -9
  6. package/docs/engineer/profile-readable.md +201 -0
  7. package/docs/engineer/profile.md +754 -0
  8. package/docs/research/graphify-token-saving-poc.md +108 -0
  9. package/docs/usage/09-workflows.md +74 -4
  10. package/docs/usage/10-observability.md +37 -13
  11. package/docs/usage/11-graphify.md +264 -0
  12. package/docs/usage/12-skill-discovery.md +294 -0
  13. package/docs/usage/README.md +5 -3
  14. package/package.json +1 -1
  15. package/payload/install/settings.hooks.json +24 -0
  16. package/payload/mishkan/AGENT_SPEC.md +25 -7
  17. package/payload/mishkan/agents/ahikam.md +4 -2
  18. package/payload/mishkan/agents/aholiab.md +4 -2
  19. package/payload/mishkan/agents/asaph.md +5 -2
  20. package/payload/mishkan/agents/baruch.md +19 -2
  21. package/payload/mishkan/agents/benaiah.md +4 -2
  22. package/payload/mishkan/agents/bezalel.md +4 -2
  23. package/payload/mishkan/agents/caleb.md +4 -2
  24. package/payload/mishkan/agents/deborah.md +4 -2
  25. package/payload/mishkan/agents/elasah.md +4 -2
  26. package/payload/mishkan/agents/eliashib.md +4 -2
  27. package/payload/mishkan/agents/ezra.md +4 -2
  28. package/payload/mishkan/agents/hanun.md +5 -2
  29. package/payload/mishkan/agents/hiram.md +5 -2
  30. package/payload/mishkan/agents/hizkiah.md +5 -2
  31. package/payload/mishkan/agents/huldah.md +4 -2
  32. package/payload/mishkan/agents/huram.md +4 -2
  33. package/payload/mishkan/agents/hushai.md +5 -2
  34. package/payload/mishkan/agents/igal.md +4 -2
  35. package/payload/mishkan/agents/ira.md +5 -2
  36. package/payload/mishkan/agents/jahaziel.md +5 -2
  37. package/payload/mishkan/agents/jakin.md +4 -2
  38. package/payload/mishkan/agents/jehonathan.md +5 -2
  39. package/payload/mishkan/agents/jehoshaphat.md +4 -2
  40. package/payload/mishkan/agents/joab.md +5 -2
  41. package/payload/mishkan/agents/joah.md +5 -2
  42. package/payload/mishkan/agents/maaseiah.md +4 -2
  43. package/payload/mishkan/agents/meremoth.md +5 -2
  44. package/payload/mishkan/agents/meshullam.md +5 -2
  45. package/payload/mishkan/agents/nathan.md +5 -2
  46. package/payload/mishkan/agents/nehemiah.md +4 -2
  47. package/payload/mishkan/agents/obed.md +4 -2
  48. package/payload/mishkan/agents/oholiab.md +5 -2
  49. package/payload/mishkan/agents/palal.md +5 -2
  50. package/payload/mishkan/agents/phinehas.md +4 -2
  51. package/payload/mishkan/agents/rehum.md +4 -2
  52. package/payload/mishkan/agents/salma.md +5 -2
  53. package/payload/mishkan/agents/seraiah.md +4 -2
  54. package/payload/mishkan/agents/shallum.md +5 -2
  55. package/payload/mishkan/agents/shaphan.md +4 -2
  56. package/payload/mishkan/agents/shemaiah.md +4 -2
  57. package/payload/mishkan/agents/shevna.md +5 -2
  58. package/payload/mishkan/agents/uriah.md +5 -2
  59. package/payload/mishkan/agents/zaccur.md +4 -2
  60. package/payload/mishkan/agents/zadok.md +5 -2
  61. package/payload/mishkan/agents/zerubbabel.md +4 -2
  62. package/payload/mishkan/commands/code-graph.md +35 -0
  63. package/payload/mishkan/commands/eval-baruch.md +57 -0
  64. package/payload/mishkan/commands/mishkan-org-reference.md +58 -0
  65. package/payload/mishkan/commands/mishkan-resume.md +19 -5
  66. package/payload/mishkan/commands/mishkan-skills-misses.md +47 -0
  67. package/payload/mishkan/commands/mishkan-skills-reindex.md +33 -0
  68. package/payload/mishkan/commands/skills.md +35 -0
  69. package/payload/mishkan/commands/sprint-close.md +3 -1
  70. package/payload/mishkan/hooks/pre-tool-knowledge-route.sh +231 -0
  71. package/payload/mishkan/hooks/pre-tool-task-skill-route.sh +122 -0
  72. package/payload/mishkan/hooks/session-start-skill-index.sh +35 -0
  73. package/payload/mishkan/observability/schema.json +1 -1
  74. package/payload/mishkan/observability/watch/src/mishkan_watch/__main__.py +84 -2
  75. package/payload/mishkan/observability/watch/src/mishkan_watch/app.py +64 -3
  76. package/payload/mishkan/observability/watch/src/mishkan_watch/org_data.py +79 -0
  77. package/payload/mishkan/observability/watch/src/mishkan_watch/skills_data.py +267 -0
  78. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/agents.py +33 -1
  79. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/knowledge.py +118 -9
  80. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/live.py +102 -2
  81. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/org.py +139 -0
  82. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/skills.py +187 -0
  83. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/usage.py +377 -0
  84. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/workflows.py +199 -27
  85. package/payload/mishkan/observability/watch/src/mishkan_watch/theme.tcss +78 -4
  86. package/payload/mishkan/observability/watchd/src/mishkan_watchd/__main__.py +36 -6
  87. package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/bus_tail.py +16 -3
  88. package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/graphify_tail.py +220 -0
  89. package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/worktree_poll.py +13 -2
  90. package/payload/mishkan/observability/watchd/src/mishkan_watchd/state.py +157 -3
  91. package/payload/mishkan/observability/watchd/tests/test_state.py +6 -2
  92. package/payload/mishkan/org/org.json +141 -0
  93. package/payload/mishkan/scripts/pre-commit-eval-gate.sh +50 -0
  94. package/payload/mishkan/scripts/skill-discovery-indexer.py +462 -0
  95. package/payload/mishkan/scripts/skill-discovery-misses.py +173 -0
  96. package/payload/mishkan/scripts/skill-discovery-router.py +457 -0
  97. package/payload/mishkan/scripts/skill-discovery-smoke.sh +156 -0
  98. package/payload/mishkan/skills/graphify-query-craft/SKILL.md +162 -0
  99. package/payload/mishkan/skills/mishkan-init/SKILL.md +15 -0
  100. package/payload/mishkan/skills/skill-discovery/SKILL.md +120 -0
  101. package/payload/mishkan/templates/case-node.schema.json +1 -0
  102. package/payload/mishkan/templates/observability-log.schema.json +1 -0
  103. package/payload/mishkan/workflows/README.md +78 -13
  104. package/payload/mishkan/workflows/chosheb-feature-ship.js +75 -0
  105. package/payload/mishkan/workflows/migdal-dr-drill.js +94 -0
  106. package/payload/mishkan/workflows/migdal-infra-change.js +67 -0
  107. package/payload/mishkan/workflows/mishkan-blast-radius.js +144 -0
  108. package/payload/mishkan/workflows/mishkan-init.js +58 -0
  109. package/payload/mishkan/workflows/mishkan-knowledge-gap-discovery.js +121 -0
  110. package/payload/mishkan/workflows/mishkan-standards-rollout.js +153 -0
  111. package/payload/mishkan/workflows/mishmar-security-gate.js +83 -0
  112. package/payload/mishkan/workflows/panim-ds-rollout.js +83 -0
  113. package/payload/mishkan/workflows/sefer-release-notes.js +85 -0
  114. package/payload/mishkan/workflows/yasad-data-migration-wave.js +78 -0
  115. package/payload/mishkan/workflows/yasad-schema-evolution.js +76 -0
  116. 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. Connects to mishkan-watchd.",
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
- yield Static("", id="status-bar")
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-5 tabs · / filter (Activity) · q quit",
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
- text.append(f"{agent} {ts}\n", style="#FC8181 bold")
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())