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
12 changes: 8 additions & 4 deletions .claude/skills/sync-docs/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -398,8 +398,12 @@ minutes, a Python version. Stale counts are this repository's most common drift.

# e. Tone: no em/en dashes or curly quotes in the human-facing prose docs. Nothing in CI
# enforces this, so it is the one anti-drift check that only exists here.
# `ls` builds the list because step 4 deletes plan docs: a bare `docs/plans/*.md` that
# matches nothing stays literal and makes grep exit 2 on a "No such file" error.
# `git ls-files` builds the list, for two reasons. Step 4 deletes plan docs, and a bare
# `docs/plans/*.md` that matches nothing stays literal and makes grep exit 2 on a
# "No such file" error. And `docs/plans/` is where per-issue deep-dive notes are kept
# locally, excluded per file in .git/info/exclude: those are somebody's scratch, not
# this repository's prose, and globbing them made the check red for text no reviewer
# will ever read. Tracked files are exactly the ones the standard governs.
# The explicit if/else is here because a bare `grep && echo` exits 1 when the docs are
# CLEAN, which reads as failure to anyone (or anything) checking the status.
# NOTE: the carve-out is for INLINE spans only, not fenced blocks. A dash inside a
Expand All @@ -411,8 +415,8 @@ minutes, a Python version. Stale counts are this repository's most common drift.
# ends with someone mangling a documented literal to quiet it.
# The per-file loop keeps the filename in the output; piping every doc through one sed
# would report a line number with nothing to open.
if for f in $(ls README.md HOW_TO_USE.md CONTRIBUTING.md COMPATIBILITY.md SECURITY.md \
docs/design-*.md docs/plans/*.md 2>/dev/null); do
if for f in $(git ls-files -- README.md HOW_TO_USE.md CONTRIBUTING.md COMPATIBILITY.md \
SECURITY.md 'docs/design-*.md' 'docs/plans/*.md'); do
sed 's/`[^`]*`//g' "$f" | grep -n '—\|–\|[“”‘’]' | sed "s|^|$f:|"
done | grep .; then
echo "TONE DRIFT: reapply Voice and tone to the files listed above"
Expand Down
7 changes: 7 additions & 0 deletions .github/workflows/plugin-compatibility.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ on:
- 'CODE_OF_CONDUCT.md'
- 'docs/**'
- '.github/PULL_REQUEST_TEMPLATE.md'
- '.github/ISSUE_TEMPLATE/**'
# A workflow file cannot change the package LAYOUT this canary measures,
# and editing one used to install four upstream CLIs over the network to
# prove it. The trade is that a change to THIS file no longer runs on its
# own PR; the push-to-main and Monday schedule runs cover it, and it is
# not a required check, so nothing is gated on the gap.
- '.github/workflows/**'
push:
branches:
- main
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/quality-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,10 @@ jobs:
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
with:
python-version: "3.11"
# The only setup-python in this workflow that was missing its cache,
# while installing the heaviest requirements file of the five jobs.
cache: pip
cache-dependency-path: requirements-dev.txt
- name: Install toolchain
run: python -m pip install -r requirements-dev.txt -r requirements-validation.txt
- name: Direct-launch smoke on the supported floor
Expand All @@ -177,6 +181,7 @@ jobs:
scripts.tests.test_lint_embedded \
scripts.tests.test_bench_collect \
scripts.tests.test_capture_hook \
scripts.tests.test_bench_event_latency \
scripts.tests.test_derive_prompt_shapes
coverage report

Expand Down Expand Up @@ -211,6 +216,7 @@ jobs:
scripts.tests.test_lint_embedded \
scripts.tests.test_bench_collect \
scripts.tests.test_capture_hook \
scripts.tests.test_bench_event_latency \
scripts.tests.test_derive_prompt_shapes
coverage report

Expand Down
8 changes: 8 additions & 0 deletions .github/workflows/validate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,18 @@ concurrency:
jobs:
validate:
runs-on: ubuntu-latest
# The only job in the repository that had no timeout, so it inherited
# GitHub's 360-minute default. Its longest step is the same `unittest
# discover` that AGENTS.md records hanging on subprocess and socket waits
# under load, and this is a required check — a hang would hold the PR
# pending for six hours rather than failing in ten minutes.
timeout-minutes: 10
steps:
# actions/checkout@v7.0.1 — pinned to immutable SHA (this is the
# required check; mutable major tags don't belong here).
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
# actions/setup-python@v7.0.0 — pinned to immutable SHA.
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97
with:
Expand Down
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ docs/visibility-2x2/items.json.tmp
# Spacedock worktrees: per-entity checkouts, never source
.worktrees/

# Agent worktrees: CLAUDE.md sends `isolation: "worktree"` checkouts here. Ignored
# in the committed file and not only in .git/info/exclude, because that exclude is
# per-clone: without this, a fresh clone greps its own worktrees and every
# repo-wide search returns each hit once per checkout.
.claude/worktrees/

# Spacedock roadmap-burndown state: a linked worktree of the spacedock-state/roadmap-burndown orphan branch
docs/roadmap-burndown/.spacedock-state/

Expand Down
5 changes: 4 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ cargento/ # plugin root: Claude Code, Codex, Antigravi
├── SKILL.md # shared skill body (all harnesses)
├── server.py # the stable launcher: calls cargento_runtime.cli.main
├── notify_hook.py # loopback POST forwarder for the user-installed Claude hooks
├── event_hook.py # posts Claude and Codex command-hook lifecycle events
├── agy_hook.py # posts Antigravity's hook events
├── statusline_hook.py # posts Antigravity's status-line state
├── mcp_server.py # stdio MCP server: the one tool a session calls to ask the reader
├── cargento_runtime/ # importable dashboard runtime package
│ ├── aggregate.py # harness registry, failure boundary, and the application
Expand Down Expand Up @@ -284,7 +287,7 @@ choose. Nobody asked for twelve hours; they asked for the work.

## Quality Gate

Every PR must pass the `quality-gate` required check (`.github/workflows/quality-gate.yml`): ruff with `select = ALL` (curated ignores documented in `pyproject.toml`), `ruff format --check`, `mypy --strict`, the HTML/CSS/JS frontend source linter (`scripts/lint_embedded.py`), a direct-launch smoke test on the Python 3.11 runtime floor, the full unittest suite under `coverage` with the `fail_under` threshold from `pyproject.toml`, and `platform-tests` — the same unit suite re-run natively on Ubuntu, macOS and Windows. The threshold only ratchets up — never lower it in a PR. A PR that must merge below threshold needs the `coverage-exception` label, which is visible in the PR timeline.
Every PR must pass the `quality-gate` required check (`.github/workflows/quality-gate.yml`): ruff with `select = ALL` (curated ignores documented in `pyproject.toml`), `ruff format --check`, `mypy --strict`, the HTML/CSS/JS frontend source linter (`scripts/lint_embedded.py`), a direct-launch smoke test on the Python 3.11 runtime floor followed by the whole suite under `coverage` there, the same suite under `coverage` on 3.12 with the `fail_under` threshold from `pyproject.toml`, and `platform-tests` — the same unit suite re-run natively on Ubuntu, macOS and Windows. The threshold only ratchets up — never lower it in a PR. A PR that must merge below threshold needs the `coverage-exception` label, which is visible in the PR timeline.

**The required context always reports; its constituent jobs may not run.** A `changes` job decides
whether the diff contains anything the gate can measure, and the five measurable jobs are gated on
Expand Down
62 changes: 31 additions & 31 deletions cargento/skills/cargento/cargento_runtime/collectors/claude.py
Original file line number Diff line number Diff line change
Expand Up @@ -394,7 +394,7 @@ def collect(
) -> list[Session]:
tasks_by_session = load_tasks(config)
team_members = load_team_members(config)
transcripts: dict[str, str] = {} # prefix -> newest transcript path
transcripts: dict[str, tuple[str, float]] = {} # prefix -> (newest path, its mtime)
agent_children: dict[str, list[dict[str, Any]]] = {} # parent prefix -> children
for fp in runtime_io.glob_stores(config, "claude.projects", "*", "*.jsonl"):
base = os.path.basename(fp)
Expand Down Expand Up @@ -424,23 +424,22 @@ def collect(
)
continue
prefix = base[:8]
try:
if prefix not in transcripts or mtime > os.path.getmtime(transcripts[prefix]):
transcripts[prefix] = fp
except OSError:
continue # transcript rotated/deleted between glob and stat
# `mtime` is this file's, measured a few lines up in this same pass. The
# newest-wins test used to re-stat the incumbent for a number the loop
# had already taken and dropped, and the per-prefix loop below stat'd the
# winner a third time. Carrying the pair costs nothing and removes both.
if prefix not in transcripts or mtime > transcripts[prefix][1]:
transcripts[prefix] = (fp, mtime)

out: list[Session] = []
for prefix in set(transcripts) | set(tasks_by_session):
transcript = transcripts.get(prefix)
newest = transcripts.get(prefix)
transcript = newest[0] if newest else None
transcript_mtime = newest[1] if newest else 0
tasks = sorted(
tasks_by_session.get(prefix, []),
key=lambda t: int(t["id"]) if str(t["id"]).isdigit() else 0,
)
try:
transcript_mtime = os.path.getmtime(transcript) if transcript else 0
except OSError:
transcript_mtime = 0
latest_task_mtime = max((t["updated"] for t in tasks), default=0)
agent_files = agent_transcripts(transcript, config=config, state=state)
children = agent_children.get(prefix, [])
Expand Down Expand Up @@ -475,6 +474,27 @@ def collect(
config, now, c["mtime"], config.working_threshold_sec
) # fresh = running
]
latest_agent_mtime = max(
(a["mtime"] for a in subagents),
default=0,
)
latest_child_mtime = max((c["mtime"] for c in children), default=0)
# Every subagent write, not just the ones fresh enough to read as
# running: a workflow that has been going for hours parks its parent
# transcript, and without this the session ages out of the window.
latest_agent_file_mtime = max((m for _, m in agent_files), default=0)
activity_sources = (
latest_task_mtime,
transcript_mtime,
latest_agent_mtime,
latest_agent_file_mtime,
latest_child_mtime,
)
last_activity = runtime_sessions.newest_plausible(config, now, activity_sources)
active = runtime_sessions.is_fresh(config, now, last_activity, window_hours * 3600)
if not (active or show_all):
continue

# Registered, joined long enough ago that a healthy agent would have
# written its first record, and still holding no transcript anywhere.
# Sandwiched between two windows that already exist rather than a
Expand All @@ -496,26 +516,6 @@ def collect(
and runtime_sessions.is_fresh(config, now, m["joined"], window_hours * 3600)
]
pending_members.sort(key=lambda m: m["joined"])
latest_agent_mtime = max(
(a["mtime"] for a in subagents),
default=0,
)
latest_child_mtime = max((c["mtime"] for c in children), default=0)
# Every subagent write, not just the ones fresh enough to read as
# running: a workflow that has been going for hours parks its parent
# transcript, and without this the session ages out of the window.
latest_agent_file_mtime = max((m for _, m in agent_files), default=0)
activity_sources = (
latest_task_mtime,
transcript_mtime,
latest_agent_mtime,
latest_agent_file_mtime,
latest_child_mtime,
)
last_activity = runtime_sessions.newest_plausible(config, now, activity_sources)
active = runtime_sessions.is_fresh(config, now, last_activity, window_hours * 3600)
if not (active or show_all):
continue

project = (
(
Expand Down
10 changes: 7 additions & 3 deletions cargento/skills/cargento/cargento_runtime/collectors/cursor.py
Original file line number Diff line number Diff line change
Expand Up @@ -728,11 +728,15 @@ def collect(
sid = os.path.basename(os.path.dirname(db))
try:
mtime = os.path.getmtime(db)
wal = db + "-wal"
if os.path.exists(wal):
mtime = max(mtime, os.path.getmtime(wal))
except OSError:
continue
# One stat rather than two: `getmtime` already answers "is there a WAL".
# Suppressed separately from the db's own stat, because sharing the
# handler meant a WAL that vanished between the `exists` and the
# `getmtime` dropped the whole chat row instead of leaving the db's mtime
# standing - and a WAL is exactly the file most likely to go while read.
with contextlib.suppress(OSError):
mtime = max(mtime, os.path.getmtime(db + "-wal"))
if not (sessions.is_fresh(config, now, mtime, window_hours * 3600) or show_all):
continue
title, cwd, model, parent_id, type_name, pending_since = _meta(config, state, db, mtime)
Expand Down
13 changes: 0 additions & 13 deletions cargento/skills/cargento/cargento_runtime/observer.py
Original file line number Diff line number Diff line change
Expand Up @@ -641,19 +641,6 @@ def write_sidecar(
return path


def read_sidecar(config: RuntimeConfig, harness: str, sid: str) -> dict[str, Any] | None:
"""Read the observer sidecar, or None if absent, unnamed or malformed."""
path = sidecar_path(config, harness, sid)
if path is None:
return None
try:
with open(path, encoding="utf-8") as handle:
value = json.loads(handle.read(config.state_read_cap_bytes))
except (OSError, ValueError, json.JSONDecodeError):
return None
return value if isinstance(value, dict) else None


def _mtime(path: str) -> float:
"""One file's mtime, or 0 when it went away between the glob and the stat."""
try:
Expand Down
5 changes: 0 additions & 5 deletions cargento/skills/cargento/cargento_runtime/spacedock.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,11 +172,6 @@ def stage_entries(config: RuntimeConfig, lines: list[str]) -> list[dict[str, Any
return entries


def stage_names(config: RuntimeConfig, lines: list[str]) -> list[str]:
"""The ordered stage names, or [] if the states block is unrecognised."""
return [entry["name"] for entry in stage_entries(config, lines)]


def _codex_tool_output(record: dict[str, Any]) -> list[str] | None:
"""A Codex rollout's tool-output text, or None when the record is not one.

Expand Down
21 changes: 21 additions & 0 deletions cargento/skills/cargento/tests/support.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,27 @@
atexit.register(_STATE_HOME.cleanup)
os.environ[CARGENTO_HOME_ENV] = STATE_HOME

# The same leak one level up. CARGENTO_HOME redirects only the dashboard's own
# state; every harness store root is resolved from the user's home instead
# (config.py:403 reads USERPROFILE on Windows and HOME everywhere else), so any
# test that collects without patching a store reads the developer's real
# ~/.claude, ~/.codex, ~/.cursor and the rest. Measured here: 24,981 files under
# ~/.claude/projects alone, and redirecting HOME took test_quota from 4.63s to
# 1.69s, test_lifecycle from 8.04s to 4.97s and test_http_api from 11.34s to
# 8.96s. Speed is the smaller half — otherwise the suite's verdict depends on
# what the developer's own agents happened to be writing while it ran.
#
# A second directory rather than STATE_HOME, because the two answer different
# questions: a test that points CARGENTO_HOME at a directory of its own must
# still see an empty harness home, not the state it just redirected. USERPROFILE
# is seeded too — unset on this platform, but config.py prefers it on Windows,
# and the suite runs natively there in platform-tests.
_USER_HOME = tempfile.TemporaryDirectory(prefix="cargento-test-user-")
USER_HOME = _USER_HOME.name
atexit.register(_USER_HOME.cleanup)
os.environ["HOME"] = USER_HOME
os.environ["USERPROFILE"] = USER_HOME

# Store key -> path. Patch with mock.patch.dict; runtime() folds it into config.
STORE_OVERRIDES: dict[str, Any] = {} # str, or a tuple/list of candidates
# RuntimeConfig field -> value, applied by runtime() after the build: how a test
Expand Down
4 changes: 2 additions & 2 deletions cargento/skills/cargento/tests/test_spacedock.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ def test_stage_names_read_document_order_past_sibling_blocks(self) -> None:
self.assertEqual("spacedock@0.22.0", spacedock.scalar(lines, "commissioned-by"))
self.assertEqual(
["intake", "review", "fix-and-harden", "escalated", "posted"],
spacedock.stage_names(config, lines),
[entry["name"] for entry in spacedock.stage_entries(config, lines)],
)
# The initial and terminal flags belong to the item they are nested
# under, and `gate:`/`worktree:`/the decision options are not flags.
Expand Down Expand Up @@ -138,7 +138,7 @@ def test_stage_names_refuse_shapes_the_scanner_cannot_model(self) -> None:
for label, block in cases.items():
with self.subTest(case=label):
lines = ("---\n" + block + "---\n").split("\n")[1:-2]
self.assertEqual([], spacedock.stage_names(config, lines))
self.assertEqual([], spacedock.stage_entries(config, lines))

def test_workers_are_attributed_to_a_known_slug(self) -> None:
"""Cycle markers appear on either side of the stage, and a slug may end
Expand Down
31 changes: 31 additions & 0 deletions cargento/skills/cargento/tests/test_sqlite_collectors.py
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,37 @@ def test_cursor_walks_past_a_child_id_that_belongs_to_no_blob(self) -> None:

self.assertEqual("vega", sessions[0]["model"])

def test_cursor_keeps_a_chat_whose_wal_disappears_between_the_two_stats(self) -> None:
# The WAL is stat'd to fold its mtime into the chat's, and it is the file
# most likely to be checkpointed away while the collector is mid-read.
# Sharing the db's own OSError handler meant losing that race withdrew
# the whole row rather than falling back to the db's mtime: the chat
# vanished from the board because a sibling file did.
if not runtime_io.sqlite_available():
self.skipTest("sqlite3 unavailable")
with tempfile.TemporaryDirectory() as tmp:
root = Path(tmp)
root_id, blobs = self._cursor_chat([self._cursor_message("vega")])
self._cursor_store(
root, "sess-wal", [{"name": "chat", "latestRootBlobId": root_id}], blobs
)
(root / "chats" / "hash1" / "sess-wal" / "store.db-wal").write_bytes(b"")
real_getmtime = os.path.getmtime

def vanishing_wal(path: Any) -> float:
# Present to the glob and to any earlier check, gone by the time
# its mtime is asked for. Only the WAL races; everything else
# answers honestly.
if str(path).endswith("-wal"):
raise OSError(2, "No such file or directory")
return real_getmtime(path)

with mock.patch("os.path.getmtime", side_effect=vanishing_wal):
sessions = self._collect_cursor(root)

self.assertEqual(1, len(sessions))
self.assertEqual("chat", sessions[0]["title"])

def test_cursor_keeps_its_title_when_the_store_has_no_blobs_table(self) -> None:
# The failure that costs the most: a store on a schema without `blobs`
# raises `no such table`, and routing that through the store-error path
Expand Down
Loading