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
@@ -40,7 +40,7 @@ Footer {
40
40
 
41
41
  #status-bar {
42
42
  dock: bottom;
43
- height: 1;
43
+ height: 2;
44
44
  background: $color-muted;
45
45
  color: white 90%;
46
46
  padding: 0 1;
@@ -182,7 +182,7 @@ Tabs > Tab.-active {
182
182
  }
183
183
 
184
184
  #workflows-list {
185
- width: 30%;
185
+ width: 38%;
186
186
  layout: vertical;
187
187
  border: solid $color-border-light;
188
188
  background: $color-surface;
@@ -190,7 +190,7 @@ Tabs > Tab.-active {
190
190
  }
191
191
 
192
192
  #workflows-detail {
193
- width: 70%;
193
+ width: 62%;
194
194
  layout: vertical;
195
195
  border: solid $color-border-light;
196
196
  background: $color-surface;
@@ -199,6 +199,19 @@ Tabs > Tab.-active {
199
199
 
200
200
  #workflows-listview {
201
201
  height: 1fr;
202
+ overflow-y: scroll;
203
+ }
204
+
205
+ /* ListItem children (the Static we yield per row) are multi-line — name
206
+ on line 1, dim description on line 2. Without an explicit ListItem
207
+ height, Textual collapses each row to 1 line and the description (and
208
+ often the cursor highlight on long items) clips off. `height: auto`
209
+ tells the ListItem to size to its child's content height; `min-height: 3`
210
+ keeps the row tappable when the description happens to be empty. */
211
+ #workflows-listview > ListItem {
212
+ height: auto;
213
+ min-height: 2;
214
+ padding: 0 1;
202
215
  }
203
216
 
204
217
  #workflows-tree {
@@ -213,7 +226,7 @@ Tabs > Tab.-active {
213
226
  }
214
227
 
215
228
  .cognee-card {
216
- width: 50%;
229
+ width: 1fr;
217
230
  border: solid $color-border-light;
218
231
  background: $color-surface;
219
232
  padding: 1 2;
@@ -252,3 +265,64 @@ Tabs > Tab.-active {
252
265
  color: $color-dim;
253
266
  text-style: italic;
254
267
  }
268
+
269
+ /* ─── Tab: Usage ───────────────────────────────────────────────── */
270
+
271
+ #usage-row {
272
+ layout: horizontal;
273
+ height: 1fr;
274
+ }
275
+
276
+ #usage-totals {
277
+ width: 26%;
278
+ border: solid $color-border-light;
279
+ background: $color-surface;
280
+ padding: 1 2;
281
+ }
282
+
283
+ #usage-sessions {
284
+ width: 38%;
285
+ layout: vertical;
286
+ border: solid $color-border-light;
287
+ background: $color-surface;
288
+ padding: 0 1;
289
+ }
290
+
291
+ #usage-sessions-table {
292
+ height: 1fr;
293
+ }
294
+
295
+ #usage-detail {
296
+ width: 36%;
297
+ border: solid $color-border-light;
298
+ background: $color-surface;
299
+ padding: 1 2;
300
+ }
301
+
302
+ /* ─── Tab: Skills ──────────────────────────────────────────────── */
303
+
304
+ #skills-row {
305
+ layout: horizontal;
306
+ height: 1fr;
307
+ }
308
+
309
+ #skills-tree {
310
+ width: 28%;
311
+ border: solid $color-border-light;
312
+ background: $color-surface;
313
+ padding: 0 1;
314
+ }
315
+
316
+ #skills-detail {
317
+ width: 44%;
318
+ border: solid $color-border-light;
319
+ background: $color-surface;
320
+ padding: 1 2;
321
+ }
322
+
323
+ #skills-adrs {
324
+ width: 28%;
325
+ border: solid $color-border-light;
326
+ background: $color-surface;
327
+ padding: 1 2;
328
+ }
@@ -20,8 +20,9 @@ from pathlib import Path
20
20
  from .lifecycle import install_systemd_user_unit
21
21
  from .server import WatchdServer
22
22
  from .state import HarnessState
23
- from .sources import (bus_tail, cognee_poll, mcp_probe, session_discover,
24
- session_tail, subagent_tail, worktree_poll)
23
+ from .sources import (bus_tail, cognee_poll, graphify_tail, mcp_probe,
24
+ session_discover, session_tail, subagent_tail,
25
+ worktree_poll)
25
26
 
26
27
 
27
28
  HOME = Path(os.path.expanduser("~"))
@@ -56,15 +57,42 @@ def _active_sessions_provider(state: HarnessState):
56
57
  return _provider
57
58
 
58
59
 
