mishkan-harness 0.2.0 → 0.2.3

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 (113) 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/research/graphify-token-saving-poc.md +108 -0
  7. package/docs/usage/09-workflows.md +74 -4
  8. package/docs/usage/10-observability.md +37 -13
  9. package/docs/usage/11-graphify.md +264 -0
  10. package/docs/usage/12-skill-discovery.md +294 -0
  11. package/docs/usage/README.md +5 -3
  12. package/package.json +1 -1
  13. package/payload/install/settings.hooks.json +24 -0
  14. package/payload/mishkan/AGENT_SPEC.md +25 -7
  15. package/payload/mishkan/agents/ahikam.md +4 -2
  16. package/payload/mishkan/agents/aholiab.md +4 -2
  17. package/payload/mishkan/agents/asaph.md +5 -2
  18. package/payload/mishkan/agents/baruch.md +19 -2
  19. package/payload/mishkan/agents/benaiah.md +4 -2
  20. package/payload/mishkan/agents/bezalel.md +4 -2
  21. package/payload/mishkan/agents/caleb.md +4 -2
  22. package/payload/mishkan/agents/deborah.md +4 -2
  23. package/payload/mishkan/agents/elasah.md +4 -2
  24. package/payload/mishkan/agents/eliashib.md +4 -2
  25. package/payload/mishkan/agents/ezra.md +4 -2
  26. package/payload/mishkan/agents/hanun.md +5 -2
  27. package/payload/mishkan/agents/hiram.md +5 -2
  28. package/payload/mishkan/agents/hizkiah.md +5 -2
  29. package/payload/mishkan/agents/huldah.md +4 -2
  30. package/payload/mishkan/agents/huram.md +4 -2
  31. package/payload/mishkan/agents/hushai.md +5 -2
  32. package/payload/mishkan/agents/igal.md +4 -2
  33. package/payload/mishkan/agents/ira.md +5 -2
  34. package/payload/mishkan/agents/jahaziel.md +5 -2
  35. package/payload/mishkan/agents/jakin.md +4 -2
  36. package/payload/mishkan/agents/jehonathan.md +5 -2
  37. package/payload/mishkan/agents/jehoshaphat.md +4 -2
  38. package/payload/mishkan/agents/joab.md +5 -2
  39. package/payload/mishkan/agents/joah.md +5 -2
  40. package/payload/mishkan/agents/maaseiah.md +4 -2
  41. package/payload/mishkan/agents/meremoth.md +5 -2
  42. package/payload/mishkan/agents/meshullam.md +5 -2
  43. package/payload/mishkan/agents/nathan.md +5 -2
  44. package/payload/mishkan/agents/nehemiah.md +4 -2
  45. package/payload/mishkan/agents/obed.md +4 -2
  46. package/payload/mishkan/agents/oholiab.md +5 -2
  47. package/payload/mishkan/agents/palal.md +5 -2
  48. package/payload/mishkan/agents/phinehas.md +4 -2
  49. package/payload/mishkan/agents/rehum.md +4 -2
  50. package/payload/mishkan/agents/salma.md +5 -2
  51. package/payload/mishkan/agents/seraiah.md +4 -2
  52. package/payload/mishkan/agents/shallum.md +5 -2
  53. package/payload/mishkan/agents/shaphan.md +4 -2
  54. package/payload/mishkan/agents/shemaiah.md +4 -2
  55. package/payload/mishkan/agents/shevna.md +5 -2
  56. package/payload/mishkan/agents/uriah.md +5 -2
  57. package/payload/mishkan/agents/zaccur.md +4 -2
  58. package/payload/mishkan/agents/zadok.md +5 -2
  59. package/payload/mishkan/agents/zerubbabel.md +4 -2
  60. package/payload/mishkan/commands/code-graph.md +35 -0
  61. package/payload/mishkan/commands/eval-baruch.md +57 -0
  62. package/payload/mishkan/commands/mishkan-org-reference.md +58 -0
  63. package/payload/mishkan/commands/mishkan-skills-misses.md +47 -0
  64. package/payload/mishkan/commands/mishkan-skills-reindex.md +33 -0
  65. package/payload/mishkan/commands/skills.md +35 -0
  66. package/payload/mishkan/commands/sprint-close.md +3 -1
  67. package/payload/mishkan/hooks/pre-tool-knowledge-route.sh +231 -0
  68. package/payload/mishkan/hooks/pre-tool-task-skill-route.sh +122 -0
  69. package/payload/mishkan/hooks/session-start-skill-index.sh +35 -0
  70. package/payload/mishkan/observability/schema.json +1 -1
  71. package/payload/mishkan/observability/watch/src/mishkan_watch/__main__.py +84 -2
  72. package/payload/mishkan/observability/watch/src/mishkan_watch/app.py +64 -3
  73. package/payload/mishkan/observability/watch/src/mishkan_watch/org_data.py +79 -0
  74. package/payload/mishkan/observability/watch/src/mishkan_watch/skills_data.py +267 -0
  75. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/agents.py +33 -1
  76. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/knowledge.py +118 -9
  77. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/live.py +102 -2
  78. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/org.py +139 -0
  79. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/skills.py +187 -0
  80. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/usage.py +377 -0
  81. package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/workflows.py +199 -27
  82. package/payload/mishkan/observability/watch/src/mishkan_watch/theme.tcss +78 -4
  83. package/payload/mishkan/observability/watchd/src/mishkan_watchd/__main__.py +36 -6
  84. package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/bus_tail.py +16 -3
  85. package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/graphify_tail.py +220 -0
  86. package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/worktree_poll.py +13 -2
  87. package/payload/mishkan/observability/watchd/src/mishkan_watchd/state.py +157 -3
  88. package/payload/mishkan/observability/watchd/tests/test_state.py +6 -2
  89. package/payload/mishkan/org/org.json +141 -0
  90. package/payload/mishkan/scripts/pre-commit-eval-gate.sh +50 -0
  91. package/payload/mishkan/scripts/skill-discovery-indexer.py +462 -0
  92. package/payload/mishkan/scripts/skill-discovery-misses.py +173 -0
  93. package/payload/mishkan/scripts/skill-discovery-router.py +457 -0
  94. package/payload/mishkan/scripts/skill-discovery-smoke.sh +156 -0
  95. package/payload/mishkan/skills/graphify-query-craft/SKILL.md +162 -0
  96. package/payload/mishkan/skills/mishkan-init/SKILL.md +15 -0
  97. package/payload/mishkan/skills/skill-discovery/SKILL.md +120 -0
  98. package/payload/mishkan/templates/case-node.schema.json +1 -0
  99. package/payload/mishkan/templates/observability-log.schema.json +1 -0
  100. package/payload/mishkan/workflows/README.md +78 -13
  101. package/payload/mishkan/workflows/chosheb-feature-ship.js +75 -0
  102. package/payload/mishkan/workflows/migdal-dr-drill.js +94 -0
  103. package/payload/mishkan/workflows/migdal-infra-change.js +67 -0
  104. package/payload/mishkan/workflows/mishkan-blast-radius.js +144 -0
  105. package/payload/mishkan/workflows/mishkan-init.js +58 -0
  106. package/payload/mishkan/workflows/mishkan-knowledge-gap-discovery.js +121 -0
  107. package/payload/mishkan/workflows/mishkan-standards-rollout.js +153 -0
  108. package/payload/mishkan/workflows/mishmar-security-gate.js +83 -0
  109. package/payload/mishkan/workflows/panim-ds-rollout.js +83 -0
  110. package/payload/mishkan/workflows/sefer-release-notes.js +85 -0
  111. package/payload/mishkan/workflows/yasad-data-migration-wave.js +78 -0
  112. package/payload/mishkan/workflows/yasad-schema-evolution.js +76 -0
  113. package/payload/mishkan/templates/user-CLAUDE.md +0 -62
