Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-3894_collected-brightgreen.svg)](#)
[![Tests](https://img.shields.io/badge/Tests-3907_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/)
Expand Down
26 changes: 20 additions & 6 deletions docs/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ and harness wiki/graph browsing.
```bash
ctx-monitor serve # http://127.0.0.1:8765
ctx-monitor serve --port 8888 # custom port
ctx-monitor serve --host 0.0.0.0 --port 8888 # LAN-visible HTML only (explicit opt-in)
ctx-monitor serve --host 0.0.0.0 --port 8888 # LAN read-only with startup token URL
```

Zero Python dependencies added by the dashboard. Everything runs on
Expand Down Expand Up @@ -150,7 +150,7 @@ Every page shows the same nav bar. The eleven tabs cover the
dashboard-supported observable surface of ctx:

```
Home · Loaded · Skills · Wiki · Graph · Status · KPIs · Runtime · Sessions · Logs · Live
Home · Loaded · Skills · Wiki · Graph · Manage · Harness Setup · Docs · Config · Status · KPIs · Runtime · Sessions · Logs · Live
```

### HTML views
Expand All @@ -173,6 +173,10 @@ per-process monitor token injected into the rendered page.
| `/wiki/<slug>?type=<entity>` | Dashboard-supported wiki entity page rendered: markdown body + full frontmatter table + grade banner + deep links to sidecar and graph-neighborhood views. The optional `type` query disambiguates duplicate slugs such as `langgraph`. |
| `/graph` | **Graph explorer landing page** - node/edge count header, a "Popular seed slugs" block (18 highest-degree skill/agent/MCP/harness entities as clickable chips), search box for any skill/agent/MCP/harness slug, and the built-in graph list panel. Clicking a seed chip navigates to `/graph?slug=<slug>&type=<entity>`. |
| `/graph?slug=<slug>&type=<entity>` | **Built-in** 1-hop neighborhood around the target skill/agent/MCP/harness slug. Entity pills identify skill, agent, MCP server, and harness rows. Tap any node to navigate to that entity's typed wiki page. Type and tag filters run client-side. |
| `/manage` | Search, inspect, edit, delete, and manually import skill/agent/MCP/harness wiki entities through the same safe-name and mutation-token checks as live load/unload. |
| `/harness` | Harness Setup wizard for non-Claude/custom API/local model users: collects model, goals, tool needs, safety constraints, and shows the harness recommendation/install path. |
| `/docs` | Local repo docs rendered inside the dashboard with MkDocs-like tabs, sidebar table of contents, in-dashboard search, and source links. |
| `/config` | Effective ctx config with defaults, required markers, field explanations, and editable user overrides where supported. |
| `/status` | Durable queue and artifact status: job counts by state, recent queue jobs, graph/wiki artifact sizes, and crash-safe promotion metadata. |
| `/kpi` | **KPI dashboard** — total entity count with subject breakdown, grade distribution pills, two-column tables for grade counts and lifecycle tiers (active · watch · demote · archive), hard-floor reasons with counts, **By category** table (count · avg score · A/B/C/D/F mix per category), **Top demotion candidates** (active/watch entries graded D or F, sorted by consecutive-D streak desc then score asc), and the **Archived** list. Same shape as `python -m kpi_dashboard render` but HTML |
| `/runtime` | Generic harness runtime ledger from `CTX_RUNTIME_LIFECYCLE_DIR` or `~/.ctx/runtime/events.jsonl`: validation totals, failed/error checks, recent validation rows, and open escalations. |
Expand All @@ -192,13 +196,18 @@ per-process monitor token injected into the rendered page.
| `GET /api/graph/<slug>.json?type=<entity>&hops=1&limit=40` | Dashboard-shaped skill/agent/MCP/harness `{nodes, edges, center}`; `type` is optional but recommended for duplicate slugs, `hops` is [1, 3], `limit` is [5, 150]. |
| `GET /api/kpi.json` | `DashboardSummary` passthrough — `{total, by_subject, grade_counts, lifecycle_counts, category_breakdown, hard_floor_counts, low_quality_candidates, archived, generated_at}`. Returns `{total: 0, detail: "no sidecars yet"}` when the quality directory is empty |
| `GET /api/runtime.json` | Runtime lifecycle summary: source path, validation count, failed/error count, open-escalation count, latest validation, recent validations, open escalations, and session IDs. |
| `GET /api/config.json` | Effective/default/user config payload used by the Config tab. |
| `GET /api/entities/search.json?q=<text>&type=<entity>&limit=80` | Wiki entity search results for Manage, Config, and entity picker flows. |
| `GET /api/entity/<slug>.json?type=<entity>` | Frontmatter and Markdown body for one wiki entity. |
| `GET /api/events.stream` | Server-sent events tail of `~/.claude/ctx-audit.jsonl` |

### Mutation endpoints

Dashboard GET views are read-only. When `ctx-monitor` is bound to a
non-loopback host, `/api/*` JSON and SSE routes are disabled; keep the
default loopback bind for local automation. Both POST endpoints enforce
non-loopback host, HTML, `/api/*` JSON, and SSE routes require the
read-token URL printed by `ctx-monitor`; the first successful token URL
sets an HttpOnly same-site cookie for dashboard navigation. Keep the
default loopback bind for local automation. POST endpoints enforce
same-origin (browser tab open on another origin can't forge a request), require the per-process
`X-CTX-Monitor-Token` injected into the dashboard page, and reject any
slug failing the shared safe-name validator. That validator blocks path
Expand All @@ -214,6 +223,9 @@ load/unload mutation endpoint yet.
| `POST /api/unload` | `{"slug": "...", "entity_type": "skill"}` | `skill_unload.unload_from_session([slug])` |
| `POST /api/unload` | `{"slug": "...", "entity_type": "agent"}` | remove the agent row from `skill-manifest.json` and append an unload row |
| `POST /api/unload` | `{"slug": "...", "entity_type": "mcp-server"}` | `mcp_install.uninstall_mcp(slug, wiki_dir=...)` |
| `POST /api/config` | `{"updates": {...}}` | persist supported user config overrides after validation |
| `POST /api/entity/upsert` | entity metadata/body payload | write or update a wiki entity, then attach graph/recommendation metadata |
| `POST /api/entity/delete` | `{"slug": "...", "entity_type": "skill"}` | remove a dashboard-supported wiki entity after safe-name validation |

Harness load/unload POSTs are rejected with the exact
`ctx-harness-install ... --dry-run` command to run instead. Skill rows emit
Expand Down Expand Up @@ -292,8 +304,10 @@ observability proof that ctx's telemetry pipeline is live.
## Security

- **Binds to 127.0.0.1 by default**. Use `--host 0.0.0.0` only if
you actually want LAN-visible. No authentication; the server is
intended for a local developer's own machine.
you actually want LAN-visible read-only access. The startup output
prints a one-process read-token URL; without that token or the cookie
it sets, LAN HTML/API/SSE requests return 403. Mutations remain
disabled on non-loopback binds.
- **Same-origin gating on mutation**. Any POST with an `Origin`
header that doesn't match `Host` returns 403. Curl and direct
tool calls are allowed (no Origin header at all).
Expand Down
4 changes: 3 additions & 1 deletion docs/entity-onboarding.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ the update is treated like a release step.
11. Unpark and stage the graph artifacts once the release candidate is final:
`python scripts/graph_artifact_guard.py unpark`, then `git add` the graph
artifacts intentionally. Run `python scripts/graph_artifact_guard.py prune`
after interrupted Git/LFS runs or after release staging.
after interrupted Git/LFS runs or after release staging to clean prunable
local LFS cache entries. Add `--include-git-prune` only when you explicitly
want repo-wide dangling Git objects removed too.

The durable wiki worker drains `entity-upsert`, `graph-export`,
`skill-index-refresh`, `tar-refresh`, and `artifact-promotion` jobs. Use
Expand Down
2 changes: 1 addition & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,894 tests collected. Ships console scripts including `ctx-init`,
3,907 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`
Expand Down
10 changes: 6 additions & 4 deletions graph/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,14 +206,16 @@ unpark and stage the artifacts exactly once:

```bash
python scripts/graph_artifact_guard.py unpark
git add graph/wiki-graph.tar.gz graph/wiki-graph-runtime.tar.gz graph/*.json.gz
git add graph/wiki-graph.tar.gz graph/wiki-graph-runtime.tar.gz \
graph/skills-sh-catalog.json.gz graph/communities.json graph/entity-overlays.jsonl
python scripts/graph_artifact_guard.py prune
```

If a local Git integration gets interrupted while artifacts are dirty,
`python scripts/graph_artifact_guard.py prune` removes unreachable local Git
objects and prunable local LFS cache entries. It does not delete tracked graph
files, rewrite history, or change the remote LFS store.
`python scripts/graph_artifact_guard.py prune` removes prunable local LFS cache
entries. It does not delete tracked graph files, rewrite history, or change the
remote LFS store. Repo-wide `git prune --expire=now` is intentionally opt-in via
`--include-git-prune` because it can discard unrelated dangling recovery objects.

For a bulk skill refresh, update the existing shipped tarball through the
release refresh path:
Expand Down
5 changes: 0 additions & 5 deletions scripts/ci_required.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,6 @@ def failed_required_jobs(
event_name == "pull_request"
and _job_output(needs, "classify", "graph_only") == "true"
)
graph_changed_pr = (
event_name == "pull_request"
and _job_output(needs, "classify", "graph_changed") == "true"
)
graph_artifact_changed_pr = (
event_name == "pull_request"
and _job_output(needs, "classify", "graph_artifact_changed") == "true"
Expand Down Expand Up @@ -109,7 +105,6 @@ def failed_required_jobs(
and name == "graph-check"
and result == "skipped"
and not graph_artifact_changed_pr
and not (graph_changed_pr and not docs_only_pr)
):
continue
if (
Expand Down
23 changes: 19 additions & 4 deletions scripts/graph_artifact_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,15 @@ def _print_status(repo: Path, artifacts: list[str]) -> None:
_run_git(repo, ["count-objects", "-vH"])


def _prune(repo: Path, *, include_lfs: bool) -> None:
_run_git(repo, ["prune", "--expire=now", "--verbose"])
def _prune(repo: Path, *, include_lfs: bool, include_git_prune: bool) -> None:
if include_git_prune:
_run_git(repo, ["prune", "--expire=now", "--verbose"])
if include_lfs:
result = _run_git(repo, ["lfs", "prune", "--verbose"], check=False)
if result.returncode != 0:
raise SystemExit(result.returncode)
if not include_lfs and not include_git_prune:
print("No prune action selected.")


def _clean_stale_graph_files(repo: Path, *, dry_run: bool) -> None:
Expand Down Expand Up @@ -127,7 +130,7 @@ def _parser() -> argparse.ArgumentParser:
help=(
"status shows skip-worktree state; park hides generated archives from "
"normal Git status/stage scans; unpark re-enables release staging; "
"prune removes unreachable local Git/LFS objects; clean-stale "
"prune removes prunable local LFS cache entries; clean-stale "
"removes interrupted graph promotion leftovers."
),
)
Expand All @@ -151,6 +154,14 @@ def _parser() -> argparse.ArgumentParser:
action="store_true",
help="For prune only: skip git lfs prune.",
)
parser.add_argument(
"--include-git-prune",
action="store_true",
help=(
"For prune only: also run repo-wide git prune --expire=now. "
"This can discard unrelated dangling recovery objects."
),
)
parser.add_argument(
"--dry-run",
action="store_true",
Expand All @@ -171,7 +182,11 @@ def main(argv: list[str] | None = None) -> int:
elif args.command == "unpark":
_set_skip_worktree(repo, artifacts, enabled=False)
elif args.command == "prune":
_prune(repo, include_lfs=not args.skip_lfs)
_prune(
repo,
include_lfs=not args.skip_lfs,
include_git_prune=args.include_git_prune,
)
elif args.command == "clean-stale":
_clean_stale_graph_files(repo, dry_run=args.dry_run)
return 0
Expand Down
18 changes: 11 additions & 7 deletions src/ctx/core/graph/resolve_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,22 +194,26 @@ def _apply_entity_overlays(G: nx.Graph, graph_path: Path) -> nx.Graph:

for payload in active_overlay_records(records):
authoritative_nodes = _authoritative_overlay_nodes(payload)
for node_id in authoritative_nodes:
if node_id in G:
G.remove_edges_from(list(G.edges(node_id)))
nodes = payload.get("nodes", [])
if isinstance(nodes, list):
for node in nodes:
if not isinstance(node, dict):
continue
node_id = node.get("id")
if not isinstance(node_id, str) or not node_id:
incoming_node_id = node.get("id")
if not isinstance(incoming_node_id, str) or not incoming_node_id:
continue
overlay_node_id: str = incoming_node_id
attrs = {key: value for key, value in node.items() if key != "id"}
if node_id in G:
if overlay_node_id in G:
attrs = (
replace_node_attrs(G.nodes[node_id], attrs)
if node_id in authoritative_nodes
else merge_node_attrs(G.nodes[node_id], attrs)
replace_node_attrs(G.nodes[overlay_node_id], attrs)
if overlay_node_id in authoritative_nodes
else merge_node_attrs(G.nodes[overlay_node_id], attrs)
)
G.add_node(node_id, **attrs)
G.add_node(overlay_node_id, **attrs)
applied_nodes += 1

edges = payload.get("edges", [])
Expand Down
Loading
Loading