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
@@ -0,0 +1,377 @@
1
+ """Tab 7 — Usage Overview.
2
+
3
+ Aggregates token consumption, cost, request counts, and context-window
4
+ estimates across all sessions. Provides the detailed breakdown the
5
+ status bar can't fit.
6
+
7
+ Layout (left → right):
8
+ - TOTALS card : harness-wide tokens / cost / requests
9
+ - SESSIONS : per-session breakdown table
10
+ - DETAIL : selected session deep-dive (agents, top tools, recent activity)
11
+ """
12
+ from __future__ import annotations
13
+
14
+ from typing import Any
15
+
16
+ from rich.text import Text
17
+ from textual.app import ComposeResult
18
+ from textual.containers import Container, Horizontal
19
+ from textual.widgets import DataTable, Static
20
+
21
+
22
+ # Per-model context window (tokens). Authoritative as of 2026-06 — verified
23
+ # against the live Claude model catalogue (shared/models.md). Sonnet 4.6 and
24
+ # Opus 4.x both ship with 1M context windows at standard pricing; Haiku 4.5
25
+ # stays at 200K. The MISHKAN model-routing YAML keeps most write-heavy
26
+ # specialists on Sonnet and orchestration on Opus, so the dominant ceiling for
27
+ # a session is 1M — not 200K. Pinning the bar at 200K made the "context used"
28
+ # gauge read 5× higher than reality on Sonnet-routed sessions.
29
+ _CONTEXT_WINDOW_BY_MODEL = {
30
+ "opus": 1_000_000,
31
+ "sonnet": 1_000_000,
32
+ "haiku": 200_000,
33
+ }
34
+ # Fallback when no agent attribution is available. Sonnet is the default tier
35
+ # in the routing YAML (defaults.unlisted_agent), so 1M is the honest ceiling
36
+ # for an un-attributed session.
37
+ _DEFAULT_CONTEXT_WINDOW = 1_000_000
38
+
39
+ # Bytes-per-token approximation is irrelevant here; tokens are emitted
40
+ # verbatim by the daemon's token_usage source.
41
+
42
+
43
+ def _session_context_window(sess: dict) -> tuple[int, str]:
44
+ """Pick the right context window for a session.
45
+
46
+ Heuristic: walk the session's active agents, look up each agent's tier in
47
+ the routing-derived per-agent map (populated by the daemon when it sees
48
+ the model field on agent events), and take the max. An Opus + Sonnet
49
+ session has a 1M ceiling either way; an all-Haiku session caps at 200K.
50
+
51
+ Returns (window_tokens, label) where label is a short tier string for
52
+ display ("opus", "sonnet", "haiku", or "mixed" when multiple tiers run).
53
+ """
54
+ agents = sess.get("agents_active") or {}
55
+ tiers: set[str] = set()
56
+ for ag in agents.values():
57
+ m = (ag.get("model") or "").lower()
58
+ if "opus" in m:
59
+ tiers.add("opus")
60
+ elif "sonnet" in m:
61
+ tiers.add("sonnet")
62
+ elif "haiku" in m:
63
+ tiers.add("haiku")
64
+ if not tiers:
65
+ return _DEFAULT_CONTEXT_WINDOW, "sonnet?"
66
+ if len(tiers) == 1:
67
+ t = next(iter(tiers))
68
+ return _CONTEXT_WINDOW_BY_MODEL.get(t, _DEFAULT_CONTEXT_WINDOW), t
69
+ # Mixed: the limiting factor is whichever tier is loaded heaviest, but
70
+ # for a "context used" gauge the user cares about the smallest ceiling
71
+ # any active agent is bounded by. Haiku in the mix caps the harness at 200K.
72
+ window = min(_CONTEXT_WINDOW_BY_MODEL.get(t, _DEFAULT_CONTEXT_WINDOW) for t in tiers)
73
+ return window, "mixed(" + "+".join(sorted(tiers)) + ")"
74
+
75
+
76
+ def _fmt_count(n: int | float) -> str:
77
+ n = int(n or 0)
78
+ if n >= 1_000_000:
79
+ return f"{n/1_000_000:.2f}M"
80
+ if n >= 1_000:
81
+ return f"{n/1_000:.1f}k"
82
+ return str(n)
83
+
84
+
85
+ def _fmt_money(d: float) -> str:
86
+ if not d:
87
+ return "$0.00"
88
+ return f"${d:.2f}" if d < 100 else f"${d:.0f}"
89
+
90
+
91
+ class UsageTab(Container):
92
+ DEFAULT_CSS = ""
93
+
94
+ def __init__(self, **kwargs) -> None:
95
+ super().__init__(**kwargs)
96
+ # We hold a *mutable copy* of the snapshot state and patch it on every
97
+ # token_usage delta. The daemon's broadcast cadence is one delta per
98
+ # bus event, but a fresh snapshot only arrives on client connect /
99
+ # reconnect — without local accumulation the Usage tab numbers stayed
100
+ # frozen between snapshots even though the status bar moved (the bar
101
+ # accumulates into the app's `_totals` dict). Mirror the same shape
102
+ # here so this tab is live.
103
+ self._state: dict[str, Any] = {"sessions": {}}
104
+ self._selected_sid: str | None = None
105
+ # Throttle re-renders to ~4 Hz max. The bus can emit many deltas per
106
+ # second; rendering on every one costs CPU and never produces a frame
107
+ # the human can read. We dirty-flag instead and the on_mount tick
108
+ # repaints if anything changed since the last paint.
109
+ self._dirty = False
110
+
111
+ def compose(self) -> ComposeResult:
112
+ with Horizontal(id="usage-row"):
113
+ with Container(id="usage-totals"):
114
+ yield Static("", id="usage-totals-body")
115
+ with Container(id="usage-sessions"):
116
+ yield Static("SESSIONS", classes="panel-title")
117
+ yield DataTable(id="usage-sessions-table",
118
+ cursor_type="row", zebra_stripes=False)
119
+ with Container(id="usage-detail"):
120
+ yield Static("", id="usage-detail-body")
121
+
122
+ def on_mount(self) -> None:
123
+ try:
124
+ t = self.query_one("#usage-sessions-table", DataTable)
125
+ t.add_columns("session", "project", "model", "in", "out", "cached", "$")
126
+ except Exception:
127
+ pass
128
+ # 250 ms repaint tick — flushes whatever deltas accumulated since the
129
+ # last paint. Without this the tab depends on snapshot frames for
130
+ # visible motion, which only arrive on (re)connect; deltas update
131
+ # `_state` silently and the user sees a frozen panel.
132
+ self.set_interval(0.25, self._tick)
133
+ self._render_all()
134
+
135
+ def _tick(self) -> None:
136
+ if self._dirty:
137
+ self._dirty = False
138
+ self._render_all()
139
+
140
+ # ----- snapshot / event application --------------------------------------
141
+
142
+ def apply_snapshot(self, state: dict[str, Any]) -> None:
143
+ # Replace local mirror with the authoritative snapshot. Any deltas
144
+ # that landed between the snapshot's generation and its arrival are
145
+ # already folded in by the daemon.
146
+ self._state = dict(state or {})
147
+ self._state.setdefault("sessions", {})
148
+ self._render_all()
149
+
150
+ def apply_event(self, ev: dict[str, Any]) -> None:
151
+ """Patch our local state mirror so the totals move between snapshots.
152
+
153
+ Without this, the Usage tab is read-only between snapshot frames —
154
+ the daemon broadcasts deltas (not snapshots) on every bus event, so
155
+ nothing would refresh except on reconnect.
156
+ """
157
+ etype = ev.get("type")
158
+ sid = ev.get("session_id") or ev.get("sid")
159
+ sessions = self._state.setdefault("sessions", {})
160
+
161
+ if etype == "token_usage" and sid:
162
+ p = ev.get("payload") or {}
163
+ sess = sessions.setdefault(sid, {})
164
+ sess["tokens_in"] = int(sess.get("tokens_in") or 0) + int(p.get("tokens_in") or 0)
165
+ sess["tokens_out"] = int(sess.get("tokens_out") or 0) + int(p.get("tokens_out") or 0)
166
+ sess["cache_read"] = int(sess.get("cache_read") or 0) + int(p.get("cache_read") or 0)
167
+ sess["cache_write"] = int(sess.get("cache_write") or 0) + int(p.get("cache_write") or 0)
168
+ sess["cost_estimate_usd"] = float(sess.get("cost_estimate_usd") or 0.0) \
169
+ + float(p.get("cost_estimate_usd") or 0.0)
170
+ self._dirty = True
171
+ elif etype == "agent_spawn" and sid:
172
+ sess = sessions.setdefault(sid, {})
173
+ agents = sess.setdefault("agents_active", {})
174
+ name = ev.get("agent") or (ev.get("payload") or {}).get("name") or "?"
175
+ model = (ev.get("payload") or {}).get("model") or ""
176
+ ag = agents.setdefault(name, {})
177
+ if model:
178
+ ag["model"] = model
179
+ self._dirty = True
180
+ elif etype == "agent_complete" and sid:
181
+ sess = sessions.setdefault(sid, {})
182
+ agents = sess.setdefault("agents_active", {})
183
+ name = ev.get("agent") or (ev.get("payload") or {}).get("name")
184
+ if name and name in agents:
185
+ agents.pop(name, None)
186
+ self._dirty = True
187
+ elif etype == "tool_call" and sid:
188
+ sess = sessions.setdefault(sid, {})
189
+ recent = sess.setdefault("recent_events", [])
190
+ # Keep a bounded tail so the "TOP TOOLS" panel has signal.
191
+ recent.append({"type": "tool_call", "tool": ev.get("tool")})
192
+ if len(recent) > 200:
193
+ del recent[: len(recent) - 200]
194
+ self._dirty = True
195
+ elif etype in ("session_start", "session_stop"):
196
+ self._dirty = True
197
+
198
+ # ----- renderers ---------------------------------------------------------
199
+
200
+ def _render_all(self) -> None:
201
+ self._render_totals()
202
+ self._render_sessions()
203
+ self._render_detail()
204
+
205
+ def _render_totals(self) -> None:
206
+ try:
207
+ panel = self.query_one("#usage-totals-body", Static)
208
+ except Exception:
209
+ return
210
+ sessions_map = self._state.get("sessions") or {}
211
+ sessions = list(sessions_map.values())
212
+ tin = sum(int(s.get("tokens_in") or 0) for s in sessions)
213
+ tout = sum(int(s.get("tokens_out") or 0) for s in sessions)
214
+ cr = sum(int(s.get("cache_read") or 0) for s in sessions)
215
+ cw = sum(int(s.get("cache_write") or 0) for s in sessions)
216
+ cost = sum(float(s.get("cost_estimate_usd") or 0.0) for s in sessions)
217
+ agents_running = sum(len(s.get("agents_active") or {}) for s in sessions)
218
+ tool_calls = 0
219
+ for s in sessions:
220
+ for ev in (s.get("recent_events") or []):
221
+ if ev.get("type") == "tool_call":
222
+ tool_calls += 1
223
+
224
+ # Context "used" gauge: pick the worst (most-loaded) session, divide
225
+ # by *its own* context window. Sonnet 4.6 and Opus 4.x carry 1M; Haiku
226
+ # 4.5 carries 200K. A session running 600k tokens on Sonnet is 60%
227
+ # full; the same 600k on Haiku is "over the cap" (which the daemon
228
+ # would normally have already compacted around). Using a single 200k
229
+ # default for everyone (the old behaviour) pinned the bar red on any
230
+ # Sonnet session crossing 160k — a false alarm by 5×.
231
+ used_pct = 0
232
+ worst_label = "—"
233
+ worst_window = _DEFAULT_CONTEXT_WINDOW
234
+ if sessions:
235
+ best_pct = -1
236
+ for sess in sessions:
237
+ tokens_in = int(sess.get("tokens_in") or 0)
238
+ window, label = _session_context_window(sess)
239
+ pct = int(100 * tokens_in / window) if window else 0
240
+ if pct > best_pct:
241
+ best_pct = pct
242
+ worst_label = label
243
+ worst_window = window
244
+ used_pct = min(100, max(0, best_pct))
245
+
246
+ t = Text()
247
+ t.append("HARNESS USAGE\n", style="bold #B794F4")
248
+ t.append("\n")
249
+ t.append("TOKENS\n", style="bold dim")
250
+ t.append(f" in {_fmt_count(tin):>10}\n", style="cyan")
251
+ t.append(f" out {_fmt_count(tout):>10}\n", style="cyan")
252
+ t.append(f" cache {_fmt_count(cr):>10}", style="dim")
253
+ t.append(f" read\n", style="dim")
254
+ t.append(f" {_fmt_count(cw):>10}", style="dim")
255
+ t.append(f" write\n", style="dim")
256
+ t.append("\n")
257
+ t.append("COST\n", style="bold dim")
258
+ t.append(f" {_fmt_money(cost)}\n", style="#68D391 bold")
259
+ t.append("\n")
260
+ t.append("CONTEXT (worst sess.)\n", style="bold dim")
261
+ bar = "█" * (used_pct // 5) + "░" * (20 - used_pct // 5)
262
+ bar_color = "#FC8181" if used_pct > 80 else ("#F6AD55" if used_pct > 60 else "#00D4AA")
263
+ t.append(f" {bar}", style=bar_color)
264
+ t.append(f" {used_pct}%\n", style="dim")
265
+ # Show the actual ceiling we used — 1M for opus/sonnet, 200k for haiku,
266
+ # honest "?" when the session has no agent attribution yet.
267
+ cap_str = f"{worst_window // 1000}k" if worst_window < 1_000_000 else "1M"
268
+ t.append(f" cap {cap_str} ({worst_label})\n", style="dim italic")
269
+ t.append("\n")
270
+ t.append("REQUESTS\n", style="bold dim")
271
+ t.append(f" tool_calls {tool_calls:>6}\n", style="white")
272
+ t.append(f" agents now {agents_running:>6}\n", style="white")
273
+ panel.update(t)
274
+
275
+ def _render_sessions(self) -> None:
276
+ try:
277
+ table = self.query_one("#usage-sessions-table", DataTable)
278
+ except Exception:
279
+ return
280
+ table.clear()
281
+ sessions = (self._state.get("sessions") or {})
282
+ # Sort by tokens_in desc so the heaviest is on top.
283
+ rows = sorted(sessions.items(),
284
+ key=lambda kv: int((kv[1].get("tokens_in") or 0)),
285
+ reverse=True)
286
+ for sid, sess in rows[:15]:
287
+ proj = sess.get("project") or "unknown"
288
+ # Decode encoded form.
289
+ if proj.startswith("-"):
290
+ proj = proj.replace("-", "/")
291
+ tin = int(sess.get("tokens_in") or 0)
292
+ tout = int(sess.get("tokens_out") or 0)
293
+ cr = int(sess.get("cache_read") or 0)
294
+ cost = float(sess.get("cost_estimate_usd") or 0.0)
295
+ _, model_label = _session_context_window(sess)
296
+ table.add_row(
297
+ Text(sid[:8] + "…", style="cyan"),
298
+ Text(proj[-30:], style="dim"),
299
+ Text(model_label[:10], style="#B794F4"),
300
+ Text(_fmt_count(tin), style="cyan"),
301
+ Text(_fmt_count(tout), style="cyan"),
302
+ Text(_fmt_count(cr), style="dim"),
303
+ Text(_fmt_money(cost), style="#68D391"),
304
+ )
305
+
306
+ def on_data_table_row_selected(self, event) -> None:
307
+ # Pick the session id from the first column of the selected row.
308
+ try:
309
+ table = self.query_one("#usage-sessions-table", DataTable)
310
+ row = table.get_row(event.row_key)
311
+ sid_text = str(row[0])
312
+ sid_prefix = sid_text.rstrip("…").strip()
313
+ sessions = self._state.get("sessions") or {}
314
+ for sid in sessions:
315
+ if sid.startswith(sid_prefix):
316
+ self._selected_sid = sid
317
+ self._render_detail()
318
+ return
319
+ except Exception:
320
+ return
321
+
322
+ def _render_detail(self) -> None:
323
+ try:
324
+ panel = self.query_one("#usage-detail-body", Static)
325
+ except Exception:
326
+ return
327
+ sessions = self._state.get("sessions") or {}
328
+ sid = self._selected_sid
329
+ # Default to the heaviest session if none selected.
330
+ if not sid or sid not in sessions:
331
+ if sessions:
332
+ sid = max(sessions, key=lambda k: int(sessions[k].get("tokens_in") or 0))
333
+ else:
334
+ panel.update(Text("(no session)", style="dim italic"))
335
+ return
336
+ sess = sessions[sid]
337
+ t = Text()
338
+ t.append("DETAIL\n", style="bold #B794F4")
339
+ t.append(f" session {sid[:12]}…\n", style="cyan")
340
+ proj = sess.get("project") or "unknown"
341
+ if proj.startswith("-"):
342
+ proj = proj.replace("-", "/")
343
+ t.append(f" project {proj}\n", style="dim")
344
+ t.append("\n")
345
+ # Per-agent breakdown
346
+ agents = sess.get("agents_active") or {}
347
+ t.append("AGENTS ACTIVE\n", style="bold dim")
348
+ if not agents:
349
+ t.append(" (none)\n", style="dim italic")
350
+ else:
351
+ for name, ag in list(agents.items())[:10]:
352
+ last = ag.get("last_tool") or "-"
353
+ tin = _fmt_count(int(ag.get("tokens_in") or 0))
354
+ tout = _fmt_count(int(ag.get("tokens_out") or 0))
355
+ t.append(f" ● {name:14}", style="#00D4AA")
356
+ t.append(f" in {tin:>5}", style="cyan")
357
+ t.append(f" out {tout:>5}", style="cyan")
358
+ t.append(f" {last}\n", style="dim")
359
+ t.append("\n")
360
+ # Top tools from recent events
361
+ tool_count: dict[str, int] = {}
362
+ for ev in (sess.get("recent_events") or []):
363
+ if ev.get("type") == "tool_call":
364
+ tool = ev.get("tool") or "?"
365
+ tool_count[tool] = tool_count.get(tool, 0) + 1
366
+ if tool_count:
367
+ t.append("TOP TOOLS\n", style="bold dim")
368
+ top = sorted(tool_count.items(), key=lambda kv: -kv[1])[:8]
369
+ for tool, count in top:
370
+ t.append(f" {tool:14} {count:>4}\n", style="white")
371
+ panel.update(t)
372
+
373
+ # ----- API expected by app.py -------------------------------------------
374
+
375
+ def set_project_filter(self, project: Any) -> None:
376
+ # Usage tab is informational across all sessions — filter is a no-op.
377
+ return
@@ -1,8 +1,13 @@
1
1
  """Tab 3 — Workflows.
2
2
 
3
3
  2-panel layout per §7.3:
4
- - WORKFLOW LIST (left, 30%) — cards of recent workflow runs
5
- - PHASE TREE (right, 70%) — phases + agent fan-out of the selected run
4
+ - WORKFLOW LIST (left, 30%) — static catalogue + recent runs
5
+ - PHASE TREE (right, 70%) — phases + agent fan-out OR catalog detail
6
+
7
+ The list panel shows BOTH:
8
+ - Live runs (top) — recent workflow invocations with status
9
+ - Available catalogue (below) — workflows installed on the system,
10
+ with name, description, whenToUse — clickable to read full meta.
6
11
 
7
12
  Tracks workflow_start / workflow_phase / workflow_agent_call /
8
13
  workflow_agent_result / workflow_complete events emitted by the
@@ -11,7 +16,10 @@ in post-tool-observe.sh.
11
16
  """
12
17
  from __future__ import annotations
13
18
 
19
+ import os
20
+ import re
14
21
  from collections import OrderedDict
22
+ from pathlib import Path
15
23
  from typing import Any, Optional
16
24
 
17
25
  from rich.text import Text
@@ -21,6 +29,97 @@ from textual.widgets import ListItem, ListView, Static, Tree
21
29
  from textual.widgets.tree import TreeNode
22
30
 
23
31
 
32
+ # Locations to scan for installed workflow scripts. First hit wins per name.
33
+ _WORKFLOW_PATHS = [
34
+ Path.home() / ".claude" / "mishkan" / "workflows",
35
+ ]
36
+
37
+
38
+ def _scan_catalogue() -> list[dict[str, Any]]:
39
+ """Parse meta blocks from every .js workflow on disk.
40
+
41
+ Extracts: name, description, whenToUse, phases (titles only). The meta
42
+ block is required by the workflow contract (`export const meta = {...}`)
43
+ so a missing one is a malformed workflow — silently skipped.
44
+
45
+ Repo-mode fallback: walk up from this file looking for
46
+ payload/mishkan/workflows/ in case mishkan-watch runs from a source
47
+ checkout without the runtime payload installed.
48
+ """
49
+ paths = list(_WORKFLOW_PATHS)
50
+ here = Path(__file__).resolve()
51
+ for parent in here.parents:
52
+ c = parent / "payload" / "mishkan" / "workflows"
53
+ if c.is_dir():
54
+ paths.append(c)
55
+ break
56
+ if parent == parent.parent:
57
+ break
58
+ seen: set[str] = set()
59
+ out: list[dict[str, Any]] = []
60
+ for d in paths:
61
+ if not d.is_dir():
62
+ continue
63
+ for js in sorted(d.glob("*.js")):
64
+ try:
65
+ txt = js.read_text(encoding="utf-8", errors="replace")
66
+ except OSError:
67
+ continue
68
+ meta = _extract_meta(txt)
69
+ if not meta or not meta.get("name"):
70
+ continue
71
+ if meta["name"] in seen:
72
+ continue
73
+ seen.add(meta["name"])
74
+ meta["_path"] = str(js)
75
+ out.append(meta)
76
+ return out
77
+
78
+
79
+ # Accept both " and ' quoting — JS allows both in literal strings.
80
+ _META_KEY_RE = re.compile(
81
+ r'(name|description|whenToUse|title|detail)\s*:\s*'
82
+ r'(?:"((?:[^"\\]|\\.)*)"|\'((?:[^\'\\]|\\.)*)\')'
83
+ )
84
+
85
+
86
+ def _extract_meta(txt: str) -> dict[str, Any]:
87
+ """Best-effort extract of the `export const meta = {...}` literal.
88
+
89
+ The contract guarantees meta is a pure literal — no computed values —
90
+ so a key/value regex over the meta block is enough. We don't run a
91
+ full JS parser; we just lift the four fields we render.
92
+ """
93
+ m = re.search(r'export\s+const\s+meta\s*=\s*\{', txt)
94
+ if not m:
95
+ return {}
96
+ # Naive brace match — meta blocks don't contain strings with unmatched
97
+ # braces in practice; if they do we just truncate, which is fine for
98
+ # display purposes.
99
+ start = m.end()
100
+ depth = 1
101
+ i = start
102
+ while i < len(txt) and depth > 0:
103
+ c = txt[i]
104
+ if c == '{':
105
+ depth += 1
106
+ elif c == '}':
107
+ depth -= 1
108
+ i += 1
109
+ block = txt[start:i]
110
+ out: dict[str, Any] = {}
111
+ for key, dq, sq in _META_KEY_RE.findall(block):
112
+ val = dq or sq
113
+ # JS escape unescape for the common cases.
114
+ val = val.replace('\\"', '"').replace("\\'", "'").replace("\\n", " ").replace("\\\\", "\\")
115
+ out.setdefault(key, val)
116
+ # Phase titles (best-effort): every `title: "..."` or `title: '...'`.
117
+ titles = re.findall(r'\{\s*title:\s*[\'"]([^\'"]+)[\'"]', block)
118
+ if titles:
119
+ out["phase_titles"] = titles
120
+ return out
121
+
122
+
24
123
  class WorkflowsTab(Container):
25
124
  DEFAULT_CSS = ""
26
125
 
@@ -29,6 +128,10 @@ class WorkflowsTab(Container):
29
128
  # run_id -> { name, started, phases: {phase: [agent_call dicts]}, ... }
30
129
  self._runs: OrderedDict[str, dict[str, Any]] = OrderedDict()
31
130
  self._selected: Optional[str] = None
131
+ # Static catalogue parsed from disk at mount.
132
+ self._catalogue: list[dict[str, Any]] = []
133
+ # Currently-shown catalogue entry (selected name) for the detail panel.
134
+ self._cat_selected: Optional[str] = None
32
135
 
33
136
  def compose(self) -> ComposeResult:
34
137
  with Horizontal(id="workflows-row"):
@@ -39,9 +142,20 @@ class WorkflowsTab(Container):
39
142
  yield Static("PHASE TREE", classes="panel-title")
40
143
  yield Tree("(no workflow selected)", id="workflows-tree")
41
144
 
42
- def on_mount(self) -> None:
43
- self._render_list()
145
+ async def on_mount(self) -> None:
146
+ try:
147
+ self._catalogue = _scan_catalogue()
148
+ except Exception:
149
+ self._catalogue = []
150
+ await self._render_list()
44
151
  self._render_tree()
152
+ # Re-render once after mount completes — when the tab is created
153
+ # before TabbedContent settles its layout, the first ListView paint
154
+ # can land before ListItem rows have a size computed and the items
155
+ # show as zero-height (visible as "header only, no rows"). A delayed
156
+ # repaint guarantees the second pass runs with the final container
157
+ # dimensions.
158
+ self.set_timer(0.1, self._re_render_list)
45
159
 
46
160
  # ----- snapshot / event application --------------------------------------
47
161
 
@@ -50,16 +164,16 @@ class WorkflowsTab(Container):
50
164
  for sess in (state.get("sessions") or {}).values():
51
165
  for ev in (sess.get("recent_events") or []):
52
166
  self._ingest_event(ev)
53
- self._render_list()
54
167
  self._render_tree()
168
+ self.call_later(self._re_render_list)
55
169
 
56
170
  def apply_event(self, ev: dict[str, Any]) -> None:
57
171
  if not self._is_workflow_event(ev):
58
172
  return
59
173
  self._ingest_event(ev)
60
- self._render_list()
61
174
  if self._selected:
62
175
  self._render_tree()
176
+ self.call_later(self._re_render_list)
63
177
 
64
178
  def _is_workflow_event(self, ev: dict[str, Any]) -> bool:
65
179
  return (ev.get("type") or "").startswith("workflow_")
@@ -116,35 +230,67 @@ class WorkflowsTab(Container):
116
230
 
117
231
  # ----- renderers ---------------------------------------------------------
118
232
 
119
- def _render_list(self) -> None:
233
+ async def _re_render_list(self) -> None:
234
+ await self._render_list()
235
+
236
+ async def _render_list(self) -> None:
120
237
  try:
121
238
  lv = self.query_one("#workflows-listview", ListView)
122
239
  except Exception:
123
240
  return
124
- lv.clear()
125
- if not self._runs:
126
- lv.append(ListItem(Static(Text("(none yet)\nrun a workflow to populate",
127
- style="dim italic"))))
128
- return
129
- for run_id, run in list(self._runs.items())[-10:]:
130
- t = Text()
131
- mark = "║ " if self._selected == run_id else " "
132
- t.append(mark, style="#B794F4 bold")
133
- t.append(run["name"][:24], style="bold")
134
- t.append(f" [{run['status']}]", style="dim")
135
- phases_done = sum(1 for items in run["phases"].values()
136
- if items and all(a.get("status") != "running" for a in items))
137
- phases_total = max(1, len(run["phases"]) or 1)
138
- t.append(f"\n {phases_done}/{phases_total} phases", style="dim")
139
- cost = run.get("cost_usd") or 0.0
140
- tok = run.get("tokens") or 0
141
- t.append(f" ${cost:.2f} · {tok/1000:.1f}k tok", style="#B794F4")
142
- lv.append(ListItem(Static(t), id=f"wf-{run_id}"))
241
+ await lv.clear()
242
+ # ----- Recent runs (top) ---------------------------------------------
243
+ if self._runs:
244
+ head = Text()
245
+ head.append("RECENT RUNS\n", style="bold #B794F4")
246
+ lv.append(ListItem(Static(head)))
247
+ for run_id, run in list(self._runs.items())[-8:]:
248
+ t = Text()
249
+ mark = " " if self._selected == run_id else " "
250
+ t.append(mark, style="#B794F4 bold")
251
+ t.append(run["name"][:24], style="bold")
252
+ t.append(f" [{run['status']}]", style="dim")
253
+ phases_done = sum(1 for items in run["phases"].values()
254
+ if items and all(a.get("status") != "running" for a in items))
255
+ phases_total = max(1, len(run["phases"]) or 1)
256
+ t.append(f"\n {phases_done}/{phases_total} phases", style="dim")
257
+ cost = run.get("cost_usd") or 0.0
258
+ tok = run.get("tokens") or 0
259
+ t.append(f" ${cost:.2f} · {tok/1000:.1f}k tok", style="#B794F4")
260
+ lv.append(ListItem(Static(t), id=f"wf-{run_id}"))
261
+ # ----- Catalogue (bottom) --------------------------------------------
262
+ if self._catalogue:
263
+ head = Text()
264
+ head.append("AVAILABLE", style="bold #00D4AA")
265
+ head.append(f" ({len(self._catalogue)} workflows)", style="dim italic")
266
+ lv.append(ListItem(Static(head)))
267
+ for entry in self._catalogue:
268
+ name = entry.get("name", "?")
269
+ desc = entry.get("description") or ""
270
+ mark = "║ " if self._cat_selected == name else " "
271
+ t = Text()
272
+ t.append(mark, style="#00D4AA bold")
273
+ t.append(name[:30], style="bold")
274
+ if desc:
275
+ t.append(f"\n {desc[:60]}", style="dim")
276
+ lv.append(ListItem(Static(t), id=f"cat-{name}"))
277
+ elif not self._runs:
278
+ lv.append(ListItem(Static(Text(
279
+ "(no workflows installed and no runs yet)\n"
280
+ "Install via `npx mishkan-harness install`,\n"
281
+ "or run a workflow from the main session.",
282
+ style="dim italic",
283
+ ))))
143
284
 
144
285
  def on_list_view_selected(self, event: ListView.Selected) -> None:
145
286
  node_id = event.item.id or ""
146
287
  if node_id.startswith("wf-"):
147
288
  self._selected = node_id[3:]
289
+ self._cat_selected = None
290
+ self._render_tree()
291
+ elif node_id.startswith("cat-"):
292
+ self._cat_selected = node_id[4:]
293
+ self._selected = None
148
294
  self._render_tree()
149
295
 
150
296
  def _render_tree(self) -> None:
@@ -154,8 +300,34 @@ class WorkflowsTab(Container):
154
300
  return
155
301
  tree.clear()
156
302
  root = tree.root
303
+ # Catalogue detail mode — shows description, whenToUse, phases.
304
+ if self._cat_selected:
305
+ entry = next((e for e in self._catalogue if e.get("name") == self._cat_selected), None)
306
+ if entry:
307
+ rl = Text()
308
+ rl.append(entry.get("name", ""), style="bold #00D4AA")
309
+ rl.append(" (catalogue)", style="dim")
310
+ root.label = rl
311
+ root.expand()
312
+ desc = entry.get("description") or "(no description)"
313
+ root.add_leaf(Text(f"description: {desc}", style="white"))
314
+ wtu = entry.get("whenToUse")
315
+ if wtu:
316
+ root.add_leaf(Text(f"when to use: {wtu}", style="#F6AD55"))
317
+ phases = entry.get("phase_titles") or []
318
+ if phases:
319
+ pn = root.add(Text(f"phases ({len(phases)})", style="bold dim"))
320
+ pn.expand()
321
+ for i, ph in enumerate(phases, 1):
322
+ pn.add_leaf(Text(f" {i}. {ph}", style="white"))
323
+ path = entry.get("_path") or ""
324
+ if path:
325
+ root.add_leaf(Text(f"source: {path}", style="dim italic"))
326
+ else:
327
+ root.label = Text("(workflow not in catalogue)", style="dim italic")
328
+ return
157
329
  if not self._selected or self._selected not in self._runs:
158
- root.label = Text("(no workflow selected)", style="dim italic")
330
+ root.label = Text("(select a workflow run or a catalogue entry)", style="dim italic")
159
331
  return
160
332
  run = self._runs[self._selected]
161
333
  rl = Text()