@@ -21,13 +21,14 @@ class KnowledgeTab(Container):
21
21
 
22
22
  def __init__(self, **kwargs) -> None:
23
23
  super().__init__(**kwargs)
24
- self._state: dict[str, Any] = {"mcp_servers": {}, "cognee": {}}
24
+ self._state: dict[str, Any] = {"mcp_servers": {}, "cognee": {}, "graphify": {}}
25
25
  self._recent_ops: deque[dict[str, Any]] = deque(maxlen=30)
26
26
 
27
27
  def compose(self) -> ComposeResult:
28
28
  with Horizontal(id="knowledge-stores"):
29
29
  yield Static("", id="knowledge-work", classes="cognee-card")
30
30
  yield Static("", id="knowledge-curated", classes="cognee-card")
31
+ yield Static("", id="knowledge-graphify", classes="cognee-card")
31
32
  with Container(id="knowledge-ops"):
32
33
  yield Static("RECENT OPS", classes="panel-title")
33
34
  yield DataTable(id="knowledge-ops-table", cursor_type="row",
@@ -40,7 +41,7 @@ class KnowledgeTab(Container):
40
41
  def on_mount(self) -> None:
41
42
  try:
42
43
  ops = self.query_one("#knowledge-ops-table", DataTable)
43
- ops.add_columns("time", "store", "op", "query/path", "ms")
44
+ ops.add_columns("time", "store", "op", "detail", "metric")
44
45
  mcp = self.query_one("#knowledge-mcp-table", DataTable)
45
46
  mcp.add_columns("server", "status", "since", "last event")
46
47
  except Exception:
@@ -64,11 +65,65 @@ class KnowledgeTab(Container):
64
65
  if etype in ("cognee_op", "graphify_query", "graphify_scan"):
65
66
  self._recent_ops.append(ev)
66
67
  self._render_ops()
68
+ if etype == "cognee_op":
69
+ self._mutate_cognee(ev)
70
+ self._render_cards()
71
+ if etype in ("graphify_scan", "graphify_query"):
72
+ self._mutate_graphify(ev)
73
+ self._render_cards()
67
74
  if etype == "mcp_server":
68
75
  self._mutate_mcp(ev)
69
76
  self._render_mcp()
70
77
  self._render_cards()
71
78
 
79
+ def _mutate_graphify(self, ev: dict[str, Any]) -> None:
80
+ """Update self._state['graphify'] from graphify_scan / graphify_query.
81
+
82
+ Aggregates: latest scan (nodes/edges/project/ts) + query counter.
83
+ """
84
+ try:
85
+ p = ev.get("payload") or {}
86
+ etype = ev.get("type")
87
+ g = self._state.setdefault("graphify", {})
88
+ if etype == "graphify_scan":
89
+ g["last_scan_project"] = p.get("project")
90
+ g["last_scan_at"] = p.get("scanned_at") or ev.get("ts")
91
+ g["nodes"] = p.get("nodes")
92
+ g["edges"] = p.get("edges")
93
+ g["communities"] = p.get("communities")
94
+ g["scans"] = (g.get("scans") or 0) + 1
95
+ elif etype == "graphify_query":
96
+ g["last_query_project"] = p.get("project")
97
+ g["last_query_at"] = ev.get("ts")
98
+ g["last_query_text"] = p.get("question") or p.get("file")
99
+ g["queries"] = (g.get("queries") or 0) + 1
100
+ except Exception:
101
+ return
102
+
103
+ def _mutate_cognee(self, ev: dict[str, Any]) -> None:
104
+ """Update self._state['cognee'][store] from a cognee_op probe event.
105
+
106
+ Without this, _render_cards keeps reading an empty dict and the
107
+ top work/curated cards stay at zero even though the daemon is
108
+ emitting healthy probe events every 30s.
109
+ """
110
+ try:
111
+ p = ev.get("payload") or {}
112
+ store = p.get("store")
113
+ if not store:
114
+ return
115
+ cognee = self._state.setdefault("cognee", {})
116
+ entry = cognee.setdefault(store, {})
117
+ entry["up"] = bool(p.get("up"))
118
+ entry["url"] = p.get("url") or entry.get("url", "")
119
+ if "nodes" in p:
120
+ entry["nodes"] = p.get("nodes")
121
+ entry["last_event"] = ev.get("ts")
122
+ if p.get("status_changed"):
123
+ entry["since"] = ev.get("ts")
124
+ except Exception:
125
+ return
126
+
72
127
  def _mutate_mcp(self, ev: dict[str, Any]) -> None:
73
128
  try:
74
129
  p = ev.get("payload") or {}
@@ -97,6 +152,7 @@ class KnowledgeTab(Container):
97
152
  try:
98
153
  work_panel = self.query_one("#knowledge-work", Static)
99
154
  cur_panel = self.query_one("#knowledge-curated", Static)
155
+ graphify_panel = self.query_one("#knowledge-graphify", Static)
100
156
  except Exception:
101
157
  return
102
158
  mcps = self._state.get("mcp_servers") or {}
@@ -106,6 +162,40 @@ class KnowledgeTab(Container):
106
162
  cur_entry = cognee_state.get("curated") or self._mcp_match(mcps, "cognee", ("curated", "7730"))
107
163
  work_panel.update(self._card_for("work :7777", work_entry))
108
164
  cur_panel.update(self._card_for("curated :7730", cur_entry))
165
+ graphify_panel.update(self._graphify_card(self._state.get("graphify") or {}))
166
+
167
+ def _graphify_card(self, g: dict[str, Any]) -> Text:
168
+ text = Text()
169
+ has_data = bool(g.get("last_scan_at") or g.get("last_query_at"))
170
+ dot = "●" if has_data else "○"
171
+ color = "#00D4AA" if has_data else "#F6AD55"
172
+ text.append(f"{dot} ", style=color)
173
+ text.append("graphify", style="bold")
174
+ text.append(" (code-graph)\n", style="dim")
175
+ text.append("\n")
176
+ nodes = g.get("nodes")
177
+ edges = g.get("edges")
178
+ if nodes is not None:
179
+ text.append(f"{nodes:>6}", style="bold")
180
+ text.append(" nodes ", style="dim")
181
+ if edges is not None:
182
+ text.append(f"{edges:,}", style="bold")
183
+ text.append(" edges", style="dim")
184
+ text.append("\n")
185
+ scans = g.get("scans") or 0
186
+ queries = g.get("queries") or 0
187
+ text.append(f"{scans:>6}", style="bold")
188
+ text.append(" scans ", style="dim")
189
+ text.append(f"{queries}", style="bold")
190
+ text.append(" queries\n", style="dim")
191
+ last_scan = g.get("last_scan_at")
192
+ if last_scan and len(str(last_scan)) > 19:
193
+ text.append("scan ", style="dim")
194
+ text.append(str(last_scan)[11:19], style="white")
195
+ text.append("\n")
196
+ if not has_data:
197
+ text.append("(no scan emitted yet)\n", style="dim italic")
198
+ return text
109
199
 
110
200
  def _mcp_match(self, mcps: dict[str, Any], must_have: str,
111
201
  prefer_tokens: tuple[str, ...]) -> dict[str, Any] | None:
@@ -151,18 +241,37 @@ class KnowledgeTab(Container):
151
241
  table.clear()
152
242
  for ev in list(self._recent_ops)[-20:]:
153
243
  ts = (ev.get("ts") or "")[11:19]
244
+ etype = ev.get("type") or ""
154
245
  p = ev.get("payload") or {}
155
- store = p.get("store") or ("graphify" if ev.get("type", "").startswith("graphify") else "?")
156
- op = p.get("op") or ev.get("type", "").replace("graphify_", "g:")
157
- query = (p.get("query") or p.get("path") or "")[:40]
158
- dur = ev.get("duration_ms") or p.get("duration_ms") or 0
159
- style = "#F687B3" if "cognee" in (ev.get("type") or "") else "#B794F4"
246
+ store = p.get("store") or ("graphify" if etype.startswith("graphify") else "?")
247
+ op = p.get("op") or etype.replace("graphify_", "g:")
248
+ # Each event family fills the "query/path" + "ms" columns
249
+ # with the most useful field it carries:
250
+ # cognee_op → up/down status, nodes count
251
+ # graphify_scan → project basename, nodes/edges
252
+ # graphify_query → question excerpt or filename
253
+ if etype == "cognee_op":
254
+ detail = ("up " if p.get("up") else "down ") + str(p.get("url") or "")
255
+ n = p.get("nodes")
256
+ metric = f"{n} nodes" if n is not None else "-"
257
+ elif etype == "graphify_scan":
258
+ detail = (p.get("project") or "").rsplit("/", 1)[-1] or "(?)"
259
+ nodes = p.get("nodes")
260
+ edges = p.get("edges")
261
+ metric = f"{nodes}n {edges}e" if nodes is not None and edges is not None else "-"
262
+ elif etype == "graphify_query":
263
+ detail = (p.get("question") or p.get("file") or "")[:40]
264
+ metric = p.get("query_type") or "query"
265
+ else:
266
+ detail = (p.get("query") or p.get("path") or "")[:40]
267
+ metric = f"{ev.get('duration_ms') or 0}ms"
268
+ style = "#F687B3" if "cognee" in etype else "#B794F4"
160
269
  table.add_row(
161
270
  Text(ts, style="dim"),
162
271
  Text(str(store)[:10], style=style),
163
272
  Text(str(op)[:10]),
164
- Text(query),
165
- Text(f"{dur}ms" if dur else "-", style="dim"),
273
+ Text(detail[:50]),
274
+ Text(metric, style="dim"),
166
275
  )
167
276
 
168
277
  def _render_mcp(self) -> None:
@@ -16,6 +16,27 @@ from textual.containers import Container, Horizontal, Vertical
16
16
  from textual.widgets import RichLog, Static
17
17
  from rich.text import Text
18
18
 
19
+ # Module-level cache for the alias→role lookup (org.json is static).
20
+ _ROLE_CACHE: dict[str, str | None] | None = None
21
+
22
+
23
+ def _role_for(alias: str) -> str | None:
24
+ global _ROLE_CACHE
25
+ if _ROLE_CACHE is None:
26
+ try:
27
+ from ..org_data import load_org
28
+ org = load_org()
29
+ cache: dict[str, str | None] = {}
30
+ for grp in org.get("groups", []):
31
+ for ag in grp.get("agents", []):
32
+ a = (ag.get("alias") or "").lower()
33
+ if a:
34
+ cache[a] = ag.get("short") or ag.get("role")
35
+ _ROLE_CACHE = cache
36
+ except Exception:
37
+ _ROLE_CACHE = {}
38
+ return _ROLE_CACHE.get((alias or "").lower())
39
+
19
40
 
20
41
  MAX_FEED_LINES = 200
21
42
 
@@ -116,6 +137,9 @@ class LiveTab(Container):
116
137
  def __init__(self, **kwargs) -> None:
117
138
  super().__init__(**kwargs)
118
139
  self._state: dict[str, Any] = {}
140
+ # Project filter — None means show all; a Path means filter to that project.
141
+ # Toggled by the app via set_project_filter() in response to the 'p' key.
142
+ self._project_filter: Any = None
119
143
  self._feed_buf: deque[Text] = deque(maxlen=MAX_FEED_LINES)
120
144
 
121
145
  def compose(self) -> ComposeResult:
@@ -159,7 +183,7 @@ class LiveTab(Container):
159
183
  self._render_active(self._state)
160
184
  if etype == "worktree_change":
161
185
  self._render_worktrees(self._state)
162
- if etype in ("mcp_server", "cognee_op"):
186
+ if etype in ("mcp_server", "cognee_op", "graphify_scan", "graphify_query"):
163
187
  self._render_knowledge(self._state)
164
188
 
165
189
  def _mutate_state(self, ev: dict[str, Any]) -> None:
@@ -219,6 +243,29 @@ class LiveTab(Container):
219
243
  existing["status"] = p.get("status") or existing.get("status", "?")
220
244
  existing["last_event"] = ev.get("ts")
221
245
  mcps[name] = existing
246
+ elif etype == "cognee_op":
247
+ p = ev.get("payload") or {}
248
+ store = p.get("store")
249
+ if store:
250
+ cognee = self._state.setdefault("cognee", {})
251
+ entry = cognee.setdefault(store, {})
252
+ entry["up"] = bool(p.get("up"))
253
+ entry["url"] = p.get("url") or entry.get("url", "")
254
+ if "nodes" in p:
255
+ entry["nodes"] = p.get("nodes")
256
+ entry["last_event"] = ev.get("ts")
257
+ elif etype in ("graphify_scan", "graphify_query"):
258
+ p = ev.get("payload") or {}
259
+ g = self._state.setdefault("graphify", {})
260
+ if etype == "graphify_scan":
261
+ g["nodes"] = p.get("nodes")
262
+ g["edges"] = p.get("edges")
263
+ g["communities"] = p.get("communities")
264
+ g["scans"] = (g.get("scans") or 0) + 1
265
+ g["last_scan_at"] = p.get("scanned_at") or ev.get("ts")
266
+ else:
267
+ g["queries"] = (g.get("queries") or 0) + 1
268
+ g["last_query_at"] = ev.get("ts")
222
269
  elif etype == "session_stop" and sid:
223
270
  sessions.pop(sid, None)
224
271
  except Exception:
@@ -226,6 +273,38 @@ class LiveTab(Container):
226
273
 
227
274
  # ----- panel renderers ---------------------------------------------------
228
275
 
276
+ def set_project_filter(self, project: Any) -> None:
277
+ """Called by the app when the user toggles the project filter (key 'p')."""
278
+ self._project_filter = project
279
+ self._render_active(self._state)
280
+ self._render_worktrees(self._state)
281
+
282
+ def _session_matches_filter(self, sess: dict[str, Any]) -> bool:
283
+ """Match a session to the active project filter, tolerating both
284
+ absolute paths and Claude Code's encoded dir form.
285
+
286
+ Claude Code stores sessions under ~/.claude/projects/<encoded>/...
287
+ where <encoded> is the absolute project path with '/' replaced by
288
+ '-' (e.g. -home-ogu-theY4NN-harness). The daemon's session.project
289
+ field carries this encoded form. The filter target is an absolute
290
+ Path. We compare both:
291
+ - absolute → encoded (replace '/' with '-')
292
+ - encoded → basename match (last segment)
293
+ """
294
+ flt = getattr(self, "_project_filter", None)
295
+ if not flt:
296
+ return True
297
+ proj = (sess.get("project") or "").rstrip("/")
298
+ if not proj:
299
+ return False
300
+ target_abs = str(flt).rstrip("/")
301
+ # Encoded form: leading dash + path with '/' → '-'.
302
+ target_encoded = target_abs.replace("/", "-")
303
+ if proj == target_abs or proj == target_encoded:
304
+ return True
305
+ # Last-segment fallback (covers both encoded and absolute forms).
306
+ return proj.rsplit("-", 1)[-1] == target_abs.rsplit("/", 1)[-1]
307
+
229
308
  def _render_active(self, state: dict[str, Any]) -> None:
230
309
  try:
231
310
  panel = self.query_one("#live-active", Static)
@@ -233,9 +312,13 @@ class LiveTab(Container):
233
312
  return
234
313
  sessions = (state or {}).get("sessions") or {}
235
314
  text = Text()
236
- text.append("ACTIVE\n", style="bold dim")
315
+ flt = getattr(self, "_project_filter", None)
316
+ title = "ACTIVE" if not flt else f"ACTIVE · {str(flt).split('/')[-1]} only (p to toggle)"
317
+ text.append(title + "\n", style="bold dim")
237
318
  any_agent = False
238
319
  for sid, sess in sessions.items():
320
+ if not self._session_matches_filter(sess):
321
+ continue
239
322
  for name, ag in (sess.get("agents_active") or {}).items():
240
323
  any_agent = True
241
324
  started = (ag.get("started") or "")[11:19]
@@ -245,6 +328,9 @@ class LiveTab(Container):
245
328
  style = _style_for_type("agent_spawn") if status == "running" else "dim"
246
329
  text.append(f"{dot} ", style=style)
247
330
  text.append(f"{name:14}", style="bold")
331
+ role = _role_for(name)
332
+ if role:
333
+ text.append(f" · {role:18}", style="dim")
248
334
  text.append(f" {started} {last_tool}\n", style="dim")
249
335
  if not any_agent:
250
336
  text.append("(no active agents)\n", style="dim italic")
@@ -295,6 +381,20 @@ class LiveTab(Container):
295
381
  text.append(f"{nodes:>6} nodes\n", style="white")
296
382
  if not cognee:
297
383
  text.append("(cognee not yet probed)\n", style="dim italic")
384
+ g = (state or {}).get("graphify") or {}
385
+ if g:
386
+ nodes = g.get("nodes")
387
+ edges = g.get("edges")
388
+ scans = g.get("scans") or 0
389
+ queries = g.get("queries") or 0
390
+ text.append("● ", style="#00D4AA")
391
+ text.append(f"{'graphify':12} ", style="bold")
392
+ if nodes is not None:
393
+ text.append(f"{nodes:>6} nodes", style="white")
394
+ if edges is not None:
395
+ text.append(f" / {edges:,} edges", style="dim")
396
+ text.append("\n")
397
+ text.append(f" {scans} scans · {queries} queries\n", style="dim")
298
398
  mcp = (state or {}).get("mcp_servers") or {}
299
399
  if mcp:
300
400
  text.append("\nMCP\n", style="bold dim")
@@ -0,0 +1,139 @@
1
+ """Org tab — MISHKAN organisation reference.
2
+
3
+ Read-only browser of the 45-agent org chart. Tree on the left
4
+ (groups → agents), details panel on the right (role + description +
5
+ source). Static data: loaded from org.json via org_data.load_org(),
6
+ never depends on the bus.
7
+
8
+ Purpose: small recalls until the org is memorised.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ from typing import Any
13
+
14
+ from rich.text import Text
15
+ from textual.app import ComposeResult
16
+ from textual.containers import Container, Horizontal
17
+ from textual.widgets import Static, Tree
18
+
19
+ from ..org_data import load_org as _load_org
20
+
21
+
22
+ class OrgTab(Container):
23
+ """Org reference tab."""
24
+
25
+ def __init__(self, **kwargs) -> None:
26
+ super().__init__(**kwargs)
27
+ self._org = _load_org()
28
+ self._selected: tuple[str, str] | None = None # (group_id, alias)
29
+
30
+ def compose(self) -> ComposeResult:
31
+ with Horizontal(id="org-row"):
32
+ yield Tree("MISHKAN", id="org-tree")
33
+ with Container(id="org-detail"):
34
+ yield Static("(select an agent)", id="org-detail-body")
35
+
36
+ def on_mount(self) -> None:
37
+ self._build_tree()
38
+
39
+ def _build_tree(self) -> None:
40
+ try:
41
+ tree = self.query_one("#org-tree", Tree)
42
+ except Exception:
43
+ return
44
+ tree.root.expand()
45
+ for grp in self._org.get("groups", []):
46
+ label = Text()
47
+ label.append(grp["label"], style="bold")
48
+ domain = grp.get("domain")
49
+ if domain:
50
+ label.append(f" · {domain}", style="#00D4AA")
51
+ label.append(f" ({len(grp.get('agents', []))})", style="dim")
52
+ node = tree.root.add(label, data={"kind": "group", "id": grp["id"]})
53
+ node.expand()
54
+ for ag in grp.get("agents", []):
55
+ al = Text()
56
+ al.append("● ", style="#00D4AA")
57
+ al.append(ag["alias"].capitalize(), style="bold")
58
+ al.append(f" · {ag['role']}", style="dim")
59
+ node.add_leaf(al, data={"kind": "agent", "group": grp["id"], "alias": ag["alias"]})
60
+
61
+ def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
62
+ data = event.node.data or {}
63
+ kind = data.get("kind")
64
+ if kind == "agent":
65
+ self._render_agent_detail(data["group"], data["alias"])
66
+ elif kind == "group":
67
+ self._render_group_detail(data["id"])
68
+
69
+ def _render_group_detail(self, gid: str) -> None:
70
+ try:
71
+ panel = self.query_one("#org-detail-body", Static)
72
+ except Exception:
73
+ return
74
+ grp = next((g for g in self._org.get("groups", []) if g["id"] == gid), None)
75
+ if not grp:
76
+ return
77
+ t = Text()
78
+ t.append(grp["label"], style="bold #B794F4")
79
+ if grp.get("domain"):
80
+ t.append(f" · {grp['domain']}", style="bold #00D4AA")
81
+ t.append("\n")
82
+ if grp.get("hebrew"):
83
+ t.append(grp["hebrew"], style="cyan")
84
+ if grp.get("hebrew_meaning"):
85
+ t.append(f" — {grp['hebrew_meaning']}", style="dim italic")
86
+ t.append("\n")
87
+ t.append("\n")
88
+ if grp.get("mission"):
89
+ t.append("mission\n", style="dim")
90
+ t.append(grp["mission"], style="white")
91
+ t.append("\n\n")
92
+ if grp.get("charter"):
93
+ t.append("charter\n", style="dim")
94
+ t.append(grp["charter"], style="white")
95
+ t.append("\n\n")
96
+ if grp.get("relationships"):
97
+ t.append("relationships\n", style="dim")
98
+ t.append(grp["relationships"], style="white")
99
+ t.append("\n\n")
100
+ t.append(f"{len(grp.get('agents', []))} agents — select one for role detail.", style="dim italic")
101
+ panel.update(t)
102
+
103
+ def _render_agent_detail(self, gid: str, alias: str) -> None:
104
+ try:
105
+ panel = self.query_one("#org-detail-body", Static)
106
+ except Exception:
107
+ return
108
+ grp = next((g for g in self._org.get("groups", []) if g["id"] == gid), None)
109
+ if not grp:
110
+ return
111
+ ag = next((a for a in grp.get("agents", []) if a["alias"] == alias), None)
112
+ if not ag:
113
+ return
114
+ t = Text()
115
+ t.append(ag["alias"].capitalize(), style="bold #B794F4")
116
+ t.append(f" · {grp['label']}", style="bold")
117
+ if grp.get("domain"):
118
+ t.append(f" · {grp['domain']}", style="#00D4AA")
119
+ t.append("\n\n")
120
+ t.append("role ", style="dim")
121
+ t.append(ag["role"], style="cyan")
122
+ t.append("\n")
123
+ if ag.get("short"):
124
+ t.append("short ", style="dim")
125
+ t.append(ag["short"], style="white")
126
+ t.append("\n")
127
+ t.append("source ", style="dim")
128
+ t.append(ag.get("source", "—"), style="dim italic")
129
+ t.append("\n\n")
130
+ t.append(ag.get("description", ""), style="white")
131
+ panel.update(t)
132
+
133
+ # ----- API expected by app.py (no-op since static data) -----------------
134
+
135
+ def apply_snapshot(self, state: dict[str, Any]) -> None:
136
+ return
137
+
138
+ def apply_event(self, ev: dict[str, Any]) -> None:
139
+ return
@@ -0,0 +1,187 @@
1
+ """Tab 8 — Skills.
2
+
3
+ 3-column browser of every installed skill on the user's Claude Code
4
+ instance, with cross-reference to the CTO decisions (ADRs) where each
5
+ skill is mentioned.
6
+
7
+ Layout:
8
+ - LEFT (28%) : tree grouped by origin (MISHKAN / user / plugin)
9
+ then by category. Selecting a leaf loads detail.
10
+ - CENTER (44%): selected skill's full metadata — origin, source path,
11
+ category, description, triggers ("Use when..." lines).
12
+ - RIGHT (28%) : ADRs that mention this skill, with section bodies
13
+ truncated for at-a-glance reading.
14
+
15
+ Data via skills_data.load_skills() (prefers the indexer output if
16
+ Bezalel's index.json exists; otherwise scans paths directly).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from collections import defaultdict
21
+ from typing import Any
22
+
23
+ from rich.text import Text
24
+ from textual.app import ComposeResult
25
+ from textual.containers import Container, Horizontal
26
+ from textual.widgets import Static, Tree
27
+
28
+ from ..skills_data import load_skills
29
+
30
+
31
+ class SkillsTab(Container):
32
+ DEFAULT_CSS = ""
33
+
34
+ def __init__(self, **kwargs) -> None:
35
+ super().__init__(**kwargs)
36
+ self._skills: list[dict[str, Any]] = []
37
+ self._selected_name: str | None = None
38
+
39
+ def compose(self) -> ComposeResult:
40
+ with Horizontal(id="skills-row"):
41
+ yield Tree("Skills", id="skills-tree")
42
+ with Container(id="skills-detail"):
43
+ yield Static("", id="skills-detail-body")
44
+ with Container(id="skills-adrs"):
45
+ yield Static("", id="skills-adrs-body")
46
+
47
+ def on_mount(self) -> None:
48
+ try:
49
+ self._skills = load_skills()
50
+ except Exception:
51
+ self._skills = []
52
+ self._rebuild_tree()
53
+ self._render_detail()
54
+ self._render_adrs()
55
+
56
+ def _rebuild_tree(self) -> None:
57
+ try:
58
+ tree = self.query_one("#skills-tree", Tree)
59
+ except Exception:
60
+ return
61
+ tree.clear()
62
+ root = tree.root
63
+ root.expand()
64
+ # Group origin → category → entries.
65
+ groups: dict[str, dict[str, list[dict[str, Any]]]] = defaultdict(
66
+ lambda: defaultdict(list))
67
+ for s in self._skills:
68
+ groups[s.get("origin") or "?"][s.get("category") or "?"].append(s)
69
+ origin_labels = {
70
+ "mishkan": "MISHKAN craft",
71
+ "user": "Community",
72
+ "plugin": "Plugins",
73
+ "project": "Project-local",
74
+ "builtin": "Built-in",
75
+ }
76
+ # Render in fixed order so MISHKAN is always first.
77
+ for origin in ("mishkan", "user", "plugin", "project", "builtin"):
78
+ if origin not in groups:
79
+ continue
80
+ cats = groups[origin]
81
+ total = sum(len(v) for v in cats.values())
82
+ label = Text()
83
+ label.append(origin_labels.get(origin, origin), style="bold")
84
+ label.append(f" ({total})", style="dim")
85
+ on = root.add(label, data={"kind": "origin"})
86
+ on.expand()
87
+ for cat in sorted(cats.keys()):
88
+ entries = cats[cat]
89
+ cl = Text()
90
+ cl.append(cat, style="bold #00D4AA")
91
+ cl.append(f" ({len(entries)})", style="dim")
92
+ cn = on.add(cl, data={"kind": "category"})
93
+ for entry in sorted(entries, key=lambda e: e.get("name") or ""):
94
+ sl = Text()
95
+ sl.append("● ", style="#B794F4")
96
+ sl.append(entry.get("name") or "?", style="white")
97
+ if entry.get("adrs"):
98
+ sl.append(f" · {','.join(entry['adrs'])}", style="#F6AD55 italic")
99
+ cn.add_leaf(sl, data={"kind": "skill", "name": entry.get("name")})
100
+
101
+ def on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
102
+ data = event.node.data or {}
103
+ if data.get("kind") == "skill":
104
+ self._selected_name = data.get("name")
105
+ self._render_detail()
106
+ self._render_adrs()
107
+
108
+ def _render_detail(self) -> None:
109
+ try:
110
+ panel = self.query_one("#skills-detail-body", Static)
111
+ except Exception:
112
+ return
113
+ if not self._selected_name:
114
+ panel.update(Text("(select a skill on the left)\n\n"
115
+ f"{len(self._skills)} skills installed",
116
+ style="dim italic"))
117
+ return
118
+ entry = next((s for s in self._skills if s.get("name") == self._selected_name), None)
119
+ if not entry:
120
+ panel.update(Text("(skill no longer in index)", style="dim italic"))
121
+ return
122
+ t = Text()
123
+ t.append(entry["name"], style="bold #B794F4")
124
+ t.append("\n\n")
125
+ t.append("origin ", style="dim")
126
+ t.append(entry.get("origin") or "?", style="cyan")
127
+ t.append("\n")
128
+ t.append("category ", style="dim")
129
+ t.append(entry.get("category") or "?", style="cyan")
130
+ t.append("\n")
131
+ t.append("source ", style="dim")
132
+ t.append(str(entry.get("source_path") or "?")[-60:], style="dim italic")
133
+ t.append("\n\n")
134
+ desc = entry.get("description") or "(no description)"
135
+ t.append("description\n", style="bold dim")
136
+ t.append(desc + "\n", style="white")
137
+ triggers = entry.get("triggers") or []
138
+ if triggers:
139
+ t.append("\nuse when\n", style="bold dim")
140
+ for tr in triggers[:4]:
141
+ t.append(f" · {tr[:80]}\n", style="white")
142
+ # Frontmatter dump for the curious.
143
+ fm = entry.get("frontmatter") or {}
144
+ if fm:
145
+ t.append("\nfrontmatter\n", style="bold dim")
146
+ for k, v in list(fm.items())[:6]:
147
+ if k in ("name", "description"):
148
+ continue
149
+ t.append(f" {k}: ", style="dim")
150
+ t.append(str(v)[:60] + "\n", style="white")
151
+ panel.update(t)
152
+
153
+ def _render_adrs(self) -> None:
154
+ try:
155
+ panel = self.query_one("#skills-adrs-body", Static)
156
+ except Exception:
157
+ return
158
+ t = Text()
159
+ t.append("CTO decisions\n", style="bold #B794F4")
160
+ if not self._selected_name:
161
+ t.append("\n(select a skill to see linked ADRs)", style="dim italic")
162
+ panel.update(t)
163
+ return
164
+ entry = next((s for s in self._skills if s.get("name") == self._selected_name), None)
165
+ adrs = (entry or {}).get("adrs") or []
166
+ if not adrs:
167
+ t.append("\n(no ADR mentions this skill)\n", style="dim italic")
168
+ t.append("\nADRs live in docs/design/MISHKAN_decisions.md", style="dim")
169
+ panel.update(t)
170
+ return
171
+ t.append(f"\n{len(adrs)} ADR mention(s):\n\n", style="dim")
172
+ for adr_id in adrs:
173
+ t.append(f" ● {adr_id}\n", style="#F6AD55 bold")
174
+ t.append("\nFull text:\n", style="bold dim")
175
+ t.append(" docs/design/MISHKAN_decisions.md\n", style="dim italic")
176
+ panel.update(t)
177
+
178
+ # ----- API expected by app.py -------------------------------------------
179
+
180
+ def apply_snapshot(self, state: dict[str, Any]) -> None:
181
+ return # static data, no daemon dependency
182
+
183
+ def apply_event(self, ev: dict[str, Any]) -> None:
184
+ return
185
+
186
+ def set_project_filter(self, project: Any) -> None:
187
+ return