Stevesolun commited on
Commit
b102502
·
verified ·
1 Parent(s): 547daee

Sync ctx bc869ed

Browse files

GitHub commit: bc869edd370d56ea070776cf452c7fc459cd495b

CHANGELOG.md CHANGED
@@ -7,6 +7,35 @@ Format loosely follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
7
 
8
  - No unreleased changes yet.
9
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
  ## [1.0.11] - 2026-05-28
11
 
12
  ### Fixed
@@ -1608,6 +1637,7 @@ pass. Full test suite: **1316 passed, 2 skipped**.
1608
  - 5 dead imports removed (`os`, `Mapping`, `timedelta` from
1609
  `ctx_lifecycle`; `Path` from `intake_gate`, `intake_pipeline`).
1610
 
 
1611
  [1.0.11]: https://github.com/stevesolun/ctx/releases/tag/v1.0.11
1612
  [1.0.10]: https://github.com/stevesolun/ctx/releases/tag/v1.0.10
1613
  [1.0.9]: https://github.com/stevesolun/ctx/releases/tag/v1.0.9
 
7
 
8
  - No unreleased changes yet.
9
 
10
+ ## [1.0.12] - 2026-06-16
11
+
12
+ ### Added
13
+
14
+ - Added first-class `ctx-skill-unload` and `ctx-agent-unload` console
15
+ scripts so skill and agent lifecycle cleanup has the same public CLI shape
16
+ as MCP uninstall flows.
17
+ - Added browser A-Z dashboard coverage for Manage CRUD, Config save, Harness
18
+ Setup, Sessions, KPI, Runtime, graph/wiki/docs, live events, and mutation
19
+ security paths.
20
+
21
+ ### Changed
22
+
23
+ - Made custom/API/local model harness recommendations fast enough for the
24
+ interactive wizard by using the harness-only catalog path before falling
25
+ back to the full graph.
26
+ - Added Hugging Face to the dashboard Harness Setup provider list and carried
27
+ `--api-key-env` consistently through `ctx-init` and
28
+ `ctx-harness-install --recommend`.
29
+
30
+ ### Fixed
31
+
32
+ - Fixed Windows UTF-8 BOM JSON parsing in repo scans so PowerShell-created
33
+ `package.json` files do not degrade stack detection.
34
+ - Fixed dashboard Config saves so unchanged boolean defaults are not written
35
+ as accidental user overrides.
36
+ - Suppressed the expected empty-graph warning in clean-host fast mode while
37
+ preserving warnings for real graph corruption.
38
+
39
  ## [1.0.11] - 2026-05-28
40
 
41
  ### Fixed
 
1637
  - 5 dead imports removed (`os`, `Mapping`, `timedelta` from
1638
  `ctx_lifecycle`; `Path` from `intake_gate`, `intake_pipeline`).
1639
 
1640
+ [1.0.12]: https://github.com/stevesolun/ctx/releases/tag/v1.0.12
1641
  [1.0.11]: https://github.com/stevesolun/ctx/releases/tag/v1.0.11
1642
  [1.0.10]: https://github.com/stevesolun/ctx/releases/tag/v1.0.10
1643
  [1.0.9]: https://github.com/stevesolun/ctx/releases/tag/v1.0.9
