From 52ceb67d24b16014a4e0db29850c2dc8261257d7 Mon Sep 17 00:00:00 2001 From: stevesolun Date: Thu, 28 May 2026 00:05:22 +0300 Subject: [PATCH 1/8] Align incremental attach with runtime recommendations --- src/ctx/adapters/generic/ctx_core_tools.py | 4 +- src/ctx/core/graph/incremental_attach.py | 19 +++++- .../test_incremental_attach_calibration.py | 32 +++++++++- .../test_recommendation_surfaces_golden.py | 61 +++++++++++++++++++ 4 files changed, 110 insertions(+), 6 deletions(-) diff --git a/src/ctx/adapters/generic/ctx_core_tools.py b/src/ctx/adapters/generic/ctx_core_tools.py index 4a75039..0c353ff 100644 --- a/src/ctx/adapters/generic/ctx_core_tools.py +++ b/src/ctx/adapters/generic/ctx_core_tools.py @@ -320,7 +320,8 @@ def _dispatch_recommend(self, args: dict[str, Any]) -> str: ) tags = _query_to_tags(query) - if not tags: + use_semantic_query = bool(args.get("use_semantic_query")) + if not tags and not use_semantic_query: return json.dumps({ "error": "query produced no usable tags", "results": [], @@ -335,7 +336,6 @@ def _dispatch_recommend(self, args: dict[str, Any]) -> str: from ctx.core.resolve.recommendations import recommend_by_tags # noqa: PLC0415 - use_semantic_query = bool(args.get("use_semantic_query")) if use_semantic_query: self._refresh_semantic_cache_signature() raw = recommend_by_tags( diff --git a/src/ctx/core/graph/incremental_attach.py b/src/ctx/core/graph/incremental_attach.py index f952e73..297a9ad 100644 --- a/src/ctx/core/graph/incremental_attach.py +++ b/src/ctx/core/graph/incremental_attach.py @@ -20,7 +20,7 @@ from ctx.core.graph.vector_index import load_vector_index _PERCENTILES = (50, 60, 75, 90, 95) -_DEFAULT_MIN_SEMANTIC_SCORE = 0.75 +_DEFAULT_MIN_SEMANTIC_SCORE = 0.80 _DEFAULT_MIN_FINAL_WEIGHT = 0.03 _ATTACH_METHOD = "ann_attach_v1" @@ -218,7 +218,7 @@ def main(argv: list[str] | None = None) -> int: attach.add_argument("--embedding-backend", default="sentence-transformers") attach.add_argument("--embedding-model") attach.add_argument("--top-k", type=int, default=20) - attach.add_argument("--min-score", type=float, default=_DEFAULT_MIN_SEMANTIC_SCORE) + attach.add_argument("--min-score", type=float) attach.add_argument("--min-final-weight", type=float, default=_DEFAULT_MIN_FINAL_WEIGHT) attach.add_argument("--dry-run", action="store_true", help="Print the overlay record without writing") attach.add_argument("--json", action="store_true", help="Emit machine-readable JSON") @@ -246,7 +246,11 @@ def main(argv: list[str] | None = None) -> int: vector_json=args.vector_json, model_id=args.model_id, top_k=args.top_k, - min_score=args.min_score, + min_score=( + args.min_score + if args.min_score is not None + else _default_min_semantic_score() + ), min_final_weight=args.min_final_weight, dry_run=args.dry_run, embedding_backend=args.embedding_backend, @@ -275,6 +279,15 @@ def _resolve_text_input(text: str | None, text_file: str | None) -> str | None: raise ValueError(f"cannot read --text-file {path}") from exc +def _default_min_semantic_score() -> float: + try: + from ctx_config import cfg # noqa: PLC0415 + + return float(cfg.graph_semantic_min_cosine) + except Exception: # pragma: no cover - standalone CLI fallback. + return _DEFAULT_MIN_SEMANTIC_SCORE + + def _read_index_meta(index_dir: Path) -> dict[str, Any]: meta_path = index_dir / "vector-index.meta.json" try: diff --git a/src/tests/test_incremental_attach_calibration.py b/src/tests/test_incremental_attach_calibration.py index cc6fbe3..2053b01 100644 --- a/src/tests/test_incremental_attach_calibration.py +++ b/src/tests/test_incremental_attach_calibration.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json + import networkx as nx import numpy as np import pytest @@ -47,7 +49,7 @@ def test_calibrate_attach_defaults_ignores_missing_semantic_scores() -> None: assert summary.semantic_score_percentiles == {} assert summary.final_weight_percentiles[50] == pytest.approx(0.4) - assert summary.recommended_min_semantic_score == pytest.approx(0.75) + assert summary.recommended_min_semantic_score == pytest.approx(0.80) assert summary.recommended_max_edges_per_node == 1 @@ -201,3 +203,31 @@ def test_main_attach_writes_idempotent_overlay_used_by_resolver(tmp_path) -> Non assert overlay.read_text(encoding="utf-8").count("\n") == 2 assert loaded_after_change.has_edge("skill:new-python", "skill:ruby-testing") assert not loaded_after_change.has_edge("skill:new-python", "skill:python-testing") + + +def test_main_attach_default_min_score_matches_runtime_semantic_floor(tmp_path) -> None: + index_dir = tmp_path / "vector-index" + build_vector_index( + kind="numpy-flat", + model_id="model-a", + node_ids=["skill:almost-python"], + content_hashes=["ha"], + vectors=np.asarray([[0.76, 0.65]], dtype="float32"), + ).save(index_dir) + overlay = tmp_path / "entity-overlays.jsonl" + + assert main([ + "attach", + "--index-dir", str(index_dir), + "--overlay", str(overlay), + "--node-id", "skill:new-python", + "--label", "new-python", + "--type", "skill", + "--text", "new python testing helper", + "--model-id", "model-a", + "--vector-json", "[1.0, 0.0]", + "--top-k", "1", + ]) == 0 + + payload = json.loads(overlay.read_text(encoding="utf-8")) + assert payload["edges"] == [] diff --git a/src/tests/test_recommendation_surfaces_golden.py b/src/tests/test_recommendation_surfaces_golden.py index 4907e36..93da706 100644 --- a/src/tests/test_recommendation_surfaces_golden.py +++ b/src/tests/test_recommendation_surfaces_golden.py @@ -305,6 +305,67 @@ def fake_recommend_by_tags( assert calls["use_semantic_query"] is True +def test_generic_toolbox_allows_tagless_semantic_query_opt_in( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + graph_path = tmp_path / "skill-wiki" / "graphify-out" / "graph.json" + _write_golden_graph(graph_path) + calls: dict[str, Any] = {} + + def fake_recommend_by_tags( + graph: Any, + tags: list[str], + **kwargs: Any, + ) -> list[dict[str, Any]]: + calls["tags"] = tags + calls.update(kwargs) + return [ + { + "name": "go-helper", + "type": "skill", + "score": 0.9, + "normalized_score": 1.0, + } + ] + + monkeypatch.setitem( + sys.modules, + "ctx.core.resolve.recommendations", + type( + "FakeRecommendModule", + (), + { + "query_to_tags": staticmethod(lambda query: []), + "recommend_by_tags": staticmethod(fake_recommend_by_tags), + }, + ), + ) + toolbox = CtxCoreToolbox( + wiki_dir=tmp_path / "skill-wiki", + graph_path=graph_path, + ) + + payload = json.loads( + toolbox.dispatch( + ToolCall( + id="semantic", + name="ctx__recommend_bundle", + arguments={ + "query": "go", + "top_k": 3, + "use_semantic_query": True, + }, + ) + ) + ) + + assert payload["results"][0]["name"] == "go-helper" + assert calls["tags"] == [] + assert calls["query"] == "go" + assert calls["use_semantic_query"] is True + + def test_scan_repo_recommendations_use_shared_graph_bundle( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, From 9030e2150cf90ed567078bdd023b2b22cd42f1b1 Mon Sep 17 00:00:00 2001 From: stevesolun Date: Thu, 28 May 2026 00:13:34 +0300 Subject: [PATCH 2/8] Tighten similarity CI and graph score validation --- .github/workflows/test.yml | 2 +- scripts/ci_required.py | 11 +++++- src/tests/test_ci_classifier.py | 19 ++++++++++ src/tests/test_validate_graph_artifacts.py | 42 ++++++++++++++++++++++ src/validate_graph_artifacts.py | 37 ++++++++++++++++++- 5 files changed, 108 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f98dd69..25e77b5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -129,7 +129,7 @@ jobs: similarity-integration: name: "Similarity precision/recall" needs: classify - if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.graph_only != 'true' && needs.classify.outputs.similarity_changed == 'true') }} + if: ${{ github.event_name != 'pull_request' || (needs.classify.outputs.docs_only != 'true' && needs.classify.outputs.similarity_changed == 'true') }} runs-on: ubuntu-latest steps: - name: Checkout diff --git a/scripts/ci_required.py b/scripts/ci_required.py index b48600d..63f61d9 100644 --- a/scripts/ci_required.py +++ b/scripts/ci_required.py @@ -75,6 +75,10 @@ def failed_required_jobs( event_name == "pull_request" and _job_output(needs, "classify", "graph_artifact_changed") == "true" ) + similarity_changed_pr = ( + event_name == "pull_request" + and _job_output(needs, "classify", "similarity_changed") == "true" + ) cheap_pr = docs_only_pr or graph_only_pr for name, details in sorted(needs.items()): result = details.get("result") @@ -86,7 +90,12 @@ def failed_required_jobs( and result == "skipped" ): continue - if cheap_pr and name in CHEAP_PR_SKIPPABLE_JOBS and result == "skipped": + if ( + cheap_pr + and name in CHEAP_PR_SKIPPABLE_JOBS + and not (name == "similarity-integration" and similarity_changed_pr) + and result == "skipped" + ): continue if ( event_name == "pull_request" diff --git a/src/tests/test_ci_classifier.py b/src/tests/test_ci_classifier.py index 3d9bf0a..cd34bec 100644 --- a/src/tests/test_ci_classifier.py +++ b/src/tests/test_ci_classifier.py @@ -593,6 +593,25 @@ def test_ci_required_rejects_missing_similarity_gate_on_source_pr() -> None: } +def test_ci_required_rejects_missing_similarity_gate_on_graph_only_pr() -> None: + needs = _required_needs( + classify={ + "result": "success", + "outputs": { + "docs_only": "false", + "graph_only": "true", + "graph_artifact_changed": "true", + "similarity_changed": "true", + }, + }, + **{"similarity-integration": {"result": "skipped"}}, + ) + + assert failed_required_jobs(needs, event_name="pull_request") == { + "similarity-integration": "skipped", + } + + def test_ci_required_allows_similarity_skip_for_unrelated_source_pr() -> None: needs = _required_needs( classify={ diff --git a/src/tests/test_validate_graph_artifacts.py b/src/tests/test_validate_graph_artifacts.py index 00c14e4..fa32393 100644 --- a/src/tests/test_validate_graph_artifacts.py +++ b/src/tests/test_validate_graph_artifacts.py @@ -854,6 +854,48 @@ def test_scan_graph_json_handles_pretty_printed_graph() -> None: assert _scan_graph_json(BytesIO(payload)) == (2, 2, 1, 1, 1, None) +def test_scan_graph_json_rejects_out_of_range_edge_scores() -> None: + graph = { + "nodes": [{"id": "skill:a", "type": "skill"}], + "edges": [ + { + "source": "skill:a", + "target": "skill:b", + "semantic_sim": 2.0, + "tag_sim": 0.0, + "token_sim": 0.0, + "weight": 0.5, + "final_weight": 0.5, + }, + ], + } + payload = json.dumps(graph).encode("utf-8") + + with pytest.raises(GraphArtifactError, match="semantic_sim must be 0..1"): + _scan_graph_json(BytesIO(payload)) + + +def test_scan_graph_json_rejects_weight_final_weight_drift() -> None: + graph = { + "nodes": [{"id": "skill:a", "type": "skill"}], + "edges": [ + { + "source": "skill:a", + "target": "skill:b", + "semantic_sim": 0.8, + "tag_sim": 0.0, + "token_sim": 0.0, + "weight": 0.7, + "final_weight": 0.5, + }, + ], + } + payload = json.dumps(graph).encode("utf-8") + + with pytest.raises(GraphArtifactError, match="weight must equal final_weight"): + _scan_graph_json(BytesIO(payload)) + + @pytest.mark.parametrize("field", ["semantic_sim", "tag_sim", "token_sim"]) def test_overlay_validation_rejects_out_of_range_similarity_fields( tmp_path: Path, diff --git a/src/validate_graph_artifacts.py b/src/validate_graph_artifacts.py index 2397d71..4445ee1 100644 --- a/src/validate_graph_artifacts.py +++ b/src/validate_graph_artifacts.py @@ -58,6 +58,11 @@ _SEMANTIC_SIM_RE = re.compile( rb'"semantic_sim"\s*:\s*(-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)', ) +_EDGE_SCORE_VALUE_RE = re.compile( + rb'"(weight|final_weight|similarity_score|semantic_sim|tag_sim|token_sim)"' + rb'\s*:\s*("[^"]*"|-?(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?)', +) +_EDGE_OBJECT_RE = re.compile(rb'\{[^{}]*"source"\s*:[^{}]*"target"\s*:[^{}]*\}') _EDGE_SCORE_FIELDS = ( "weight", "final_weight", @@ -348,6 +353,8 @@ def _scan_graph_json(stream: IO[bytes]) -> tuple[int, int, int, int, int, str | while chunk := stream.read(1024 * 1024): old_tail = tail data = tail + chunk + _validate_graph_edge_score_fields(data) + _validate_graph_edge_weight_drift(data) if export_id is None: graph_probe = (graph_probe + chunk)[-1024 * 1024:] export_id = _extract_graph_export_id(graph_probe) @@ -365,7 +372,7 @@ def _scan_graph_json(stream: IO[bytes]) -> tuple[int, int, int, int, int, str | len(_HARNESS_TYPE_RE.findall(data)) - len(_HARNESS_TYPE_RE.findall(old_tail)) ) - tail = data[-512:] + tail = data[-65536:] return nodes, edges, semantic_edges, skills_sh_nodes, harness_nodes, export_id @@ -432,6 +439,34 @@ def _count_nonzero_semantic_matches(data: bytes) -> int: return count +def _validate_graph_edge_score_fields(data: bytes) -> None: + for match in _EDGE_SCORE_VALUE_RE.finditer(data): + field = match.group(1).decode("ascii") + raw_value = match.group(2) + try: + value = float(raw_value) + except ValueError as exc: + raise GraphArtifactError(f"graph.json edge {field} must be numeric") from exc + if not 0 <= value <= 1: + raise GraphArtifactError(f"graph.json edge {field} must be 0..1") + + +def _validate_graph_edge_weight_drift(data: bytes) -> None: + for match in _EDGE_OBJECT_RE.finditer(data): + edge = match.group(0) + values: dict[str, float] = {} + for field_match in _EDGE_SCORE_VALUE_RE.finditer(edge): + field = field_match.group(1).decode("ascii") + if field in {"weight", "final_weight"}: + values[field] = float(field_match.group(2)) + if ( + "weight" in values + and "final_weight" in values + and abs(values["weight"] - values["final_weight"]) > 1e-9 + ): + raise GraphArtifactError("graph.json edge weight must equal final_weight") + + def _catalog_skills(catalog: dict[str, Any]) -> list[dict[str, Any]]: raw = catalog.get("skills", []) return [item for item in raw if isinstance(item, dict)] From 3942fb9a373edf3a7fbb2b749b721f9aa8714b8c Mon Sep 17 00:00:00 2001 From: stevesolun Date: Thu, 28 May 2026 00:37:29 +0300 Subject: [PATCH 3/8] Refresh graph stats coverage in docs --- README.md | 4 +- docs/index.md | 21 ++- docs/knowledge-graph.md | 23 ++- src/tests/test_update_repo_stats.py | 91 +++++++++++- src/update_repo_stats.py | 217 +++++++++++++++++++++++++++- 5 files changed, 328 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 5dac2d9..4caab36 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Python 3.11+](https://img.shields.io/badge/Python-3.11+-green.svg)](https://python.org) [![PyPI](https://img.shields.io/pypi/v/claude-ctx.svg)](https://pypi.org/project/claude-ctx/) -[![Tests](https://img.shields.io/badge/Tests-3872_collected-brightgreen.svg)](#) +[![Tests](https://img.shields.io/badge/Tests-3879_collected-brightgreen.svg)](#) [![Graph](https://img.shields.io/badge/Graph-102%2C928_nodes_/_2.9M_edges-red.svg)](graph/) [![Docs](https://img.shields.io/badge/docs-MkDocs_Material-blue.svg)](https://stevesolun.github.io/ctx/) [![Repo views](https://hits.sh/github.com/stevesolun/ctx.svg?label=repo%20views)](https://hits.sh/github.com/stevesolun/ctx/) @@ -62,7 +62,7 @@ ctx-init --graph --graph-install-mode full ``` The full `wiki-graph.tar.gz` includes the shipped skill index, -91,463 skill entity pages under `entities/skills/`, 89,465 hydrated +91,464 skill entity pages under `entities/skills/`, 89,465 hydrated installable `SKILL.md` files under `converted/`, and 207 harness pages under `entities/harnesses/`. diff --git a/docs/index.md b/docs/index.md index 58b9a7b..5bf234c 100644 --- a/docs/index.md +++ b/docs/index.md @@ -7,8 +7,7 @@ hide: [![Repo views](https://hits.sh/github.com/stevesolun/ctx.svg?label=repo%20views)](https://hits.sh/github.com/stevesolun/ctx/) -Watches what you develop, walks a knowledge graph of **91,463 skill pages, 467 -agents, 10,788 MCP servers, and 207 harnesses**, and recommends the +Watches what you develop, walks a knowledge graph of **91,464 skill pages, 467 agents, 10,790 MCP servers, and 207 cataloged harnesses**, and recommends the right execution bundle on the fly. The live execution bundle is skills, agents, and MCP servers only; custom/API/local model users get a separate harness recommendation based on model choice and task goal. You decide @@ -91,12 +90,12 @@ graph-based discovery: - A Karpathy 3-layer wiki at `~/.claude/skill-wiki/` is the single source of truth. -- **102,925 graph nodes** for the shipped skill/agent/MCP/harness - inventory, including 91,463 skill pages +- **102,928 graph nodes** for the shipped skill/agent/MCP/harness + inventory, including 91,464 skill pages and 207 harness pages under `entities/harnesses/`. Each page tracks tags, status, provenance, and usage where it applies. -- A **knowledge graph** (102,925 nodes, 2,913,930 edges) built from a - 13,460-node core plus 89,465 body-backed skill nodes. +- A **knowledge graph** (102,928 nodes, 2,913,960 edges) built from a + 13,463-node core plus 89,465 body-backed skill nodes. The graph has 52 Louvain communities and blends semantic cosine, tag overlap, and slug-token overlap; 89,465 skill bodies are shipped as installable `SKILL.md` files. Entries over the configured line @@ -132,9 +131,8 @@ ones are flagged. New ones self-ingest. --- - 102,925 shipped graph nodes: 13,460 curated skill/agent/MCP/harness - nodes plus 89,465 body-backed skill nodes. The graph has - 2,913,930 weighted edges and 52 Louvain communities. + 102,928 shipped graph nodes: 13,463 curated skill/agent/MCP/harness nodes plus 89,465 body-backed skill nodes. The graph has + 2,913,960 weighted edges and 52 Louvain communities. Ships pre-built in `graph/wiki-graph.tar.gz` and powers the graph-aware recommendations + the pre-ship `ctx-dedup-check` gate. @@ -204,14 +202,13 @@ ones are flagged. New ones self-ingest. --- **v1.0.9** — MIT, CI-matrixed (Ubuntu 3.12 plus Windows/macOS 3.11/3.12), - 3,872 tests collected. Ships console scripts including `ctx-init`, + 3,879 tests collected. Ships console scripts including `ctx-init`, `ctx-monitor` (local dashboard with graph + wiki + load/unload for skills, agents, and MCP servers, plus Harness Setup for user-owned LLMs), `ctx-incremental-attach`, `ctx-incremental-shadow`, `ctx-dedup-check` (pre-ship near-duplicate gate), and `ctx-tag-backfill` (entity hygiene), plus a fast runtime graph artifact - and the full ~439 MiB wiki tarball with **102,925 nodes / 2,913,930 - edges / 52 Louvain communities**. + and the full ~439 MiB wiki tarball with **102,928 nodes / 2,913,960 edges / 52 Louvain communities**. [:octicons-arrow-right-24: CHANGELOG](https://github.com/stevesolun/ctx/blob/main/CHANGELOG.md) · [Repository](https://github.com/stevesolun/ctx) diff --git a/docs/knowledge-graph.md b/docs/knowledge-graph.md index 5bfb28b..7eb5702 100644 --- a/docs/knowledge-graph.md +++ b/docs/knowledge-graph.md @@ -13,10 +13,9 @@ agents, and MCP servers. ## What's in it Authoritative numbers from the shipped tarball. The curated-core snapshot -is **13,460 nodes** (1,998 curated skills + 467 agents + 10,788 MCP servers -+ 207 harnesses). Harness pages under `entities/harnesses/` are ingested into +is **13,463 nodes** (1,999 curated skills + 467 agents + 10,790 MCP servers + 207 harnesses). Harness pages under `entities/harnesses/` are ingested into local rebuilds and the separate harness recommendation path. The -tarball also carries **91,463 skill pages**; **89,465** +tarball also carries **91,464 skill pages**; **89,465** skill bodies are hydrated as installable `SKILL.md` files under `converted/`; the **28,612** entries over the configured line limit were converted to gated micro-skill orchestrators. Full original bodies @@ -26,18 +25,18 @@ are omitted from the shipped tarball. | | Count | |---|---:| -| Total nodes | **102,925** | -| Curated core nodes | **13,460** (1,998 skills + 467 agents + 10,788 MCP servers + 207 harnesses) | +| Total nodes | **102,928** | +| Curated core nodes | **13,463** (1,999 skills + 467 agents + 10,790 MCP servers + 207 harnesses) | | Body-backed skill nodes | **89,465** hydrated installable skill entries | -| Total edges | **2,913,930** | +| Total edges | **2,913,960** | | Hydrated skill incident edges | **2,605,721** | | Hydrated skill semantic incident edges | **1,500,648** | | Communities | **52** (Louvain) | -| Edge sources (overlap-deduped) | semantic 1,683,163 - tag 897,754 - token 433,245 | -| Cross-type edges (skill <-> agent) | ~67K | -| Cross-type edges (skill <-> MCP) | ~41K | -| Cross-type edges (agent <-> MCP) | ~223 | -| Harness edges | **6,571** | +| Edge sources (overlap-deduped) | semantic 1,683,193 - tag 897,784 - token 433,245 | +| Cross-type edges (skill <-> agent) | ~66,799 | +| Cross-type edges (skill <-> MCP) | ~41,521 | +| Cross-type edges (agent <-> MCP) | ~229 | +| Harness edges | **6,576** | | Shipped skill index | **89,465** observed body-backed skill entries | ## Install @@ -169,7 +168,7 @@ raw = json.loads( edges_key = "links" if "links" in raw else "edges" G = node_link_graph(raw, edges=edges_key) -# 102,925 nodes, 2,913,930 edges +# 102,928 nodes, 2,913,960 edges print(G.number_of_nodes(), G.number_of_edges()) # Find entities related to 'fastapi-pro' by edge weight diff --git a/src/tests/test_update_repo_stats.py b/src/tests/test_update_repo_stats.py index 3e0f515..fd913af 100644 --- a/src/tests/test_update_repo_stats.py +++ b/src/tests/test_update_repo_stats.py @@ -134,7 +134,12 @@ def test_tarball_stats_uses_report_when_graph_json_is_large( ], ) - assert urs._read_graph_from_tarball() == { + stats = urs._read_graph_from_tarball() + assert stats is not None + assert { + key: stats[key] + for key in ("nodes", "edges", "skills", "agents", "mcps", "harnesses", "communities") + } == { "nodes": 104078, "edges": 2881027, "skills": 1, @@ -187,6 +192,55 @@ def test_docs_landing_test_count_is_updated() -> None: assert "3,617 tests collected" not in patched +def test_knowledge_graph_counts_are_updated() -> None: + text = "\n".join([ + "| Total nodes | **102,925** |", + "| Curated core nodes | **13,460** (1,998 skills + 467 agents + 10,788 MCP servers + 207 harnesses) |", + "| Body-backed skill nodes | **89,463** hydrated installable skill entries |", + "| Total edges | **2,913,930** |", + "| Hydrated skill incident edges | **2,605,000** |", + "| Hydrated skill semantic incident edges | **1,500,000** |", + "| Edge sources (overlap-deduped) | semantic 1,683,163 - tag 897,754 - token 433,245 |", + "| Cross-type edges (skill <-> agent) | ~67K |", + "| Cross-type edges (skill <-> MCP) | ~41K |", + "| Cross-type edges (agent <-> MCP) | ~223 |", + "| Harness edges | **6,571** |", + ]) + stats = { + "nodes": 102928, + "edges": 2913960, + "skills": 91464, + "agents": 467, + "mcps": 10790, + "harnesses": 207, + "communities": 52, + "skills_sh_entries": 89465, + "skills_sh_bodies": 89465, + "semantic_edges": 1683193, + "tag_edges": 897784, + "token_edges": 433245, + "hydrated_incident_edges": 2605721, + "hydrated_semantic_incident_edges": 1500648, + "cross_skill_agent_edges": 66799, + "cross_skill_mcp_edges": 41521, + "cross_agent_mcp_edges": 229, + "harness_edges": 6576, + } + patched = text + for pattern, replacement in urs.build_replacements(stats, tests=None, converted=None): + patched = pattern.sub(replacement, patched) + + assert "| Total nodes | **102,928** |" in patched + assert "| Curated core nodes | **13,463** (1,999 skills + 467 agents + 10,790 MCP servers + 207 harnesses) |" in patched + assert "| Body-backed skill nodes | **89,465** hydrated installable skill entries |" in patched + assert "| Total edges | **2,913,960** |" in patched + assert "semantic 1,683,193 - tag 897,784 - token 433,245" in patched + assert "| Cross-type edges (skill <-> agent) | ~66,799 |" in patched + assert "| Cross-type edges (skill <-> MCP) | ~41,521 |" in patched + assert "| Cross-type edges (agent <-> MCP) | ~229 |" in patched + assert "| Harness edges | **6,576** |" in patched + + def test_harness_aware_readme_prose_is_updated() -> None: text = ( "walks a **1,000 skills, 20 agents, 30 MCP servers, " @@ -208,6 +262,41 @@ def test_harness_aware_readme_prose_is_updated() -> None: assert "**92,815 skills, 464 agents, 10,787 MCP servers, and 13 harnesses**" in patched +def test_patch_readme_checks_knowledge_graph_doc( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + readme = tmp_path / "README.md" + docs = tmp_path / "docs" + docs.mkdir() + docs_index = docs / "index.md" + docs_knowledge = docs / "knowledge-graph.md" + readme.write_text("Graph has 10 nodes and 20 edges\n", encoding="utf-8") + docs_index.write_text("3 tests collected\n", encoding="utf-8") + docs_knowledge.write_text("| Total nodes | **10** |\n", encoding="utf-8") + monkeypatch.setattr(urs, "REPO_ROOT", tmp_path) + monkeypatch.setattr(urs, "README", readme) + monkeypatch.setattr(urs, "DOCS_INDEX", docs_index) + monkeypatch.setattr(urs, "DOCS_KNOWLEDGE_GRAPH", docs_knowledge) + monkeypatch.setattr( + urs, + "read_graph_stats", + lambda: { + "nodes": 11, + "edges": 21, + "skills": None, + "agents": None, + "mcps": None, + "harnesses": None, + "communities": 1, + }, + ) + monkeypatch.setattr(urs, "read_test_count", lambda: None) + monkeypatch.setattr(urs, "read_converted_count", lambda: None) + + assert urs.patch_readme(check_only=True) == 1 + + def test_read_test_count_prefers_project_python( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/src/update_repo_stats.py b/src/update_repo_stats.py index 0c8c874..993af5f 100644 --- a/src/update_repo_stats.py +++ b/src/update_repo_stats.py @@ -32,6 +32,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent README = REPO_ROOT / "README.md" DOCS_INDEX = REPO_ROOT / "docs" / "index.md" +DOCS_KNOWLEDGE_GRAPH = REPO_ROOT / "docs" / "knowledge-graph.md" _MAX_TAR_JSON_BYTES = 512 * 1024 * 1024 _MAX_TAR_TEXT_BYTES = 2 * 1024 * 1024 _GRAPH_JSON_MEMBER = "graphify-out/graph.json" @@ -117,6 +118,63 @@ def _parse_graph_report(text: str) -> dict[str, int]: } +def _read_edge_source_stats(tf: tarfile.TarFile) -> dict[str, int]: + matches = [ + member for member in tf.getmembers() if _safe_tar_name(member.name) == _GRAPH_JSON_MEMBER + ] + if len(matches) != 1 or not matches[0].isfile(): + return {} + graph_stream = tf.extractfile(matches[0]) + if graph_stream is None: + return {} + try: + graph = json.load(graph_stream) + except json.JSONDecodeError: + return {} + finally: + graph_stream.close() + edges = graph.get("edges", []) if isinstance(graph, dict) else [] + totals = { + "semantic_edges": 0, + "tag_edges": 0, + "token_edges": 0, + "hydrated_incident_edges": 0, + "hydrated_semantic_incident_edges": 0, + "cross_skill_agent_edges": 0, + "cross_skill_mcp_edges": 0, + "cross_agent_mcp_edges": 0, + "harness_edges": 0, + } + for edge in edges: + if not isinstance(edge, dict): + continue + source = str(edge.get("source") or "") + target = str(edge.get("target") or "") + semantic = float(edge.get("semantic_sim") or 0.0) != 0.0 + if semantic: + totals["semantic_edges"] += 1 + if float(edge.get("tag_sim") or 0.0) != 0.0: + totals["tag_edges"] += 1 + if float(edge.get("token_sim") or 0.0) != 0.0: + totals["token_edges"] += 1 + if "skills-sh-" in source or "skills-sh-" in target: + totals["hydrated_incident_edges"] += 1 + if semantic: + totals["hydrated_semantic_incident_edges"] += 1 + source_type = source.split(":", 1)[0] + target_type = target.split(":", 1)[0] + edge_types = {source_type, target_type} + if edge_types == {"skill", "agent"}: + totals["cross_skill_agent_edges"] += 1 + elif edge_types == {"skill", "mcp-server"}: + totals["cross_skill_mcp_edges"] += 1 + elif edge_types == {"agent", "mcp-server"}: + totals["cross_agent_mcp_edges"] += 1 + if source_type == "harness" or target_type == "harness": + totals["harness_edges"] += 1 + return totals + + def _read_skills_sh_catalog_stats() -> dict[str, int]: catalog_path = REPO_ROOT / "graph" / "skills-sh-catalog.json.gz" if not catalog_path.exists(): @@ -235,6 +293,7 @@ def _read_graph_from_tarball() -> dict[str, int | None] | None: for key in ("nodes", "edges", "communities"): if key in parsed: stats[key] = parsed[key] + stats.update(_read_edge_source_stats(tf)) if stats["nodes"] is None or stats["edges"] is None: body = _read_json_member(tf, _GRAPH_JSON_MEMBER) @@ -452,6 +511,16 @@ def build_replacements( f"{stats['mcps']:,} MCP servers, and " f"{stats['harnesses']:,} harnesses**", )) + reps.append(( + re.compile( + r"\*\*[\d,]+\s+skill pages,\s+[\d,]+\s+agents,\s+" + r"[\d,]+\s+MCP\s+servers,\s+and\s+[\d,]+\s+" + r"(?:cataloged\s+)?harnesses\*\*" + ), + f"**{s:,} skill pages, {stats['agents']:,} agents, " + f"{stats['mcps']:,} MCP servers, and " + f"{stats['harnesses']:,} cataloged harnesses**", + )) # 3-type pattern: "1,789 skills, 464 agents, and 10,786 MCP servers" # Order matters — this regex is more specific than the 2-type one # below, so match it first. Handles the MCP-aware tagline that @@ -497,6 +566,10 @@ def build_replacements( re.compile(r"\*\*[\d,]+-node\*\*\s+graph"), f"**{n:,}-node** graph", )) + reps.append(( + re.compile(r"\*\*[\d,]+\s+graph nodes\*\*"), + f"**{n:,} graph nodes**", + )) # "A pre-built knowledge graph of 2,211 nodes and 642K edges" # style phrasing. Caught a stale v0.6.0 README sentence that # the older regex only matched on "nodes, edges, communities". @@ -504,11 +577,23 @@ def build_replacements( re.compile(r"([\d,]+)\s+nodes\s+and\s+[\d,.]+[KM]?\s+edges"), f"{n:,} nodes and {e_fmt} edges", )) + reps.append(( + re.compile(r"\(([\d,]+)\s+nodes,\s+[\d,]+\s+edges\)"), + f"({n:,} nodes, {e:,} edges)", + )) + reps.append(( + re.compile(r"\*\*[\d,]+\s+nodes\s*/\s*[\d,]+\s+edges\s*/\s*[\d,]+\s+Louvain communities\*\*"), + f"**{n:,} nodes / {e:,} edges / {stats['communities']:,} Louvain communities**", + )) # Graph.json inline Python example: "# 2,211 nodes, 642,468 edges" reps.append(( re.compile(r"#\s*([\d,]+)\s+nodes,\s*([\d,]+)\s+edges"), f"# {n:,} nodes, {e:,} edges", )) + reps.append(( + re.compile(r"[\d,]+ weighted edges and [\d,]+ Louvain communities"), + f"{e:,} weighted edges and {stats['communities']:,} Louvain communities", + )) # "2,211 nodes, 642K edges, 865 communities" reps.append((re.compile(r"([\d,]+)\s+nodes,\s+[\w.]+\s+edges,\s+([\d,]+)\s+communities"), f"{n:,} nodes, {e_fmt} edges, {stats['communities']:,} communities")) @@ -526,6 +611,102 @@ def build_replacements( f"**{n:,} entity pages** ({stats['skills']:,} skills + {stats['agents']:,} agents)", )) + bodies_for_core = stats.get("skills_sh_bodies") + if ( + bodies_for_core is not None + and stats.get("skills") + and stats.get("agents") + and stats.get("mcps") + and stats.get("harnesses") + ): + core_nodes = int(n) - int(bodies_for_core) + curated_skills = int(stats["skills"] or 0) - int(bodies_for_core) + reps.append(( + re.compile( + r"[\d,]+-node core plus [\d,]+ body-backed skill nodes" + ), + f"{core_nodes:,}-node core plus {int(bodies_for_core):,} " + "body-backed skill nodes", + )) + reps.append(( + re.compile( + r"[\d,]+ shipped graph nodes: [\d,]+ curated " + r"skill/agent/MCP/harness\s+nodes plus [\d,]+ " + r"body-backed skill nodes" + ), + f"{n:,} shipped graph nodes: {core_nodes:,} curated " + "skill/agent/MCP/harness nodes plus " + f"{int(bodies_for_core):,} body-backed skill nodes", + )) + reps.append(( + re.compile( + r"\*\*[\d,]+\*\* \([\d,]+ skills \+ [\d,]+ agents " + r"\+ [\d,]+ MCP servers \+ [\d,]+ harnesses\)" + ), + f"**{core_nodes:,}** ({curated_skills:,} skills + " + f"{stats['agents']:,} agents + {stats['mcps']:,} MCP servers " + f"+ {stats['harnesses']:,} harnesses)", + )) + reps.append(( + re.compile(r"including [\d,]+ skill pages"), + f"including {stats['skills']:,} skill pages", + )) + reps.append(( + re.compile( + r"is \*\*[\d,]+ nodes\*\* \([\d,]+ curated skills " + r"\+ [\d,]+ agents \+ [\d,]+ MCP servers\s+\+ " + r"[\d,]+ harnesses\)" + ), + f"is **{core_nodes:,} nodes** ({curated_skills:,} curated skills " + f"+ {stats['agents']:,} agents + {stats['mcps']:,} MCP servers " + f"+ {stats['harnesses']:,} harnesses)", + )) + + table_values = { + "Total nodes": n, + "Total edges": e, + "Harness edges": stats.get("harness_edges"), + } + for label, value in table_values.items(): + if value is not None: + reps.append(( + re.compile(rf"\| {re.escape(label)} \| \*\*[\d,]+\*\* \|"), + f"| {label} | **{int(value):,}** |", + )) + + if stats.get("semantic_edges") and stats.get("tag_edges") and stats.get("token_edges"): + reps.append(( + re.compile( + r"semantic [\d,]+ - tag [\d,]+ - token [\d,]+" + ), + f"semantic {stats['semantic_edges']:,} - " + f"tag {stats['tag_edges']:,} - token {stats['token_edges']:,}", + )) + + for label, key in ( + ("Hydrated skill incident edges", "hydrated_incident_edges"), + ("Hydrated skill semantic incident edges", "hydrated_semantic_incident_edges"), + ): + value = stats.get(key) + if value is not None: + reps.append(( + re.compile(rf"\| {re.escape(label)} \| \*\*?[\d,]+\*?\*? \|"), + f"| {label} | **{int(value):,}** |", + )) + + for label, key in ( + ("Cross-type edges \\(skill <-> agent\\)", "cross_skill_agent_edges"), + ("Cross-type edges \\(skill <-> MCP\\)", "cross_skill_mcp_edges"), + ("Cross-type edges \\(agent <-> MCP\\)", "cross_agent_mcp_edges"), + ): + value = stats.get(key) + if value is not None: + display_label = label.replace("\\", "") + reps.append(( + re.compile(rf"\| {label} \| ~?[\d,.]+[KM]? \|"), + f"| {display_label} | ~{int(value):,} |", + )) + skills_sh_entries = stats.get("skills_sh_entries") skills_sh_bodies = stats.get("skills_sh_bodies") if skills_sh_entries is not None and skills_sh_bodies is not None: @@ -541,6 +722,40 @@ def build_replacements( f"**{skill_pages:,} skill entity pages**, with **{bodies:,}** " "hydrated installable `SKILL.md` bodies.", )) + reps.append(( + re.compile( + r"[\d,]+ skill entity pages under `entities/skills/`, " + r"[\d,]+ hydrated" + ), + f"{skill_pages:,} skill entity pages under `entities/skills/`, " + f"{bodies:,} hydrated", + )) + reps.append(( + re.compile(r"\*\*[\d,]+ skill pages\*\*; \*\*[\d,]+\*\*"), + f"**{skill_pages:,} skill pages**; **{bodies:,}**", + )) + reps.append(( + re.compile( + r"\*\*[\d,]+\*\*\s+hydrated installable skill entries" + ), + f"**{bodies:,}** hydrated installable skill entries", + )) + reps.append(( + re.compile( + r"\| Body-backed skill nodes \| \*\*[\d,]+\*\* " + r"hydrated installable skill entries \|" + ), + f"| Body-backed skill nodes | **{bodies:,}** " + "hydrated installable skill entries |", + )) + reps.append(( + re.compile(r"\*\*[\d,]+\*\*\s+observed body-backed skill entries"), + f"**{bodies:,}** observed body-backed skill entries", + )) + reps.append(( + re.compile(r"\*\*[\d,]+\*\*\s+have hydrated catalog bodies"), + f"**{bodies:,}** have hydrated catalog bodies", + )) reps.append(( re.compile( r"The shipped wiki includes [\d,]+(?: Skills\.sh)? entries, " @@ -603,7 +818,7 @@ def patch_readme(check_only: bool = False) -> int: print(f"warning: could not resolve {missing}; those fields will be left untouched", file=sys.stderr) changes: list[tuple[Path, str, str]] = [] - for target in (README, DOCS_INDEX): + for target in (README, DOCS_INDEX, DOCS_KNOWLEDGE_GRAPH): if not target.exists(): continue replacements = ( From 148192f8e0163bf0c8848d715826724074206a99 Mon Sep 17 00:00:00 2001 From: stevesolun Date: Thu, 28 May 2026 00:45:55 +0300 Subject: [PATCH 4/8] Validate dashboard read host headers --- src/ctx_monitor.py | 3 ++- src/tests/test_ctx_monitor.py | 40 ++++++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/ctx_monitor.py b/src/ctx_monitor.py index 313c1c6..4063802 100644 --- a/src/ctx_monitor.py +++ b/src/ctx_monitor.py @@ -6409,8 +6409,9 @@ def _api_reads_enabled(self) -> bool: return self._mutations_enabled() def _read_authorized(self, qs: dict[str, str]) -> bool: + request_host = _request_host_name(self.headers.get("Host", "")) if self._mutations_enabled(): - return True + return _host_allows_mutations(request_host) token = self.headers.get("X-CTX-Monitor-Token") or qs.get("token", "") return bool(_MONITOR_TOKEN) and secrets.compare_digest(token, _MONITOR_TOKEN) diff --git a/src/tests/test_ctx_monitor.py b/src/tests/test_ctx_monitor.py index 81d0ae2..c350f3c 100644 --- a/src/tests/test_ctx_monitor.py +++ b/src/tests/test_ctx_monitor.py @@ -127,6 +127,25 @@ def _get_json(port: int, path: str) -> tuple[int, dict]: return exc.code, json.loads(exc.read().decode("utf-8")) +def _get_raw( + port: int, + path: str, + *, + headers: dict[str, str], +) -> tuple[int, dict]: + conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5) + try: + conn.putrequest("GET", path, skip_host=True) + for key, value in headers.items(): + conn.putheader(key, value) + conn.endheaders() + response = conn.getresponse() + payload = json.loads(response.read().decode("utf-8")) + return response.status, payload + finally: + conn.close() + + def _post_raw( port: int, path: str, @@ -136,7 +155,7 @@ def _post_raw( ) -> tuple[int, dict]: conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5) try: - conn.putrequest("POST", path) + conn.putrequest("POST", path, skip_host=True) for key, value in headers.items(): conn.putheader(key, value) conn.endheaders() @@ -814,6 +833,25 @@ def fake_load(slug: str, entity_type: str = "skill") -> tuple[bool, str]: thread.join(timeout=2) +def test_monitor_get_rejects_rebound_host_in_loopback_mode( + fake_claude: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + server, thread, port = _serve_monitor(monkeypatch) + try: + status, payload = _get_raw( + port, + "/api/status.json", + headers={"Host": "evil.example"}, + ) + assert status == 403 + assert "monitor read" in payload["detail"] + finally: + server.shutdown() + server.server_close() + thread.join(timeout=2) + + @pytest.mark.parametrize( ("length", "status", "detail"), [ From 4fd8daa1db536a618dfa4cd28b8dee226753a024 Mon Sep 17 00:00:00 2001 From: stevesolun Date: Thu, 28 May 2026 00:51:47 +0300 Subject: [PATCH 5/8] Guard dashboard entity delete live state --- src/ctx_monitor.py | 22 +++++++++++++ src/tests/test_ctx_monitor.py | 61 +++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+) diff --git a/src/ctx_monitor.py b/src/ctx_monitor.py index 4063802..a615f6f 100644 --- a/src/ctx_monitor.py +++ b/src/ctx_monitor.py @@ -528,6 +528,20 @@ def _upsert_wiki_entity(payload: dict[str, Any]) -> tuple[bool, str]: return True, f"saved {entity_type}:{slug} and queued graph refresh" +def _entity_live_in_manifest(slug: str, entity_type: str) -> bool: + manifest = _read_manifest() + for entry in manifest.get("load", []): + if not isinstance(entry, dict): + continue + entry_slug = str(entry.get("skill") or entry.get("slug") or "") + entry_type = _normalize_dashboard_entity_type( + str(entry.get("entity_type") or entry.get("type") or "skill"), + ) + if entry_slug == slug and entry_type == entity_type: + return True + return False + + def _delete_wiki_entity(slug: str, entity_type: str) -> tuple[bool, str]: try: normalized = _normalize_dashboard_entity_type(entity_type) @@ -538,6 +552,14 @@ def _delete_wiki_entity(slug: str, entity_type: str) -> tuple[bool, str]: path = _wiki_entity_path(slug, entity_type=normalized) if path is None: return False, f"no wiki entity found for {normalized}:{slug}" + if _entity_live_in_manifest(slug, normalized): + unloaded, unload_detail = _perform_unload(slug, normalized) + if not unloaded: + return ( + False, + f"{normalized}:{slug} is loaded; unload before delete failed: " + f"{unload_detail}", + ) with file_lock(path): path.unlink() _queue_entity_refresh( diff --git a/src/tests/test_ctx_monitor.py b/src/tests/test_ctx_monitor.py index c350f3c..b1e8921 100644 --- a/src/tests/test_ctx_monitor.py +++ b/src/tests/test_ctx_monitor.py @@ -2608,6 +2608,67 @@ def test_entity_delete_api_removes_wiki_page_and_queues_graph_refresh( server.server_close() +def test_entity_delete_unloads_live_entity_before_removing_page( + fake_claude: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + skill_dir = fake_claude / "skill-wiki" / "entities" / "skills" + skill_dir.mkdir(parents=True) + entity_path = skill_dir / "python-patterns.md" + entity_path.write_text( + "---\ntitle: Python Patterns\ntype: skill\n---\n# Python Patterns\n", + encoding="utf-8", + ) + calls: list[tuple[str, str]] = [] + monkeypatch.setattr( + cm, + "_read_manifest", + lambda: {"load": [{"skill": "python-patterns", "entity_type": "skill"}]}, + ) + + def fake_unload(slug: str, entity_type: str = "skill") -> tuple[bool, str]: + calls.append((slug, entity_type)) + return True, "unloaded" + + monkeypatch.setattr(cm, "_perform_unload", fake_unload) + + ok, detail = cm._delete_wiki_entity("python-patterns", "skill") + + assert ok is True + assert "deleted skill:python-patterns" in detail + assert calls == [("python-patterns", "skill")] + assert not entity_path.exists() + + +def test_entity_delete_keeps_page_when_live_unload_fails( + fake_claude: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + harness_dir = fake_claude / "skill-wiki" / "entities" / "harnesses" + harness_dir.mkdir(parents=True) + entity_path = harness_dir / "local-harness.md" + entity_path.write_text( + "---\ntitle: Local Harness\ntype: harness\n---\n# Local Harness\n", + encoding="utf-8", + ) + monkeypatch.setattr( + cm, + "_read_manifest", + lambda: {"load": [{"skill": "local-harness", "entity_type": "harness"}]}, + ) + monkeypatch.setattr( + cm, + "_perform_unload", + lambda slug, entity_type="skill": (False, "use ctx-harness-install"), + ) + + ok, detail = cm._delete_wiki_entity("local-harness", "harness") + + assert ok is False + assert "is loaded" in detail + assert entity_path.exists() + + def test_sidecar_cache_invalidates_on_file_rewrite(fake_claude: Path) -> None: cm._SIDECAR_INDEX_CACHE_KEY = None cm._SIDECAR_INDEX_CACHE_VALUE = None From 273740be733782b86dd485cf457fc8c8a4c60704 Mon Sep 17 00:00:00 2001 From: stevesolun Date: Thu, 28 May 2026 00:59:01 +0300 Subject: [PATCH 6/8] Mirror added skills and agents for install --- src/agent_add.py | 9 ++++++ src/skill_add.py | 9 ++++++ src/tests/test_agent_add.py | 50 ++++++++++++++++++++++++++++++ src/tests/test_skill_add.py | 61 +++++++++++++++++++++++++++++++++++++ 4 files changed, 129 insertions(+) diff --git a/src/agent_add.py b/src/agent_add.py index 21d5652..4c45685 100644 --- a/src/agent_add.py +++ b/src/agent_add.py @@ -53,6 +53,14 @@ def install_agent(source: Path, agents_dir: Path, name: str) -> Path: return dest +def mirror_agent_body(installed_path: Path, wiki_path: Path, name: str) -> Path: + """Mirror the installed agent body into the wiki install source tree.""" + mirror_root = wiki_path / "converted-agents" + dest = mirror_root / f"{name}.md" + safe_copy_file(installed_path, dest, dest_root=mirror_root) + return dest + + def write_entity_page(wiki_path: Path, name: str, content: str) -> bool: """Write agent entity page. Returns True if newly created.""" page = wiki_path / "entities" / "agents" / f"{name}.md" @@ -147,6 +155,7 @@ def add_agent( # 1. Install into agents-dir. installed_path = install_agent(source_path, agents_dir, name) + mirror_agent_body(installed_path, wiki_path, name) # 2. Record embedding. Non-fatal on failure — install already # succeeded and a missing vector only weakens the next check. diff --git a/src/skill_add.py b/src/skill_add.py index 492e553..221e59e 100644 --- a/src/skill_add.py +++ b/src/skill_add.py @@ -52,6 +52,13 @@ def install_skill(source: Path, skills_dir: Path, name: str) -> Path: return dest +def mirror_install_body(installed_path: Path, name: str, converted_root: Path) -> Path: + """Mirror a short installed SKILL.md into the wiki install source tree.""" + dest = converted_root / name / "SKILL.md" + safe_copy_file(installed_path, dest, dest_root=converted_root) + return dest + + # ── Conversion ──────────────────────────────────────────────────────────────── def maybe_convert( @@ -368,6 +375,8 @@ def add_skill( # 2. Convert if above threshold converted_root = wiki_path / "converted" converted, pipeline_path = maybe_convert(installed_path, name, converted_root, line_count) + if not converted: + mirror_install_body(installed_path, name, converted_root) # 3. Detect related skills and scan sources (before writing new page) related = find_related_skills(wiki_path, name, tags) diff --git a/src/tests/test_agent_add.py b/src/tests/test_agent_add.py index 4830922..7c477e9 100644 --- a/src/tests/test_agent_add.py +++ b/src/tests/test_agent_add.py @@ -138,6 +138,56 @@ def test_existing_agent_update_existing_applies_change( ) +def test_new_agent_add_writes_converted_agent_mirror( + tmp_path: Path, + monkeypatch: Any, +) -> None: + wiki, agents_dir, source = _setup_paths(tmp_path) + source_text = _agent_text(description="Installable mirrored agent.") + source.write_text(source_text, encoding="utf-8") + _patch_side_effects(monkeypatch) + + result = agent_add.add_agent( + source_path=source, + name="reviewer-agent", + wiki_path=wiki, + agents_dir=agents_dir, + ) + + mirror = wiki / "converted-agents" / "reviewer-agent.md" + assert result["is_new_page"] is True + assert mirror.read_text(encoding="utf-8") == source_text + + +def test_existing_agent_update_refreshes_converted_agent_mirror( + tmp_path: Path, + monkeypatch: Any, +) -> None: + wiki, agents_dir, source = _setup_paths(tmp_path) + installed = agents_dir / "reviewer-agent.md" + installed.write_text(_agent_text(), encoding="utf-8") + mirror = wiki / "converted-agents" / "reviewer-agent.md" + mirror.parent.mkdir(parents=True) + mirror.write_text("old mirror\n", encoding="utf-8") + entity = wiki / "entities" / "agents" / "reviewer-agent.md" + entity.write_text("# existing entity\n", encoding="utf-8") + updated_text = _agent_text(description="Updated mirrored agent.") + source.write_text(updated_text, encoding="utf-8") + _patch_side_effects(monkeypatch) + + result = agent_add.add_agent( + source_path=source, + name="reviewer-agent", + wiki_path=wiki, + agents_dir=agents_dir, + review_existing=True, + update_existing=True, + ) + + assert result["is_new_page"] is False + assert mirror.read_text(encoding="utf-8") == updated_text + + def test_main_existing_agent_prints_update_review( tmp_path: Path, monkeypatch: Any, diff --git a/src/tests/test_skill_add.py b/src/tests/test_skill_add.py index a13ae52..71533b0 100644 --- a/src/tests/test_skill_add.py +++ b/src/tests/test_skill_add.py @@ -852,6 +852,67 @@ def test_existing_skill_update_existing_applies_change( assert installed.read_text(encoding="utf-8") == updated_text assert entity.read_text(encoding="utf-8").startswith("---\n") + def test_short_skill_add_writes_converted_install_mirror( + self, tmp_path, monkeypatch + ): + wiki = self._setup_wiki(tmp_path) + skills_dir = tmp_path / "skills" + source = tmp_path / "SKILL.md" + source_text = self._skill_text( + description="Short installable skill with enough prose for intake.", + tags=["python", "testing"], + ) + source.write_text(source_text, encoding="utf-8") + self._setup_intake_allow() + monkeypatch.setattr(_sa, "update_index", MagicMock()) + monkeypatch.setattr(_sa, "append_log", MagicMock()) + + result = add_skill( + source_path=source, + name="myskill", + wiki_path=wiki, + skills_dir=skills_dir, + ) + + mirror = wiki / "converted" / "myskill" / "SKILL.md" + assert result["converted"] is False + assert mirror.read_text(encoding="utf-8") == source_text + + def test_existing_short_skill_update_refreshes_converted_install_mirror( + self, tmp_path, monkeypatch + ): + wiki = self._setup_wiki(tmp_path) + skills_dir = tmp_path / "skills" + installed = skills_dir / "myskill" / "SKILL.md" + installed.parent.mkdir(parents=True) + installed.write_text(self._skill_text(), encoding="utf-8") + mirror = wiki / "converted" / "myskill" / "SKILL.md" + mirror.parent.mkdir(parents=True) + mirror.write_text("old mirror\n", encoding="utf-8") + entity = wiki / "entities" / "skills" / "myskill.md" + entity.write_text("# existing entity\n", encoding="utf-8") + source = tmp_path / "SKILL.md" + updated_text = self._skill_text( + description="Updated short skill with better guidance.", + tags=["python", "api"], + ) + source.write_text(updated_text, encoding="utf-8") + self._setup_intake_allow() + monkeypatch.setattr(_sa, "update_index", MagicMock()) + monkeypatch.setattr(_sa, "append_log", MagicMock()) + + result = add_skill( + source_path=source, + name="myskill", + wiki_path=wiki, + skills_dir=skills_dir, + review_existing=True, + update_existing=True, + ) + + assert result["converted"] is False + assert mirror.read_text(encoding="utf-8") == updated_text + def test_main_with_skip_existing_skips(self, tmp_path, monkeypatch, capsys): """--skip-existing skips already-installed skills.""" skills_dir = tmp_path / "skills" From 44a1361099c41b0e472cc676b40440468d5ba290 Mon Sep 17 00:00:00 2001 From: stevesolun Date: Thu, 28 May 2026 01:08:33 +0300 Subject: [PATCH 7/8] Refresh docs test count --- README.md | 2 +- docs/index.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 4caab36..6c81700 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) [![Python 3.11+](https://img.shields.io/badge/Python-3.11+-green.svg)](https://python.org) [![PyPI](https://img.shields.io/pypi/v/claude-ctx.svg)](https://pypi.org/project/claude-ctx/) -[![Tests](https://img.shields.io/badge/Tests-3879_collected-brightgreen.svg)](#) +[![Tests](https://img.shields.io/badge/Tests-3886_collected-brightgreen.svg)](#) [![Graph](https://img.shields.io/badge/Graph-102%2C928_nodes_/_2.9M_edges-red.svg)](graph/) [![Docs](https://img.shields.io/badge/docs-MkDocs_Material-blue.svg)](https://stevesolun.github.io/ctx/) [![Repo views](https://hits.sh/github.com/stevesolun/ctx.svg?label=repo%20views)](https://hits.sh/github.com/stevesolun/ctx/) diff --git a/docs/index.md b/docs/index.md index 5bf234c..29378d8 100644 --- a/docs/index.md +++ b/docs/index.md @@ -202,7 +202,7 @@ ones are flagged. New ones self-ingest. --- **v1.0.9** — MIT, CI-matrixed (Ubuntu 3.12 plus Windows/macOS 3.11/3.12), - 3,879 tests collected. Ships console scripts including `ctx-init`, + 3,886 tests collected. Ships console scripts including `ctx-init`, `ctx-monitor` (local dashboard with graph + wiki + load/unload for skills, agents, and MCP servers, plus Harness Setup for user-owned LLMs), `ctx-incremental-attach`, `ctx-incremental-shadow`, `ctx-dedup-check` From 56ec1ac00a315254af27942e743cc1849beb90a0 Mon Sep 17 00:00:00 2001 From: stevesolun Date: Thu, 28 May 2026 01:27:07 +0300 Subject: [PATCH 8/8] Fix dashboard raw POST host test helper --- src/tests/test_ctx_monitor.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/tests/test_ctx_monitor.py b/src/tests/test_ctx_monitor.py index b1e8921..9a35df6 100644 --- a/src/tests/test_ctx_monitor.py +++ b/src/tests/test_ctx_monitor.py @@ -156,6 +156,8 @@ def _post_raw( conn = http.client.HTTPConnection("127.0.0.1", port, timeout=5) try: conn.putrequest("POST", path, skip_host=True) + if "Host" not in headers: + conn.putheader("Host", f"127.0.0.1:{port}") for key, value in headers.items(): conn.putheader(key, value) conn.endheaders()