mishkan-harness 0.2.0 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +108 -172
- package/bin/mishkan.js +677 -16
- package/docs/design/MISHKAN_decisions.md +450 -8
- package/docs/design/MISHKAN_harness_design.md +1 -33
- package/docs/design/MISHKAN_observability.md +13 -9
- package/docs/engineer/profile-readable.md +201 -0
- package/docs/engineer/profile.md +754 -0
- package/docs/research/graphify-token-saving-poc.md +108 -0
- package/docs/usage/09-workflows.md +74 -4
- package/docs/usage/10-observability.md +37 -13
- package/docs/usage/11-graphify.md +264 -0
- package/docs/usage/12-skill-discovery.md +294 -0
- package/docs/usage/README.md +5 -3
- package/package.json +1 -1
- package/payload/install/settings.hooks.json +24 -0
- package/payload/mishkan/AGENT_SPEC.md +25 -7
- package/payload/mishkan/agents/ahikam.md +4 -2
- package/payload/mishkan/agents/aholiab.md +4 -2
- package/payload/mishkan/agents/asaph.md +5 -2
- package/payload/mishkan/agents/baruch.md +19 -2
- package/payload/mishkan/agents/benaiah.md +4 -2
- package/payload/mishkan/agents/bezalel.md +4 -2
- package/payload/mishkan/agents/caleb.md +4 -2
- package/payload/mishkan/agents/deborah.md +4 -2
- package/payload/mishkan/agents/elasah.md +4 -2
- package/payload/mishkan/agents/eliashib.md +4 -2
- package/payload/mishkan/agents/ezra.md +4 -2
- package/payload/mishkan/agents/hanun.md +5 -2
- package/payload/mishkan/agents/hiram.md +5 -2
- package/payload/mishkan/agents/hizkiah.md +5 -2
- package/payload/mishkan/agents/huldah.md +4 -2
- package/payload/mishkan/agents/huram.md +4 -2
- package/payload/mishkan/agents/hushai.md +5 -2
- package/payload/mishkan/agents/igal.md +4 -2
- package/payload/mishkan/agents/ira.md +5 -2
- package/payload/mishkan/agents/jahaziel.md +5 -2
- package/payload/mishkan/agents/jakin.md +4 -2
- package/payload/mishkan/agents/jehonathan.md +5 -2
- package/payload/mishkan/agents/jehoshaphat.md +4 -2
- package/payload/mishkan/agents/joab.md +5 -2
- package/payload/mishkan/agents/joah.md +5 -2
- package/payload/mishkan/agents/maaseiah.md +4 -2
- package/payload/mishkan/agents/meremoth.md +5 -2
- package/payload/mishkan/agents/meshullam.md +5 -2
- package/payload/mishkan/agents/nathan.md +5 -2
- package/payload/mishkan/agents/nehemiah.md +4 -2
- package/payload/mishkan/agents/obed.md +4 -2
- package/payload/mishkan/agents/oholiab.md +5 -2
- package/payload/mishkan/agents/palal.md +5 -2
- package/payload/mishkan/agents/phinehas.md +4 -2
- package/payload/mishkan/agents/rehum.md +4 -2
- package/payload/mishkan/agents/salma.md +5 -2
- package/payload/mishkan/agents/seraiah.md +4 -2
- package/payload/mishkan/agents/shallum.md +5 -2
- package/payload/mishkan/agents/shaphan.md +4 -2
- package/payload/mishkan/agents/shemaiah.md +4 -2
- package/payload/mishkan/agents/shevna.md +5 -2
- package/payload/mishkan/agents/uriah.md +5 -2
- package/payload/mishkan/agents/zaccur.md +4 -2
- package/payload/mishkan/agents/zadok.md +5 -2
- package/payload/mishkan/agents/zerubbabel.md +4 -2
- package/payload/mishkan/commands/code-graph.md +35 -0
- package/payload/mishkan/commands/eval-baruch.md +57 -0
- package/payload/mishkan/commands/mishkan-org-reference.md +58 -0
- package/payload/mishkan/commands/mishkan-resume.md +19 -5
- package/payload/mishkan/commands/mishkan-skills-misses.md +47 -0
- package/payload/mishkan/commands/mishkan-skills-reindex.md +33 -0
- package/payload/mishkan/commands/skills.md +35 -0
- package/payload/mishkan/commands/sprint-close.md +3 -1
- package/payload/mishkan/hooks/pre-tool-knowledge-route.sh +231 -0
- package/payload/mishkan/hooks/pre-tool-task-skill-route.sh +122 -0
- package/payload/mishkan/hooks/session-start-skill-index.sh +35 -0
- package/payload/mishkan/observability/schema.json +1 -1
- package/payload/mishkan/observability/watch/src/mishkan_watch/__main__.py +84 -2
- package/payload/mishkan/observability/watch/src/mishkan_watch/app.py +64 -3
- package/payload/mishkan/observability/watch/src/mishkan_watch/org_data.py +79 -0
- package/payload/mishkan/observability/watch/src/mishkan_watch/skills_data.py +267 -0
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/agents.py +33 -1
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/knowledge.py +118 -9
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/live.py +102 -2
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/org.py +139 -0
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/skills.py +187 -0
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/usage.py +377 -0
- package/payload/mishkan/observability/watch/src/mishkan_watch/tabs/workflows.py +199 -27
- package/payload/mishkan/observability/watch/src/mishkan_watch/theme.tcss +78 -4
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/__main__.py +36 -6
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/bus_tail.py +16 -3
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/graphify_tail.py +220 -0
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/sources/worktree_poll.py +13 -2
- package/payload/mishkan/observability/watchd/src/mishkan_watchd/state.py +157 -3
- package/payload/mishkan/observability/watchd/tests/test_state.py +6 -2
- package/payload/mishkan/org/org.json +141 -0
- package/payload/mishkan/scripts/pre-commit-eval-gate.sh +50 -0
- package/payload/mishkan/scripts/skill-discovery-indexer.py +462 -0
- package/payload/mishkan/scripts/skill-discovery-misses.py +173 -0
- package/payload/mishkan/scripts/skill-discovery-router.py +457 -0
- package/payload/mishkan/scripts/skill-discovery-smoke.sh +156 -0
- package/payload/mishkan/skills/graphify-query-craft/SKILL.md +162 -0
- package/payload/mishkan/skills/mishkan-init/SKILL.md +15 -0
- package/payload/mishkan/skills/skill-discovery/SKILL.md +120 -0
- package/payload/mishkan/templates/case-node.schema.json +1 -0
- package/payload/mishkan/templates/observability-log.schema.json +1 -0
- package/payload/mishkan/workflows/README.md +78 -13
- package/payload/mishkan/workflows/chosheb-feature-ship.js +75 -0
- package/payload/mishkan/workflows/migdal-dr-drill.js +94 -0
- package/payload/mishkan/workflows/migdal-infra-change.js +67 -0
- package/payload/mishkan/workflows/mishkan-blast-radius.js +144 -0
- package/payload/mishkan/workflows/mishkan-init.js +58 -0
- package/payload/mishkan/workflows/mishkan-knowledge-gap-discovery.js +121 -0
- package/payload/mishkan/workflows/mishkan-standards-rollout.js +153 -0
- package/payload/mishkan/workflows/mishmar-security-gate.js +83 -0
- package/payload/mishkan/workflows/panim-ds-rollout.js +83 -0
- package/payload/mishkan/workflows/sefer-release-notes.js +85 -0
- package/payload/mishkan/workflows/yasad-data-migration-wave.js +78 -0
- package/payload/mishkan/workflows/yasad-schema-evolution.js +76 -0
- package/payload/mishkan/templates/user-CLAUDE.md +0 -62
|
@@ -40,7 +40,7 @@ Footer {
|
|
|
40
40
|
|
|
41
41
|
#status-bar {
|
|
42
42
|
dock: bottom;
|
|
43
|
-
height:
|
|
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:
|
|
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:
|
|
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:
|
|
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,
|
|
24
|
-
session_tail, subagent_tail,
|
|
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
|
|
66
|
-
|
|
67
|
-
|
|
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.
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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",
|