README.md CHANGED
@@ -18,7 +18,7 @@ tags:
18
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
19
  [![Python 3.11+](https://img.shields.io/badge/Python-3.11+-green.svg)](https://python.org)
20
  [![PyPI](https://img.shields.io/pypi/v/claude-ctx.svg)](https://pypi.org/project/claude-ctx/)
21
- [![Tests](https://img.shields.io/badge/Tests-4058_collected-brightgreen.svg)](https://github.com/stevesolun/ctx/actions/workflows/test.yml)
22
  [![Graph](https://img.shields.io/badge/Graph-102%2C928_nodes_/_2.9M_edges-red.svg)](https://stevesolun.github.io/ctx/knowledge-graph/)
23
  [![Skills](https://img.shields.io/badge/Skills-91%2C464-blue.svg)](https://stevesolun.github.io/ctx/catalog/?type=skill)
24
  [![Agents](https://img.shields.io/badge/Agents-467-purple.svg)](https://stevesolun.github.io/ctx/catalog/?type=agent)
 
18
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE)
19
  [![Python 3.11+](https://img.shields.io/badge/Python-3.11+-green.svg)](https://python.org)
20
  [![PyPI](https://img.shields.io/pypi/v/claude-ctx.svg)](https://pypi.org/project/claude-ctx/)
21
+ [![Tests](https://img.shields.io/badge/Tests-4067_collected-brightgreen.svg)](https://github.com/stevesolun/ctx/actions/workflows/test.yml)
22
  [![Graph](https://img.shields.io/badge/Graph-102%2C928_nodes_/_2.9M_edges-red.svg)](https://stevesolun.github.io/ctx/knowledge-graph/)
23
  [![Skills](https://img.shields.io/badge/Skills-91%2C464-blue.svg)](https://stevesolun.github.io/ctx/catalog/?type=skill)
24
  [![Agents](https://img.shields.io/badge/Agents-467-purple.svg)](https://stevesolun.github.io/ctx/catalog/?type=agent)
docs/index.md CHANGED
@@ -201,8 +201,8 @@ ones are flagged. New ones self-ingest.
201
 
202
  ---
203
 
204
- **v1.0.11** — MIT, CI-matrixed (Ubuntu 3.12 plus Windows/macOS 3.11/3.12),
205
- 4,058 tests collected. Ships console scripts including `ctx-init`,
206
  `ctx-monitor` (local dashboard with graph + wiki + load/unload for
207
  skills, agents, and MCP servers, plus Harness Setup for user-owned LLMs),
208
  `ctx-incremental-attach`, `ctx-incremental-shadow`, `ctx-dedup-check`
 
201
 
202
  ---
203
 
204
+ **v1.0.12** — MIT, CI-matrixed (Ubuntu 3.12 plus Windows/macOS 3.11/3.12),
205
+ 4,067 tests collected. Ships console scripts including `ctx-init`,
206
  `ctx-monitor` (local dashboard with graph + wiki + load/unload for
207
  skills, agents, and MCP servers, plus Harness Setup for user-owned LLMs),
208
  `ctx-incremental-attach`, `ctx-incremental-shadow`, `ctx-dedup-check`
pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
 
5
  [project]
6
  name = "claude-ctx"
7
- version = "1.0.11"
8
  description = "Skill and agent recommendation system for Claude Code — knowledge graph, wiki, and intake quality gates"
9
  authors = [{ name = "Steve Solun" }]
10
  license = "MIT"
@@ -41,12 +41,14 @@ ctx-toolbox = "toolbox:main"
41
  ctx-lifecycle = "ctx_lifecycle:main"
42
  ctx-skill-add = "skill_add:main"
43
  ctx-skill-install = "ctx.adapters.claude_code.install.skill_install:main"
 
44
  ctx-skill-mirror = "skill_mirror:main"
45
  ctx-bundle-suggest = "ctx.adapters.claude_code.hooks.bundle_orchestrator:main"
46
  ctx-recommend = "ctx.cli.recommend:main"
47
  ctx-agent-add = "agent_add:main"
48
  ctx-agent-mirror = "agent_mirror:main"
49
  ctx-agent-install = "ctx.adapters.claude_code.install.agent_install:main"
 
50
  ctx-harness-add = "harness_add:main"
51
  ctx-harness-install = "harness_install:main"
52
  ctx-mcp-add = "mcp_add:main"
 
4
 
5
  [project]
6
  name = "claude-ctx"
7
+ version = "1.0.12"
8
  description = "Skill and agent recommendation system for Claude Code — knowledge graph, wiki, and intake quality gates"
9
  authors = [{ name = "Steve Solun" }]
10
  license = "MIT"
 
41
  ctx-lifecycle = "ctx_lifecycle:main"
42
  ctx-skill-add = "skill_add:main"
43
  ctx-skill-install = "ctx.adapters.claude_code.install.skill_install:main"
44
+ ctx-skill-unload = "ctx.adapters.claude_code.install.skill_unload:skill_main"
45
  ctx-skill-mirror = "skill_mirror:main"
46
  ctx-bundle-suggest = "ctx.adapters.claude_code.hooks.bundle_orchestrator:main"
47
  ctx-recommend = "ctx.cli.recommend:main"
48
  ctx-agent-add = "agent_add:main"
49
  ctx-agent-mirror = "agent_mirror:main"
50
  ctx-agent-install = "ctx.adapters.claude_code.install.agent_install:main"
51
+ ctx-agent-unload = "ctx.adapters.claude_code.install.skill_unload:agent_main"
52
  ctx-harness-add = "harness_add:main"
53
  ctx-harness-install = "harness_install:main"
54
  ctx-mcp-add = "mcp_add:main"
scripts/clean_host_contract.py CHANGED
@@ -179,6 +179,7 @@ def isolated_env(paths: ContractPaths, *, extra_pythonpath: Path | None = None)
179
  "XDG_CONFIG_HOME": str(paths.xdg_config),
180
  "XDG_CACHE_HOME": str(paths.xdg_cache),
181
  "PIP_CACHE_DIR": str(paths.pip_cache),
 
182
  "PYTHONUTF8": "1",
183
  })
184
  if extra_pythonpath is not None:
 
179
  "XDG_CONFIG_HOME": str(paths.xdg_config),
180
  "XDG_CACHE_HOME": str(paths.xdg_cache),
181
  "PIP_CACHE_DIR": str(paths.pip_cache),
182
+ "CTX_ALLOW_MISSING_GRAPH": "1",
183
  "PYTHONUTF8": "1",
184
  })
185
  if extra_pythonpath is not None:
src/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
  """ctx — skill and agent recommendation for Claude Code."""
2
 
3
- __version__ = "1.0.11"
 
1
  """ctx — skill and agent recommendation for Claude Code."""
2
 
3
+ __version__ = "1.0.12"
src/ctx/__init__.py CHANGED
@@ -30,7 +30,7 @@ Package layout:
30
  ctx.utils - low-level primitives (safe names, atomic IO)
31
  """
32
 
33
- __version__ = "1.0.11"
34
 
35
 
36
  # Public library surface — anything listed here is safe for third-
 
30
  ctx.utils - low-level primitives (safe names, atomic IO)
31
  """
32
 
33
+ __version__ = "1.0.12"
34
 
35
 
36
  # Public library surface — anything listed here is safe for third-
src/ctx/adapters/claude_code/install/skill_unload.py CHANGED
@@ -116,7 +116,7 @@ def set_frontmatter_field(filepath: Path, field: str, value: str) -> bool:
116
  return False
117
 
118
 
119
- def find_entity_page(name: str) -> Path | None:
120
  """Find entity page for a skill or agent by name.
121
 
122
  Hardened against path traversal (CWE-22): name is validated against
@@ -126,6 +126,12 @@ def find_entity_page(name: str) -> Path | None:
126
  validate_skill_name(name)
127
  except ValueError:
128
  return None
 
 
 
 
 
 
129
  skill_page = SKILL_ENTITIES / f"{name}.md"
130
  if skill_page.exists():
131
  return skill_page
@@ -168,6 +174,7 @@ def unload_from_session(
168
  with file_lock(MANIFEST_PATH):
169
  manifest = load_manifest()
170
  removed: list[str] = []
 
171
  remaining = []
172
  for entry in manifest.get("load", []):
173
  entry_type = entry.get("entity_type", "skill")
@@ -176,6 +183,7 @@ def unload_from_session(
176
  and (entity_type is None or entry_type == entity_type)
177
  ):
178
  removed.append(entry["skill"])
 
179
  manifest.setdefault("unload", []).append(entry)
180
  else:
181
  remaining.append(entry)
@@ -191,11 +199,13 @@ def unload_from_session(
191
  try:
192
  events_path.parent.mkdir(parents=True, exist_ok=True)
193
  with events_path.open("a", encoding="utf-8") as fh:
194
- for slug in removed:
 
195
  fh.write(json.dumps({
196
  "event": "unload",
197
  "event_id": uuid.uuid4().hex,
198
  "meta": {"source": "skill_unload"},
 
199
  "session_id": session_id,
200
  "skill": slug,
201
  "timestamp": now_iso,
@@ -204,9 +214,11 @@ def unload_from_session(
204
  print(f"Warning: failed to log unload events: {exc}", file=sys.stderr)
205
  try:
206
  from ctx_audit_log import log_skill_event
207
- for slug in removed:
 
 
208
  log_skill_event(
209
- "skill.unloaded", slug,
210
  actor="cli", session_id=session_id,
211
  meta={"via": "skill_unload"},
212
  )
@@ -216,11 +228,11 @@ def unload_from_session(
216
  return removed
217
 
218
 
219
- def set_never_load(names: list[str]) -> list[str]:
220
  """Set never_load: true in wiki entity pages."""
221
  updated: list[str] = []
222
  for name in names:
223
- page = find_entity_page(name)
224
  if page:
225
  changed = set_frontmatter_field(page, "never_load", "true")
226
  graph_changed = _sync_graph_never_load(name, page, True)
@@ -236,11 +248,11 @@ def set_never_load(names: list[str]) -> list[str]:
236
  return updated
237
 
238
 
239
- def restore_load(names: list[str]) -> list[str]:
240
  """Remove never_load flag from wiki entity pages."""
241
  restored: list[str] = []
242
  for name in names:
243
- page = find_entity_page(name)
244
  if page:
245
  changed = set_frontmatter_field(page, "never_load", "false")
246
  graph_changed = _sync_graph_never_load(name, page, False)
@@ -256,10 +268,15 @@ def restore_load(names: list[str]) -> list[str]:
256
  return restored
257
 
258
 
259
- def get_stale_skills() -> list[str]:
260
  """Find all skills with status: stale in their entity pages."""
261
  stale: list[str] = []
262
- for entity_dir in [SKILL_ENTITIES, AGENT_ENTITIES]:
 
 
 
 
 
263
  if not entity_dir.exists():
264
  continue
265
  for page in entity_dir.glob("*.md"):
@@ -269,10 +286,13 @@ def get_stale_skills() -> list[str]:
269
  return stale
270
 
271
 
272
- def list_loaded() -> None:
273
  """Show currently loaded skills/agents."""
274
  manifest = load_manifest()
275
- loaded = manifest.get("load", [])
 
 
 
276
  if not loaded:
277
  print("No skills/agents currently loaded in this session.")
278
  return
@@ -282,10 +302,15 @@ def list_loaded() -> None:
282
  print(f" - {entry['skill']} (source: {source})")
283
 
284
 
285
- def list_never_load() -> None:
286
  """Show permanently suppressed skills/agents."""
287
  suppressed: list[str] = []
288
- for entity_dir in [SKILL_ENTITIES, AGENT_ENTITIES]:
 
 
 
 
 
289
  if not entity_dir.exists():
290
  continue
291
  for page in entity_dir.glob("*.md"):
@@ -301,7 +326,7 @@ def list_never_load() -> None:
301
  print("\nTo restore: python src/skill_unload.py --restore <name>")
302
 
303
 
304
- def main() -> None:
305
  parser = argparse.ArgumentParser(description="Unload skills/agents from session or suppress permanently")
306
  # ``--slug`` is an alias for ``--name`` so docs/playbooks that say
307
  # "slug" (the canonical vocabulary everywhere else in ctx) work
@@ -313,11 +338,18 @@ def main() -> None:
313
  help="Override CTX_SESSION_ID env for the emitted "
314
  "unload events (default: env var or synthetic id)")
315
  parser.add_argument("--permanent", action="store_true", help="Set never_load: true (won't be recommended again)")
 
 
 
 
 
 
316
  parser.add_argument("--stale", action="store_true", help="Unload all stale skills")
317
  parser.add_argument("--restore", help="Remove never_load flag from a skill/agent")
318
  parser.add_argument("--list-loaded", action="store_true", help="Show currently loaded skills")
319
  parser.add_argument("--list-never", action="store_true", help="Show permanently suppressed skills")
320
- args = parser.parse_args()
 
321
 
322
  # Propagate --session-id into the env so unload_from_session() and
323
  # its audit-log call both tag the emitted events with the caller's
@@ -326,11 +358,11 @@ def main() -> None:
326
  os.environ["CTX_SESSION_ID"] = args.session_id
327
 
328
  if args.list_loaded:
329
- list_loaded()
330
  return
331
 
332
  if args.list_never:
333
- list_never_load()
334
  return
335
 
336
  if args.restore:
@@ -342,7 +374,7 @@ def main() -> None:
342
  except ValueError as exc:
343
  print(f"Skipping invalid name {n!r}: {exc}", file=sys.stderr)
344
  if valid:
345
- restore_load(valid)
346
  return
347
 
348
  names: list[str] = []
@@ -351,7 +383,7 @@ def main() -> None:
351
  if args.names:
352
  names.extend(n.strip() for n in args.names.split(","))
353
  if args.stale:
354
- stale = get_stale_skills()
355
  print(f"Found {len(stale)} stale skills")
356
  names.extend(stale)
357
 
@@ -371,7 +403,7 @@ def main() -> None:
371
  sys.exit(1)
372
 
373
  # Unload from current session manifest
374
- removed = unload_from_session(names)
375
  if removed:
376
  print(f"Unloaded from session: {', '.join(removed)}")
377
 
@@ -379,7 +411,7 @@ def main() -> None:
379
  not_removed = [n for n in names if n not in removed]
380
  if not_removed:
381
  for name in not_removed:
382
- page = find_entity_page(name)
383
  if page:
384
  set_frontmatter_field(page, "status", "stale")
385
  print(f" {name}: marked stale (lower priority next session)")
@@ -390,7 +422,15 @@ def main() -> None:
390
  # Permanently suppress if requested
391
  if args.permanent:
392
  print("Setting never_load: true (will not be recommended again):")
393
- set_never_load(names)
 
 
 
 
 
 
 
 
394
 
395
 
396
  if __name__ == "__main__":
 
116
  return False
117
 
118
 
119
+ def find_entity_page(name: str, entity_type: str | None = None) -> Path | None:
120
  """Find entity page for a skill or agent by name.
121
 
122
  Hardened against path traversal (CWE-22): name is validated against
 
126
  validate_skill_name(name)
127
  except ValueError:
128
  return None
129
+ if entity_type == "agent":
130
+ agent_page = AGENT_ENTITIES / f"{name}.md"
131
+ return agent_page if agent_page.exists() else None
132
+ if entity_type == "skill":
133
+ skill_page = SKILL_ENTITIES / f"{name}.md"
134
+ return skill_page if skill_page.exists() else None
135
  skill_page = SKILL_ENTITIES / f"{name}.md"
136
  if skill_page.exists():
137
  return skill_page
 
174
  with file_lock(MANIFEST_PATH):
175
  manifest = load_manifest()
176
  removed: list[str] = []
177
+ removed_entries: list[dict] = []
178
  remaining = []
179
  for entry in manifest.get("load", []):
180
  entry_type = entry.get("entity_type", "skill")
 
183
  and (entity_type is None or entry_type == entity_type)
184
  ):
185
  removed.append(entry["skill"])
186
+ removed_entries.append(dict(entry))
187
  manifest.setdefault("unload", []).append(entry)
188
  else:
189
  remaining.append(entry)
 
199
  try:
200
  events_path.parent.mkdir(parents=True, exist_ok=True)
201
  with events_path.open("a", encoding="utf-8") as fh:
202
+ for entry in removed_entries:
203
+ slug = str(entry.get("skill") or "")
204
  fh.write(json.dumps({
205
  "event": "unload",
206
  "event_id": uuid.uuid4().hex,
207
  "meta": {"source": "skill_unload"},
208
+ "entity_type": entry.get("entity_type", "skill"),
209
  "session_id": session_id,
210
  "skill": slug,
211
  "timestamp": now_iso,
 
214
  print(f"Warning: failed to log unload events: {exc}", file=sys.stderr)
215
  try:
216
  from ctx_audit_log import log_skill_event
217
+ for entry in removed_entries:
218
+ slug = str(entry.get("skill") or "")
219
+ entry_type = str(entry.get("entity_type") or "skill")
220
  log_skill_event(
221
+ f"{entry_type}.unloaded", slug,
222
  actor="cli", session_id=session_id,
223
  meta={"via": "skill_unload"},
224
  )
 
228
  return removed
229
 
230
 
231
+ def set_never_load(names: list[str], *, entity_type: str | None = None) -> list[str]:
232
  """Set never_load: true in wiki entity pages."""
233
  updated: list[str] = []
234
  for name in names:
235
+ page = find_entity_page(name, entity_type=entity_type)
236
  if page:
237
  changed = set_frontmatter_field(page, "never_load", "true")
238
  graph_changed = _sync_graph_never_load(name, page, True)
 
248
  return updated
249
 
250
 
251
+ def restore_load(names: list[str], *, entity_type: str | None = None) -> list[str]:
252
  """Remove never_load flag from wiki entity pages."""
253
  restored: list[str] = []
254
  for name in names:
255
+ page = find_entity_page(name, entity_type=entity_type)
256
  if page:
257
  changed = set_frontmatter_field(page, "never_load", "false")
258
  graph_changed = _sync_graph_never_load(name, page, False)
 
268
  return restored
269
 
270
 
271
+ def get_stale_skills(*, entity_type: str | None = None) -> list[str]:
272
  """Find all skills with status: stale in their entity pages."""
273
  stale: list[str] = []
274
+ entity_dirs = [SKILL_ENTITIES, AGENT_ENTITIES]
275
+ if entity_type == "skill":
276
+ entity_dirs = [SKILL_ENTITIES]
277
+ elif entity_type == "agent":
278
+ entity_dirs = [AGENT_ENTITIES]
279
+ for entity_dir in entity_dirs:
280
  if not entity_dir.exists():
281
  continue
282
  for page in entity_dir.glob("*.md"):
 
286
  return stale
287
 
288
 
289
+ def list_loaded(*, entity_type: str | None = None) -> None:
290
  """Show currently loaded skills/agents."""
291
  manifest = load_manifest()
292
+ loaded = [
293
+ entry for entry in manifest.get("load", [])
294
+ if entity_type is None or entry.get("entity_type", "skill") == entity_type
295
+ ]
296
  if not loaded:
297
  print("No skills/agents currently loaded in this session.")
298
  return
 
302
  print(f" - {entry['skill']} (source: {source})")
303
 
304
 
305
+ def list_never_load(*, entity_type: str | None = None) -> None:
306
  """Show permanently suppressed skills/agents."""
307
  suppressed: list[str] = []
308
+ entity_dirs = [SKILL_ENTITIES, AGENT_ENTITIES]
309
+ if entity_type == "skill":
310
+ entity_dirs = [SKILL_ENTITIES]
311
+ elif entity_type == "agent":
312
+ entity_dirs = [AGENT_ENTITIES]
313
+ for entity_dir in entity_dirs:
314
  if not entity_dir.exists():
315
  continue
316
  for page in entity_dir.glob("*.md"):
 
326
  print("\nTo restore: python src/skill_unload.py --restore <name>")
327
 
328
 
329
+ def main(argv: list[str] | None = None, *, default_entity_type: str | None = None) -> None:
330
  parser = argparse.ArgumentParser(description="Unload skills/agents from session or suppress permanently")
331
  # ``--slug`` is an alias for ``--name`` so docs/playbooks that say
332
  # "slug" (the canonical vocabulary everywhere else in ctx) work
 
338
  help="Override CTX_SESSION_ID env for the emitted "
339
  "unload events (default: env var or synthetic id)")
340
  parser.add_argument("--permanent", action="store_true", help="Set never_load: true (won't be recommended again)")
341
+ parser.add_argument(
342
+ "--entity-type",
343
+ choices=("skill", "agent"),
344
+ default=default_entity_type,
345
+ help="Limit the operation to one entity type",
346
+ )
347
  parser.add_argument("--stale", action="store_true", help="Unload all stale skills")
348
  parser.add_argument("--restore", help="Remove never_load flag from a skill/agent")
349
  parser.add_argument("--list-loaded", action="store_true", help="Show currently loaded skills")
350
  parser.add_argument("--list-never", action="store_true", help="Show permanently suppressed skills")
351
+ args = parser.parse_args(argv)
352
+ entity_type = args.entity_type
353
 
354
  # Propagate --session-id into the env so unload_from_session() and
355
  # its audit-log call both tag the emitted events with the caller's
 
358
  os.environ["CTX_SESSION_ID"] = args.session_id
359
 
360
  if args.list_loaded:
361
+ list_loaded(entity_type=entity_type)
362
  return
363
 
364
  if args.list_never:
365
+ list_never_load(entity_type=entity_type)
366
  return
367
 
368
  if args.restore:
 
374
  except ValueError as exc:
375
  print(f"Skipping invalid name {n!r}: {exc}", file=sys.stderr)
376
  if valid:
377
+ restore_load(valid, entity_type=entity_type)
378
  return
379
 
380
  names: list[str] = []
 
383
  if args.names:
384
  names.extend(n.strip() for n in args.names.split(","))
385
  if args.stale:
386
+ stale = get_stale_skills(entity_type=entity_type)
387
  print(f"Found {len(stale)} stale skills")
388
  names.extend(stale)
389
 
 
403
  sys.exit(1)
404
 
405
  # Unload from current session manifest
406
+ removed = unload_from_session(names, entity_type=entity_type)
407
  if removed:
408
  print(f"Unloaded from session: {', '.join(removed)}")
409
 
 
411
  not_removed = [n for n in names if n not in removed]
412
  if not_removed:
413
  for name in not_removed:
414
+ page = find_entity_page(name, entity_type=entity_type)
415
  if page:
416
  set_frontmatter_field(page, "status", "stale")
417
  print(f" {name}: marked stale (lower priority next session)")
 
422
  # Permanently suppress if requested
423
  if args.permanent:
424
  print("Setting never_load: true (will not be recommended again):")
425
+ set_never_load(names, entity_type=entity_type)
426
+
427
+
428
+ def skill_main() -> None:
429
+ main(default_entity_type="skill")
430
+
431
+
432
+ def agent_main() -> None:
433
+ main(default_entity_type="agent")
434
 
435
 
436
  if __name__ == "__main__":
src/ctx/assets/monitor.css CHANGED
@@ -1,6 +1,6 @@
1
  :root {
2
  color-scheme: light dark;
3
- --bg: #f5f7fb;
4
  --surface: #ffffff;
5
  --surface-2: #f8fafc;
6
  --surface-3: #eef2f7;
@@ -10,16 +10,32 @@
10
  --accent: #2563eb;
11
  --accent-strong: #1d4ed8;
12
  --accent-soft: #dbeafe;
 
13
  --ok: #059669;
14
  --warning: #d97706;
15
  --danger: #dc2626;
16
  --radius: 8px;
17
- --shadow: 0 12px 34px rgba(15, 23, 42, 0.08);
 
18
  }
19
  * { box-sizing: border-box; }
20
  body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
21
- max-width: 1180px; margin: 0 auto; padding: 1.25rem 1rem 2.5rem;
22
- line-height: 1.5; background: var(--bg); color: var(--text); }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
23
  h1 { margin-top: 0; letter-spacing: 0; }
24
  h2, h3 { letter-spacing: 0; }
25
  a { color: var(--accent); text-decoration: none; }
@@ -71,17 +87,19 @@ pre { padding: 0.6rem 0.8rem; overflow-x: auto; }
71
  border-radius: 6px; padding: 0.5rem 0.65rem; }
72
  .nav { display: flex; gap: 0.35rem; margin: 0 0 1.4rem; align-items: center; flex-wrap: wrap;
73
  position: sticky; top: 0; z-index: 10; padding: 0.55rem 0.45rem;
74
- border: 1px solid var(--border); border-radius: var(--radius);
75
- background: rgba(255,255,255,0.92); box-shadow: var(--shadow); backdrop-filter: blur(10px); }
76
  .nav a[draggable="true"] { cursor: grab; border-radius: 6px; padding: 0.28rem 0.48rem;
77
  font-weight: 620; color: #334155; }
78
- .nav a[draggable="true"]:hover { background: var(--surface-3); text-decoration: none; }
 
79
  .nav a.nav-dragging { opacity: 0.45; cursor: grabbing; }
80
  .nav a.nav-drag-over { outline: 2px solid #93c5fd; background: rgba(147,197,253,0.18); }
81
  .nav-reset { border: 1px solid #d1d5db; border-radius: 4px; background: transparent;
82
  color: #6b7280; cursor: pointer; padding: 0.1rem 0.35rem; font-size: 0.78rem; }
83
- .card { border: 1px solid var(--border); border-radius: var(--radius); padding: 1rem 1.25rem;
84
- margin-bottom: 1rem; background: var(--surface); box-shadow: 0 1px 2px rgba(15,23,42,0.04); }
 
85
  .stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); }
86
  .wiki-entity-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
87
  gap: 1rem; align-items: start; }
@@ -98,6 +116,147 @@ pre { padding: 0.6rem 0.8rem; overflow-x: auto; }
98
  color: inherit; cursor: pointer; padding: 0.35rem 0.7rem; }
99
  .entity-tab-button.active { background: #111827; color: #fff; border-color: #111827; }
100
  .entity-tab-panel[hidden] { display: none; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  .docs-shell { display: flex; flex-direction: column; gap: 1rem; }
102
  .docs-hero { border: 1px solid var(--border); border-radius: 12px;
103
  background: linear-gradient(135deg, #ffffff 0%, #f8fbff 62%, #eef6ff 100%);
@@ -224,15 +383,33 @@ pre { padding: 0.6rem 0.8rem; overflow-x: auto; }
224
  --accent: #60a5fa;
225
  --accent-strong: #93c5fd;
226
  --accent-soft: rgba(96,165,250,0.28);
 
227
  --shadow: 0 12px 34px rgba(0,0,0,0.28);
 
228
  }
229
  body { background: var(--bg); color: var(--text); }
 
 
 
 
 
 
 
230
  th { background: rgba(255,255,255,0.05); }
231
  tr:hover { background: rgba(255,255,255,0.03); }
232
  .nav { background: rgba(17,24,39,0.92); }
233
  .nav a[draggable="true"] { color: #e5e7eb; }
234
  .nav a[draggable="true"]:hover { background: #1f2937; }
235
  .card { border-color: var(--border); }
 
 
 
 
 
 
 
 
 
236
  .entity-tab-button { background: #0f172a; border-color: #334155; }
237
  .entity-tab-button.active { background: #e2e8f0; color: #0f172a; }
238
  .docs-hero { background: linear-gradient(135deg, #111827 0%, #0f172a 62%, #0b1120 100%); }
 
1
  :root {
2
  color-scheme: light dark;
3
+ --bg: #eef3f8;
4
  --surface: #ffffff;
5
  --surface-2: #f8fafc;
6
  --surface-3: #eef2f7;
 
10
  --accent: #2563eb;
11
  --accent-strong: #1d4ed8;
12
  --accent-soft: #dbeafe;
13
+ --accent-ring: rgba(37, 99, 235, 0.22);
14
  --ok: #059669;
15
  --warning: #d97706;
16
  --danger: #dc2626;
17
  --radius: 8px;
18
+ --shadow: 0 18px 48px rgba(15, 23, 42, 0.10);
19
+ --shadow-soft: 0 1px 2px rgba(15, 23, 42, 0.06);
20
  }
21
  * { box-sizing: border-box; }
22
  body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
23
+ margin: 0; padding: 0; line-height: 1.5; background: var(--bg); color: var(--text); }
24
+ body::before { content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none;
25
+ background:
26
+ radial-gradient(circle at 18% -10%, rgba(37,99,235,0.16), transparent 30rem),
27
+ radial-gradient(circle at 88% 4%, rgba(20,184,166,0.12), transparent 28rem),
28
+ linear-gradient(180deg, #f8fbff 0%, var(--bg) 42%, #edf2f7 100%); }
29
+ .app-shell { width: min(1500px, calc(100vw - 32px)); margin: 0 auto; padding: 1rem 0 2.5rem; }
30
+ .app-header { display: flex; align-items: center; justify-content: space-between; gap: 1rem;
31
+ margin-bottom: 0.75rem; padding: 0.8rem 0.15rem; }
32
+ .app-brand { display: inline-flex; align-items: center; gap: 0.55rem; color: var(--text);
33
+ font-weight: 800; letter-spacing: 0; text-decoration: none; }
34
+ .app-brand:hover { text-decoration: none; }
35
+ .app-brand-mark { display: inline-grid; place-items: center; width: 2.15rem; height: 2.15rem;
36
+ border-radius: 8px; background: #0f172a; color: #fff; box-shadow: var(--shadow-soft); }
37
+ .app-header-meta { color: var(--muted-text); font-size: 0.86rem; text-align: right; }
38
+ .app-main { min-width: 0; }
39
  h1 { margin-top: 0; letter-spacing: 0; }
40
  h2, h3 { letter-spacing: 0; }
41
  a { color: var(--accent); text-decoration: none; }
 
87
  border-radius: 6px; padding: 0.5rem 0.65rem; }
88
  .nav { display: flex; gap: 0.35rem; margin: 0 0 1.4rem; align-items: center; flex-wrap: wrap;
89
  position: sticky; top: 0; z-index: 10; padding: 0.55rem 0.45rem;
90
+ border: 1px solid rgba(216,225,238,0.86); border-radius: 12px;
91
+ background: rgba(255,255,255,0.88); box-shadow: var(--shadow); backdrop-filter: blur(14px); }
92
  .nav a[draggable="true"] { cursor: grab; border-radius: 6px; padding: 0.28rem 0.48rem;
93
  font-weight: 620; color: #334155; }
94
+ .nav a[draggable="true"]:hover { background: rgba(37,99,235,0.09); color: var(--accent);
95
+ text-decoration: none; }
96
  .nav a.nav-dragging { opacity: 0.45; cursor: grabbing; }
97
  .nav a.nav-drag-over { outline: 2px solid #93c5fd; background: rgba(147,197,253,0.18); }
98
  .nav-reset { border: 1px solid #d1d5db; border-radius: 4px; background: transparent;
99
  color: #6b7280; cursor: pointer; padding: 0.1rem 0.35rem; font-size: 0.78rem; }
100
+ .card { border: 1px solid rgba(216,225,238,0.92); border-radius: 12px; padding: 1rem 1.25rem;
101
+ margin-bottom: 1rem; background: rgba(255,255,255,0.94); box-shadow: var(--shadow-soft); }
102
+ .card:hover { box-shadow: 0 10px 28px rgba(15,23,42,0.07); }
103
  .stat-card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--radius); }
104
  .wiki-entity-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
105
  gap: 1rem; align-items: start; }
 
116
  color: inherit; cursor: pointer; padding: 0.35rem 0.7rem; }
117
  .entity-tab-button.active { background: #111827; color: #fff; border-color: #111827; }
118
  .entity-tab-panel[hidden] { display: none; }
119
+ .graph-live-results { display: flex; flex-direction: column; gap: 0.35rem; margin-top: 0.45rem;
120
+ max-height: 18rem; overflow-y: auto; }
121
+ .graph-live-results[hidden] { display: none; }
122
+ .graph-live-result { display: flex; flex-direction: column; align-items: stretch; gap: 0.18rem;
123
+ text-align: left; background: var(--surface-2); color: var(--text);
124
+ border-color: var(--border); padding: 0.48rem 0.55rem; }
125
+ .graph-live-result:hover { background: var(--accent-soft); border-color: var(--accent); }
126
+ .graph-live-result small { color: var(--muted-text); font-weight: 500; line-height: 1.25; }
127
+ .graph-live-empty { color: var(--muted-text); font-size: 0.82rem; padding: 0.35rem 0.1rem; }
128
+ .graph-match-card { overflow: hidden; }
129
+ .graph-match-summary { display: flex; align-items: center; gap: 0.35rem; margin-top: 0.35rem; }
130
+ .graph-match-histogram { height: 2.2rem; display: flex; align-items: end; gap: 3px;
131
+ padding: 0.25rem 0.15rem 0.1rem; }
132
+ .graph-match-bar { flex: 1 1 0; min-width: 0; min-height: 0.22rem; border: 0;
133
+ border-radius: 4px 4px 0 0; background: #bfd0e4; opacity: 0.74;
134
+ cursor: pointer; padding: 0; transition: background 120ms ease,
135
+ opacity 120ms ease, transform 120ms ease; }
136
+ .graph-match-bar:hover,
137
+ .graph-match-bar:focus-visible { background: #1d4ed8; opacity: 1; outline: none;
138
+ transform: translateY(-1px); }
139
+ .graph-match-bar.active { background: #2563eb; opacity: 0.96; }
140
+ .graph-range-wrap { position: relative; height: 1.65rem; margin-top: 0.05rem; }
141
+ .graph-range-track,
142
+ .graph-range-fill { position: absolute; left: 0; right: 0; top: 50%; height: 0.3rem;
143
+ transform: translateY(-50%); border-radius: 999px; }
144
+ .graph-range-track { background: #dbe3ef; box-shadow: inset 0 1px 2px rgba(15,23,42,0.10); }
145
+ .graph-range-fill { right: auto; background: #2563eb; box-shadow: 0 0 0 1px rgba(37,99,235,0.10); }
146
+ .graph-range-wrap input[type="range"] { position: absolute; inset: 0; width: 100%; margin: 0;
147
+ appearance: none; -webkit-appearance: none;
148
+ background: transparent; border: 0; box-shadow: none;
149
+ color: transparent; padding: 0; pointer-events: none; }
150
+ .graph-range-wrap input[type="range"]:focus,
151
+ .graph-range-wrap input[type="range"]:focus-visible,
152
+ .graph-range-wrap input[type="range"]:active { outline: none !important; box-shadow: none !important; }
153
+ .graph-range-wrap input[type="range"]::-moz-focus-outer { border: 0; }
154
+ .graph-range-wrap input[type="range"]::-webkit-slider-runnable-track { height: 0.3rem; background: transparent;
155
+ border: 0; box-shadow: none; }
156
+ .graph-range-wrap input[type="range"]::-moz-range-track { height: 0.3rem; background: transparent;
157
+ border: 0; box-shadow: none; }
158
+ .graph-range-wrap input[type="range"]::-webkit-slider-thumb { appearance: none; -webkit-appearance: none;
159
+ width: 1rem; height: 1rem;
160
+ margin-top: calc((0.3rem - 1rem) / 2);
161
+ border: 0.25rem solid #2563eb;
162
+ border-radius: 999px; background: #fff;
163
+ outline: none;
164
+ box-shadow: 0 1px 4px rgba(15,23,42,0.24);
165
+ cursor: ew-resize; pointer-events: auto; }
166
+ .graph-range-wrap input[type="range"]::-moz-range-thumb { width: 0.5rem; height: 0.5rem;
167
+ border: 0.25rem solid #2563eb;
168
+ border-radius: 999px; background: #fff;
169
+ outline: none;
170
+ box-shadow: 0 1px 4px rgba(15,23,42,0.24);
171
+ cursor: ew-resize; pointer-events: auto; }
172
+ .graph-range-wrap input[type="range"]:focus-visible::-webkit-slider-thumb {
173
+ box-shadow: 0 0 0 0.24rem var(--accent-ring), 0 1px 4px rgba(15,23,42,0.24);
174
+ }
175
+ .graph-range-wrap input[type="range"]:focus-visible::-moz-range-thumb {
176
+ box-shadow: 0 0 0 0.24rem var(--accent-ring), 0 1px 4px rgba(15,23,42,0.24);
177
+ }
178
+ .graph-range-scale { display: flex; justify-content: space-between; color: var(--muted-text);
179
+ font-size: 0.72rem; line-height: 1; margin-top: 0.22rem; }
180
+ .graph-stage { width: 100%; height: calc(100vh - 2rem); height: calc(100dvh - 2rem);
181
+ min-height: 42rem; border: 1px solid var(--border); border-radius: 12px;
182
+ background: var(--surface); overflow: hidden; box-shadow: var(--shadow); }
183
+ .graph-renderer-shell { --graph-inspector-height: 14rem; height: 100%; display: grid; overflow: hidden; border-radius: 12px;
184
+ background: linear-gradient(180deg, rgba(248,251,255,0.94), rgba(239,246,255,0.88));
185
+ grid-template-rows: auto minmax(18rem, 1fr) 0.65rem minmax(10rem, var(--graph-inspector-height)); }
186
+ .graph-toolbar { display: flex; align-items: center; gap: 0.45rem; padding: 0.4rem 0.55rem;
187
+ border-bottom: 1px solid var(--border); background: rgba(255,255,255,0.86);
188
+ backdrop-filter: blur(10px); flex-wrap: wrap; }
189
+ .graph-toolbar button { padding: 0.22rem 0.48rem; line-height: 1.2; }
190
+ .graph-toolbar .muted { font-size: 0.78rem; line-height: 1.25; }
191
+ .graph-canvas-wrap { position: relative; min-height: 0; background: transparent; cursor: grab; }
192
+ .graph-canvas-wrap:active { cursor: grabbing; }
193
+ .graph-canvas-wrap [data-3d-node-id],
194
+ .graph-canvas-wrap [data-testid="graph-svg-node"] { cursor: pointer; }
195
+ .graph-canvas-wrap [data-edge-detail] { cursor: help; }
196
+ .graph-canvas-wrap svg { background:
197
+ radial-gradient(circle at 50% 42%, rgba(37,99,235,0.08), transparent 22rem),
198
+ linear-gradient(180deg, #f8fbff 0%, #eef4fb 100%) !important; }
199
+ .graph-canvas-wrap svg text { user-select: none; }
200
+ .graph-canvas-wrap [data-3d-node-id],
201
+ .graph-canvas-wrap [data-testid="graph-svg-node"] { outline: none; }
202
+ .graph-canvas-wrap [data-3d-node-id]:focus,
203
+ .graph-canvas-wrap [data-3d-node-id]:focus-visible,
204
+ .graph-canvas-wrap [data-testid="graph-svg-node"]:focus,
205
+ .graph-canvas-wrap [data-testid="graph-svg-node"]:focus-visible { outline: none; }
206
+ .graph-resize-handle { display: flex; align-items: center; justify-content: center;
207
+ border-top: 1px solid rgba(148,163,184,0.35);
208
+ border-bottom: 1px solid rgba(148,163,184,0.35);
209
+ background: linear-gradient(180deg, rgba(255,255,255,0.75), rgba(226,232,240,0.82));
210
+ cursor: ns-resize; touch-action: none; }
211
+ .graph-resize-handle::before { content: ""; width: 3.25rem; height: 0.22rem; border-radius: 999px;
212
+ background: rgba(100,116,139,0.72); box-shadow: 0 0.32rem 0 rgba(100,116,139,0.32); }
213
+ .graph-resize-handle:hover,
214
+ .graph-resize-handle:focus-visible { background: rgba(219,234,254,0.96); outline: none; }
215
+ .graph-resizing { user-select: none; }
216
+ .graph-tooltip { position: absolute; z-index: 3; max-width: 250px; pointer-events: none;
217
+ padding: 0.45rem 0.55rem; border: 1px solid var(--border);
218
+ border-radius: var(--radius); background: rgba(15,23,42,0.92);
219
+ color: #f8fafc; font-size: 0.78rem; line-height: 1.35;
220
+ box-shadow: var(--shadow); }
221
+ .graph-node-selected [data-testid="graph-svg-node"],
222
+ .graph-canvas-wrap [data-3d-node-id]:focus-visible [data-testid="graph-svg-node"] {
223
+ fill: #facc15 !important;
224
+ stroke: #0f172a !important;
225
+ stroke-width: 4 !important;
226
+ filter: drop-shadow(0 0 10px rgba(250,204,21,0.46));
227
+ }
228
+ .graph-edge-selected { stroke: #3b82f6 !important; stroke-opacity: 0.42 !important; }
229
+ .graph-inspector-grid { display: grid; grid-template-columns: minmax(0,1fr);
230
+ gap: 0.6rem; padding: 0.55rem 0.65rem; border-top: 1px solid var(--border);
231
+ background: rgba(255,255,255,0.9); min-height: 0; overflow: hidden; }
232
+ .graph-list-panel { overflow: auto; border-top: 1px solid var(--border);
233
+ background: rgba(255,255,255,0.82); }
234
+ .graph-fallback-label, .graph-neighbor-label, .graph-link-label { font-weight: 700; color: var(--text); }
235
+ .graph-fallback-node:hover .graph-fallback-label { color: var(--accent); }
236
+ .graph-node-detail-panel,
237
+ [data-testid="graph-edge-detail"] { min-height: 0; max-height: 100%; overflow: auto; }
238
+ .graph-node-detail-panel { width: 100%; }
239
+ .graph-edge-detail-inline { width: 100%; margin-bottom: 0.55rem; padding: 0.4rem 0.55rem;
240
+ border: 1px solid var(--border); border-radius: var(--radius);
241
+ background: rgba(248,250,252,0.92); }
242
+ .graph-selected-summary { display: grid; gap: 0.25rem; margin-bottom: 0.55rem; }
243
+ .graph-selected-summary p { margin: 0.15rem 0 0; }
244
+ .graph-neighbor-tree h3 { margin: 0.45rem 0 0.35rem; font-size: 1rem; }
245
+ .graph-neighbor-group { border: 1px solid var(--border); border-radius: var(--radius);
246
+ margin-bottom: 0.45rem; background: var(--surface); }
247
+ .graph-weak-links { border-style: dashed; background: rgba(100,116,139,0.06); }
248
+ .graph-weak-links summary { color: var(--muted-text); }
249
+ .graph-weak-links p { margin: 0; padding: 0 0.55rem 0.55rem; }
250
+ .graph-neighbor-group > summary { cursor: pointer; padding: 0.42rem 0.55rem; font-weight: 700; }
251
+ .graph-neighbor-group ul { list-style: none; padding: 0; margin: 0; }
252
+ .graph-neighbor-item { border-top: 1px solid var(--border); padding: 0.5rem 0.55rem; }
253
+ .graph-neighbor-row { display: flex; justify-content: space-between; gap: 0.55rem; align-items: center; }
254
+ .graph-edge-signals { display: flex; gap: 0.35rem; flex-wrap: wrap; margin-top: 0.28rem; }
255
+ .graph-metric { background: var(--surface-3); border-radius: 999px; padding: 0.08rem 0.42rem;
256
+ font-size: 0.76rem; }
257
+ .graph-inner-links { margin-top: 0.35rem; }
258
+ .graph-inner-links summary { cursor: pointer; color: var(--muted-text); font-size: 0.82rem; }
259
+ .graph-inner-links li { padding: 0.18rem 0; font-size: 0.82rem; }
260
  .docs-shell { display: flex; flex-direction: column; gap: 1rem; }
261
  .docs-hero { border: 1px solid var(--border); border-radius: 12px;
262
  background: linear-gradient(135deg, #ffffff 0%, #f8fbff 62%, #eef6ff 100%);
 
383
  --accent: #60a5fa;
384
  --accent-strong: #93c5fd;
385
  --accent-soft: rgba(96,165,250,0.28);
386
+ --accent-ring: rgba(96,165,250,0.26);
387
  --shadow: 0 12px 34px rgba(0,0,0,0.28);
388
+ --shadow-soft: 0 1px 2px rgba(0,0,0,0.28);
389
  }
390
  body { background: var(--bg); color: var(--text); }
391
+ body::before {
392
+ background:
393
+ radial-gradient(circle at 18% -10%, rgba(37,99,235,0.20), transparent 30rem),
394
+ radial-gradient(circle at 88% 4%, rgba(20,184,166,0.12), transparent 28rem),
395
+ linear-gradient(180deg, #0f172a 0%, #0b1120 100%);
396
+ }
397
+ .app-brand-mark { background: #e2e8f0; color: #0f172a; }
398
  th { background: rgba(255,255,255,0.05); }
399
  tr:hover { background: rgba(255,255,255,0.03); }
400
  .nav { background: rgba(17,24,39,0.92); }
401
  .nav a[draggable="true"] { color: #e5e7eb; }
402
  .nav a[draggable="true"]:hover { background: #1f2937; }
403
  .card { border-color: var(--border); }
404
+ .graph-renderer-shell,
405
+ .graph-toolbar,
406
+ .graph-inspector-grid,
407
+ .graph-list-panel { background: rgba(17,24,39,0.90); }
408
+ .graph-canvas-wrap svg {
409
+ background:
410
+ radial-gradient(circle at 50% 42%, rgba(96,165,250,0.14), transparent 22rem),
411
+ linear-gradient(180deg, #0f172a 0%, #111827 100%) !important;
412
+ }
413
  .entity-tab-button { background: #0f172a; border-color: #334155; }
414
  .entity-tab-button.active { background: #e2e8f0; color: #0f172a; }
415
  .docs-hero { background: linear-gradient(135deg, #111827 0%, #0f172a 62%, #0b1120 100%); }
src/ctx/cli/run.py CHANGED
@@ -73,6 +73,7 @@ _PROVIDER_KEY_ENV: dict[str, str] = {
73
  "openrouter": "OPENROUTER_API_KEY",
74
  "anthropic": "ANTHROPIC_API_KEY",
75
  "openai": "OPENAI_API_KEY",
 
76
  "gemini": "GEMINI_API_KEY",
77
  "mistral": "MISTRAL_API_KEY",
78
  "deepseek": "DEEPSEEK_API_KEY",
 
73
  "openrouter": "OPENROUTER_API_KEY",
74
  "anthropic": "ANTHROPIC_API_KEY",
75
  "openai": "OPENAI_API_KEY",
76
+ "huggingface": "HF_TOKEN",
77
  "gemini": "GEMINI_API_KEY",
78
  "mistral": "MISTRAL_API_KEY",
79
  "deepseek": "DEEPSEEK_API_KEY",
src/ctx/core/graph/resolve_graph.py CHANGED
@@ -288,7 +288,11 @@ def load_graph(
288
  """
289
  graph_path = path if path is not None else GRAPH_PATH
290
  if not graph_path.exists():
291
- logger.warning("graph.json not found at %s; returning empty graph", graph_path)
 
 
 
 
292
  return nx.Graph()
293
  try:
294
  with open(graph_path, encoding="utf-8") as f:
 
288
  """
289
  graph_path = path if path is not None else GRAPH_PATH
290
  if not graph_path.exists():
291
+ message = "graph.json not found at %s; returning empty graph"
292
+ if os.environ.get("CTX_ALLOW_MISSING_GRAPH") == "1":
293
+ logger.debug(message, graph_path)
294
+ else:
295
+ logger.warning(message, graph_path)
296
  return nx.Graph()
297
  try:
298
  with open(graph_path, encoding="utf-8") as f:
src/ctx_init.py CHANGED
@@ -988,6 +988,7 @@ _PROVIDER_KEY_ENV: dict[str, str] = {
988
  "openrouter": "OPENROUTER_API_KEY",
989
  "anthropic": "ANTHROPIC_API_KEY",
990
  "openai": "OPENAI_API_KEY",
 
991
  "gemini": "GEMINI_API_KEY",
992
  "mistral": "MISTRAL_API_KEY",
993
  "deepseek": "DEEPSEEK_API_KEY",
@@ -1003,6 +1004,7 @@ _HARNESS_REQUIREMENT_FIELDS = (
1003
  ("verification", "harness_verify"),
1004
  ("privacy", "harness_privacy"),
1005
  ("attach_mode", "harness_attach_mode"),
 
1006
  )
1007
 
1008
  _HARNESS_REQUIREMENT_FLAGS = {
@@ -1012,6 +1014,7 @@ _HARNESS_REQUIREMENT_FLAGS = {
1012
  "verification": "--harness-verify",
1013
  "privacy": "--harness-privacy",
1014
  "attach_mode": "--harness-attach-mode",
 
1015
  }
1016
 
1017
 
@@ -1062,7 +1065,7 @@ def recommend_harnesses(
1062
  )
1063
  from ctx_config import cfg # noqa: PLC0415
1064
 
1065
- graph = _load_recommendation_graph()
1066
  if graph.number_of_nodes() == 0:
1067
  return []
1068
  limit = max(1, min(int(top_k), cfg.recommendation_top_k))
@@ -1514,6 +1517,63 @@ def _harness_frontmatter_from_wiki(slug: str) -> dict[str, Any]:
1514
  return {}
1515
 
1516
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1517
  def _load_recommendation_graph() -> Any:
1518
  """Load the ctx knowledge graph for harness onboarding."""
1519
  from ctx.core.graph.resolve_graph import load_graph # noqa: PLC0415
 
988
  "openrouter": "OPENROUTER_API_KEY",
989
  "anthropic": "ANTHROPIC_API_KEY",
990
  "openai": "OPENAI_API_KEY",
991
+ "huggingface": "HF_TOKEN",
992
  "gemini": "GEMINI_API_KEY",
993
  "mistral": "MISTRAL_API_KEY",
994
  "deepseek": "DEEPSEEK_API_KEY",
 
1004
  ("verification", "harness_verify"),
1005
  ("privacy", "harness_privacy"),
1006
  ("attach_mode", "harness_attach_mode"),
1007
+ ("api_key_env", "api_key_env"),
1008
  )
1009
 
1010
  _HARNESS_REQUIREMENT_FLAGS = {
 
1014
  "verification": "--harness-verify",
1015
  "privacy": "--harness-privacy",
1016
  "attach_mode": "--harness-attach-mode",
1017
+ "api_key_env": "--api-key-env",
1018
  }
1019
 
1020
 
 
1065
  )
1066
  from ctx_config import cfg # noqa: PLC0415
1067
 
1068
+ graph = _load_harness_recommendation_graph()
1069
  if graph.number_of_nodes() == 0:
1070
  return []
1071
  limit = max(1, min(int(top_k), cfg.recommendation_top_k))
 
1517
  return {}
1518
 
1519
 
1520
+ def _load_harness_recommendation_graph() -> Any:
1521
+ """Load a tiny harness-only graph for interactive onboarding.
1522
+
1523
+ ``ctx-init`` and ``ctx-harness-install --recommend`` are user-facing
1524
+ wizard paths. Loading the full 100k-node graph there is unnecessary and
1525
+ can take tens of seconds on slower hosts; harness fit only needs harness
1526
+ entity metadata.
1527
+ """
1528
+ graph = _load_harness_catalog_graph()
1529
+ try:
1530
+ if graph.number_of_nodes() > 0:
1531
+ return graph
1532
+ except Exception:
1533
+ pass
1534
+ return _load_recommendation_graph()
1535
+
1536
+
1537
+ def _load_harness_catalog_graph() -> Any:
1538
+ try:
1539
+ import networkx as nx # noqa: PLC0415
1540
+ from ctx.core.wiki.wiki_utils import parse_frontmatter_and_body # noqa: PLC0415
1541
+ from ctx_config import cfg # noqa: PLC0415
1542
+ except Exception:
1543
+ return _empty_harness_graph()
1544
+
1545
+ graph = nx.Graph()
1546
+ harness_dir = cfg.wiki_dir / "entities" / "harnesses"
1547
+ if not harness_dir.is_dir():
1548
+ return graph
1549
+ for page in sorted(harness_dir.glob("*.md")):
1550
+ try:
1551
+ fm, _body = parse_frontmatter_and_body(
1552
+ page.read_text(encoding="utf-8", errors="replace"),
1553
+ )
1554
+ except OSError:
1555
+ continue
1556
+ slug = page.stem
1557
+ data = dict(fm) if isinstance(fm, dict) else {}
1558
+ display_name = str(data.get("name") or data.get("title") or slug).strip() or slug
1559
+ data.update({
1560
+ "label": slug,
1561
+ "display_name": display_name,
1562
+ "type": "harness",
1563
+ "source": data.get("source") or "wiki",
1564
+ })
1565
+ graph.add_node(f"harness:{slug}", **data)
1566
+ return graph
1567
+
1568
+
1569
+ def _empty_harness_graph() -> Any:
1570
+ class _EmptyHarnessGraph:
1571
+ def number_of_nodes(self) -> int:
1572
+ return 0
1573
+
1574
+ return _EmptyHarnessGraph()
1575
+
1576
+
1577
  def _load_recommendation_graph() -> Any:
1578
  """Load the ctx knowledge graph for harness onboarding."""
1579
  from ctx.core.graph.resolve_graph import load_graph # noqa: PLC0415
src/ctx_monitor.py CHANGED
@@ -347,6 +347,9 @@ def _search_wiki_entities(
347
  *,
348
  limit: int = 80,
349
  ) -> list[dict[str, Any]]:
 
 
 
350
  return dashboard_entities.search_wiki_entities(
351
  query,
352
  entity_type,
@@ -355,6 +358,83 @@ def _search_wiki_entities(
355
  )
356
 
357
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
358
  def _queue_entity_refresh(
359
  *,
360
  entity_type: str,
@@ -888,7 +968,7 @@ def _render_entity_subgraph_svg(
888
  " document.getElementById('entity-subgraph-zoom-in')?.addEventListener('click', () => { zoom = Math.min(2.5, zoom * 1.18); draw(); });\n"
889
  " document.getElementById('entity-subgraph-zoom-out')?.addEventListener('click', () => { zoom = Math.max(0.35, zoom / 1.18); draw(); });\n"
890
  " let dragging = false, lastX = 0, lastY = 0;\n"
891
- " svg.addEventListener('pointerdown', ev => { dragging = true; lastX = ev.clientX; lastY = ev.clientY; svg.setPointerCapture(ev.pointerId); });\n"
892
  " svg.addEventListener('pointerup', ev => { dragging = false; try { svg.releasePointerCapture(ev.pointerId); } catch (_) {} });\n"
893
  " svg.addEventListener('pointermove', ev => { if (!dragging) return; yaw += (ev.clientX - lastX) * 0.01; pitch += (ev.clientY - lastY) * 0.01; pitch = Math.max(-1.35, Math.min(1.35, pitch)); lastX = ev.clientX; lastY = ev.clientY; draw(); });\n"
894
  " svg.addEventListener('wheel', ev => { ev.preventDefault(); zoom = Math.max(0.35, Math.min(2.5, zoom * (ev.deltaY < 0 ? 1.08 : 0.92))); draw(); }, {passive:false});\n"
@@ -2051,6 +2131,12 @@ def _layout(title: str, body: str) -> str:
2051
  "<meta name='viewport' content='width=device-width, initial-scale=1'>"
2052
  f"<title>{html.escape(title)} — ctx monitor</title>"
2053
  f"<style>{_CSS}</style></head><body>"
 
 
 
 
 
 
2054
  "<div class='nav' id='dashboard-nav' "
2055
  "data-nav-storage-key='ctx-monitor-nav-order' "
2056
  f"data-nav-default-keys='{nav_default_keys}' "
@@ -2060,8 +2146,9 @@ def _layout(title: str, body: str) -> str:
2060
  "title='Reset dashboard tab order'>reset</button>"
2061
  "</div>"
2062
  + _monitor_inline_script("monitor-nav.js")
 
2063
  + body
2064
- + "</body></html>"
2065
  )
2066
 
2067
 
@@ -2155,6 +2242,18 @@ def _unit_score(value: Any) -> float | None:
2155
  return max(0.0, min(1.0, score))
2156
 
2157
 
 
 
 
 
 
 
 
 
 
 
 
 
2158
  def _format_count(value: Any) -> str:
2159
  try:
2160
  return f"{int(value):,}"
@@ -2785,8 +2884,8 @@ def _graph_neighborhood_from_index(
2785
  "degree": degree,
2786
  "tags": tags[:6],
2787
  "description": row["description"] or "",
2788
- "quality_score": row["quality_score"],
2789
- "usage_score": row["usage_score"],
2790
  "filter_tokens": [
2791
  node_id,
2792
  row["label"],
@@ -2964,8 +3063,8 @@ def _graph_neighborhood(
2964
  "degree": degree,
2965
  "tags": tags[:6],
2966
  "description": data.get("description", ""),
2967
- "quality_score": data.get("quality_score"),
2968
- "usage_score": data.get("usage_score"),
2969
  "filter_tokens": [nid, label, node_slug, _display_slug(node_slug), *tags],
2970
  **size_data,
2971
  },
@@ -3760,12 +3859,17 @@ def _render_config() -> str:
3760
  required = bool(spec.get("required"))
3761
  req_html = " <span class='pill grade-A'>Required</span>" if required else ""
3762
  help_text = html.escape(str(spec.get("help", "")))
 
 
 
 
 
3763
  default_html = html.escape(json.dumps(default) if not isinstance(default, str) else default)
3764
  example_value = spec.get("example")
3765
  example_html = html.escape(json.dumps(example_value) if not isinstance(example_value, str) else str(example_value))
3766
  common_attrs = (
3767
  f"name='{html.escape(path)}' data-config-path='{html.escape(path)}' "
3768
- f"data-original-value='{html.escape(str(value))}' "
3769
  f"data-default='{default_html}' {'required' if required else ''}"
3770
  )
3771
  if spec.get("type") == "choice":
@@ -3864,6 +3968,19 @@ def _render_config() -> str:
3864
  return _layout("Config", body)
3865
 
3866
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3867
  def _render_graph(focus: str | None = None, focus_type: str | None = None) -> str:
3868
  """Interactive graph view backed by a dependency-free SVG renderer."""
3869
  focus_slug = focus or ""
@@ -3882,6 +3999,7 @@ def _render_graph(focus: str | None = None, focus_type: str | None = None) -> st
3882
  initial_slug = _DEFAULT_GRAPH_FOCUS_SLUG
3883
  focus_js = _json_for_script(initial_slug)
3884
  focus_type_js = _json_for_script(initial_type)
 
3885
  seed_html = ""
3886
  if seeds:
3887
  chips = "".join(
@@ -3924,6 +4042,8 @@ def _render_graph(focus: str | None = None, focus_type: str | None = None) -> st
3924
  f"value='{html.escape(initial_slug)}' "
3925
  "style='width:100%; margin-top:0.4rem; padding:0.35rem 0.5rem; "
3926
  "border:1px solid #ccc; border-radius:4px;'>"
 
 
3927
  "<select id='focus-type' "
3928
  "style='width:100%; margin-top:0.4rem; padding:0.35rem 0.5rem; "
3929
  "border:1px solid #ccc; border-radius:4px;'>"
@@ -3949,6 +4069,26 @@ def _render_graph(focus: str | None = None, focus_type: str | None = None) -> st
3949
  "<span><input type='checkbox' class='graph-type-filter' value='harness' checked> harness</span>"
3950
  "<span class='muted' id='graph-count-harness' style='font-size:0.78rem;'>-</span></label>"
3951
  "</div>"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3952
  "<div class='card'><strong>Tag filter</strong>"
3953
  "<input type='text' id='tag-filter' "
3954
  "placeholder='shared_tag or slug_token' "
@@ -3967,13 +4107,20 @@ def _render_graph(focus: str | None = None, focus_type: str | None = None) -> st
3967
  "</p></div>"
3968
  "</aside>"
3969
  # Right: graph list panel
3970
- "<div id='cy' style='width:100%; height:75vh; border:1px solid #ddd; "
3971
- "border-radius:6px; background:#fafafa;'></div>"
3972
  "</div>"
3973
  "<script>\n"
3974
  f"const initial = {focus_js};\n"
3975
  f"const initialType = {focus_type_js};\n"
3976
  "const cyMount = document.getElementById('cy');\n"
 
 
 
 
 
 
 
 
3977
  "function nodeColor(type) {\n"
3978
  " if (type === 'agent') return '#f59e0b';\n"
3979
  " if (type === 'mcp-server') return '#ef4444';\n"
@@ -3981,11 +4128,21 @@ def _render_graph(focus: str | None = None, focus_type: str | None = None) -> st
3981
  " return '#6366f1';\n"
3982
  "}\n"
3983
  "function escapeHtml(s) { return String(s).replace(/[&<>\"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[ch])); }\n"
 
3984
  "function fmtCount(n) { return Number(n || 0).toLocaleString(); }\n"
3985
  "function rawNodeSlug(id) { return String(id || '').replace(/^(skill|agent|mcp-server|harness):/, ''); }\n"
3986
  "function displaySlug(slug) { return String(slug || '').replace(/^skills-sh-/, ''); }\n"
3987
  "function nodeSlug(id) { return displaySlug(rawNodeSlug(id)); }\n"
3988
  "function nodeDomId(id) { return 'graph-node-' + String(id || '').replace(/[^a-zA-Z0-9_-]/g, '_'); }\n"
 
 
 
 
 
 
 
 
 
3989
  "function wikiHref(data) {\n"
3990
  " const nodeType = data.type || '';\n"
3991
  " const suffix = nodeType ? '?type=' + encodeURIComponent(nodeType) : '';\n"
@@ -4005,7 +4162,7 @@ def _render_graph(focus: str | None = None, focus_type: str | None = None) -> st
4005
  " + 'data-tags=\"' + escapeHtml(tags.toLowerCase()) + '\" '\n"
4006
  " + 'href=\"' + escapeHtml(wikiHref(d)) + '\" '\n"
4007
  " + 'style=\"display:flex; justify-content:space-between; gap:0.75rem; padding:0.45rem 0.6rem; border-bottom:1px solid #e5e7eb; color:inherit; text-decoration:none;\">'\n"
4008
- " + '<code>' + escapeHtml(d.label || nodeSlug(d.id)) + '</code>'\n"
4009
  " + '<span class=\"pill entity-type-' + escapeHtml(typeKey) + '\">' + escapeHtml(d.type || 'entity') + '</span></a>';\n"
4010
  " }).join('');\n"
4011
  " cyMount.innerHTML = '<div data-testid=\"graph-fallback\" style=\"padding:0.75rem; height:100%; overflow:auto;\">'\n"
@@ -4014,36 +4171,258 @@ def _render_graph(focus: str | None = None, focus_type: str | None = None) -> st
4014
  "}\n"
4015
  "function nodeDetail(d) {\n"
4016
  " const tags = Array.from(d.tags || []).join(', ') || 'none';\n"
4017
- " const quality = d.quality_score == null ? 'unknown' : Number(d.quality_score).toFixed(3);\n"
4018
- " const usage = d.usage_score == null ? 'unknown' : Number(d.usage_score).toFixed(3);\n"
4019
  " const size = d.node_size == null ? 'auto' : Number(d.node_size).toFixed(1);\n"
4020
  " const sizeReason = d.size_reason ? ' · size: ' + size + ' (' + d.size_reason + ')' : ' · size: ' + size;\n"
4021
  " const desc = d.description ? ' · ' + d.description : '';\n"
4022
- " return (d.label || nodeSlug(d.id)) + ' · ' + (d.type || 'entity') + desc + ' · tags: ' + tags + ' · quality: ' + quality + ' · usage: ' + usage + sizeReason;\n"
4023
  "}\n"
4024
  "function nodeRadius(d, isCenter, scale) {\n"
4025
  " const base = Math.max(isCenter ? 14 : 8, Math.min(24, Number(d.node_size || (isCenter ? 16 : 11))));\n"
4026
  " const perspective = Math.max(0.8, Math.min(1.2, Number(scale || 1)));\n"
4027
  " return Math.max(8, Math.min(28, base * perspective));\n"
4028
  "}\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4029
  "function edgeDetail(d) {\n"
4030
  " const tags = Array.from(d.shared_tags || []).join(', ') || 'none';\n"
4031
- " const reasons = Array.from(d.reasons || []).join(', ') || 'graph score';\n"
4032
- " const weight = Number(d.weight || 0).toFixed(3);\n"
4033
- " return nodeSlug(d.source) + ' ↔ ' + nodeSlug(d.target) + ' · weight ' + weight + ' · shared: ' + tags + ' · reasons: ' + reasons;\n"
4034
  "}\n"
4035
  "function graphExplanation(g) {\n"
4036
  " const e = g.explanations || {};\n"
4037
  " return [e.focus, e.source, e.search, e.layout, e.edges].filter(Boolean).join(' ');\n"
4038
  "}\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4039
  "function renderGraph3d(g) {\n"
4040
  " const nodes = g.nodes || [];\n"
4041
  " const edges = g.edges || [];\n"
4042
  " if (!nodes.length) { renderFallback(g); return; }\n"
 
 
4043
  " const width = Math.max(640, Math.floor(cyMount.clientWidth || 800));\n"
4044
  " const height = Math.max(420, Math.floor(cyMount.clientHeight || 520));\n"
4045
  " const center = nodes.find(n => (n.data || {}).depth === 0) || nodes[0];\n"
4046
  " const centerId = (center.data || {}).id;\n"
 
4047
  " const points = new Map([[centerId, {x: 0, y: 0, z: 0}]]);\n"
4048
  " const others = nodes.filter(n => (n.data || {}).id !== centerId);\n"
4049
  " others.forEach((n, idx) => {\n"
@@ -4055,23 +4434,74 @@ def _render_graph(focus: str | None = None, focus_type: str | None = None) -> st
4055
  " const radius = 180 + depth * 90;\n"
4056
  " points.set(d.id, {x: radius * Math.cos(theta) * Math.sin(phi), y: radius * Math.sin(theta) * Math.sin(phi), z: radius * Math.cos(phi)});\n"
4057
  " });\n"
4058
- " renderFallback(g);\n"
4059
- " const list = cyMount.querySelector('[data-testid=\"graph-fallback\"]');\n"
4060
- " const heading = list ? list.querySelector('.muted') : null;\n"
4061
- " if (heading) heading.remove();\n"
4062
- " const rows = list ? list.innerHTML : '';\n"
4063
- " cyMount.innerHTML = '<div data-testid=\"graph-renderer\" style=\"height:100%; display:grid; grid-template-rows:auto minmax(0,1fr) auto 30%;\">'\n"
4064
- " + '<div style=\"display:flex; align-items:center; gap:0.5rem; padding:0.45rem 0.6rem; border-bottom:1px solid #e5e7eb; background:#fff;\">'\n"
4065
- " + '<button id=\"graph-zoom-in\" type=\"button\">zoom in</button><button id=\"graph-zoom-out\" type=\"button\">zoom out</button>'\n"
4066
- " + '<span class=\"muted\">drag to rotate · wheel to zoom · hover nodes or edges</span></div>'\n"
4067
- " + '<svg data-testid=\"graph-3d\" viewBox=\"0 0 ' + width + ' ' + height + '\" style=\"display:block; width:100%; height:100%; min-height:0; background:#f8fafc; touch-action:none;\"></svg>'\n"
4068
- " + '<div style=\"display:grid; grid-template-columns:1fr 1fr; gap:0.5rem; padding:0.45rem 0.6rem; border-top:1px solid #e5e7eb; background:#fff;\">'\n"
4069
- " + '<div data-testid=\"graph-node-detail\" class=\"muted\">Hover a node for entity highlights.</div>'\n"
4070
- " + '<div data-testid=\"graph-edge-detail\" class=\"muted\">Hover an edge for relationship signals.</div></div>'\n"
4071
- " + '<div data-testid=\"graph-list\" style=\"overflow:auto; border-top:1px solid #e5e7eb;\">' + rows + '</div></div>';\n"
4072
  " const svg = cyMount.querySelector('[data-testid=\"graph-3d\"]');\n"
4073
- " const nodeDetailBox = cyMount.querySelector('[data-testid=\"graph-node-detail\"]');\n"
 
4074
  " const edgeDetailBox = cyMount.querySelector('[data-testid=\"graph-edge-detail\"]');\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4075
  " let yaw = -0.4;\n"
4076
  " let pitch = 0.55;\n"
4077
  " let zoom = 1;\n"
@@ -4085,9 +4515,126 @@ def _render_graph(focus: str | None = None, focus_type: str | None = None) -> st
4085
  " const scale = zoom * 600 / (720 + z2);\n"
4086
  " return {x: width / 2 + x1 * scale, y: height / 2 + y1 * scale, z: z2, scale};\n"
4087
  " }\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4088
  " function attach3dHoverHandlers() {\n"
4089
- " svg.querySelectorAll('[data-node-detail]').forEach(n => n.addEventListener('mouseenter', () => { nodeDetailBox.textContent = n.dataset.nodeDetail || ''; }));\n"
4090
- " svg.querySelectorAll('[data-edge-detail]').forEach(e => e.addEventListener('mouseenter', () => { edgeDetailBox.textContent = e.dataset.edgeDetail || ''; }));\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4091
  " }\n"
4092
  " function drawGraph3d() {\n"
4093
  " const projected = new Map();\n"
@@ -4097,8 +4644,8 @@ def _render_graph(focus: str | None = None, focus_type: str | None = None) -> st
4097
  " const s = projected.get(d.source);\n"
4098
  " const t = projected.get(d.target);\n"
4099
  " if (!s || !t) return '';\n"
4100
- " const w = Math.max(1, Math.min(4, 1 + Math.sqrt(Math.max(0, Number(d.weight || 1)))));\n"
4101
- " return '<line data-testid=\"graph-svg-edge\" data-3d-edge-source=\"' + escapeHtml(d.source || '') + '\" data-3d-edge-target=\"' + escapeHtml(d.target || '') + '\" data-svg-edge-source=\"' + escapeHtml(d.source || '') + '\" data-svg-edge-target=\"' + escapeHtml(d.target || '') + '\" x1=\"' + s.x.toFixed(1) + '\" y1=\"' + s.y.toFixed(1) + '\" x2=\"' + t.x.toFixed(1) + '\" y2=\"' + t.y.toFixed(1) + '\" stroke=\"#64748b\" stroke-opacity=\"' + (0.35 + Math.min(0.4, Number(d.weight || 0) / 3)).toFixed(2) + '\" stroke-width=\"' + w.toFixed(2) + '\" />';\n"
4102
  " }).join('');\n"
4103
  " const nodeHtml = nodes.slice().sort((a, b) => (projected.get((a.data || {}).id)?.z || 0) - (projected.get((b.data || {}).id)?.z || 0)).map(n => {\n"
4104
  " const d = n.data || {};\n"
@@ -4107,7 +4654,7 @@ def _render_graph(focus: str | None = None, focus_type: str | None = None) -> st
4107
  " const label = d.label || nodeSlug(d.id);\n"
4108
  " const isCenter = d.id === centerId;\n"
4109
  " const r = nodeRadius(d, isCenter, p.scale);\n"
4110
- " return '<a href=\"' + escapeHtml(wikiHref(d)) + '\"><g data-testid=\"graph-3d-node\" id=\"' + escapeHtml(nodeDomId(d.id)) + '\" data-3d-node-id=\"' + escapeHtml(d.id || '') + '\" data-node-detail=\"' + escapeHtml(nodeDetail(d)) + '\" data-type=\"' + escapeHtml(d.type || '') + '\" data-depth=\"' + escapeHtml(d.depth || 0) + '\" data-tags=\"' + escapeHtml(tags.toLowerCase()) + '\"><title>' + escapeHtml(nodeDetail(d)) + '</title><circle data-testid=\"graph-svg-node\" cx=\"' + p.x.toFixed(1) + '\" cy=\"' + p.y.toFixed(1) + '\" r=\"' + r + '\" fill=\"' + nodeColor(d.type) + '\" stroke=\"#fff\" stroke-width=\"2\" /><text x=\"' + p.x.toFixed(1) + '\" y=\"' + (p.y + r + 14).toFixed(1) + '\" text-anchor=\"middle\" font-size=\"11\" fill=\"#111827\" style=\"pointer-events:none;\">' + escapeHtml(label).slice(0, 28) + '</text></g></a>';\n"
4111
  " }).join('');\n"
4112
  " const edgeHitHtml = edges.map(e => {\n"
4113
  " const d = e.data || {};\n"
@@ -4116,20 +4663,25 @@ def _render_graph(focus: str | None = None, focus_type: str | None = None) -> st
4116
  " if (!s || !t) return '';\n"
4117
  " const hx1 = s.x + (t.x - s.x) * 0.18, hy1 = s.y + (t.y - s.y) * 0.18;\n"
4118
  " const hx2 = s.x + (t.x - s.x) * 0.82, hy2 = s.y + (t.y - s.y) * 0.82;\n"
4119
- " return '<line data-testid=\"graph-3d-edge\" data-3d-edge-source=\"' + escapeHtml(d.source || '') + '\" data-3d-edge-target=\"' + escapeHtml(d.target || '') + '\" data-svg-edge-source=\"' + escapeHtml(d.source || '') + '\" data-svg-edge-target=\"' + escapeHtml(d.target || '') + '\" data-edge-detail=\"' + escapeHtml(edgeDetail(d)) + '\" x1=\"' + hx1.toFixed(1) + '\" y1=\"' + hy1.toFixed(1) + '\" x2=\"' + hx2.toFixed(1) + '\" y2=\"' + hy2.toFixed(1) + '\" stroke=\"transparent\" stroke-width=\"12\" style=\"pointer-events:stroke;\"><title>' + escapeHtml(edgeDetail(d)) + '</title></line>';\n"
4120
  " }).join('');\n"
4121
- " svg.innerHTML = '<rect width=\"100%\" height=\"100%\" fill=\"#f8fafc\" />' + edgeHtml + nodeHtml + edgeHitHtml;\n"
4122
  " attach3dHoverHandlers();\n"
4123
  " applyFilters();\n"
 
4124
  " }\n"
4125
  " document.getElementById('graph-zoom-in').addEventListener('click', () => { zoom = Math.min(2.5, zoom * 1.18); drawGraph3d(); });\n"
4126
  " document.getElementById('graph-zoom-out').addEventListener('click', () => { zoom = Math.max(0.35, zoom / 1.18); drawGraph3d(); });\n"
4127
  " let dragging = false, lastX = 0, lastY = 0;\n"
4128
- " svg.addEventListener('pointerdown', ev => { dragging = true; lastX = ev.clientX; lastY = ev.clientY; svg.setPointerCapture(ev.pointerId); });\n"
 
 
 
4129
  " svg.addEventListener('pointerup', ev => { dragging = false; try { svg.releasePointerCapture(ev.pointerId); } catch (_) {} });\n"
4130
  " svg.addEventListener('pointermove', ev => { if (!dragging) return; yaw += (ev.clientX - lastX) * 0.01; pitch += (ev.clientY - lastY) * 0.01; pitch = Math.max(-1.35, Math.min(1.35, pitch)); lastX = ev.clientX; lastY = ev.clientY; drawGraph3d(); });\n"
4131
  " svg.addEventListener('wheel', ev => { ev.preventDefault(); zoom = Math.max(0.35, Math.min(2.5, zoom * (ev.deltaY < 0 ? 1.08 : 0.92))); drawGraph3d(); }, {passive:false});\n"
4132
  " drawGraph3d();\n"
 
4133
  "}\n"
4134
  "cyMount.innerHTML = '<div data-testid=\"graph-empty\" class=\"muted\" style=\"padding:0.75rem;\">Enter a slug to render the graph.</div>';\n"
4135
  # ── Client-side filtering (type + tag substring) ─────────────
@@ -4139,16 +4691,32 @@ def _render_graph(focus: str | None = None, focus_type: str | None = None) -> st
4139
  " .filter(cb => cb.checked).map(cb => cb.value));\n"
4140
  " const tagQ = (document.getElementById('tag-filter').value || '')\n"
4141
  " .trim().toLowerCase();\n"
 
 
 
4142
  " const counts = {skill: 0, agent: 0, 'mcp-server': 0, harness: 0};\n"
4143
  " let visible = 0;\n"
4144
  " const hiddenIds = new Set();\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4145
  " document.querySelectorAll('[data-testid=\"graph-fallback-node\"]').forEach(n => {\n"
4146
  " const t = n.dataset.type;\n"
4147
- " const isFocus = n.dataset.depth === '0';\n"
4148
- " const tags = (n.dataset.tags || '').split(/\\s+/).map(x => x.toLowerCase());\n"
4149
- " const typeOk = isFocus || allowedTypes.has(t);\n"
4150
- " const tagOk = isFocus || !tagQ || tags.some(tag => tag.includes(tagQ));\n"
4151
- " const hidden = !(typeOk && tagOk);\n"
4152
  " n.style.display = hidden ? 'none' : 'flex';\n"
4153
  " if (hidden) hiddenIds.add(n.dataset.nodeId || '');\n"
4154
  " if (!hidden) {\n"
@@ -4157,13 +4725,22 @@ def _render_graph(focus: str | None = None, focus_type: str | None = None) -> st
4157
  " }\n"
4158
  " });\n"
4159
  " document.querySelectorAll('[data-3d-node-id]').forEach(n => {\n"
4160
- " n.style.display = hiddenIds.has(n.dataset['3dNodeId'] || '') ? 'none' : '';\n"
 
 
 
 
 
 
 
 
4161
  " });\n"
4162
  " const edgeEls = new Set([...document.querySelectorAll('[data-3d-edge-source]'), ...document.querySelectorAll('[data-svg-edge-source]')]);\n"
4163
  " edgeEls.forEach(e => {\n"
4164
- " const source = e.dataset['3dEdgeSource'] || e.dataset.svgEdgeSource || '';\n"
4165
- " const target = e.dataset['3dEdgeTarget'] || e.dataset.svgEdgeTarget || '';\n"
4166
- " const hidden = hiddenIds.has(source) || hiddenIds.has(target);\n"
 
4167
  " e.style.display = hidden ? 'none' : '';\n"
4168
  " });\n"
4169
  " document.getElementById('graph-count-skill').textContent = fmtCount(counts.skill);\n"
@@ -4172,24 +4749,88 @@ def _render_graph(focus: str | None = None, focus_type: str | None = None) -> st
4172
  " document.getElementById('graph-count-harness').textContent = fmtCount(counts.harness);\n"
4173
  " document.getElementById('graph-match-count').textContent = fmtCount(visible) + ' visible';\n"
4174
  "}\n"
4175
- "async function load(slug, entityType = '') {\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4176
  " if (!slug) return;\n"
 
 
4177
  " document.getElementById('msg').textContent = 'loading…';\n"
4178
  " const suffix = entityType ? '?type=' + encodeURIComponent(entityType) : '';\n"
4179
  " const r = await fetch('/api/graph/' + encodeURIComponent(slug) + '.json' + suffix);\n"
4180
  " if (!r.ok) { document.getElementById('msg').textContent = 'not found'; return; }\n"
4181
  " const g = await r.json();\n"
4182
  " if (!g.center) { document.getElementById('msg').textContent = 'slug not in graph'; return; }\n"
 
 
 
 
 
 
 
4183
  " try { renderGraph3d(g); } catch (err) { renderFallback(g); }\n"
4184
  " const resolved = g.resolved && g.resolved.query !== g.resolved.slug ? ' · showing ' + g.resolved.slug + ' for ' + g.resolved.query : '';\n"
4185
  " document.getElementById('msg').textContent = fmtCount(g.nodes.length) + ' nodes · ' + fmtCount(g.edges.length) + ' edges' + resolved;\n"
4186
  " document.getElementById('graph-explanation').textContent = graphExplanation(g);\n"
4187
  " applyFilters();\n"
4188
  "}\n"
4189
- "function selectedFocusType() { return document.getElementById('focus-type').value || ''; }\n"
4190
- "document.getElementById('go').addEventListener('click', () => load(document.getElementById('focus').value.trim(), selectedFocusType()));\n"
4191
- "document.getElementById('focus').addEventListener('keydown', (ev) => { if (ev.key === 'Enter') load(ev.target.value.trim(), selectedFocusType()); });\n"
 
 
4192
  "document.querySelectorAll('.graph-type-filter').forEach(cb => cb.addEventListener('change', applyFilters));\n"
 
 
4193
  "document.getElementById('tag-filter').addEventListener('input', applyFilters);\n"
4194
  "if (initial) load(initial, initialType);\n"
4195
  "</script>"
@@ -5078,8 +5719,8 @@ def _render_harness_wizard() -> str:
5078
  """Manual harness interview for users who bring their own LLM."""
5079
  harnesses = _harness_wizard_entries()
5080
  provider_options = (
5081
- "openai", "anthropic", "google", "openrouter", "ollama",
5082
- "lm-studio", "local", "other",
5083
  )
5084
  provider_html = "".join(
5085
  f"<option value='{html.escape(provider)}'>{html.escape(provider)}</option>"
 
347
  *,
348
  limit: int = 80,
349
  ) -> list[dict[str, Any]]:
350
+ indexed = _search_wiki_entities_from_index(query, entity_type, limit=limit)
351
+ if indexed is not None:
352
+ return indexed
353
  return dashboard_entities.search_wiki_entities(
354
  query,
355
  entity_type,
 
358
  )
359
 
360
 
361
+ def _search_wiki_entities_from_index(
362
+ query: str = "",
363
+ entity_type: str | None = None,
364
+ *,
365
+ limit: int = 80,
366
+ ) -> list[dict[str, Any]] | None:
367
+ terms = [term for term in re.split(r"\s+", query.lower().strip()) if term]
368
+ if not terms:
369
+ return None
370
+ normalized = _normalize_dashboard_entity_type(entity_type) if entity_type else None
371
+ if entity_type is not None and normalized is None:
372
+ raise ValueError(f"unsupported entity_type: {entity_type!r}")
373
+ index_path = _dashboard_graph_index_path()
374
+ if not index_path.is_file() or not _dashboard_index_matches_manifest(index_path):
375
+ return None
376
+ try:
377
+ conn = sqlite3.connect(f"file:{index_path.as_posix()}?mode=ro", uri=True)
378
+ except sqlite3.Error:
379
+ return None
380
+ try:
381
+ where: list[str] = []
382
+ params: list[object] = []
383
+ if normalized is not None:
384
+ where.append("type = ?")
385
+ params.append(normalized)
386
+ for term in terms:
387
+ where.append(
388
+ "lower(id || ' ' || coalesce(label,'') || ' ' || "
389
+ "coalesce(type,'') || ' ' || coalesce(tags,'') || ' ' || "
390
+ "coalesce(description,'')) LIKE ?"
391
+ )
392
+ params.append(f"%{term}%")
393
+ sql = (
394
+ "SELECT id,label,type,tags,description,quality_score,usage_score,degree "
395
+ f"FROM nodes WHERE {' AND '.join(where)} "
396
+ "ORDER BY CASE "
397
+ "WHEN lower(label) = ? THEN 0 "
398
+ "WHEN lower(id) = ? THEN 1 "
399
+ "WHEN lower(label) LIKE ? THEN 2 "
400
+ "WHEN lower(id) LIKE ? THEN 3 "
401
+ "ELSE 4 END, degree DESC, label COLLATE NOCASE "
402
+ "LIMIT ?"
403
+ )
404
+ first = terms[0]
405
+ params.extend([first, first, f"{first}%", f"%:{first}%", max(1, limit)])
406
+ rows = conn.execute(sql, params).fetchall()
407
+ except (sqlite3.Error, TypeError):
408
+ return None
409
+ finally:
410
+ conn.close()
411
+
412
+ results: list[dict[str, Any]] = []
413
+ for node_id, label, row_type, raw_tags, description, quality, usage, degree in rows:
414
+ current_type = _normalize_dashboard_entity_type(row_type) or _graph_type_from_node_id(str(node_id))
415
+ slug = _graph_slug_from_node_id(str(node_id))
416
+ try:
417
+ tags = json.loads(raw_tags or "[]")
418
+ except json.JSONDecodeError:
419
+ tags = []
420
+ if not isinstance(tags, list):
421
+ tags = []
422
+ results.append({
423
+ "slug": slug,
424
+ "display_slug": _display_slug(slug),
425
+ "type": current_type,
426
+ "title": _display_label(label, fallback_slug=slug),
427
+ "description": str(description or ""),
428
+ "tags": [str(tag) for tag in tags[:12]],
429
+ "path": "",
430
+ "href": _entity_wiki_href(slug, current_type),
431
+ "quality_score": quality,
432
+ "usage_score": usage,
433
+ "degree": int(degree or 0),
434
+ })
435
+ return results
436
+
437
+
438
  def _queue_entity_refresh(
439
  *,
440
  entity_type: str,
 
968
  " document.getElementById('entity-subgraph-zoom-in')?.addEventListener('click', () => { zoom = Math.min(2.5, zoom * 1.18); draw(); });\n"
969
  " document.getElementById('entity-subgraph-zoom-out')?.addEventListener('click', () => { zoom = Math.max(0.35, zoom / 1.18); draw(); });\n"
970
  " let dragging = false, lastX = 0, lastY = 0;\n"
971
+ " svg.addEventListener('pointerdown', ev => { if (ev.target.closest('[data-3d-node-id]') || ev.target.closest('[data-edge-detail]')) return; dragging = true; lastX = ev.clientX; lastY = ev.clientY; svg.setPointerCapture(ev.pointerId); });\n"
972
  " svg.addEventListener('pointerup', ev => { dragging = false; try { svg.releasePointerCapture(ev.pointerId); } catch (_) {} });\n"
973
  " svg.addEventListener('pointermove', ev => { if (!dragging) return; yaw += (ev.clientX - lastX) * 0.01; pitch += (ev.clientY - lastY) * 0.01; pitch = Math.max(-1.35, Math.min(1.35, pitch)); lastX = ev.clientX; lastY = ev.clientY; draw(); });\n"
974
  " svg.addEventListener('wheel', ev => { ev.preventDefault(); zoom = Math.max(0.35, Math.min(2.5, zoom * (ev.deltaY < 0 ? 1.08 : 0.92))); draw(); }, {passive:false});\n"
 
2131
  "<meta name='viewport' content='width=device-width, initial-scale=1'>"
2132
  f"<title>{html.escape(title)} — ctx monitor</title>"
2133
  f"<style>{_CSS}</style></head><body>"
2134
+ "<div class='app-shell'>"
2135
+ "<header class='app-header'>"
2136
+ "<a class='app-brand' href='/' aria-label='ctx monitor home'>"
2137
+ "<span class='app-brand-mark'>ctx</span><span>monitor</span></a>"
2138
+ "<div class='app-header-meta'>local graph, wiki, skills, agents, MCPs, and harnesses</div>"
2139
+ "</header>"
2140
  "<div class='nav' id='dashboard-nav' "
2141
  "data-nav-storage-key='ctx-monitor-nav-order' "
2142
  f"data-nav-default-keys='{nav_default_keys}' "
 
2146
  "title='Reset dashboard tab order'>reset</button>"
2147
  "</div>"
2148
  + _monitor_inline_script("monitor-nav.js")
2149
+ + "<main class='app-main'>"
2150
  + body
2151
+ + "</main></div></body></html>"
2152
  )
2153
 
2154
 
 
2242
  return max(0.0, min(1.0, score))
2243
 
2244
 
2245
+ def _dashboard_score_payload(field: str, value: Any) -> dict[str, float | None]:
2246
+ score = _unit_score(value)
2247
+ payload: dict[str, float | None] = {field: score}
2248
+ try:
2249
+ raw = float(value)
2250
+ except (TypeError, ValueError):
2251
+ return payload
2252
+ if math.isfinite(raw) and score is not None and raw != score:
2253
+ payload[f"{field}_raw"] = raw
2254
+ return payload
2255
+
2256
+
2257
  def _format_count(value: Any) -> str:
2258
  try:
2259
  return f"{int(value):,}"
 
2884
  "degree": degree,
2885
  "tags": tags[:6],
2886
  "description": row["description"] or "",
2887
+ **_dashboard_score_payload("quality_score", row["quality_score"]),
2888
+ **_dashboard_score_payload("usage_score", row["usage_score"]),
2889
  "filter_tokens": [
2890
  node_id,
2891
  row["label"],
 
3063
  "degree": degree,
3064
  "tags": tags[:6],
3065
  "description": data.get("description", ""),
3066
+ **_dashboard_score_payload("quality_score", data.get("quality_score")),
3067
+ **_dashboard_score_payload("usage_score", data.get("usage_score")),
3068
  "filter_tokens": [nid, label, node_slug, _display_slug(node_slug), *tags],
3069
  **size_data,
3070
  },
 
3859
  required = bool(spec.get("required"))
3860
  req_html = " <span class='pill grade-A'>Required</span>" if required else ""
3861
  help_text = html.escape(str(spec.get("help", "")))
3862
+ control_value = (
3863
+ "true" if value is True
3864
+ else "false" if value is False
3865
+ else str(value)
3866
+ )
3867
  default_html = html.escape(json.dumps(default) if not isinstance(default, str) else default)
3868
  example_value = spec.get("example")
3869
  example_html = html.escape(json.dumps(example_value) if not isinstance(example_value, str) else str(example_value))
3870
  common_attrs = (
3871
  f"name='{html.escape(path)}' data-config-path='{html.escape(path)}' "
3872
+ f"data-original-value='{html.escape(control_value)}' "
3873
  f"data-default='{default_html}' {'required' if required else ''}"
3874
  )
3875
  if spec.get("type") == "choice":
 
3968
  return _layout("Config", body)
3969
 
3970
 
3971
+ def _graph_match_default_min_percent() -> int:
3972
+ """Return the dashboard's default lower match bound from graph config."""
3973
+ try:
3974
+ from ctx_config import cfg # type: ignore
3975
+
3976
+ value = float(getattr(cfg, "graph_edge_min_weight", 0.0))
3977
+ except Exception:
3978
+ value = 0.0
3979
+ if not math.isfinite(value):
3980
+ value = 0.0
3981
+ return max(0, min(100, int(round(value * 100))))
3982
+
3983
+
3984
  def _render_graph(focus: str | None = None, focus_type: str | None = None) -> str:
3985
  """Interactive graph view backed by a dependency-free SVG renderer."""
3986
  focus_slug = focus or ""
 
3999
  initial_slug = _DEFAULT_GRAPH_FOCUS_SLUG
4000
  focus_js = _json_for_script(initial_slug)
4001
  focus_type_js = _json_for_script(initial_type)
4002
+ match_default_min = _graph_match_default_min_percent()
4003
  seed_html = ""
4004
  if seeds:
4005
  chips = "".join(
 
4042
  f"value='{html.escape(initial_slug)}' "
4043
  "style='width:100%; margin-top:0.4rem; padding:0.35rem 0.5rem; "
4044
  "border:1px solid #ccc; border-radius:4px;'>"
4045
+ "<div id='graph-live-results' data-testid=\"graph-live-results\" "
4046
+ "class='graph-live-results' hidden></div>"
4047
  "<select id='focus-type' "
4048
  "style='width:100%; margin-top:0.4rem; padding:0.35rem 0.5rem; "
4049
  "border:1px solid #ccc; border-radius:4px;'>"
 
4069
  "<span><input type='checkbox' class='graph-type-filter' value='harness' checked> harness</span>"
4070
  "<span class='muted' id='graph-count-harness' style='font-size:0.78rem;'>-</span></label>"
4071
  "</div>"
4072
+ "<div class='card graph-match-card'><strong>Match</strong>"
4073
+ "<div class='graph-match-summary'>"
4074
+ "<span id='match-filter-min-value' class='pill'>"
4075
+ f"{match_default_min}%</span>"
4076
+ "<span class='muted'>-</span>"
4077
+ "<span id='match-filter-max-value' class='pill'>100%</span></div>"
4078
+ "<div id='match-histogram' data-testid=\"match-histogram\" "
4079
+ "class='graph-match-histogram' aria-label='Match distribution'></div>"
4080
+ "<div class='graph-range-wrap' data-testid=\"match-range-control\">"
4081
+ "<div class='graph-range-track'></div>"
4082
+ "<div id='match-range-fill' class='graph-range-fill'></div>"
4083
+ "<input type='range' id='match-filter-min' aria-label='minimum match' "
4084
+ "min='0' max='100' step='1' "
4085
+ f"value='{match_default_min}'>"
4086
+ "<input type='range' id='match-filter-max' aria-label='maximum match' "
4087
+ "min='0' max='100' step='1' value='100'>"
4088
+ "</div>"
4089
+ "<div class='graph-range-scale'><span>0%</span><span>100%</span></div>"
4090
+ "<p class='muted' style='font-size:0.72rem; margin:0.35rem 0 0 0;'>"
4091
+ "Shows nodes with at least one relationship inside this match range.</p></div>"
4092
  "<div class='card'><strong>Tag filter</strong>"
4093
  "<input type='text' id='tag-filter' "
4094
  "placeholder='shared_tag or slug_token' "
 
4107
  "</p></div>"
4108
  "</aside>"
4109
  # Right: graph list panel
4110
+ "<div id='cy' class='graph-stage'></div>"
 
4111
  "</div>"
4112
  "<script>\n"
4113
  f"const initial = {focus_js};\n"
4114
  f"const initialType = {focus_type_js};\n"
4115
  "const cyMount = document.getElementById('cy');\n"
4116
+ "const focus = document.getElementById('focus');\n"
4117
+ "const focusType = document.getElementById('focus-type');\n"
4118
+ "const liveSearchBox = document.getElementById('graph-live-results');\n"
4119
+ "let liveSearchTimer = null;\n"
4120
+ "let liveSearchAbort = null;\n"
4121
+ "let currentGraphQuery = null;\n"
4122
+ "let graphReturnStack = [];\n"
4123
+ "let pendingDrillKey = '';\n"
4124
  "function nodeColor(type) {\n"
4125
  " if (type === 'agent') return '#f59e0b';\n"
4126
  " if (type === 'mcp-server') return '#ef4444';\n"
 
4128
  " return '#6366f1';\n"
4129
  "}\n"
4130
  "function escapeHtml(s) { return String(s).replace(/[&<>\"']/g, ch => ({'&':'&amp;','<':'&lt;','>':'&gt;','\"':'&quot;',\"'\":'&#39;'}[ch])); }\n"
4131
+ "function dataAttr(el, name) { return el.getAttribute(name) || ''; }\n"
4132
  "function fmtCount(n) { return Number(n || 0).toLocaleString(); }\n"
4133
  "function rawNodeSlug(id) { return String(id || '').replace(/^(skill|agent|mcp-server|harness):/, ''); }\n"
4134
  "function displaySlug(slug) { return String(slug || '').replace(/^skills-sh-/, ''); }\n"
4135
  "function nodeSlug(id) { return displaySlug(rawNodeSlug(id)); }\n"
4136
  "function nodeDomId(id) { return 'graph-node-' + String(id || '').replace(/[^a-zA-Z0-9_-]/g, '_'); }\n"
4137
+ "function graphQueryKey(q) { return q ? (String(q.slug || '') + '|' + String(q.type || '')) : ''; }\n"
4138
+ "function sameGraphQuery(a, b) { return graphQueryKey(a) === graphQueryKey(b); }\n"
4139
+ "function graphQueryFromNodeData(d) { return {slug: rawNodeSlug(d.id), type: d.type || ''}; }\n"
4140
+ "function graphQueryFromPayload(g, requestedSlug, requestedType) {\n"
4141
+ " const centerId = g.center || '';\n"
4142
+ " const centerData = (g.nodes || []).map(n => n.data || {}).find(d => d.id === centerId) || {};\n"
4143
+ " const resolved = g.resolved || {};\n"
4144
+ " return {slug: resolved.slug || requestedSlug || rawNodeSlug(centerId), type: resolved.type || centerData.type || requestedType || ''};\n"
4145
+ "}\n"
4146
  "function wikiHref(data) {\n"
4147
  " const nodeType = data.type || '';\n"
4148
  " const suffix = nodeType ? '?type=' + encodeURIComponent(nodeType) : '';\n"
 
4162
  " + 'data-tags=\"' + escapeHtml(tags.toLowerCase()) + '\" '\n"
4163
  " + 'href=\"' + escapeHtml(wikiHref(d)) + '\" '\n"
4164
  " + 'style=\"display:flex; justify-content:space-between; gap:0.75rem; padding:0.45rem 0.6rem; border-bottom:1px solid #e5e7eb; color:inherit; text-decoration:none;\">'\n"
4165
+ " + '<span class=\"graph-fallback-label\">' + escapeHtml(d.label || nodeSlug(d.id)) + '</span>'\n"
4166
  " + '<span class=\"pill entity-type-' + escapeHtml(typeKey) + '\">' + escapeHtml(d.type || 'entity') + '</span></a>';\n"
4167
  " }).join('');\n"
4168
  " cyMount.innerHTML = '<div data-testid=\"graph-fallback\" style=\"padding:0.75rem; height:100%; overflow:auto;\">'\n"
 
4171
  "}\n"
4172
  "function nodeDetail(d) {\n"
4173
  " const tags = Array.from(d.tags || []).join(', ') || 'none';\n"
 
 
4174
  " const size = d.node_size == null ? 'auto' : Number(d.node_size).toFixed(1);\n"
4175
  " const sizeReason = d.size_reason ? ' · size: ' + size + ' (' + d.size_reason + ')' : ' · size: ' + size;\n"
4176
  " const desc = d.description ? ' · ' + d.description : '';\n"
4177
+ " return (d.label || nodeSlug(d.id)) + ' · ' + (d.type || 'entity') + desc + ' · tags: ' + tags + ' · quality: ' + qualityText(d.quality_score) + ' · usage: ' + usageText(d.usage_score) + sizeReason;\n"
4178
  "}\n"
4179
  "function nodeRadius(d, isCenter, scale) {\n"
4180
  " const base = Math.max(isCenter ? 14 : 8, Math.min(24, Number(d.node_size || (isCenter ? 16 : 11))));\n"
4181
  " const perspective = Math.max(0.8, Math.min(1.2, Number(scale || 1)));\n"
4182
  " return Math.max(8, Math.min(28, base * perspective));\n"
4183
  "}\n"
4184
+ "function polygonPoints(cx, cy, points) {\n"
4185
+ " return points.map(p => (cx + p[0]).toFixed(1) + ',' + (cy + p[1]).toFixed(1)).join(' ');\n"
4186
+ "}\n"
4187
+ "function nodeShapeSvg(d, p, r) {\n"
4188
+ " const fill = nodeColor(d.type);\n"
4189
+ " const common = ' data-testid=\"graph-svg-node\" data-node-shape=\"' + escapeHtml(d.type || 'skill') + '\" data-radius=\"' + r.toFixed(1) + '\" fill=\"' + fill + '\" stroke=\"#fff\" stroke-width=\"2\"';\n"
4190
+ " if (d.type === 'agent') {\n"
4191
+ " return '<polygon' + common + ' points=\"' + polygonPoints(p.x, p.y, [[0, -r], [r, 0], [0, r], [-r, 0]]) + '\" />';\n"
4192
+ " }\n"
4193
+ " if (d.type === 'mcp-server') {\n"
4194
+ " const side = (r * 1.62).toFixed(1);\n"
4195
+ " return '<rect' + common + ' x=\"' + (p.x - r * 0.81).toFixed(1) + '\" y=\"' + (p.y - r * 0.81).toFixed(1) + '\" width=\"' + side + '\" height=\"' + side + '\" rx=\"4\" />';\n"
4196
+ " }\n"
4197
+ " if (d.type === 'harness') {\n"
4198
+ " return '<polygon' + common + ' points=\"' + polygonPoints(p.x, p.y, [[0, -r], [r * 0.87, -r * 0.5], [r * 0.87, r * 0.5], [0, r], [-r * 0.87, r * 0.5], [-r * 0.87, -r * 0.5]]) + '\" />';\n"
4199
+ " }\n"
4200
+ " return '<circle' + common + ' cx=\"' + p.x.toFixed(1) + '\" cy=\"' + p.y.toFixed(1) + '\" r=\"' + r + '\" />';\n"
4201
+ "}\n"
4202
  "function edgeDetail(d) {\n"
4203
  " const tags = Array.from(d.shared_tags || []).join(', ') || 'none';\n"
4204
+ " const reasons = Array.from(d.reasons || d.edge_reasons || []).join(', ') || (edgeHasEvidence(d) ? 'graph signal' : 'none');\n"
4205
+ " const prefix = isWeakGraphOnlyEdge(d) ? 'Low-confidence auto link: ' : 'Relationship: ';\n"
4206
+ " return prefix + nodeSlug(d.source) + ' ↔ ' + nodeSlug(d.target) + ' · ' + edgeStrengthText(d) + ' · shared: ' + tags + ' · evidence: ' + reasons;\n"
4207
  "}\n"
4208
  "function graphExplanation(g) {\n"
4209
  " const e = g.explanations || {};\n"
4210
  " return [e.focus, e.source, e.search, e.layout, e.edges].filter(Boolean).join(' ');\n"
4211
  "}\n"
4212
+ "let currentGraphState = null;\n"
4213
+ "let currentGraphEdges = [];\n"
4214
+ "function numericValue(value) {\n"
4215
+ " if (value == null || value === '') return null;\n"
4216
+ " const n = Number(value);\n"
4217
+ " return Number.isFinite(n) ? n : null;\n"
4218
+ "}\n"
4219
+ "function clampedUnit(value) {\n"
4220
+ " const n = numericValue(value);\n"
4221
+ " if (n == null) return null;\n"
4222
+ " return Math.max(0, Math.min(1, n));\n"
4223
+ "}\n"
4224
+ "function percentText(value) {\n"
4225
+ " const n = clampedUnit(value);\n"
4226
+ " return n == null ? 'unknown' : Math.round(n * 100) + '%';\n"
4227
+ "}\n"
4228
+ "function qualityText(value) {\n"
4229
+ " const raw = numericValue(value);\n"
4230
+ " if (raw == null) return 'not scored yet';\n"
4231
+ " const suffix = raw < 0 || raw > 1 ? ' (raw score clamped)' : '';\n"
4232
+ " return percentText(raw) + suffix;\n"
4233
+ "}\n"
4234
+ "function usageText(value) {\n"
4235
+ " return numericValue(value) == null ? 'no telemetry yet' : percentText(value);\n"
4236
+ "}\n"
4237
+ "function scoreText(value) {\n"
4238
+ " return clampedUnit(value) == null ? 'unknown' : percentText(value);\n"
4239
+ "}\n"
4240
+ "function currentMatchWindow() {\n"
4241
+ " const minInput = document.getElementById('match-filter-min');\n"
4242
+ " const maxInput = document.getElementById('match-filter-max');\n"
4243
+ " const matchA = Math.max(0, Math.min(100, Number(minInput?.value || 0)));\n"
4244
+ " const matchB = Math.max(0, Math.min(100, Number(maxInput?.value || 100)));\n"
4245
+ " const lowPct = Math.min(matchA, matchB);\n"
4246
+ " const highPct = Math.max(matchA, matchB);\n"
4247
+ " return {lowPct, highPct, min: lowPct / 100, max: highPct / 100};\n"
4248
+ "}\n"
4249
+ "function renderMatchRange(matchWindow) {\n"
4250
+ " document.getElementById('match-filter-min-value').textContent = Math.round(matchWindow.lowPct) + '%';\n"
4251
+ " document.getElementById('match-filter-max-value').textContent = Math.round(matchWindow.highPct) + '%';\n"
4252
+ " const fill = document.getElementById('match-range-fill');\n"
4253
+ " if (fill) {\n"
4254
+ " fill.style.left = matchWindow.lowPct + '%';\n"
4255
+ " fill.style.width = Math.max(0, matchWindow.highPct - matchWindow.lowPct) + '%';\n"
4256
+ " }\n"
4257
+ "}\n"
4258
+ "function setMatchWindow(lowPct, highPct) {\n"
4259
+ " const minInput = document.getElementById('match-filter-min');\n"
4260
+ " const maxInput = document.getElementById('match-filter-max');\n"
4261
+ " if (!minInput || !maxInput) return;\n"
4262
+ " minInput.value = String(Math.max(0, Math.min(100, Number(lowPct || 0))));\n"
4263
+ " maxInput.value = String(Math.max(0, Math.min(100, Number(highPct || 100))));\n"
4264
+ " applyFilters();\n"
4265
+ "}\n"
4266
+ "function renderMatchHistogram(edges, matchWindow) {\n"
4267
+ " const box = document.getElementById('match-histogram');\n"
4268
+ " if (!box) return;\n"
4269
+ " const bins = Array(10).fill(0);\n"
4270
+ " (edges || []).forEach(edge => {\n"
4271
+ " const d = edge.data || edge || {};\n"
4272
+ " const weight = clampedUnit(d.weight);\n"
4273
+ " if (weight == null) return;\n"
4274
+ " const idx = Math.min(9, Math.max(0, Math.floor(Math.min(100, weight * 100) / 10)));\n"
4275
+ " bins[idx] += 1;\n"
4276
+ " });\n"
4277
+ " const maxBin = Math.max(1, ...bins);\n"
4278
+ " box.innerHTML = bins.map((count, idx) => {\n"
4279
+ " const start = idx * 10;\n"
4280
+ " const end = idx === 9 ? 100 : idx * 10 + 9;\n"
4281
+ " const active = end >= matchWindow.lowPct && start <= matchWindow.highPct;\n"
4282
+ " const height = Math.max(10, Math.round((count / maxBin) * 100));\n"
4283
+ " const label = start + '-' + end + '%: ' + fmtCount(count) + ' relationships';\n"
4284
+ " return '<button type=\"button\" class=\"graph-match-bar' + (active ? ' active' : '') + '\" '\n"
4285
+ " + 'data-match-bin-min=\"' + start + '\" data-match-bin-max=\"' + end + '\" '\n"
4286
+ " + 'aria-label=\"Filter match ' + label + '\" title=\"' + label + '\" '\n"
4287
+ " + 'style=\"height:' + height + '%\"></button>';\n"
4288
+ " }).join('');\n"
4289
+ " box.querySelectorAll('[data-match-bin-min]').forEach(bar => {\n"
4290
+ " const useBin = () => setMatchWindow(bar.dataset.matchBinMin, bar.dataset.matchBinMax);\n"
4291
+ " bar.addEventListener('click', useBin);\n"
4292
+ " bar.addEventListener('keydown', ev => {\n"
4293
+ " if (ev.key !== 'Enter' && ev.key !== ' ') return;\n"
4294
+ " ev.preventDefault();\n"
4295
+ " useBin();\n"
4296
+ " });\n"
4297
+ " });\n"
4298
+ "}\n"
4299
+ "function metricChip(label, value) {\n"
4300
+ " const n = numericValue(value);\n"
4301
+ " if (n == null || n <= 0) return '';\n"
4302
+ " return '<span class=\"graph-metric\"><strong>' + escapeHtml(label) + '</strong> ' + escapeHtml(scoreText(value)) + '</span>';\n"
4303
+ "}\n"
4304
+ "function edgeHasEvidence(edge) {\n"
4305
+ " const d = edge || {};\n"
4306
+ " const shared = Array.from(d.shared_tags || []);\n"
4307
+ " const reasons = Array.from(d.reasons || d.edge_reasons || []);\n"
4308
+ " return shared.length > 0 || reasons.length > 0\n"
4309
+ " || Number(d.semantic || 0) > 0 || Number(d.tag_sim || 0) > 0\n"
4310
+ " || Number(d.slug_token_sim || 0) > 0 || Number(d.source_overlap || 0) > 0;\n"
4311
+ "}\n"
4312
+ "function isGraphOnlyEdge(edge) {\n"
4313
+ " return !edgeHasEvidence(edge || {});\n"
4314
+ "}\n"
4315
+ "function isWeakGraphOnlyEdge(edge) {\n"
4316
+ " const d = edge || {};\n"
4317
+ " return Number(d.weight || 0) < 0.15 && isGraphOnlyEdge(d);\n"
4318
+ "}\n"
4319
+ "function edgeStrengthText(edge) {\n"
4320
+ " const d = edge || {};\n"
4321
+ " const weight = clampedUnit(d.weight) == null ? 'unknown' : percentText(d.weight);\n"
4322
+ " if (isGraphOnlyEdge(d)) return weight + ' graph neighbor';\n"
4323
+ " return weight + ' relation strength';\n"
4324
+ "}\n"
4325
+ "function edgeSignalHtml(edge) {\n"
4326
+ " const d = edge || {};\n"
4327
+ " const shared = Array.from(d.shared_tags || []);\n"
4328
+ " const reasons = Array.from(d.reasons || d.edge_reasons || []);\n"
4329
+ " if (isGraphOnlyEdge(d)) {\n"
4330
+ " return '<div class=\"graph-edge-signals\"><span class=\"graph-metric\"><strong>match</strong> '\n"
4331
+ " + escapeHtml(percentText(d.weight)) + '</span></div>';\n"
4332
+ " }\n"
4333
+ " const evidence = reasons.length ? reasons.join(', ') : (edgeHasEvidence(d) ? 'graph signal' : 'none');\n"
4334
+ " return '<div class=\"graph-edge-signals\">'\n"
4335
+ " + '<span class=\"graph-metric\"><strong>strength</strong> ' + escapeHtml(edgeStrengthText(d)) + '</span>'\n"
4336
+ " + metricChip('semantic', d.semantic)\n"
4337
+ " + metricChip('tag', d.tag_sim)\n"
4338
+ " + metricChip('slug', d.slug_token_sim)\n"
4339
+ " + metricChip('source', d.source_overlap)\n"
4340
+ " + '</div>'\n"
4341
+ " + '<div class=\"muted\">shared: ' + escapeHtml(shared.join(', ') || 'none')\n"
4342
+ " + ' - evidence: ' + escapeHtml(evidence) + '</div>';\n"
4343
+ "}\n"
4344
+ "function buildGraphState(nodes, edges) {\n"
4345
+ " const nodesById = new Map();\n"
4346
+ " const adjacency = new Map();\n"
4347
+ " nodes.forEach(n => {\n"
4348
+ " const d = n.data || {};\n"
4349
+ " if (!d.id) return;\n"
4350
+ " nodesById.set(d.id, d);\n"
4351
+ " adjacency.set(d.id, []);\n"
4352
+ " });\n"
4353
+ " edges.forEach(e => {\n"
4354
+ " const d = e.data || {};\n"
4355
+ " const source = nodesById.get(d.source);\n"
4356
+ " const target = nodesById.get(d.target);\n"
4357
+ " if (!source || !target) return;\n"
4358
+ " adjacency.get(d.source).push({node: target, edge: d});\n"
4359
+ " adjacency.get(d.target).push({node: source, edge: d});\n"
4360
+ " });\n"
4361
+ " adjacency.forEach(items => items.sort((a, b) => Number(b.edge.weight || 0) - Number(a.edge.weight || 0)));\n"
4362
+ " return {nodesById, adjacency};\n"
4363
+ "}\n"
4364
+ "function nodeSummaryHtml(d) {\n"
4365
+ " const tags = Array.from(d.tags || []);\n"
4366
+ " return '<div class=\"graph-selected-summary\">'\n"
4367
+ " + '<div><strong>' + escapeHtml(d.label || nodeSlug(d.id)) + '</strong> '\n"
4368
+ " + '<span class=\"pill entity-type-' + escapeHtml(d.type || 'skill') + '\">' + escapeHtml(d.type || 'entity') + '</span></div>'\n"
4369
+ " + '<a href=\"' + escapeHtml(wikiHref(d)) + '\">open wiki</a>'\n"
4370
+ " + '<div class=\"muted\">Connections: ' + fmtCount(d.degree) + ' in graph - quality: ' + escapeHtml(qualityText(d.quality_score))\n"
4371
+ " + ' - usage: ' + escapeHtml(usageText(d.usage_score)) + '</div>'\n"
4372
+ " + '<div class=\"muted\">tags: ' + escapeHtml(tags.join(', ') || 'none') + '</div>'\n"
4373
+ " + (d.description ? '<p>' + escapeHtml(d.description) + '</p>' : '')\n"
4374
+ " + '</div>';\n"
4375
+ "}\n"
4376
+ "function renderNodeTree(nodeId) {\n"
4377
+ " if (!currentGraphState) return '<div class=\"muted\">No graph loaded.</div>';\n"
4378
+ " const d = currentGraphState.nodesById.get(nodeId);\n"
4379
+ " if (!d) return '<div class=\"muted\">Node not found in this view.</div>';\n"
4380
+ " const neighbors = currentGraphState.adjacency.get(nodeId) || [];\n"
4381
+ " const visibleNeighbors = neighbors;\n"
4382
+ " const groups = new Map();\n"
4383
+ " visibleNeighbors.forEach(item => {\n"
4384
+ " const type = item.node.type || 'entity';\n"
4385
+ " if (!groups.has(type)) groups.set(type, []);\n"
4386
+ " groups.get(type).push(item);\n"
4387
+ " });\n"
4388
+ " const groupsHtml = Array.from(groups.entries()).map(([type, items]) => {\n"
4389
+ " const rows = items.map(item => {\n"
4390
+ " const n = item.node;\n"
4391
+ " const inner = (currentGraphState.adjacency.get(n.id) || [])\n"
4392
+ " .filter(rel => rel.node.id !== nodeId)\n"
4393
+ " .slice(0, 8);\n"
4394
+ " const innerHtml = inner.length ? '<details class=\"graph-inner-links\"><summary>'\n"
4395
+ " + fmtCount(inner.length) + ' in-view links</summary><ul>'\n"
4396
+ " + inner.map(rel => '<li><span class=\"graph-link-label\">' + escapeHtml(n.label || nodeSlug(n.id)) + '</span> -> <span class=\"graph-link-label\">'\n"
4397
+ " + escapeHtml(rel.node.label || nodeSlug(rel.node.id)) + '</span> '\n"
4398
+ " + '<span class=\"muted\">' + escapeHtml(scoreText(rel.edge.weight)) + '</span></li>').join('')\n"
4399
+ " + '</ul></details>' : '';\n"
4400
+ " return '<li class=\"graph-neighbor-item\">'\n"
4401
+ " + '<div class=\"graph-neighbor-row\"><span><span class=\"graph-neighbor-label\">' + escapeHtml(n.label || nodeSlug(n.id)) + '</span> '\n"
4402
+ " + '<span class=\"pill entity-type-' + escapeHtml(n.type || 'skill') + '\">' + escapeHtml(n.type || 'entity') + '</span></span>'\n"
4403
+ " + '<a href=\"' + escapeHtml(wikiHref(n)) + '\">wiki</a></div>'\n"
4404
+ " + edgeSignalHtml(item.edge)\n"
4405
+ " + innerHtml\n"
4406
+ " + '</li>';\n"
4407
+ " }).join('');\n"
4408
+ " return '<details class=\"graph-neighbor-group\" open><summary>' + escapeHtml(type) + ' (' + fmtCount(items.length) + ')</summary><ul>' + rows + '</ul></details>';\n"
4409
+ " }).join('');\n"
4410
+ " return '<div class=\"graph-neighbor-tree\">' + nodeSummaryHtml(d)\n"
4411
+ " + '<h3>Neighbors</h3>'\n"
4412
+ " + (groupsHtml || '<p class=\"muted\">No neighbors in this view.</p>')\n"
4413
+ " + '</div>';\n"
4414
+ "}\n"
4415
  "function renderGraph3d(g) {\n"
4416
  " const nodes = g.nodes || [];\n"
4417
  " const edges = g.edges || [];\n"
4418
  " if (!nodes.length) { renderFallback(g); return; }\n"
4419
+ " currentGraphState = buildGraphState(nodes, edges);\n"
4420
+ " currentGraphEdges = edges;\n"
4421
  " const width = Math.max(640, Math.floor(cyMount.clientWidth || 800));\n"
4422
  " const height = Math.max(420, Math.floor(cyMount.clientHeight || 520));\n"
4423
  " const center = nodes.find(n => (n.data || {}).depth === 0) || nodes[0];\n"
4424
  " const centerId = (center.data || {}).id;\n"
4425
+ " let selectedNodeId = centerId;\n"
4426
  " const points = new Map([[centerId, {x: 0, y: 0, z: 0}]]);\n"
4427
  " const others = nodes.filter(n => (n.data || {}).id !== centerId);\n"
4428
  " others.forEach((n, idx) => {\n"
 
4434
  " const radius = 180 + depth * 90;\n"
4435
  " points.set(d.id, {x: radius * Math.cos(theta) * Math.sin(phi), y: radius * Math.sin(theta) * Math.sin(phi), z: radius * Math.cos(phi)});\n"
4436
  " });\n"
4437
+ " cyMount.innerHTML = '<div data-testid=\"graph-renderer\" class=\"graph-renderer-shell\">'\n"
4438
+ " + '<div class=\"graph-toolbar\">'\n"
4439
+ " + '<button id=\"graph-zoom-in\" type=\"button\">zoom +</button><button id=\"graph-zoom-out\" type=\"button\">zoom -</button>'\n"
4440
+ " + '<span class=\"muted\">drag rotate - wheel zoom - click drill - double-click back</span></div>'\n"
4441
+ " + '<div class=\"graph-canvas-wrap\"><svg data-testid=\"graph-3d\" viewBox=\"0 0 ' + width + ' ' + height + '\" style=\"display:block; width:100%; height:100%; min-height:0; background:transparent; touch-action:none;\"></svg><div id=\"graph-tooltip\" class=\"graph-tooltip\" hidden></div></div>'\n"
4442
+ " + '<div data-testid=\"graph-inspector-resize\" class=\"graph-resize-handle\" role=\"separator\" tabindex=\"0\" aria-orientation=\"horizontal\" aria-label=\"Resize graph details panel\" title=\"Drag to resize details panel; double-click to reset\"></div>'\n"
4443
+ " + '<div class=\"graph-inspector-grid\">'\n"
4444
+ " + '<div data-testid=\"graph-node-detail\" class=\"graph-node-detail-panel\"><div data-testid=\"graph-edge-detail\" class=\"muted graph-edge-detail-inline\">Hover an edge for relationship signals.</div><div data-testid=\"graph-node-detail-tree\" class=\"muted\">Click a node to inspect neighbors.</div></div></div>'\n"
4445
+ " + '<div data-testid=\"graph-list\" class=\"graph-list-panel\" hidden></div></div>';\n"
4446
+ " const rendererShell = cyMount.querySelector('[data-testid=\"graph-renderer\"]');\n"
 
 
 
 
4447
  " const svg = cyMount.querySelector('[data-testid=\"graph-3d\"]');\n"
4448
+ " const resizeHandle = cyMount.querySelector('[data-testid=\"graph-inspector-resize\"]');\n"
4449
+ " const nodeDetailBox = cyMount.querySelector('[data-testid=\"graph-node-detail-tree\"]');\n"
4450
  " const edgeDetailBox = cyMount.querySelector('[data-testid=\"graph-edge-detail\"]');\n"
4451
+ " const tooltip = cyMount.querySelector('#graph-tooltip');\n"
4452
+ " const inspectorStorageKey = 'ctx-monitor-graph-inspector-height';\n"
4453
+ " function clampInspectorHeight(value) {\n"
4454
+ " const shellRect = rendererShell.getBoundingClientRect();\n"
4455
+ " const toolbar = cyMount.querySelector('.graph-toolbar');\n"
4456
+ " const toolbarHeight = toolbar ? toolbar.getBoundingClientRect().height : 42;\n"
4457
+ " const min = 140;\n"
4458
+ " const max = Math.max(min, shellRect.height - toolbarHeight - 300);\n"
4459
+ " return Math.max(min, Math.min(max, Number(value) || 0));\n"
4460
+ " }\n"
4461
+ " function setInspectorHeight(value, persist = true) {\n"
4462
+ " const heightPx = clampInspectorHeight(value);\n"
4463
+ " rendererShell.style.setProperty('--graph-inspector-height', heightPx + 'px');\n"
4464
+ " resizeHandle.setAttribute('aria-valuenow', String(Math.round(heightPx)));\n"
4465
+ " resizeHandle.setAttribute('aria-valuemin', '140');\n"
4466
+ " if (persist) {\n"
4467
+ " try { localStorage.setItem(inspectorStorageKey, String(Math.round(heightPx))); } catch (_) {}\n"
4468
+ " }\n"
4469
+ " }\n"
4470
+ " function resetInspectorHeight() {\n"
4471
+ " rendererShell.style.removeProperty('--graph-inspector-height');\n"
4472
+ " resizeHandle.removeAttribute('aria-valuenow');\n"
4473
+ " try { localStorage.removeItem(inspectorStorageKey); } catch (_) {}\n"
4474
+ " }\n"
4475
+ " try {\n"
4476
+ " const savedInspectorHeight = Number(localStorage.getItem(inspectorStorageKey));\n"
4477
+ " if (Number.isFinite(savedInspectorHeight) && savedInspectorHeight > 0) setInspectorHeight(savedInspectorHeight, false);\n"
4478
+ " } catch (_) {}\n"
4479
+ " resizeHandle.addEventListener('pointerdown', ev => {\n"
4480
+ " ev.preventDefault();\n"
4481
+ " rendererShell.classList.add('graph-resizing');\n"
4482
+ " resizeHandle.setPointerCapture(ev.pointerId);\n"
4483
+ " const onMove = moveEv => {\n"
4484
+ " const shellRect = rendererShell.getBoundingClientRect();\n"
4485
+ " setInspectorHeight(shellRect.bottom - moveEv.clientY);\n"
4486
+ " };\n"
4487
+ " const onUp = upEv => {\n"
4488
+ " rendererShell.classList.remove('graph-resizing');\n"
4489
+ " try { resizeHandle.releasePointerCapture(upEv.pointerId); } catch (_) {}\n"
4490
+ " resizeHandle.removeEventListener('pointermove', onMove);\n"
4491
+ " resizeHandle.removeEventListener('pointerup', onUp);\n"
4492
+ " resizeHandle.removeEventListener('pointercancel', onUp);\n"
4493
+ " };\n"
4494
+ " resizeHandle.addEventListener('pointermove', onMove);\n"
4495
+ " resizeHandle.addEventListener('pointerup', onUp);\n"
4496
+ " resizeHandle.addEventListener('pointercancel', onUp);\n"
4497
+ " });\n"
4498
+ " resizeHandle.addEventListener('keydown', ev => {\n"
4499
+ " const current = Number(getComputedStyle(rendererShell).getPropertyValue('--graph-inspector-height').replace('px', '')) || cyMount.querySelector('.graph-inspector-grid').getBoundingClientRect().height;\n"
4500
+ " if (ev.key === 'ArrowUp') { ev.preventDefault(); setInspectorHeight(current + 28); }\n"
4501
+ " if (ev.key === 'ArrowDown') { ev.preventDefault(); setInspectorHeight(current - 28); }\n"
4502
+ " if (ev.key === 'Home') { ev.preventDefault(); resetInspectorHeight(); }\n"
4503
+ " });\n"
4504
+ " resizeHandle.addEventListener('dblclick', resetInspectorHeight);\n"
4505
  " let yaw = -0.4;\n"
4506
  " let pitch = 0.55;\n"
4507
  " let zoom = 1;\n"
 
4515
  " const scale = zoom * 600 / (720 + z2);\n"
4516
  " return {x: width / 2 + x1 * scale, y: height / 2 + y1 * scale, z: z2, scale};\n"
4517
  " }\n"
4518
+ " function showTooltip(text, ev) {\n"
4519
+ " tooltip.textContent = text || '';\n"
4520
+ " tooltip.hidden = !text;\n"
4521
+ " const rect = cyMount.getBoundingClientRect();\n"
4522
+ " tooltip.style.left = Math.min(rect.width - 260, Math.max(8, ev.clientX - rect.left + 12)) + 'px';\n"
4523
+ " tooltip.style.top = Math.max(8, ev.clientY - rect.top + 12) + 'px';\n"
4524
+ " }\n"
4525
+ " function hideTooltip() { tooltip.hidden = true; }\n"
4526
+ " function highlightSelected() {\n"
4527
+ " svg.querySelectorAll('[data-3d-node-id]').forEach(n => {\n"
4528
+ " n.classList.toggle('graph-node-selected', dataAttr(n, 'data-3d-node-id') === selectedNodeId);\n"
4529
+ " });\n"
4530
+ " const edgeEls = new Set([...svg.querySelectorAll('[data-testid=\"graph-svg-edge\"]')]);\n"
4531
+ " edgeEls.forEach(e => {\n"
4532
+ " const source = dataAttr(e, 'data-3d-edge-source') || dataAttr(e, 'data-svg-edge-source');\n"
4533
+ " const target = dataAttr(e, 'data-3d-edge-target') || dataAttr(e, 'data-svg-edge-target');\n"
4534
+ " e.classList.toggle('graph-edge-selected', source === selectedNodeId || target === selectedNodeId);\n"
4535
+ " });\n"
4536
+ " }\n"
4537
+ " function focusCameraOnNode(nodeId) {\n"
4538
+ " const p = points.get(nodeId);\n"
4539
+ " if (!p) return;\n"
4540
+ " if (nodeId !== centerId) {\n"
4541
+ " yaw = Math.atan2(-p.z, Math.max(1, p.x));\n"
4542
+ " pitch = Math.max(-1.1, Math.min(1.1, Math.atan2(p.y, Math.max(120, Math.hypot(p.x, p.z)))));\n"
4543
+ " }\n"
4544
+ " zoom = Math.max(zoom, 1.45);\n"
4545
+ " drawGraph3d();\n"
4546
+ " }\n"
4547
+ " function selectNode(nodeId, shouldFocus = true) {\n"
4548
+ " if (!nodeId) return;\n"
4549
+ " selectedNodeId = nodeId;\n"
4550
+ " nodeDetailBox.innerHTML = renderNodeTree(nodeId);\n"
4551
+ " if (shouldFocus) focusCameraOnNode(nodeId);\n"
4552
+ " highlightSelected();\n"
4553
+ " }\n"
4554
+ " let nodeClickTimer = null;\n"
4555
+ " function selectNodeElement(node) {\n"
4556
+ " if (!node) return;\n"
4557
+ " const id = dataAttr(node, 'data-3d-node-id');\n"
4558
+ " const d = currentGraphState.nodesById.get(id) || {};\n"
4559
+ " if (d.id) drillIntoNode(d.id); else selectNode(id);\n"
4560
+ " }\n"
4561
+ " function scheduleNodeClick(node) {\n"
4562
+ " if (nodeClickTimer) clearTimeout(nodeClickTimer);\n"
4563
+ " nodeClickTimer = setTimeout(() => {\n"
4564
+ " nodeClickTimer = null;\n"
4565
+ " selectNodeElement(node);\n"
4566
+ " }, 180);\n"
4567
+ " }\n"
4568
+ " function handleNodeDoubleClick() {\n"
4569
+ " if (nodeClickTimer) {\n"
4570
+ " clearTimeout(nodeClickTimer);\n"
4571
+ " nodeClickTimer = null;\n"
4572
+ " }\n"
4573
+ " restorePreviousGraph();\n"
4574
+ " }\n"
4575
+ " function drillIntoNode(nodeId) {\n"
4576
+ " if (!nodeId || !currentGraphState) return;\n"
4577
+ " const d = currentGraphState.nodesById.get(nodeId) || {};\n"
4578
+ " if (!d.id) return;\n"
4579
+ " selectNode(d.id, true);\n"
4580
+ " const query = graphQueryFromNodeData(d);\n"
4581
+ " if (!query.slug || sameGraphQuery(currentGraphQuery, query)) return;\n"
4582
+ " const key = graphQueryKey(query);\n"
4583
+ " if (pendingDrillKey === key) return;\n"
4584
+ " pendingDrillKey = key;\n"
4585
+ " load(query.slug, query.type, {pushCurrent: true}).finally(() => {\n"
4586
+ " if (pendingDrillKey === key) pendingDrillKey = '';\n"
4587
+ " });\n"
4588
+ " }\n"
4589
+ " function restorePreviousGraph() {\n"
4590
+ " const previous = graphReturnStack.pop();\n"
4591
+ " if (!previous || !previous.slug) return;\n"
4592
+ " load(previous.slug, previous.type || '', {pushCurrent: false});\n"
4593
+ " }\n"
4594
+ " window.ctxGraphSelectNodeFromElement = (el) => {\n"
4595
+ " const node = el && el.closest ? el.closest('[data-3d-node-id]') : null;\n"
4596
+ " selectNodeElement(node || el);\n"
4597
+ " };\n"
4598
  " function attach3dHoverHandlers() {\n"
4599
+ " svg.querySelectorAll('[data-node-detail]').forEach(n => {\n"
4600
+ " n.addEventListener('mousemove', ev => showTooltip(n.dataset.nodeDetail || '', ev));\n"
4601
+ " n.addEventListener('mouseleave', hideTooltip);\n"
4602
+ " n.addEventListener('click', ev => {\n"
4603
+ " ev.preventDefault();\n"
4604
+ " ev.stopPropagation();\n"
4605
+ " if (ev.detail && ev.detail > 1) return;\n"
4606
+ " scheduleNodeClick(n);\n"
4607
+ " });\n"
4608
+ " n.addEventListener('dblclick', ev => {\n"
4609
+ " ev.preventDefault();\n"
4610
+ " ev.stopPropagation();\n"
4611
+ " handleNodeDoubleClick();\n"
4612
+ " });\n"
4613
+ " n.addEventListener('keydown', ev => {\n"
4614
+ " if (ev.key !== 'Enter' && ev.key !== ' ') return;\n"
4615
+ " ev.preventDefault();\n"
4616
+ " scheduleNodeClick(n);\n"
4617
+ " });\n"
4618
+ " });\n"
4619
+ " svg.querySelectorAll('[data-testid=\"graph-svg-node\"]').forEach(circle => {\n"
4620
+ " circle.addEventListener('click', ev => {\n"
4621
+ " ev.preventDefault();\n"
4622
+ " ev.stopPropagation();\n"
4623
+ " const node = ev.target.closest ? ev.target.closest('[data-3d-node-id]') : ev.target.parentNode;\n"
4624
+ " if (ev.detail && ev.detail > 1) return;\n"
4625
+ " scheduleNodeClick(node);\n"
4626
+ " });\n"
4627
+ " circle.addEventListener('dblclick', ev => {\n"
4628
+ " ev.preventDefault();\n"
4629
+ " ev.stopPropagation();\n"
4630
+ " handleNodeDoubleClick();\n"
4631
+ " });\n"
4632
+ " });\n"
4633
+ " svg.querySelectorAll('[data-edge-detail]').forEach(e => {\n"
4634
+ " e.addEventListener('mouseenter', () => { edgeDetailBox.textContent = e.dataset.edgeDetail || ''; });\n"
4635
+ " e.addEventListener('mousemove', ev => showTooltip(e.dataset.edgeDetail || '', ev));\n"
4636
+ " e.addEventListener('mouseleave', hideTooltip);\n"
4637
+ " });\n"
4638
  " }\n"
4639
  " function drawGraph3d() {\n"
4640
  " const projected = new Map();\n"
 
4644
  " const s = projected.get(d.source);\n"
4645
  " const t = projected.get(d.target);\n"
4646
  " if (!s || !t) return '';\n"
4647
+ " const w = Math.max(0.8, Math.min(2.2, 0.75 + Math.sqrt(Math.max(0, Number(d.weight || 0.4))) * 0.85));\n"
4648
+ " return '<line data-testid=\"graph-svg-edge\" data-3d-edge-source=\"' + escapeHtml(d.source || '') + '\" data-3d-edge-target=\"' + escapeHtml(d.target || '') + '\" data-svg-edge-source=\"' + escapeHtml(d.source || '') + '\" data-svg-edge-target=\"' + escapeHtml(d.target || '') + '\" data-edge-weight=\"' + escapeHtml(Number(d.weight || 0).toFixed(4)) + '\" x1=\"' + s.x.toFixed(1) + '\" y1=\"' + s.y.toFixed(1) + '\" x2=\"' + t.x.toFixed(1) + '\" y2=\"' + t.y.toFixed(1) + '\" stroke=\"#64748b\" stroke-opacity=\"' + (0.35 + Math.min(0.4, Number(d.weight || 0) / 3)).toFixed(2) + '\" stroke-width=\"' + w.toFixed(2) + '\" />';\n"
4649
  " }).join('');\n"
4650
  " const nodeHtml = nodes.slice().sort((a, b) => (projected.get((a.data || {}).id)?.z || 0) - (projected.get((b.data || {}).id)?.z || 0)).map(n => {\n"
4651
  " const d = n.data || {};\n"
 
4654
  " const label = d.label || nodeSlug(d.id);\n"
4655
  " const isCenter = d.id === centerId;\n"
4656
  " const r = nodeRadius(d, isCenter, p.scale);\n"
4657
+ " return '<g data-testid=\"graph-3d-node\" role=\"button\" tabindex=\"0\" id=\"' + escapeHtml(nodeDomId(d.id)) + '\" data-3d-node-id=\"' + escapeHtml(d.id || '') + '\" data-node-detail=\"' + escapeHtml(nodeDetail(d)) + '\" data-type=\"' + escapeHtml(d.type || '') + '\" data-depth=\"' + escapeHtml(d.depth || 0) + '\" data-tags=\"' + escapeHtml(tags.toLowerCase()) + '\"><title>' + escapeHtml(nodeDetail(d)) + '</title>' + nodeShapeSvg(d, p, r) + '<text x=\"' + p.x.toFixed(1) + '\" y=\"' + (p.y + r + 14).toFixed(1) + '\" text-anchor=\"middle\" font-size=\"11\" fill=\"#111827\" style=\"pointer-events:none;\">' + escapeHtml(label).slice(0, 28) + '</text></g>';\n"
4658
  " }).join('');\n"
4659
  " const edgeHitHtml = edges.map(e => {\n"
4660
  " const d = e.data || {};\n"
 
4663
  " if (!s || !t) return '';\n"
4664
  " const hx1 = s.x + (t.x - s.x) * 0.18, hy1 = s.y + (t.y - s.y) * 0.18;\n"
4665
  " const hx2 = s.x + (t.x - s.x) * 0.82, hy2 = s.y + (t.y - s.y) * 0.82;\n"
4666
+ " return '<line data-testid=\"graph-3d-edge\" data-3d-edge-source=\"' + escapeHtml(d.source || '') + '\" data-3d-edge-target=\"' + escapeHtml(d.target || '') + '\" data-svg-edge-source=\"' + escapeHtml(d.source || '') + '\" data-svg-edge-target=\"' + escapeHtml(d.target || '') + '\" data-edge-weight=\"' + escapeHtml(Number(d.weight || 0).toFixed(4)) + '\" data-edge-detail=\"' + escapeHtml(edgeDetail(d)) + '\" x1=\"' + hx1.toFixed(1) + '\" y1=\"' + hy1.toFixed(1) + '\" x2=\"' + hx2.toFixed(1) + '\" y2=\"' + hy2.toFixed(1) + '\" stroke=\"transparent\" stroke-width=\"12\" style=\"pointer-events:stroke;\"><title>' + escapeHtml(edgeDetail(d)) + '</title></line>';\n"
4667
  " }).join('');\n"
4668
+ " svg.innerHTML = '<rect width=\"100%\" height=\"100%\" fill=\"transparent\" pointer-events=\"all\" />' + edgeHtml + edgeHitHtml + nodeHtml;\n"
4669
  " attach3dHoverHandlers();\n"
4670
  " applyFilters();\n"
4671
+ " highlightSelected();\n"
4672
  " }\n"
4673
  " document.getElementById('graph-zoom-in').addEventListener('click', () => { zoom = Math.min(2.5, zoom * 1.18); drawGraph3d(); });\n"
4674
  " document.getElementById('graph-zoom-out').addEventListener('click', () => { zoom = Math.max(0.35, zoom / 1.18); drawGraph3d(); });\n"
4675
  " let dragging = false, lastX = 0, lastY = 0;\n"
4676
+ " svg.addEventListener('pointerdown', ev => {\n"
4677
+ " if (ev.target.closest && ev.target.closest('[data-3d-node-id], [data-edge-detail]')) return;\n"
4678
+ " dragging = true; lastX = ev.clientX; lastY = ev.clientY; svg.setPointerCapture(ev.pointerId);\n"
4679
+ " });\n"
4680
  " svg.addEventListener('pointerup', ev => { dragging = false; try { svg.releasePointerCapture(ev.pointerId); } catch (_) {} });\n"
4681
  " svg.addEventListener('pointermove', ev => { if (!dragging) return; yaw += (ev.clientX - lastX) * 0.01; pitch += (ev.clientY - lastY) * 0.01; pitch = Math.max(-1.35, Math.min(1.35, pitch)); lastX = ev.clientX; lastY = ev.clientY; drawGraph3d(); });\n"
4682
  " svg.addEventListener('wheel', ev => { ev.preventDefault(); zoom = Math.max(0.35, Math.min(2.5, zoom * (ev.deltaY < 0 ? 1.08 : 0.92))); drawGraph3d(); }, {passive:false});\n"
4683
  " drawGraph3d();\n"
4684
+ " selectNode(centerId, false);\n"
4685
  "}\n"
4686
  "cyMount.innerHTML = '<div data-testid=\"graph-empty\" class=\"muted\" style=\"padding:0.75rem;\">Enter a slug to render the graph.</div>';\n"
4687
  # ── Client-side filtering (type + tag substring) ─────────────
 
4691
  " .filter(cb => cb.checked).map(cb => cb.value));\n"
4692
  " const tagQ = (document.getElementById('tag-filter').value || '')\n"
4693
  " .trim().toLowerCase();\n"
4694
+ " const matchWindow = currentMatchWindow();\n"
4695
+ " renderMatchRange(matchWindow);\n"
4696
+ " renderMatchHistogram(currentGraphEdges, matchWindow);\n"
4697
  " const counts = {skill: 0, agent: 0, 'mcp-server': 0, harness: 0};\n"
4698
  " let visible = 0;\n"
4699
  " const hiddenIds = new Set();\n"
4700
+ " function edgeInMatchWindow(weight) {\n"
4701
+ " const w = Number(weight || 0);\n"
4702
+ " return w >= matchWindow.min && w <= matchWindow.max;\n"
4703
+ " }\n"
4704
+ " function nodePassesMatch(nodeId, depth) {\n"
4705
+ " if (String(depth || '') === '0' || !currentGraphState) return true;\n"
4706
+ " const links = currentGraphState.adjacency.get(nodeId) || [];\n"
4707
+ " return links.some(item => edgeInMatchWindow(item.edge.weight));\n"
4708
+ " }\n"
4709
+ " function graphNodeHidden(nodeId, type, depth, tagsValue) {\n"
4710
+ " const isFocus = String(depth || '') === '0';\n"
4711
+ " const tags = String(tagsValue || '').split(/\\s+/).map(x => x.toLowerCase());\n"
4712
+ " const typeOk = isFocus || allowedTypes.has(type);\n"
4713
+ " const tagOk = isFocus || !tagQ || tags.some(tag => tag.includes(tagQ));\n"
4714
+ " const matchOk = nodePassesMatch(nodeId, depth);\n"
4715
+ " return !(typeOk && tagOk && matchOk);\n"
4716
+ " }\n"
4717
  " document.querySelectorAll('[data-testid=\"graph-fallback-node\"]').forEach(n => {\n"
4718
  " const t = n.dataset.type;\n"
4719
+ " const hidden = graphNodeHidden(n.dataset.nodeId || '', t, n.dataset.depth, n.dataset.tags);\n"
 
 
 
 
4720
  " n.style.display = hidden ? 'none' : 'flex';\n"
4721
  " if (hidden) hiddenIds.add(n.dataset.nodeId || '');\n"
4722
  " if (!hidden) {\n"
 
4725
  " }\n"
4726
  " });\n"
4727
  " document.querySelectorAll('[data-3d-node-id]').forEach(n => {\n"
4728
+ " const id = dataAttr(n, 'data-3d-node-id');\n"
4729
+ " const t = dataAttr(n, 'data-type');\n"
4730
+ " const hidden = graphNodeHidden(id, t, dataAttr(n, 'data-depth'), dataAttr(n, 'data-tags'));\n"
4731
+ " n.style.display = hidden ? 'none' : '';\n"
4732
+ " if (hidden) hiddenIds.add(id);\n"
4733
+ " if (!hidden) {\n"
4734
+ " visible++;\n"
4735
+ " if (t in counts) counts[t]++;\n"
4736
+ " }\n"
4737
  " });\n"
4738
  " const edgeEls = new Set([...document.querySelectorAll('[data-3d-edge-source]'), ...document.querySelectorAll('[data-svg-edge-source]')]);\n"
4739
  " edgeEls.forEach(e => {\n"
4740
+ " const source = dataAttr(e, 'data-3d-edge-source') || dataAttr(e, 'data-svg-edge-source');\n"
4741
+ " const target = dataAttr(e, 'data-3d-edge-target') || dataAttr(e, 'data-svg-edge-target');\n"
4742
+ " const weight = Number(dataAttr(e, 'data-edge-weight') || 0);\n"
4743
+ " const hidden = hiddenIds.has(source) || hiddenIds.has(target) || !edgeInMatchWindow(weight);\n"
4744
  " e.style.display = hidden ? 'none' : '';\n"
4745
  " });\n"
4746
  " document.getElementById('graph-count-skill').textContent = fmtCount(counts.skill);\n"
 
4749
  " document.getElementById('graph-count-harness').textContent = fmtCount(counts.harness);\n"
4750
  " document.getElementById('graph-match-count').textContent = fmtCount(visible) + ' visible';\n"
4751
  "}\n"
4752
+ "function hideLiveSearch() {\n"
4753
+ " liveSearchBox.hidden = true;\n"
4754
+ " liveSearchBox.innerHTML = '';\n"
4755
+ "}\n"
4756
+ "function renderLiveSearchResults(items, query) {\n"
4757
+ " liveSearchBox.hidden = false;\n"
4758
+ " if (!items.length) {\n"
4759
+ " liveSearchBox.innerHTML = '<div class=\"graph-live-empty\">No graph entities match <code>' + escapeHtml(query) + '</code>.</div>';\n"
4760
+ " return;\n"
4761
+ " }\n"
4762
+ " liveSearchBox.innerHTML = items.map(item => {\n"
4763
+ " const tags = Array.from(item.tags || []).slice(0, 4).join(', ');\n"
4764
+ " const title = item.title || item.display_slug || item.slug;\n"
4765
+ " const detail = [item.description, tags ? 'tags: ' + tags : '', item.degree != null ? 'connections ' + fmtCount(item.degree) : '']\n"
4766
+ " .filter(Boolean).join(' - ');\n"
4767
+ " return '<button type=\"button\" class=\"graph-live-result\" data-live-slug=\"' + escapeHtml(item.slug || '') + '\" data-live-type=\"' + escapeHtml(item.type || '') + '\">'\n"
4768
+ " + '<span><code>' + escapeHtml(item.display_slug || item.slug || title) + '</code> '\n"
4769
+ " + '<span class=\"pill entity-type-' + escapeHtml(item.type || 'skill') + '\">' + escapeHtml(item.type || 'entity') + '</span></span>'\n"
4770
+ " + '<small>' + escapeHtml(detail || title) + '</small></button>';\n"
4771
+ " }).join('');\n"
4772
+ " liveSearchBox.querySelectorAll('[data-live-slug]').forEach(btn => {\n"
4773
+ " btn.addEventListener('click', () => {\n"
4774
+ " focus.value = btn.dataset.liveSlug || '';\n"
4775
+ " focusType.value = btn.dataset.liveType || '';\n"
4776
+ " hideLiveSearch();\n"
4777
+ " load(focus.value.trim(), selectedFocusType());\n"
4778
+ " });\n"
4779
+ " });\n"
4780
+ "}\n"
4781
+ "async function runLiveSearch() {\n"
4782
+ " const query = focus.value.trim();\n"
4783
+ " if (query.length < 2) { hideLiveSearch(); return; }\n"
4784
+ " if (liveSearchAbort) liveSearchAbort.abort();\n"
4785
+ " liveSearchAbort = new AbortController();\n"
4786
+ " liveSearchBox.hidden = false;\n"
4787
+ " liveSearchBox.innerHTML = '<div class=\"graph-live-empty\">Searching...</div>';\n"
4788
+ " const suffix = '';\n"
4789
+ " try {\n"
4790
+ " const r = await fetch('/api/entities/search.json?q=' + encodeURIComponent(query) + suffix + '&limit=12', {signal: liveSearchAbort.signal});\n"
4791
+ " if (!r.ok) throw new Error('search failed');\n"
4792
+ " const payload = await r.json();\n"
4793
+ " renderLiveSearchResults(payload.results || [], query);\n"
4794
+ " } catch (err) {\n"
4795
+ " if (err.name === 'AbortError') return;\n"
4796
+ " liveSearchBox.innerHTML = '<div class=\"graph-live-empty\">Search failed. Press Enter to resolve the slug directly.</div>';\n"
4797
+ " }\n"
4798
+ "}\n"
4799
+ "function scheduleLiveSearch() {\n"
4800
+ " clearTimeout(liveSearchTimer);\n"
4801
+ " liveSearchTimer = setTimeout(runLiveSearch, 180);\n"
4802
+ "}\n"
4803
+ "async function load(slug, entityType = '', options = {}) {\n"
4804
  " if (!slug) return;\n"
4805
+ " hideLiveSearch();\n"
4806
+ " const previousQuery = currentGraphQuery;\n"
4807
  " document.getElementById('msg').textContent = 'loading…';\n"
4808
  " const suffix = entityType ? '?type=' + encodeURIComponent(entityType) : '';\n"
4809
  " const r = await fetch('/api/graph/' + encodeURIComponent(slug) + '.json' + suffix);\n"
4810
  " if (!r.ok) { document.getElementById('msg').textContent = 'not found'; return; }\n"
4811
  " const g = await r.json();\n"
4812
  " if (!g.center) { document.getElementById('msg').textContent = 'slug not in graph'; return; }\n"
4813
+ " const nextQuery = graphQueryFromPayload(g, slug, entityType);\n"
4814
+ " if (options.pushCurrent && previousQuery && !sameGraphQuery(previousQuery, nextQuery)) {\n"
4815
+ " graphReturnStack.push(previousQuery);\n"
4816
+ " }\n"
4817
+ " currentGraphQuery = nextQuery;\n"
4818
+ " focus.value = nextQuery.slug || slug;\n"
4819
+ " focusType.value = nextQuery.type || entityType || '';\n"
4820
  " try { renderGraph3d(g); } catch (err) { renderFallback(g); }\n"
4821
  " const resolved = g.resolved && g.resolved.query !== g.resolved.slug ? ' · showing ' + g.resolved.slug + ' for ' + g.resolved.query : '';\n"
4822
  " document.getElementById('msg').textContent = fmtCount(g.nodes.length) + ' nodes · ' + fmtCount(g.edges.length) + ' edges' + resolved;\n"
4823
  " document.getElementById('graph-explanation').textContent = graphExplanation(g);\n"
4824
  " applyFilters();\n"
4825
  "}\n"
4826
+ "function selectedFocusType() { return focusType.value || ''; }\n"
4827
+ "document.getElementById('go').addEventListener('click', () => load(focus.value.trim(), selectedFocusType()));\n"
4828
+ "focus.addEventListener('input', scheduleLiveSearch);\n"
4829
+ "focus.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') load(ev.target.value.trim(), selectedFocusType()); });\n"
4830
+ "focusType.addEventListener('change', scheduleLiveSearch);\n"
4831
  "document.querySelectorAll('.graph-type-filter').forEach(cb => cb.addEventListener('change', applyFilters));\n"
4832
+ "document.getElementById('match-filter-min').addEventListener('input', applyFilters);\n"
4833
+ "document.getElementById('match-filter-max').addEventListener('input', applyFilters);\n"
4834
  "document.getElementById('tag-filter').addEventListener('input', applyFilters);\n"
4835
  "if (initial) load(initial, initialType);\n"
4836
  "</script>"
 
5719
  """Manual harness interview for users who bring their own LLM."""
5720
  harnesses = _harness_wizard_entries()
5721
  provider_options = (
5722
+ "openai", "anthropic", "google", "huggingface", "openrouter",
5723
+ "ollama", "lm-studio", "local", "other",
5724
  )
5725
  provider_html = "".join(
5726
  f"<option value='{html.escape(provider)}'>{html.escape(provider)}</option>"
src/harness_install.py CHANGED
@@ -1039,6 +1039,7 @@ def _format_harness_requirement_lines(
1039
  "verification": "Verification",
1040
  "privacy": "Privacy/network",
1041
  "attach_mode": "Preferred ctx attachment",
 
1042
  }
1043
  return [
1044
  f"- {label}: {requirements[key]}"
@@ -1110,6 +1111,10 @@ def main(argv: list[str] | None = None) -> int:
1110
  parser.add_argument("--goal", help="What you want to build or automate")
1111
  parser.add_argument("--model-provider", help="Model provider prefix, e.g. openai or ollama")
1112
  parser.add_argument("--model", help="Model slug, e.g. openrouter/openai/gpt-5.5")
 
 
 
 
1113
  parser.add_argument("--harness-runtime", help="Runtime/OS target for harness fit")
1114
  parser.add_argument("--harness-autonomy", help="Desired autonomy level")
1115
  parser.add_argument("--harness-tools", help="Allowed tools/access for the harness")
 
1039
  "verification": "Verification",
1040
  "privacy": "Privacy/network",
1041
  "attach_mode": "Preferred ctx attachment",
1042
+ "api_key_env": "API key env var",
1043
  }
1044
  return [
1045
  f"- {label}: {requirements[key]}"
 
1111
  parser.add_argument("--goal", help="What you want to build or automate")
1112
  parser.add_argument("--model-provider", help="Model provider prefix, e.g. openai or ollama")
1113
  parser.add_argument("--model", help="Model slug, e.g. openrouter/openai/gpt-5.5")
1114
+ parser.add_argument(
1115
+ "--api-key-env",
1116
+ help="Environment variable that stores the custom provider API key",
1117
+ )
1118
  parser.add_argument("--harness-runtime", help="Runtime/OS target for harness fit")
1119
  parser.add_argument("--harness-autonomy", help="Desired autonomy level")
1120
  parser.add_argument("--harness-tools", help="Allowed tools/access for the harness")
src/scan_repo.py CHANGED
@@ -125,7 +125,7 @@ def scan_directory(repo_path: str, max_depth: int = MAX_DEPTH) -> dict:
125
  def read_json_safe(path: str) -> dict | None:
126
  """Read a JSON file, return None on failure."""
127
  try:
128
- with open(path) as f:
129
  return json.load(f)
130
  except Exception as exc:
131
  print(f"Warning: failed to read JSON file {path}: {exc}", file=sys.stderr)
 
125
  def read_json_safe(path: str) -> dict | None:
126
  """Read a JSON file, return None on failure."""
127
  try:
128
+ with open(path, encoding="utf-8-sig") as f:
129
  return json.load(f)
130
  except Exception as exc:
131
  print(f"Warning: failed to read JSON file {path}: {exc}", file=sys.stderr)
src/tests/test_clean_host_contract.py CHANGED
@@ -167,6 +167,7 @@ def test_isolated_env_does_not_inherit_caller_secrets(
167
  assert "GITHUB_TOKEN" not in env
168
  assert "CTX_WIKI_DIR" not in env
169
  assert "CLAUDE_HOME" not in env
 
170
 
171
 
172
  def test_assert_inside_rejects_escape(tmp_path: Path) -> None:
 
167
  assert "GITHUB_TOKEN" not in env
168
  assert "CTX_WIKI_DIR" not in env
169
  assert "CLAUDE_HOME" not in env
170
+ assert env["CTX_ALLOW_MISSING_GRAPH"] == "1"
171
 
172
 
173
  def test_assert_inside_rejects_escape(tmp_path: Path) -> None:
src/tests/test_ctx_init.py CHANGED
@@ -230,6 +230,7 @@ def test_main_auto_wizard_in_terminal_configures_custom_model(
230
  "verification": "pytest ruff",
231
  "privacy": "private repo",
232
  "attach_mode": "mcp",
 
233
  }
234
  user_config = json.loads((tmp_path / "skill-system-config.json").read_text())
235
  assert user_config["knowledge"]["mode"] == "enriched"
@@ -1278,7 +1279,7 @@ def test_recommend_harnesses_avoids_semantic_model_load_by_default(
1278
  ) -> None:
1279
  graph = nx.Graph()
1280
  graph.add_node("harness:langgraph", label="langgraph", type="harness")
1281
- monkeypatch.setattr(ci, "_load_recommendation_graph", lambda: graph)
1282
  monkeypatch.setattr(ci, "_harness_supports_provider", lambda *args, **kwargs: True)
1283
  monkeypatch.setattr(ci, "_installed_harness_slugs", lambda _path: set())
1284
  monkeypatch.setattr(
 
230
  "verification": "pytest ruff",
231
  "privacy": "private repo",
232
  "attach_mode": "mcp",
233
+ "api_key_env": "OPENAI_API_KEY",
234
  }
235
  user_config = json.loads((tmp_path / "skill-system-config.json").read_text())
236
  assert user_config["knowledge"]["mode"] == "enriched"
 
1279
  ) -> None:
1280
  graph = nx.Graph()
1281
  graph.add_node("harness:langgraph", label="langgraph", type="harness")
1282
+ monkeypatch.setattr(ci, "_load_harness_recommendation_graph", lambda: graph)
1283
  monkeypatch.setattr(ci, "_harness_supports_provider", lambda *args, **kwargs: True)
1284
  monkeypatch.setattr(ci, "_installed_harness_slugs", lambda _path: set())
1285
  monkeypatch.setattr(
src/tests/test_ctx_monitor.py CHANGED
@@ -1539,6 +1539,7 @@ def test_render_graph_uses_builtin_3d_mount(monkeypatch: pytest.MonkeyPatch) ->
1539
  "_graph_stats",
1540
  lambda: {"nodes": 0, "edges": 0, "available": False},
1541
  )
 
1542
  html_out = cm._render_graph("python-patterns")
1543
  assert "id='cy'" in html_out
1544
  assert "https://unpkg.com" not in html_out
@@ -1546,7 +1547,80 @@ def test_render_graph_uses_builtin_3d_mount(monkeypatch: pytest.MonkeyPatch) ->
1546
  assert "data-testid=\"graph-3d\"" in html_out
1547
  assert "button id=\"graph-zoom-in\"" in html_out
1548
  assert "button id=\"graph-zoom-out\"" in html_out
 
 
1549
  assert "data-testid=\"graph-edge-detail\"" in html_out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1550
  assert "id='graph-explanation'" in html_out
1551
  assert "g.explanations" in html_out
1552
  assert "Graph renderer unavailable" not in html_out
@@ -1564,7 +1638,8 @@ def test_render_graph_focus_controls_preserve_type(monkeypatch: pytest.MonkeyPat
1564
  html_out = cm._render_graph("langgraph", focus_type="harness")
1565
  assert "id='focus-type'" in html_out
1566
  assert "<option value='harness' selected>harness</option>" in html_out
1567
- assert "load(document.getElementById('focus').value.trim(), selectedFocusType())" in html_out
 
1568
 
1569
 
1570
  def test_graph_neighborhood_rejects_unsafe_slug() -> None:
@@ -1681,6 +1756,13 @@ def test_graph_neighborhood_sizes_nodes_by_score_usage_and_popularity(
1681
  assert "popularity" in hub["size_reason"]
1682
 
1683
 
 
 
 
 
 
 
 
1684
  def test_graph_neighborhood_uses_direct_sidecar_scores_without_global_index(
1685
  fake_claude: Path,
1686
  monkeypatch: pytest.MonkeyPatch,
@@ -3075,6 +3157,45 @@ def test_entity_search_and_detail_apis_support_edit_flow(
3075
  server.server_close()
3076
 
3077
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3078
  def test_entity_search_api_rejects_bad_type_and_clamps_limit(
3079
  fake_claude: Path,
3080
  monkeypatch: pytest.MonkeyPatch,
@@ -3522,6 +3643,9 @@ def test_layout_nav_includes_wiki_and_kpi() -> None:
3522
  assert "href='/kpi'" in out
3523
  assert "href='/graph'" in out
3524
  assert "href='/config'" in out
 
 
 
3525
  assert ">Wiki<" in out
3526
  assert ">Harness Setup<" in out
3527
  assert ">Docs<" in out
@@ -3759,6 +3883,42 @@ def test_layout_nav_tabs_are_draggable_and_persist_order() -> None:
3759
  assert css.startswith(":root")
3760
  assert f"<style>{css}</style>" in out
3761
  assert "id='dashboard-nav'" in out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3762
  assert "data-nav-storage-key='ctx-monitor-nav-order'" in out
3763
  assert "data-nav-default-keys=" in out
3764
  assert "draggable='true'" in out
@@ -3913,6 +4073,7 @@ def test_render_config_posts_only_dirty_fields_and_can_clear_overrides(
3913
 
3914
  assert "Saves only changed fields" in html_out
3915
  assert "data-original-value='4'" in html_out
 
3916
  assert "data-config-clear='resolver.recommendation_top_k'" in html_out
3917
  assert "no config changes to save" in html_out
3918
  assert "el.dataset.originalValue" in html_out
 
1539
  "_graph_stats",
1540
  lambda: {"nodes": 0, "edges": 0, "available": False},
1541
  )
1542
+ monkeypatch.setattr(cm, "_graph_match_default_min_percent", lambda: 7)
1543
  html_out = cm._render_graph("python-patterns")
1544
  assert "id='cy'" in html_out
1545
  assert "https://unpkg.com" not in html_out
 
1547
  assert "data-testid=\"graph-3d\"" in html_out
1548
  assert "button id=\"graph-zoom-in\"" in html_out
1549
  assert "button id=\"graph-zoom-out\"" in html_out
1550
+ assert "id='cy' class='graph-stage'" in html_out
1551
+ assert "height:75vh" not in html_out
1552
  assert "data-testid=\"graph-edge-detail\"" in html_out
1553
+ assert "data-testid=\"graph-live-results\"" in html_out
1554
+ assert "data-testid=\"graph-inspector-resize\"" in html_out
1555
+ assert "id='match-filter-min'" in html_out
1556
+ assert "id='match-filter-max'" in html_out
1557
+ assert "class='card graph-match-card'" in html_out
1558
+ assert "data-testid=\"match-histogram\"" in html_out
1559
+ assert "data-testid=\"match-range-control\"" in html_out
1560
+ assert "id='match-filter-min-value'" in html_out
1561
+ assert "id='match-filter-max-value'" in html_out
1562
+ assert "id='match-filter-value'" not in html_out
1563
+ assert "value='7'" in html_out
1564
+ assert "max='100'" in html_out
1565
+ assert "<span>99%+</span>" not in html_out
1566
+ assert "<span>100%</span>" in html_out
1567
+ assert "function clampInspectorHeight" in html_out
1568
+ assert "ctx-monitor-graph-inspector-height" in html_out
1569
+ assert "ArrowUp" in html_out
1570
+ assert "ArrowDown" in html_out
1571
+ assert "data-testid=\"graph-node-detail-tree\"" in html_out
1572
+ assert "function renderNodeTree" in html_out
1573
+ assert "function scheduleLiveSearch" in html_out
1574
+ assert "function qualityText" in html_out
1575
+ assert "function nodeShapeSvg" in html_out
1576
+ assert "data-node-shape=" in html_out
1577
+ assert "<polygon" in html_out
1578
+ assert "<rect" in html_out
1579
+ assert "function isGraphOnlyEdge" in html_out
1580
+ assert "function isWeakGraphOnlyEdge" in html_out
1581
+ assert "return clampedUnit(value) == null ? 'unknown' : percentText(value);" in html_out
1582
+ assert "Number(value).toFixed(3)" not in html_out
1583
+ assert "focus.addEventListener('input', scheduleLiveSearch)" in html_out
1584
+ assert "function drillIntoNode" in html_out
1585
+ assert "function restorePreviousGraph" in html_out
1586
+ assert "let nodeClickTimer" in html_out
1587
+ assert "function scheduleNodeClick" in html_out
1588
+ assert "function handleNodeDoubleClick" in html_out
1589
+ assert "svg.addEventListener('click', ev => {" not in html_out
1590
+ assert "graph-fallback-label" in html_out
1591
+ assert "class=\"graph-toolbar\"" in html_out
1592
+ assert "class=\"graph-inspector-grid\"" in html_out
1593
+ assert "graph-edge-detail-inline" in html_out
1594
+ assert "background:transparent" in html_out
1595
+ assert "fill=\"transparent\" pointer-events=\"all\"" in html_out
1596
+ assert "onmouseup=" not in html_out
1597
+ assert "onpointerup=" not in html_out
1598
+ assert "querySelectorAll('[data-testid=\"graph-svg-edge\"]')" in html_out
1599
+ assert "data-edge-weight=" in html_out
1600
+ assert "nodePassesMatch" in html_out
1601
+ assert "function currentMatchWindow" in html_out
1602
+ assert "function renderMatchRange" in html_out
1603
+ assert "function renderMatchHistogram" in html_out
1604
+ assert "function setMatchWindow" in html_out
1605
+ assert "data-match-bin-min" in html_out
1606
+ assert "aria-label=\"Filter match " in html_out
1607
+ assert "bar.addEventListener('click', useBin)" in html_out
1608
+ assert "function edgeInMatchWindow" in html_out
1609
+ assert "highPct >= 99 ? 1" not in html_out
1610
+ assert "max: highPct / 100" in html_out
1611
+ assert "document.getElementById('match-filter-min').addEventListener('input', applyFilters)" in html_out
1612
+ assert "document.getElementById('match-filter-max').addEventListener('input', applyFilters)" in html_out
1613
+ assert "No neighbors in this view." in html_out
1614
+ assert "graph neighbor" in html_out
1615
+ assert "<strong>match</strong>" in html_out
1616
+ assert "These links have no shared tags" not in html_out
1617
+ assert "unproven links filtered" not in html_out
1618
+ assert "Connections: " in html_out
1619
+ assert "raw score clamped" in html_out
1620
+ assert "graph score" not in html_out
1621
+ assert "renderFallback(g); return;" in html_out
1622
+ assert "renderFallback(g);\n const list" not in html_out
1623
+ assert "data-testid=\"graph-list\" class=\"graph-list-panel\" hidden" in html_out
1624
  assert "id='graph-explanation'" in html_out
1625
  assert "g.explanations" in html_out
1626
  assert "Graph renderer unavailable" not in html_out
 
1638
  html_out = cm._render_graph("langgraph", focus_type="harness")
1639
  assert "id='focus-type'" in html_out
1640
  assert "<option value='harness' selected>harness</option>" in html_out
1641
+ assert "document.getElementById('go').addEventListener('click', () => load(focus.value.trim(), selectedFocusType()))" in html_out
1642
+ assert "focus.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') load(ev.target.value.trim(), selectedFocusType()); })" in html_out
1643
 
1644
 
1645
  def test_graph_neighborhood_rejects_unsafe_slug() -> None:
 
1756
  assert "popularity" in hub["size_reason"]
1757
 
1758
 
1759
+ def test_dashboard_score_payload_clamps_raw_outliers() -> None:
1760
+ payload = cm._dashboard_score_payload("quality_score", 18.0)
1761
+
1762
+ assert payload == {"quality_score": 1.0, "quality_score_raw": 18.0}
1763
+ assert cm._dashboard_score_payload("quality_score", None) == {"quality_score": None}
1764
+
1765
+
1766
  def test_graph_neighborhood_uses_direct_sidecar_scores_without_global_index(
1767
  fake_claude: Path,
1768
  monkeypatch: pytest.MonkeyPatch,
 
3157
  server.server_close()
3158
 
3159
 
3160
+ def test_entity_search_uses_dashboard_index_for_live_graph_search(
3161
+ fake_claude: Path,
3162
+ monkeypatch: pytest.MonkeyPatch,
3163
+ ) -> None:
3164
+ index_path = fake_claude / "skill-wiki" / "graphify-out" / "dashboard-neighborhoods.sqlite3"
3165
+ index_path.parent.mkdir(parents=True)
3166
+ conn = sqlite3.connect(index_path)
3167
+ try:
3168
+ conn.execute(
3169
+ "CREATE TABLE nodes("
3170
+ "id TEXT PRIMARY KEY,label TEXT,type TEXT,tags TEXT,description TEXT,"
3171
+ "quality_score REAL,usage_score REAL,degree INTEGER)",
3172
+ )
3173
+ conn.execute(
3174
+ "INSERT INTO nodes VALUES(?,?,?,?,?,?,?,?)",
3175
+ (
3176
+ "skill:brainstorming",
3177
+ "brainstorming",
3178
+ "skill",
3179
+ json.dumps(["planning", "creative"]),
3180
+ "Generate better project options before implementation.",
3181
+ 0.91,
3182
+ 0.4,
3183
+ 17,
3184
+ ),
3185
+ )
3186
+ conn.commit()
3187
+ finally:
3188
+ conn.close()
3189
+ monkeypatch.setattr(cm, "_dashboard_graph_index_path", lambda: index_path)
3190
+ monkeypatch.setattr(cm, "_dashboard_index_matches_manifest", lambda _path: True)
3191
+
3192
+ results = cm._search_wiki_entities("brainstorm", "skill", limit=5)
3193
+
3194
+ assert results[0]["slug"] == "brainstorming"
3195
+ assert results[0]["type"] == "skill"
3196
+ assert results[0]["tags"] == ["planning", "creative"]
3197
+
3198
+
3199
  def test_entity_search_api_rejects_bad_type_and_clamps_limit(
3200
  fake_claude: Path,
3201
  monkeypatch: pytest.MonkeyPatch,
 
3643
  assert "href='/kpi'" in out
3644
  assert "href='/graph'" in out
3645
  assert "href='/config'" in out
3646
+ assert "class='app-shell'" in out
3647
+ assert "class='app-header'" in out
3648
+ assert "class='app-brand'" in out
3649
  assert ">Wiki<" in out
3650
  assert ">Harness Setup<" in out
3651
  assert ">Docs<" in out
 
3883
  assert css.startswith(":root")
3884
  assert f"<style>{css}</style>" in out
3885
  assert "id='dashboard-nav'" in out
3886
+ assert "app-shell" in out
3887
+ assert ".app-shell" in css
3888
+ assert ".graph-canvas-wrap [data-3d-node-id]:focus" in css
3889
+ assert ".graph-stage { width: 100%; height: calc(100vh - 2rem); height: calc(100dvh - 2rem);" in css
3890
+ assert ".graph-canvas-wrap { position: relative; min-height: 0; background: transparent; cursor: grab; }" in css
3891
+ assert ".graph-canvas-wrap:active { cursor: grabbing; }" in css
3892
+ assert ".graph-canvas-wrap [data-edge-detail] { cursor: help; }" in css
3893
+ assert ".graph-match-histogram" in css
3894
+ assert ".graph-match-bar:hover" in css
3895
+ assert ".graph-range-wrap" in css
3896
+ assert ".graph-range-fill" in css
3897
+ assert "width: 1rem; height: 1rem" in css
3898
+ assert "margin-top: calc((0.3rem - 1rem) / 2);" in css
3899
+ assert 'background: transparent; border: 0; box-shadow: none;' in css
3900
+ assert '.graph-range-wrap input[type="range"]:focus-visible,' in css
3901
+ assert "outline: none !important; box-shadow: none !important;" in css
3902
+ assert '.graph-range-wrap input[type="range"]::-moz-focus-outer { border: 0; }' in css
3903
+ assert 'border: 0; box-shadow: none; }' in css
3904
+ assert 'outline: none;' in css
3905
+ assert 'box-shadow: 0 0 0 0.24rem var(--accent-ring)' in css
3906
+ assert "font-size: 0.72rem; line-height: 1; margin-top: 0.22rem;" in css
3907
+ assert "pointer-events: none" in css
3908
+ assert "pointer-events: auto" in css
3909
+ assert "cursor: none" not in css
3910
+ assert "grid-template-rows: auto minmax(18rem, 1fr) 0.65rem minmax(10rem, var(--graph-inspector-height))" in css
3911
+ assert "grid-template-columns: minmax(0,1fr)" in css
3912
+ assert "clamp(18rem, 34vw, 30rem)" not in css
3913
+ assert ".graph-resize-handle" in css
3914
+ assert "cursor: ns-resize" in css
3915
+ assert "[data-testid=\"graph-edge-detail\"] { min-height: 0; max-height: 100%; overflow: auto; }" in css
3916
+ assert ".graph-edge-detail-inline" in css
3917
+ assert "border-left: 1px solid var(--border)" not in css
3918
+ assert "minmax(180px, 0.42fr) 30%" not in css
3919
+ assert ".graph-node-selected [data-testid=\"graph-svg-node\"]" in css
3920
+ assert "fill: #facc15" in css
3921
+ assert "outline: none" in css
3922
  assert "data-nav-storage-key='ctx-monitor-nav-order'" in out
3923
  assert "data-nav-default-keys=" in out
3924
  assert "draggable='true'" in out
 
4073
 
4074
  assert "Saves only changed fields" in html_out
4075
  assert "data-original-value='4'" in html_out
4076
+ assert "data-config-path='intake.enabled' data-original-value='true'" in html_out
4077
  assert "data-config-clear='resolver.recommendation_top_k'" in html_out
4078
  assert "no config changes to save" in html_out
4079
  assert "el.dataset.originalValue" in html_out
src/tests/test_ctx_monitor_browser.py CHANGED
@@ -40,6 +40,13 @@ def fake_claude(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
40
  (claude / "skill-quality").mkdir(parents=True)
41
  monkeypatch.setattr(cm, "_claude_dir", lambda: claude)
42
  monkeypatch.setattr(cm, "_dashboard_graph_index_archives", lambda: [])
 
 
 
 
 
 
 
43
  return claude
44
 
45
 
@@ -98,6 +105,20 @@ def _write_wiki_entity(root: Path, entity_type: str, slug: str, body: str) -> No
98
  path.write_text(body, encoding="utf-8")
99
 
100
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  def _wait_for_browser_state(page: Any, expression: str, *, timeout: float = 5.0) -> None:
102
  deadline = time.monotonic() + timeout
103
  while time.monotonic() < deadline:
@@ -112,6 +133,7 @@ def test_graph_page_uses_builtin_svg_renderer(
112
  monkeypatch: pytest.MonkeyPatch,
113
  page: Any,
114
  ) -> None:
 
115
  G = nx.Graph()
116
  G.add_node("skill:python-patterns", label="python-patterns", type="skill", tags=["python"])
117
  G.add_node(
@@ -119,9 +141,23 @@ def test_graph_page_uses_builtin_svg_renderer(
119
  label="code-reviewer",
120
  type="agent",
121
  tags=["review"],
122
- quality_score=0.95,
123
  usage_score=0.8,
124
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  G.add_node(
126
  "mcp-server:github-mcp-server",
127
  label="github-mcp-server",
@@ -131,9 +167,11 @@ def test_graph_page_uses_builtin_svg_renderer(
131
  usage_score=0.0,
132
  )
133
  G.add_node("harness:langgraph", label="langgraph", type="harness", tags=["agent"])
134
- G.add_edge("skill:python-patterns", "agent:code-reviewer", weight=0.9, shared_tags=["review"])
135
  G.add_edge("skill:python-patterns", "mcp-server:github-mcp-server", weight=0.8, shared_tags=["github"])
136
  G.add_edge("skill:python-patterns", "harness:langgraph", weight=0.7, shared_tags=["agent"])
 
 
137
  monkeypatch.setattr(cm, "_load_dashboard_graph", lambda: G)
138
  _write_wiki_entity(fake_claude, "skill", "python-patterns", "# python-patterns\n")
139
  _write_wiki_entity(fake_claude, "agent", "code-reviewer", "# code-reviewer\n")
@@ -142,25 +180,236 @@ def test_graph_page_uses_builtin_svg_renderer(
142
  try:
143
  page.goto(f"{harness.base_url}/graph?slug=python-patterns&type=skill")
144
  page.wait_for_selector("[data-testid='graph-renderer']", timeout=5000)
145
- assert "4 nodes" in page.locator("#msg").inner_text()
146
- assert page.locator("[data-testid='graph-svg-node']").count() == 4
147
- assert page.locator("[data-testid='graph-fallback-node']").count() == 4
 
148
  assert "Graph renderer unavailable" not in page.locator("#cy").inner_text()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
149
  reviewer_radius = float(page.locator(
150
  "[data-3d-node-id='agent:code-reviewer'] [data-testid='graph-svg-node']",
151
- ).get_attribute("r") or "0")
152
  mcp_radius = float(page.locator(
153
  "[data-3d-node-id='mcp-server:github-mcp-server'] [data-testid='graph-svg-node']",
154
- ).get_attribute("r") or "0")
155
  assert reviewer_radius > mcp_radius
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  page.fill("#tag-filter", "review")
158
  _wait_for_browser_state(
159
  page,
160
  "() => document.getElementById('graph-match-count').textContent === '2 visible'",
161
  timeout=5.0,
162
  )
163
- page.locator("[data-testid='graph-fallback-node'][data-slug='code-reviewer']").click()
164
  page.wait_for_url("**/wiki/code-reviewer?type=agent", timeout=5000)
165
  assert "code-reviewer" in page.locator("h1").inner_text()
166
  finally:
@@ -269,6 +518,213 @@ def test_wiki_page_autocomplete_and_type_filters_update_visible_tiles(
269
  harness.close()
270
 
271
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
272
  def test_events_page_shows_backlog_and_appends_live_events(
273
  fake_claude: Path,
274
  monkeypatch: pytest.MonkeyPatch,
 
40
  (claude / "skill-quality").mkdir(parents=True)
41
  monkeypatch.setattr(cm, "_claude_dir", lambda: claude)
42
  monkeypatch.setattr(cm, "_dashboard_graph_index_archives", lambda: [])
43
+ monkeypatch.setattr(cm, "_SIDECAR_INDEX_CACHE_KEY", None)
44
+ monkeypatch.setattr(cm, "_SIDECAR_INDEX_CACHE_VALUE", None)
45
+ monkeypatch.setattr(cm, "_SIDECAR_FILTER_CACHE_SIGNATURE", None)
46
+ monkeypatch.setattr(cm, "_SIDECAR_FILTER_CACHE_VALUE", {})
47
+ monkeypatch.setattr(cm, "_KPI_SUMMARY_CACHE_KEY", None)
48
+ monkeypatch.setattr(cm, "_KPI_SUMMARY_CACHE_VALUE", None)
49
+ monkeypatch.setattr(cm, "_KPI_SUMMARY_CACHE_AT", 0.0)
50
  return claude
51
 
52
 
 
105
  path.write_text(body, encoding="utf-8")
106
 
107
 
108
+ def _write_quality_sidecar(root: Path, slug: str, body: dict[str, Any]) -> None:
109
+ path = root / "skill-quality" / f"{slug}.json"
110
+ path.parent.mkdir(parents=True, exist_ok=True)
111
+ path.write_text(json.dumps(body), encoding="utf-8")
112
+
113
+
114
+ def _write_runtime_events(path: Path, records: list[dict[str, Any]]) -> None:
115
+ path.parent.mkdir(parents=True, exist_ok=True)
116
+ path.write_text(
117
+ "\n".join(json.dumps(record) for record in records) + "\n",
118
+ encoding="utf-8",
119
+ )
120
+
121
+
122
  def _wait_for_browser_state(page: Any, expression: str, *, timeout: float = 5.0) -> None:
123
  deadline = time.monotonic() + timeout
124
  while time.monotonic() < deadline:
 
133
  monkeypatch: pytest.MonkeyPatch,
134
  page: Any,
135
  ) -> None:
136
+ monkeypatch.setattr(cm, "_graph_match_default_min_percent", lambda: 3)
137
  G = nx.Graph()
138
  G.add_node("skill:python-patterns", label="python-patterns", type="skill", tags=["python"])
139
  G.add_node(
 
141
  label="code-reviewer",
142
  type="agent",
143
  tags=["review"],
144
+ quality_score=18.0,
145
  usage_score=0.8,
146
  )
147
+ G.add_node(
148
+ "skill:weak-graph-link",
149
+ label="weak-graph-link",
150
+ type="skill",
151
+ tags=["noise"],
152
+ quality_score=0.2,
153
+ )
154
+ G.add_node(
155
+ "skill:medium-graph-link",
156
+ label="medium-graph-link",
157
+ type="skill",
158
+ tags=["noise"],
159
+ quality_score=0.2,
160
+ )
161
  G.add_node(
162
  "mcp-server:github-mcp-server",
163
  label="github-mcp-server",
 
167
  usage_score=0.0,
168
  )
169
  G.add_node("harness:langgraph", label="langgraph", type="harness", tags=["agent"])
170
+ G.add_edge("skill:python-patterns", "agent:code-reviewer", weight=0.9, shared_tags=["review"], tag_sim=0.3333)
171
  G.add_edge("skill:python-patterns", "mcp-server:github-mcp-server", weight=0.8, shared_tags=["github"])
172
  G.add_edge("skill:python-patterns", "harness:langgraph", weight=0.7, shared_tags=["agent"])
173
+ G.add_edge("skill:python-patterns", "skill:medium-graph-link", weight=0.43, tag_sim=0.0)
174
+ G.add_edge("agent:code-reviewer", "skill:weak-graph-link", weight=0.05)
175
  monkeypatch.setattr(cm, "_load_dashboard_graph", lambda: G)
176
  _write_wiki_entity(fake_claude, "skill", "python-patterns", "# python-patterns\n")
177
  _write_wiki_entity(fake_claude, "agent", "code-reviewer", "# code-reviewer\n")
 
180
  try:
181
  page.goto(f"{harness.base_url}/graph?slug=python-patterns&type=skill")
182
  page.wait_for_selector("[data-testid='graph-renderer']", timeout=5000)
183
+ assert "5 nodes" in page.locator("#msg").inner_text()
184
+ assert page.locator("[data-testid='graph-svg-node']").count() == 5
185
+ assert page.locator("[data-testid='graph-fallback-node']").count() == 0
186
+ assert page.locator("[data-testid='graph-list']").evaluate("node => node.hidden")
187
  assert "Graph renderer unavailable" not in page.locator("#cy").inner_text()
188
+ resize_handle = page.locator("[data-testid='graph-inspector-resize']")
189
+ assert resize_handle.count() == 1
190
+ assert page.locator("[data-testid='graph-node-detail']").evaluate(
191
+ "node => getComputedStyle(node).overflowY",
192
+ ) == "auto"
193
+ assert page.locator("[data-testid='graph-edge-detail']").evaluate(
194
+ "node => node.parentElement?.getAttribute('data-testid')",
195
+ ) == "graph-node-detail"
196
+ before_resize = page.locator(".graph-inspector-grid").bounding_box()
197
+ assert before_resize is not None
198
+ node_detail_box = page.locator("[data-testid='graph-node-detail']").bounding_box()
199
+ assert node_detail_box is not None
200
+ grid_padding = page.locator(".graph-inspector-grid").evaluate(
201
+ "node => parseFloat(getComputedStyle(node).paddingLeft) + parseFloat(getComputedStyle(node).paddingRight)",
202
+ )
203
+ assert abs(node_detail_box["width"] - (before_resize["width"] - grid_padding)) < 4
204
+ resize_handle.focus()
205
+ page.keyboard.press("ArrowUp")
206
+ _wait_for_browser_state(
207
+ page,
208
+ f"() => document.querySelector('.graph-inspector-grid')"
209
+ f"?.getBoundingClientRect().height > {before_resize['height'] + 10}",
210
+ timeout=5.0,
211
+ )
212
+ after_resize = page.locator(".graph-inspector-grid").bounding_box()
213
+ assert after_resize is not None
214
+ assert after_resize["height"] > before_resize["height"]
215
+ skill_shape = page.locator(
216
+ "[data-3d-node-id='skill:python-patterns'] [data-testid='graph-svg-node']",
217
+ )
218
+ agent_shape = page.locator(
219
+ "[data-3d-node-id='agent:code-reviewer'] [data-testid='graph-svg-node']",
220
+ )
221
+ mcp_shape = page.locator(
222
+ "[data-3d-node-id='mcp-server:github-mcp-server'] [data-testid='graph-svg-node']",
223
+ )
224
+ assert skill_shape.evaluate("node => node.tagName.toLowerCase()") == "circle"
225
+ assert agent_shape.evaluate("node => node.tagName.toLowerCase()") == "polygon"
226
+ assert mcp_shape.evaluate("node => node.tagName.toLowerCase()") == "rect"
227
+ assert skill_shape.get_attribute("data-node-shape") == "skill"
228
+ assert agent_shape.get_attribute("data-node-shape") == "agent"
229
+ assert mcp_shape.get_attribute("data-node-shape") == "mcp-server"
230
+
231
+ assert page.locator("[data-testid='match-range-control']").count() == 1
232
+ assert page.locator("#match-histogram .graph-match-bar").count() == 10
233
+ assert page.locator("#match-filter-min").get_attribute("max") == "100"
234
+ assert page.locator("#match-filter-max").get_attribute("max") == "100"
235
+ assert page.locator("#match-filter-min").input_value() == "3"
236
+ assert page.locator("#match-filter-min-value").inner_text() == "3%"
237
+ assert page.locator("#match-filter-max").input_value() == "100"
238
+ assert page.locator("#match-filter-max-value").inner_text() == "100%"
239
+ page.locator("#match-filter-min").evaluate(
240
+ "node => { node.value = '50'; node.dispatchEvent(new Event('input', {bubbles: true})); }",
241
+ )
242
+ _wait_for_browser_state(
243
+ page,
244
+ "() => document.getElementById('match-filter-min-value').textContent === '50%'",
245
+ timeout=5.0,
246
+ )
247
+ _wait_for_browser_state(
248
+ page,
249
+ "() => document.getElementById('graph-match-count').textContent === '4 visible'",
250
+ timeout=5.0,
251
+ )
252
+ assert page.locator("#match-histogram .graph-match-bar.active").count() >= 1
253
+ page.locator("#match-histogram [data-match-bin-min='70']").click()
254
+ _wait_for_browser_state(
255
+ page,
256
+ "() => document.getElementById('match-filter-min-value').textContent === '70%'",
257
+ timeout=5.0,
258
+ )
259
+ _wait_for_browser_state(
260
+ page,
261
+ "() => document.getElementById('match-filter-max-value').textContent === '79%'",
262
+ timeout=5.0,
263
+ )
264
+ _wait_for_browser_state(
265
+ page,
266
+ "() => document.getElementById('graph-match-count').textContent === '2 visible'",
267
+ timeout=5.0,
268
+ )
269
+ page.locator("#match-filter-min").evaluate(
270
+ "node => { node.value = '50'; node.dispatchEvent(new Event('input', {bubbles: true})); }",
271
+ )
272
+ page.locator("#match-filter-max").evaluate(
273
+ "node => { node.value = '100'; node.dispatchEvent(new Event('input', {bubbles: true})); }",
274
+ )
275
+ assert page.locator("[data-3d-node-id='skill:medium-graph-link']").evaluate(
276
+ "node => getComputedStyle(node).display",
277
+ ) == "none"
278
+ assert page.locator("[data-testid='graph-svg-edge'][data-edge-weight='0.4300']").evaluate(
279
+ "node => getComputedStyle(node).display",
280
+ ) == "none"
281
+ page.locator("#match-filter-max").evaluate(
282
+ "node => { node.value = '80'; node.dispatchEvent(new Event('input', {bubbles: true})); }",
283
+ )
284
+ _wait_for_browser_state(
285
+ page,
286
+ "() => document.getElementById('match-filter-max-value').textContent === '80%'",
287
+ timeout=5.0,
288
+ )
289
+ _wait_for_browser_state(
290
+ page,
291
+ "() => document.getElementById('graph-match-count').textContent === '3 visible'",
292
+ timeout=5.0,
293
+ )
294
+ assert page.locator("[data-3d-node-id='agent:code-reviewer']").evaluate(
295
+ "node => getComputedStyle(node).display",
296
+ ) == "none"
297
+ assert page.locator("[data-testid='graph-svg-edge'][data-edge-weight='0.9000']").evaluate(
298
+ "node => getComputedStyle(node).display",
299
+ ) == "none"
300
+ page.locator("#match-filter-min").evaluate(
301
+ "node => { node.value = '0'; node.dispatchEvent(new Event('input', {bubbles: true})); }",
302
+ )
303
+ page.locator("#match-filter-max").evaluate(
304
+ "node => { node.value = '100'; node.dispatchEvent(new Event('input', {bubbles: true})); }",
305
+ )
306
+ _wait_for_browser_state(
307
+ page,
308
+ "() => document.getElementById('graph-match-count').textContent === '5 visible'",
309
+ timeout=5.0,
310
+ )
311
+
312
  reviewer_radius = float(page.locator(
313
  "[data-3d-node-id='agent:code-reviewer'] [data-testid='graph-svg-node']",
314
+ ).get_attribute("data-radius") or "0")
315
  mcp_radius = float(page.locator(
316
  "[data-3d-node-id='mcp-server:github-mcp-server'] [data-testid='graph-svg-node']",
317
+ ).get_attribute("data-radius") or "0")
318
  assert reviewer_radius > mcp_radius
319
 
320
+ center_node = page.locator(
321
+ "[data-3d-node-id='skill:python-patterns'] [data-testid='graph-svg-node']",
322
+ )
323
+ center_node.click()
324
+ _wait_for_browser_state(
325
+ page,
326
+ "() => document.querySelector('[data-testid=\"graph-node-detail-tree\"]')"
327
+ "?.innerText.includes('python-patterns')",
328
+ timeout=5.0,
329
+ )
330
+ center_detail_text = page.locator("[data-testid='graph-node-detail-tree']").inner_text()
331
+ assert "medium-graph-link" in center_detail_text
332
+ assert "match 43%" in center_detail_text
333
+ assert "graph-only links hidden" not in center_detail_text
334
+ assert "tag 0.000" not in center_detail_text
335
+ assert "evidence: none" not in center_detail_text
336
+
337
+ graph_node = page.locator(
338
+ "[data-3d-node-id='agent:code-reviewer'] [data-testid='graph-svg-node']",
339
+ )
340
+ graph_node.click()
341
+ _wait_for_browser_state(
342
+ page,
343
+ "() => document.getElementById('focus').value === 'code-reviewer'",
344
+ timeout=5.0,
345
+ )
346
+ _wait_for_browser_state(
347
+ page,
348
+ "() => document.querySelector('[data-3d-node-id=\"agent:code-reviewer\"]')"
349
+ "?.getAttribute('data-depth') === '0'",
350
+ timeout=5.0,
351
+ )
352
+ detail_text = page.locator("[data-testid='graph-node-detail-tree']").inner_text()
353
+ assert "Neighbors" in detail_text
354
+ assert "strength 90% relation strength" in detail_text
355
+ assert "tag 33%" in detail_text
356
+ assert "quality: 100%" in detail_text
357
+ assert "raw score clamped" not in detail_text
358
+ assert "graph-only links hidden" not in detail_text
359
+ assert "weak-graph-link" in detail_text
360
+ assert "match 5%" in detail_text
361
+ assert "tag 0.000" not in detail_text
362
+ assert "evidence: none" not in detail_text
363
+ assert "0.333" not in detail_text
364
+ assert page.locator("[data-3d-node-id='agent:code-reviewer']").evaluate(
365
+ "node => node.classList.contains('graph-node-selected')",
366
+ )
367
+ selected_fill = page.locator(
368
+ "[data-3d-node-id='agent:code-reviewer'] [data-testid='graph-svg-node']",
369
+ ).evaluate("node => getComputedStyle(node).fill")
370
+ assert selected_fill == "rgb(250, 204, 21)"
371
+ selected_visible_edges = page.locator(
372
+ "[data-testid='graph-svg-edge'].graph-edge-selected",
373
+ ).count()
374
+ selected_hit_edges = page.locator(
375
+ "[data-testid='graph-3d-edge'].graph-edge-selected",
376
+ ).count()
377
+ assert selected_visible_edges >= 1
378
+ assert selected_hit_edges == 0
379
+ _wait_for_browser_state(
380
+ page,
381
+ "() => document.getElementById('focus').value === 'code-reviewer'",
382
+ timeout=5.0,
383
+ )
384
+ _wait_for_browser_state(
385
+ page,
386
+ "() => document.querySelector('[data-3d-node-id=\"agent:code-reviewer\"]')"
387
+ "?.getAttribute('data-depth') === '0'",
388
+ timeout=5.0,
389
+ )
390
+ page.locator("[data-3d-node-id='agent:code-reviewer']").dispatch_event("dblclick")
391
+ _wait_for_browser_state(
392
+ page,
393
+ "() => document.getElementById('focus').value === 'python-patterns'",
394
+ timeout=5.0,
395
+ )
396
+
397
+ page.fill("#focus", "code")
398
+ page.wait_for_selector("[data-testid='graph-live-results'] [data-live-slug='code-reviewer']", timeout=5000)
399
+ page.locator("[data-live-slug='code-reviewer']").click()
400
+ _wait_for_browser_state(
401
+ page,
402
+ "() => document.getElementById('focus').value === 'code-reviewer'",
403
+ timeout=5.0,
404
+ )
405
+
406
  page.fill("#tag-filter", "review")
407
  _wait_for_browser_state(
408
  page,
409
  "() => document.getElementById('graph-match-count').textContent === '2 visible'",
410
  timeout=5.0,
411
  )
412
+ page.locator("[data-testid='graph-node-detail-tree'] a[href='/wiki/code-reviewer?type=agent']").click()
413
  page.wait_for_url("**/wiki/code-reviewer?type=agent", timeout=5000)
414
  assert "code-reviewer" in page.locator("h1").inner_text()
415
  finally:
 
518
  harness.close()
519
 
520
 
521
+ def test_manage_page_supports_create_search_update_and_delete(
522
+ fake_claude: Path,
523
+ monkeypatch: pytest.MonkeyPatch,
524
+ page: Any,
525
+ ) -> None:
526
+ harness = _start_monitor(monkeypatch, fake_load=False)
527
+ entity_path = fake_claude / "skill-wiki" / "entities" / "agents" / "custom-reviewer.md"
528
+ try:
529
+ page.goto(f"{harness.base_url}/manage")
530
+ page.wait_for_selector("#entity-editor-form", timeout=5000)
531
+
532
+ page.fill("input[name='slug']", "custom-reviewer")
533
+ page.select_option("select[name='entity_type']", "agent")
534
+ page.fill("input[name='title']", "Custom Reviewer")
535
+ page.fill("input[name='tags']", "python, review, policy")
536
+ page.fill("input[name='description']", "Reviews Python changes with local policy.")
537
+ page.fill("textarea[name='body']", "# Custom Reviewer\n\nUse before merging Python changes.\n")
538
+ page.locator("#entity-editor-form button[type='submit']").click()
539
+ _wait_for_browser_state(
540
+ page,
541
+ "() => document.getElementById('entity-editor-status').textContent.includes('saved agent:custom-reviewer')",
542
+ timeout=5.0,
543
+ )
544
+ assert entity_path.is_file()
545
+
546
+ page.fill("#manage-search", "custom")
547
+ _wait_for_browser_state(
548
+ page,
549
+ "() => document.getElementById('manage-search-status').textContent === '1 result'",
550
+ timeout=5.0,
551
+ )
552
+ page.locator(".manage-result[data-slug='custom-reviewer']").click()
553
+ _wait_for_browser_state(
554
+ page,
555
+ "() => document.getElementById('entity-editor-status').textContent.includes('editing agent:custom-reviewer')",
556
+ timeout=5.0,
557
+ )
558
+ assert page.locator("input[name='title']").input_value() == "Custom Reviewer"
559
+
560
+ page.once("dialog", lambda dialog: dialog.accept())
561
+ page.fill("input[name='title']", "Custom Reviewer Updated")
562
+ page.locator("#entity-editor-form button[type='submit']").click()
563
+ _wait_for_browser_state(
564
+ page,
565
+ "() => document.getElementById('entity-editor-status').textContent.includes('saved agent:custom-reviewer')",
566
+ timeout=5.0,
567
+ )
568
+ assert "title: Custom Reviewer Updated" in entity_path.read_text(encoding="utf-8")
569
+
570
+ page.once("dialog", lambda dialog: dialog.accept())
571
+ page.locator("[data-testid='entity-delete-button']").click()
572
+ _wait_for_browser_state(
573
+ page,
574
+ "() => document.getElementById('entity-editor-status').textContent.includes('deleted agent:custom-reviewer')",
575
+ timeout=5.0,
576
+ )
577
+ assert not entity_path.exists()
578
+ finally:
579
+ harness.close()
580
+
581
+
582
+ def test_config_and_harness_pages_support_browser_wizard_flows(
583
+ fake_claude: Path,
584
+ monkeypatch: pytest.MonkeyPatch,
585
+ page: Any,
586
+ ) -> None:
587
+ _write_wiki_entity(
588
+ fake_claude,
589
+ "harness",
590
+ "langgraph",
591
+ "---\n"
592
+ "title: LangGraph harness\n"
593
+ "type: harness\n"
594
+ "description: Durable Python agent workflows with tool routing.\n"
595
+ "tags: [python, api, local, verification]\n"
596
+ "repo_url: https://github.com/langchain-ai/langgraph\n"
597
+ "---\n"
598
+ "# LangGraph harness\n",
599
+ )
600
+ _write_quality_sidecar(fake_claude, "langgraph-harness", {
601
+ "slug": "langgraph",
602
+ "subject_type": "harness",
603
+ "grade": "A",
604
+ "raw_score": 0.93,
605
+ })
606
+
607
+ harness = _start_monitor(monkeypatch, fake_load=False)
608
+ try:
609
+ page.goto(f"{harness.base_url}/config")
610
+ page.wait_for_selector("#config-form", timeout=5000)
611
+ page.fill("input[name='skill_transformer.line_threshold']", "240")
612
+ page.locator("#config-form button[type='submit']").click()
613
+ _wait_for_browser_state(
614
+ page,
615
+ "() => document.getElementById('config-msg').textContent.includes('saved 1 config keys')",
616
+ timeout=5.0,
617
+ )
618
+ config = json.loads((fake_claude / "skill-system-config.json").read_text(encoding="utf-8"))
619
+ assert config["skill_transformer"]["line_threshold"] == 240
620
+
621
+ page.goto(f"{harness.base_url}/harness")
622
+ page.wait_for_selector("#harness-wizard-form", timeout=5000)
623
+ page.select_option("select[name='model_provider']", "huggingface")
624
+ page.fill("input[name='model']", "HuggingFaceTB/SmolLM2-135M-Instruct")
625
+ page.fill(
626
+ "textarea[name='goal']",
627
+ "Build a local Python code-review harness with pytest verification.",
628
+ )
629
+ page.fill("input[name='verify']", "pytest")
630
+ _wait_for_browser_state(
631
+ page,
632
+ "() => document.querySelector('[data-testid=\"harness-command-output\"]').textContent.includes('--model-provider \"huggingface\"')",
633
+ timeout=5.0,
634
+ )
635
+ command = page.locator("[data-testid='harness-command-output']").inner_text()
636
+ assert "--model \"HuggingFaceTB/SmolLM2-135M-Instruct\"" in command
637
+ assert "--plan-on-no-fit" in command
638
+ assert page.locator(".harness-card[data-harness-slug='langgraph']").count() == 1
639
+
640
+ page.locator("[data-select-harness='langgraph']").click()
641
+ selected = page.locator("#selected-harness-command").inner_text()
642
+ assert "ctx-harness-install langgraph --dry-run" in selected
643
+ assert "ctx-scan-repo --repo . --recommend" in selected
644
+ finally:
645
+ harness.close()
646
+
647
+
648
+ def test_sessions_kpi_and_runtime_pages_render_populated_browser_data(
649
+ fake_claude: Path,
650
+ monkeypatch: pytest.MonkeyPatch,
651
+ page: Any,
652
+ tmp_path: Path,
653
+ ) -> None:
654
+ (fake_claude / "ctx-audit.jsonl").write_text(
655
+ json.dumps({
656
+ "ts": "2026-06-16T10:00:00Z",
657
+ "event": "skill.loaded",
658
+ "subject": "python-patterns",
659
+ "subject_type": "skill",
660
+ "actor": "hook",
661
+ "session_id": "browser-session",
662
+ }) + "\n",
663
+ encoding="utf-8",
664
+ )
665
+ (fake_claude / "skill-events.jsonl").write_text(
666
+ json.dumps({
667
+ "timestamp": "2026-06-16T10:00:01Z",
668
+ "event": "load",
669
+ "skill": "python-patterns",
670
+ "session_id": "browser-session",
671
+ }) + "\n",
672
+ encoding="utf-8",
673
+ )
674
+ _write_quality_sidecar(fake_claude, "alpha", {
675
+ "slug": "alpha",
676
+ "subject_type": "skill",
677
+ "grade": "A",
678
+ "raw_score": 0.92,
679
+ "score": 0.92,
680
+ "computed_at": "2026-06-16T10:00:00Z",
681
+ })
682
+ runtime_path = tmp_path / "runtime" / "events.jsonl"
683
+ monkeypatch.setattr(cm, "_runtime_lifecycle_path", lambda: runtime_path)
684
+ _write_runtime_events(runtime_path, [
685
+ {
686
+ "action": "validation",
687
+ "session_id": "browser-session",
688
+ "check_name": "pytest",
689
+ "status": "failed",
690
+ "summary": "one failing test",
691
+ "created_at": "2026-06-16T10:02:00Z",
692
+ },
693
+ {
694
+ "action": "escalation",
695
+ "session_id": "browser-session",
696
+ "trigger": "validation-failed",
697
+ "reason": "pytest failed",
698
+ "status": "open",
699
+ "severity": "blocking",
700
+ "created_at": "2026-06-16T10:03:00Z",
701
+ },
702
+ ])
703
+
704
+ harness = _start_monitor(monkeypatch, fake_load=False)
705
+ try:
706
+ page.goto(f"{harness.base_url}/sessions")
707
+ page.wait_for_selector("table", timeout=5000)
708
+ assert "browser-session" in page.locator("body").inner_text()
709
+ assert "1 unique sessions observed" in page.locator("body").inner_text()
710
+
711
+ page.goto(f"{harness.base_url}/kpi")
712
+ page.wait_for_selector("h1", timeout=5000)
713
+ kpi_text = page.locator("body").inner_text()
714
+ assert "Total entities: 1" in kpi_text
715
+ assert "Grade distribution" in kpi_text
716
+ assert "A: 1" in kpi_text
717
+
718
+ page.goto(f"{harness.base_url}/runtime")
719
+ page.wait_for_selector("h1", timeout=5000)
720
+ runtime_text = page.locator("body").inner_text()
721
+ assert "1 validations / 1 failed / 1 open escalations" in runtime_text
722
+ assert "pytest" in runtime_text
723
+ assert "validation-failed" in runtime_text
724
+ finally:
725
+ harness.close()
726
+
727
+
728
  def test_events_page_shows_backlog_and_appends_live_events(
729
  fake_claude: Path,
730
  monkeypatch: pytest.MonkeyPatch,
src/tests/test_harness_cli_run.py CHANGED
@@ -124,9 +124,11 @@ class TestResolveApiKeyEnv:
124
  def test_inferred_from_model_prefix(self) -> None:
125
  assert _resolve_api_key_env(None, "openrouter/x", None) == "OPENROUTER_API_KEY"
126
  assert _resolve_api_key_env(None, "anthropic/claude", None) == "ANTHROPIC_API_KEY"
 
127
 
128
  def test_inferred_from_provider_flag(self) -> None:
129
  assert _resolve_api_key_env(None, "custom-x", "openai") == "OPENAI_API_KEY"
 
130
 
131
  def test_ollama_returns_none(self) -> None:
132
  assert _resolve_api_key_env(None, "ollama/llama3", None) is None
 
124
  def test_inferred_from_model_prefix(self) -> None:
125
  assert _resolve_api_key_env(None, "openrouter/x", None) == "OPENROUTER_API_KEY"
126
  assert _resolve_api_key_env(None, "anthropic/claude", None) == "ANTHROPIC_API_KEY"
127
+ assert _resolve_api_key_env(None, "huggingface/org-model", None) == "HF_TOKEN"
128
 
129
  def test_inferred_from_provider_flag(self) -> None:
130
  assert _resolve_api_key_env(None, "custom-x", "openai") == "OPENAI_API_KEY"
131
+ assert _resolve_api_key_env(None, "custom-x", "huggingface") == "HF_TOKEN"
132
 
133
  def test_ollama_returns_none(self) -> None:
134
  assert _resolve_api_key_env(None, "ollama/llama3", None) is None
src/tests/test_harness_install.py CHANGED
@@ -991,6 +991,8 @@ def test_recommend_mode_passes_structured_harness_requirements(
991
  "build a code agent",
992
  "--model",
993
  "openai/gpt-5.5",
 
 
994
  "--harness-runtime",
995
  "windows python",
996
  "--harness-autonomy",
@@ -1013,6 +1015,7 @@ def test_recommend_mode_passes_structured_harness_requirements(
1013
  "verification": "pytest ruff",
1014
  "privacy": "private repo no secrets",
1015
  "attach_mode": "mcp",
 
1016
  }
1017
 
1018
 
@@ -1099,6 +1102,32 @@ def test_recommend_no_fit_prints_custom_harness_plan(
1099
  assert "Privacy/network: offline source code" in output
1100
 
1101
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1102
  def test_recommend_no_fit_writes_custom_harness_plan(
1103
  tmp_path: Path,
1104
  monkeypatch: Any,
 
991
  "build a code agent",
992
  "--model",
993
  "openai/gpt-5.5",
994
+ "--api-key-env",
995
+ "OPENAI_API_KEY",
996
  "--harness-runtime",
997
  "windows python",
998
  "--harness-autonomy",
 
1015
  "verification": "pytest ruff",
1016
  "privacy": "private repo no secrets",
1017
  "attach_mode": "mcp",
1018
+ "api_key_env": "OPENAI_API_KEY",
1019
  }
1020
 
1021
 
 
1102
  assert "Privacy/network: offline source code" in output
1103
 
1104
 
1105
+ def test_recommend_mode_accepts_huggingface_api_key_env(
1106
+ monkeypatch: Any,
1107
+ capsys: Any,
1108
+ ) -> None:
1109
+ monkeypatch.setattr(harness_install, "recommend_harnesses_for_cli", lambda **_: [])
1110
+
1111
+ rc = harness_install.main([
1112
+ "--recommend",
1113
+ "--goal",
1114
+ "build a code review harness for a small hosted model",
1115
+ "--model-provider",
1116
+ "huggingface",
1117
+ "--model",
1118
+ "HuggingFaceTB/SmolLM2-135M-Instruct",
1119
+ "--api-key-env",
1120
+ "HF_TOKEN",
1121
+ "--plan-on-no-fit",
1122
+ ])
1123
+
1124
+ assert rc == 0
1125
+ output = capsys.readouterr().out
1126
+ assert "Model provider: huggingface" in output
1127
+ assert "API key env var: HF_TOKEN" in output
1128
+ assert "hf_" not in output
1129
+
1130
+
1131
  def test_recommend_no_fit_writes_custom_harness_plan(
1132
  tmp_path: Path,
1133
  monkeypatch: Any,
src/tests/test_harness_recommendations.py CHANGED
@@ -13,6 +13,17 @@ import ctx_init
13
  import scan_repo
14
  from ctx.core.resolve import resolve_skills
15
 
 
 
 
 
 
 
 
 
 
 
 
16
 
17
  def _minimal_profile() -> dict[str, Any]:
18
  return {
@@ -295,6 +306,58 @@ def test_ctx_init_recommends_harness_from_wiki_frontmatter_only(
295
  assert set(results[0]["fit_signals"]) >= {"agent", "automation", "browser", "openai"}
296
 
297
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
298
  def test_ctx_init_rejects_weak_single_signal_harness_match(
299
  monkeypatch: pytest.MonkeyPatch,
300
  ) -> None:
 
13
  import scan_repo
14
  from ctx.core.resolve import resolve_skills
15
 
16
+ _REAL_LOAD_HARNESS_CATALOG_GRAPH = ctx_init._load_harness_catalog_graph
17
+
18
+
19
+ @pytest.fixture(autouse=True)
20
+ def _isolate_harness_catalog_fast_path(monkeypatch: pytest.MonkeyPatch) -> None:
21
+ monkeypatch.setattr(
22
+ ctx_init,
23
+ "_load_harness_catalog_graph",
24
+ ctx_init._empty_harness_graph,
25
+ )
26
+
27
 
28
  def _minimal_profile() -> dict[str, Any]:
29
  return {
 
306
  assert set(results[0]["fit_signals"]) >= {"agent", "automation", "browser", "openai"}
307
 
308
 
309
+ def test_ctx_init_uses_harness_catalog_without_loading_full_graph(
310
+ tmp_path: Path,
311
+ monkeypatch: pytest.MonkeyPatch,
312
+ ) -> None:
313
+ harnesses = tmp_path / "entities" / "harnesses"
314
+ harnesses.mkdir(parents=True)
315
+ (harnesses / "hf-code-review.md").write_text(
316
+ "---\n"
317
+ "name: hf-code-review\n"
318
+ "type: harness\n"
319
+ "tags: [harness, huggingface, python, code, review, pytest, filesystem]\n"
320
+ "model_providers: [huggingface]\n"
321
+ "capabilities: [code review, pytest verification, filesystem tools]\n"
322
+ "runtimes: [python, windows, macos, linux]\n"
323
+ "attach_modes: [mcp]\n"
324
+ "---\n\n# HF Code Review\n",
325
+ encoding="utf-8",
326
+ )
327
+
328
+ import ctx_config
329
+
330
+ monkeypatch.setattr(
331
+ ctx_config,
332
+ "cfg",
333
+ type("Cfg", (), {
334
+ "wiki_dir": tmp_path,
335
+ "claude_dir": tmp_path / ".claude",
336
+ "recommendation_top_k": 5,
337
+ "harness_recommendation_min_fit_score": 0.85,
338
+ })(),
339
+ )
340
+ monkeypatch.setattr(
341
+ ctx_init,
342
+ "_load_recommendation_graph",
343
+ lambda: pytest.fail("interactive harness recommendation loaded full graph"),
344
+ )
345
+ monkeypatch.setattr(
346
+ ctx_init,
347
+ "_load_harness_catalog_graph",
348
+ _REAL_LOAD_HARNESS_CATALOG_GRAPH,
349
+ )
350
+
351
+ results = ctx_init.recommend_harnesses(
352
+ "build a huggingface python code review harness with pytest and filesystem tools",
353
+ model_provider="huggingface",
354
+ model="HuggingFaceTB/SmolLM2-135M-Instruct",
355
+ )
356
+
357
+ assert [row["name"] for row in results] == ["hf-code-review"]
358
+ assert results[0]["fit_score"] >= 0.85
359
+
360
+
361
  def test_ctx_init_rejects_weak_single_signal_harness_match(
362
  monkeypatch: pytest.MonkeyPatch,
363
  ) -> None:
src/tests/test_package_scaffold.py CHANGED
@@ -45,6 +45,7 @@ _EXPECTED_CONSOLE_SCRIPTS: tuple[str, ...] = (
45
  "ctx-agent-add",
46
  "ctx-agent-install",
47
  "ctx-agent-mirror",
 
48
  "ctx-bundle-suggest",
49
  "ctx-dedup-check",
50
  "ctx-harness-add",
@@ -71,6 +72,7 @@ _EXPECTED_CONSOLE_SCRIPTS: tuple[str, ...] = (
71
  "ctx-skill-install",
72
  "ctx-skill-mirror",
73
  "ctx-skill-quality",
 
74
  "ctx-source-registry",
75
  "ctx-tag-backfill",
76
  "ctx-toolbox",
 
45
  "ctx-agent-add",
46
  "ctx-agent-install",
47
  "ctx-agent-mirror",
48
+ "ctx-agent-unload",
49
  "ctx-bundle-suggest",
50
  "ctx-dedup-check",
51
  "ctx-harness-add",
 
72
  "ctx-skill-install",
73
  "ctx-skill-mirror",
74
  "ctx-skill-quality",
75
+ "ctx-skill-unload",
76
  "ctx-source-registry",
77
  "ctx-tag-backfill",
78
  "ctx-toolbox",
src/tests/test_scan_repo.py CHANGED
@@ -219,6 +219,19 @@ class TestReadJsonSafe:
219
  result = sr.read_json_safe(str(p))
220
  assert result == {"name": "x", "version": "1.0"}
221
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
  def test_invalid_json_returns_none(self, tmp_path: Path) -> None:
223
  p = _write(tmp_path / "bad.json", "not json {{")
224
  assert sr.read_json_safe(str(p)) is None
 
219
  result = sr.read_json_safe(str(p))
220
  assert result == {"name": "x", "version": "1.0"}
221
 
222
+ def test_valid_json_with_utf8_bom_returns_dict(self, tmp_path: Path) -> None:
223
+ p = tmp_path / "package.json"
224
+ p.write_bytes(
225
+ b'\xef\xbb\xbf{"scripts":{"dev":"next dev"},"dependencies":{"next":"latest"}}'
226
+ )
227
+
228
+ result = sr.read_json_safe(str(p))
229
+
230
+ assert result == {
231
+ "scripts": {"dev": "next dev"},
232
+ "dependencies": {"next": "latest"},
233
+ }
234
+
235
  def test_invalid_json_returns_none(self, tmp_path: Path) -> None:
236
  p = _write(tmp_path / "bad.json", "not json {{")
237
  assert sr.read_json_safe(str(p)) is None
src/tests/test_skill_unload.py CHANGED
@@ -183,6 +183,7 @@ def test_unload_from_session_writes_manifest_event_and_audit(
183
  assert len(events) == 1
184
  assert events[0]["event"] == "unload"
185
  assert events[0]["skill"] == "real-skill"
 
186
  assert events[0]["session_id"] == "session-123"
187
  assert events[0]["meta"] == {"source": "skill_unload"}
188
  assert audit_calls == [(
@@ -196,6 +197,59 @@ def test_unload_from_session_writes_manifest_event_and_audit(
196
  )]
197
 
198
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
199
  def test_permanent_suppression_updates_graph_node(fake_home):
200
  unload, home = fake_home
201
  graph_dir = home / ".claude" / "skill-wiki" / "graphify-out"
 
183
  assert len(events) == 1
184
  assert events[0]["event"] == "unload"
185
  assert events[0]["skill"] == "real-skill"
186
+ assert events[0]["entity_type"] == "skill"
187
  assert events[0]["session_id"] == "session-123"
188
  assert events[0]["meta"] == {"source": "skill_unload"}
189
  assert audit_calls == [(
 
197
  )]
198
 
199
 
200
+ def test_agent_unload_entrypoint_only_removes_agent_when_slug_matches_skill(
201
+ fake_home,
202
+ monkeypatch: pytest.MonkeyPatch,
203
+ capsys: pytest.CaptureFixture[str],
204
+ ) -> None:
205
+ unload, home = fake_home
206
+ agent_page = home / ".claude" / "skill-wiki" / "entities" / "agents" / "real-skill.md"
207
+ agent_page.write_text(
208
+ "---\nname: real-skill\nstatus: installed\n---\n\n# agent real-skill\n",
209
+ encoding="utf-8",
210
+ )
211
+ audit_calls: list[tuple[str, str, dict[str, object]]] = []
212
+ fake_audit = types.SimpleNamespace(
213
+ log_skill_event=lambda event, slug, **kwargs: audit_calls.append(
214
+ (event, slug, kwargs)
215
+ )
216
+ )
217
+ monkeypatch.setitem(sys.modules, "ctx_audit_log", fake_audit)
218
+ manifest_path = home / ".claude" / "skill-manifest.json"
219
+ manifest_path.write_text(
220
+ json.dumps({
221
+ "load": [{
222
+ "skill": "real-skill",
223
+ "entity_type": "skill",
224
+ "source": "test",
225
+ }, {
226
+ "skill": "real-skill",
227
+ "entity_type": "agent",
228
+ "source": "test",
229
+ }],
230
+ "unload": [],
231
+ "warnings": [],
232
+ }),
233
+ encoding="utf-8",
234
+ )
235
+
236
+ unload.main(["--name", "real-skill"], default_entity_type="agent")
237
+
238
+ assert "Unloaded from session: real-skill" in capsys.readouterr().out
239
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
240
+ assert manifest["load"] == [{
241
+ "skill": "real-skill",
242
+ "entity_type": "skill",
243
+ "source": "test",
244
+ }]
245
+ assert manifest["unload"] == [{
246
+ "skill": "real-skill",
247
+ "entity_type": "agent",
248
+ "source": "test",
249
+ }]
250
+ assert audit_calls[0][0] == "agent.unloaded"
251
+
252
+
253
  def test_permanent_suppression_updates_graph_node(fake_home):
254
  unload, home = fake_home
255
  graph_dir = home / ".claude" / "skill-wiki" / "graphify-out"