60
+ def _decode_project(p: str) -> str:
61
+ """Decode Claude Code's encoded project dir form (-home-ogu-...) to absolute.
62
+
63
+ session_discover sets sess.project to jsonl.parent.name which is the
64
+ Claude Code encoded form. Downstream consumers that open the path on
65
+ disk (worktree_poll, graphify_tail) must decode or they silently skip
66
+ every project. Idempotent on already-absolute paths.
67
+ """
68
+ if not p or p in ("", "unknown", "?"):
69
+ return p
70
+ if p.startswith("/"):
71
+ return p
72
+ if p.startswith("-"):
73
+ return p.replace("-", "/")
74
+ return p
75
+
76
+
59
77
  def _project_paths_provider(state: HarnessState):
60
- """Return distinct project paths from active sessions, plus $PWD."""
78
+ """Return distinct project paths from active sessions, plus $PWD.
79
+
80
+ Decodes Claude Code's encoded project form so downstream sources can
81
+ actually open the project on disk.
82
+ """
61
83
  def _provider() -> list[Path]:
62
84
  seen: set[str] = set()
63
85
  out: list[Path] = []
64
86
  for s in state.sessions.values():
65
- if s.project and s.project not in seen:
66
- seen.add(s.project)
67
- out.append(Path(s.project))
87
+ if not s.project or s.project in ("unknown", ""):
88
+ continue
89
+ decoded = _decode_project(s.project)
90
+ if decoded in seen:
91
+ continue
92
+ seen.add(decoded)
93
+ p = Path(decoded)
94
+ if p.is_dir():
95
+ out.append(p)
68
96
  try:
69
97
  cwd = os.getcwd()
70
98
  if cwd not in seen:
@@ -91,6 +119,8 @@ async def _run(log_dir: Path, projects_dir: Path, socket_path: Path) -> None:
91
119
  asyncio.create_task(cognee_poll.run(queue, projects_dir), name="cognee_poll"),
92
120
  asyncio.create_task(session_tail.run(queue, _active_sessions_provider(state)),
93
121
  name="session_tail"),
122
+ asyncio.create_task(graphify_tail.run(queue, _project_paths_provider(state)),
123
+ name="graphify_tail"),
94
124
  asyncio.create_task(subagent_tail.run(queue, _active_sessions_provider(state)),
95
125
  name="subagent_tail"),
96
126
  ]
@@ -74,14 +74,27 @@ class _TailHandler(FileSystemEventHandler):
74
74
 
75
75
 
76
76
  async def run(queue: asyncio.Queue[dict[str, Any]], log_dir: Path) -> None:
77
- """Start a watchdog observer on log_dir. Backfills existing files first."""
77
+ """Start a watchdog observer on log_dir. Tails from EOF — no historical replay.
78
+
79
+ Why no backfill: hook log files accumulate session_ids from sessions
80
+ long gone. Replaying them on daemon start resurrected those sessions
81
+ as "phantoms" (project=unknown, stale agents_active) in the snapshot.
82
+ The daemon's source of truth for live sessions is session_discover,
83
+ which polls active JSONLs every 10s; bus_tail's job is only to forward
84
+ new hook events as they happen.
85
+ """
78
86
  log_dir.mkdir(parents=True, exist_ok=True)
79
87
  loop = asyncio.get_running_loop()
80
88
  handler = _TailHandler(queue, loop)
81
89
 
82
- # Backfill: read each existing file from offset 0.
90
+ # Seek to EOF on each existing file. New writes after this point are
91
+ # emitted normally; everything before is treated as already-consumed
92
+ # by whichever daemon instance was alive at the time it was written.
83
93
  for f in log_dir.glob("*.jsonl"):
84
- handler._drain(str(f))
94
+ try:
95
+ handler.offsets[str(f)] = os.path.getsize(str(f))
96
+ except OSError:
97
+ handler.offsets[str(f)] = 0
85
98
 
86
99
  observer = Observer()
87
100
  observer.schedule(handler, str(log_dir), recursive=False)
@@ -0,0 +1,220 @@
1
+ """Graphify daemon source — watch graphify-out/ for scans + queries.
2
+
3
+ Graphify produces deterministic AST graphs under `<project>/graphify-out/`.
4
+ This source watches every active session's project for changes in that
5
+ directory and synthesises bus events that the Knowledge tab consumes.
6
+
7
+ Two event types emitted:
8
+
9
+ - graphify_scan : `graph.json` mtime changed → a `graphify update <path>`
10
+ completed. Payload: project path, node count, edge
11
+ count, community count (parsed from manifest.json
12
+ when present).
13
+ - graphify_query : a new file under `graphify-out/memory/` → a
14
+ `graphify query` was saved via `graphify save-result`.
15
+ Payload: question, answer excerpt (200 chars).
16
+
17
+ Fail-open per the daemon contract. Missing graphify-out/ is silent —
18
+ many projects don't use Graphify and that's fine.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import json
24
+ import os
25
+ import time
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+ from typing import Any, Optional
29
+
30
+
31
+ def _iso(ts: float | None = None) -> str:
32
+ t = datetime.fromtimestamp(ts or time.time(), tz=timezone.utc)
33
+ return t.strftime("%Y-%m-%dT%H:%M:%S.") + f"{t.microsecond // 1000:03d}Z"
34
+
35
+
36
+ class _ProjectWatcher:
37
+ """One watcher per project root that has a graphify-out/ dir."""
38
+
39
+ def __init__(self, project_path: Path, queue: asyncio.Queue[dict[str, Any]]) -> None:
40
+ self.project_path = project_path
41
+ self.queue = queue
42
+ self.graph_dir = project_path / "graphify-out"
43
+ self.graph_json = self.graph_dir / "graph.json"
44
+ self.manifest = self.graph_dir / "manifest.json"
45
+ self.memory_dir = self.graph_dir / "memory"
46
+ # Start at 0 so the first poll detects the current graph state and
47
+ # emits a graphify_scan event — otherwise the TUI shows "no scan"
48
+ # forever for projects whose graph predated the daemon. This costs
49
+ # one event per project per daemon restart; trivially cheap.
50
+ self.last_graph_mtime = 0.0
51
+ # Memory queries DO use seen-state to avoid replaying old queries
52
+ # on every restart (queries are append-only, replays would spam).
53
+ self.seen_memory: set[str] = set()
54
+ if self.memory_dir.is_dir():
55
+ try:
56
+ for f in self.memory_dir.iterdir():
57
+ if f.is_file():
58
+ self.seen_memory.add(f.name)
59
+ except OSError:
60
+ pass
61
+
62
+ async def step(self) -> None:
63
+ if not self.graph_dir.is_dir():
64
+ return
65
+ await self._check_graph()
66
+ await self._check_memory()
67
+
68
+ async def _check_graph(self) -> None:
69
+ try:
70
+ if not self.graph_json.exists():
71
+ return
72
+ mtime = self.graph_json.stat().st_mtime
73
+ except OSError:
74
+ return
75
+ if mtime <= self.last_graph_mtime:
76
+ return
77
+ self.last_graph_mtime = mtime
78
+ stats = self._read_graph_stats()
79
+ await self.queue.put({
80
+ "ts": _iso(),
81
+ "session": None,
82
+ "project": str(self.project_path),
83
+ "type": "graphify_scan",
84
+ "tool": None,
85
+ "outcome": "completed",
86
+ "payload": {
87
+ "op": "scan",
88
+ "project": str(self.project_path),
89
+ "nodes": stats.get("nodes"),
90
+ "edges": stats.get("edges"),
91
+ "communities": stats.get("communities"),
92
+ "scanned_at": _iso(mtime),
93
+ },
94
+ })
95
+
96
+ def _read_graph_stats(self) -> dict[str, Any]:
97
+ """Read node/edge/community counts from graph.json.
98
+
99
+ Graphify's manifest.json is a file→hash map, NOT a stats file —
100
+ we only read graph.json. The format is NetworkX node-link:
101
+ {nodes: [...], links: [...], hyperedges?: [...]}
102
+ Note: 'links' (NetworkX naming), not 'edges'. Communities are
103
+ not currently exported by Graphify, but we keep the field nullable
104
+ for future compatibility.
105
+ """
106
+ try:
107
+ g = json.loads(self.graph_json.read_text())
108
+ except Exception:
109
+ return {}
110
+ if not isinstance(g, dict):
111
+ return {}
112
+ nodes = g.get("nodes") or []
113
+ # Accept both 'links' (NetworkX, what Graphify writes) and 'edges'
114
+ # (alt convention) for forward-compat.
115
+ edges = g.get("links") or g.get("edges") or []
116
+ communities = g.get("communities") or g.get("hyperedges") or []
117
+ return {
118
+ "nodes": len(nodes) if isinstance(nodes, list) else None,
119
+ "edges": len(edges) if isinstance(edges, list) else None,
120
+ "communities": len(communities) if isinstance(communities, list) else None,
121
+ }
122
+
123
+ async def _check_memory(self) -> None:
124
+ if not self.memory_dir.is_dir():
125
+ return
126
+ try:
127
+ entries = [f for f in self.memory_dir.iterdir() if f.is_file()]
128
+ except OSError:
129
+ return
130
+ for f in entries:
131
+ if f.name in self.seen_memory:
132
+ continue
133
+ self.seen_memory.add(f.name)
134
+ payload = {"op": "query", "project": str(self.project_path), "file": f.name}
135
+ try:
136
+ data = json.loads(f.read_text())
137
+ if isinstance(data, dict):
138
+ payload["question"] = (data.get("question") or "")[:200]
139
+ answer = data.get("answer") or ""
140
+ payload["answer_excerpt"] = answer[:200] if isinstance(answer, str) else ""
141
+ payload["query_type"] = data.get("type") or data.get("query_type")
142
+ except Exception:
143
+ pass
144
+ await self.queue.put({
145
+ "ts": _iso(),
146
+ "session": None,
147
+ "project": str(self.project_path),
148
+ "type": "graphify_query",
149
+ "tool": None,
150
+ "outcome": "completed",
151
+ "payload": payload,
152
+ })
153
+
154
+
155
+ def _decode_project(p: str) -> str:
156
+ """Decode Claude Code's encoded project dir name into an absolute path.
157
+
158
+ ~/.claude/projects encodes each working directory as its absolute path
159
+ with '/' replaced by '-' (e.g. -home-ogu-theY4NN-harness for
160
+ /home/ogu/theY4NN/harness). When the daemon's session_discover source
161
+ grabs the project from jsonl.parent.name, it gets this encoded form.
162
+ Sources that try to open the project on disk (graphify_tail,
163
+ worktree_poll) MUST decode first or every project is silently skipped.
164
+ """
165
+ if not p or p in ("", "unknown", "?"):
166
+ return p
167
+ if p.startswith("/"):
168
+ return p
169
+ if p.startswith("-"):
170
+ return p.replace("-", "/")
171
+ return p
172
+
173
+
174
+ def _project_paths_from_state(state) -> list[Path]:
175
+ """Pull distinct project paths from the daemon's active sessions."""
176
+ seen: set[str] = set()
177
+ out: list[Path] = []
178
+ try:
179
+ for sess in (state.sessions or {}).values():
180
+ raw = sess.project if hasattr(sess, "project") else None
181
+ if not raw or raw in ("", "unknown"):
182
+ continue
183
+ p = _decode_project(raw)
184
+ if p in seen:
185
+ continue
186
+ seen.add(p)
187
+ if Path(p).is_dir():
188
+ out.append(Path(p))
189
+ except Exception:
190
+ pass
191
+ return out
192
+
193
+
194
+ async def run(queue: asyncio.Queue[dict[str, Any]],
195
+ project_paths_provider, poll_interval: float = 5.0) -> None:
196
+ """Watch every project with a graphify-out/ dir.
197
+
198
+ project_paths_provider is a callable returning list[Path]. Re-evaluated
199
+ each poll so sessions discovered later are picked up automatically.
200
+ """
201
+ watchers: dict[Path, _ProjectWatcher] = {}
202
+ try:
203
+ while True:
204
+ try:
205
+ projects = project_paths_provider() or []
206
+ except Exception:
207
+ projects = []
208
+ for p in projects:
209
+ if p not in watchers and (p / "graphify-out").is_dir():
210
+ watchers[p] = _ProjectWatcher(p, queue)
211
+ # Don't prune watchers whose graphify-out/ disappeared — same
212
+ # project may rebuild later; cheap to keep.
213
+ for w in list(watchers.values()):
214
+ try:
215
+ await w.step()
216
+ except Exception:
217
+ continue
218
+ await asyncio.sleep(poll_interval)
219
+ except asyncio.CancelledError:
220
+ return
@@ -61,7 +61,11 @@ async def _poll_once(project_paths: list[Path], known: dict[str, dict[str, str]]
61
61
  continue
62
62
  seen.add(path)
63
63
  if path not in known:
64
- known[path] = wt
64
+ # Stamp the owner project so we only emit "remove" later
65
+ # if the OWNER was actually polled this round. Prevents the
66
+ # storm of phantom removes when project list temporarily
67
+ # drops (e.g. before session_discover's first confirmation).
68
+ known[path] = {**wt, "_project": str(proj)}
65
69
  await queue.put({
66
70
  "ts": _iso(),
67
71
  "session": None,
@@ -72,13 +76,20 @@ async def _poll_once(project_paths: list[Path], known: dict[str, dict[str, str]]
72
76
  "payload": {"op": "add", **wt},
73
77
  })
74
78
 
79
+ polled_projects = {str(p) for p in project_paths}
75
80
  for path in list(known.keys()):
81
+ owner = known[path].get("_project", "")
82
+ if owner not in polled_projects:
83
+ # The project that owned this worktree wasn't polled this
84
+ # round — don't infer a removal from absence of evidence.
85
+ continue
76
86
  if path not in seen:
77
87
  wt = known.pop(path)
88
+ wt.pop("_project", None)
78
89
  await queue.put({
79
90
  "ts": _iso(),
80
91
  "session": None,
81
- "project": "",
92
+ "project": owner,
82
93
  "type": "worktree_change",
83
94
  "tool": None,
84
95
  "outcome": "completed",