CLAUDE.md · git:20260906.a408773 · 2026-09-06 · sha256 3a6c4121590fa904

CLAUDE.md git:20260906.a408773B

Immutable. This exact content is served forever at /api/v1/blob/3a6c4121590fa904.

# Working in this repo

This file tells Claude (and external contributors running Claude Code) the house rules. Not user-facing docs — see `README.md` and `CONTRIBUTING.md` for that.

## This is public OSS

Repo lives at `github.com/amirfish1/claude-command-center`. Every commit, comment, file name, and test fixture ships to the world. Assume strangers read it.

- No internal paths, client names, private URLs, or PII in code, comments, or tests.
- No secrets — not even placeholder tokens that "look like" real ones. Use obvious fakes (`sk-ant-test-XXXX`).
- No references to private internal systems. If a feature exists for one user, either generalize it or gitignore it (see the Morning view for the pattern).

## Private documentation boundary

This checkout is public. Keep non-public plans, specs, product-story source,
backlog notes, and agent working documents in the separate private
`CCC-private-docs` repository. Do not recreate `docs/superpowers/`, commit
private-document copies here, or add a private-repository submodule or
symlink. Publish only explicitly reviewed, public-safe exports.

## Commits

**Conventional Commits.** Scan `git log` for existing scopes — match them. Common types in this repo:

- `fix(layout)`, `fix(ci)`, `fix(titles)` — bug fixes
- `feat(ui)`, `feat(repo-picker)`, `feat(titles)` — user-visible features
- `docs`, `chore`, `perf` — as standard

Subject line under ~70 chars. Body (wrapped at ~80) explains the why, not the what — the diff shows what.

Co-author tag from the trailer is fine but not mandatory.

## Git commits (shared `main`, parallel sessions)

Multiple sessions share one checkout on `main`. **Commit small and often** so
pushing (or **Push all** in the CCC UI) does not require hunting other sessions.
A commit is **only** git in that turn — no extra ceremony bundled in.

### Tiers — pick one per commit

| Tier | When | Do | Do not |
|------|------|-----|--------|
| **A — lean / WIP** | Slice done, still iterating, or before idle | `git commit --only <paths> -m "type(scope): subject"` | `changelog.d/` in same turn; edit `CHANGELOG.md`; version bump; push |
| **B — slice done** | User-visible fix/feature complete | Same as A; add a `changelog.d/` snippet (same or next commit) | Hand-edit `CHANGELOG.md`; release scripts |
| **C — release** | Cutting `vX.Y.Z` | `./scripts/cut-release.sh` (rollup, version bump, tag) | Ad-hoc version bumps on random commits |

Default to **Tier A** unless the user asked for changelog or release work.

### Lean commit (Tier A)

Use the **`/lean-commit`** slash command or:

```bash
git commit --only path/to/changed path/to/other -m "fix(ui): short subject"
```

- **When:** slice done, or pausing / going idle — **not** after every assistant turn.
- **One command, then stop** — no `changelog.d/`, no push unless the user said
  push/ship/Push all.
- Candidate path list (noise filtered): `scripts/lean-commit.sh`

### Push

- **Do not push** unless the user says push/ship/Push all (or you are the
  designated integrator and the tree is clean).
- If the tree is dirty with others' work, commit **your** paths only and stop.

### CHANGELOG (`changelog.d/`)

- **Tier A:** do not add or edit `changelog.d/` in the same turn as the code commit.
- **Tier B:** drop one small file in `changelog.d/` per user-visible change (see
  `changelog.d/README.md`). Never edit `CHANGELOG.md` directly — release rolls
  snippets up.

### Multi-Agent Git Hygiene

Multiple agent sessions can share one working tree on this machine. The shared
clone stays on `main`.

1. **Never branch in the shared clone** unless the user asked. Use
   `git worktree add` for branch-isolated work.
2. **Never** `git add -A`, `git add .`, or `git commit -a`.
3. **Commit with `--only <paths>`** — the index is shared; plain `git commit -m`
   can sweep in sibling sessions' staged work.

- **NEVER** run `git checkout -- .`, `git restore .`, `git clean -f`, or
  `git reset --hard` without asking first.

## CHANGELOG

Follows [Keep a Changelog](https://keepachangelog.com). Every user-visible change drops a small markdown file in `changelog.d/` instead of editing `CHANGELOG.md` directly — that way two parallel sessions don't collide on the `[Unreleased]` section.

- Filename: `<category>-<short-slug>-<discriminator>.md` (e.g. `added-context-pill-2026-04-26.md`).
- File contents: just the bullet text. A leading `- ` is optional.
- Categories: `added`, `changed`, `fixed`, `removed`, `security`, `deprecated`.

See `changelog.d/README.md` for the full convention.

At release time, run `python3 scripts/release.py X.Y.Z` to roll snippets into a fresh `## [X.Y.Z] - YYYY-MM-DD` block in `CHANGELOG.md` and `git rm` the snippet files. The legacy `[Unreleased]` section above it stays as-is until cleared by hand at the next release boundary.

## SemVer

Two places to bump in lockstep:
- `pyproject.toml` — `version = "X.Y.Z"`
- `server.py` — `__version__ = "X.Y.Z"`

Patch for bug fixes. Minor for new features. Major for breaking `/api/*` contracts or breaking CLI flags (`run.sh` / env vars).

Tag as `vX.Y.Z`. `gh release create` with release notes copied from the CHANGELOG section.

**Cutting a release: run `./scripts/cut-release.sh X.Y.Z`.** One command does the whole sequence — changelog rollup, version bump (both files), tag + push, GitHub release, notarized DMG + Sparkle appcast, and the Homebrew formula bump (auto-computes the sha256). Always `--dry-run` first. Full reference and prereqs in `docs/RELEASING.md`. Don't hand-run the 8 steps unless the wrapper can't (the manual path is the fallback).

## API contracts

`/api/*` endpoints are the stable surface external tooling (Claude Code hooks, the browser UI, pkood integration) binds to. Treat them like public API:

- Adding a field to a response is fine.
- Adding a new endpoint is fine.
- Renaming a field, removing a field, or changing a response shape is a **breaking change** — major version bump, and update SECURITY.md / README.md.
- `/api/repo/switch` has an allow-list for CSRF defence. Don't loosen without re-reading the comment at the call site.

## Security posture

Read `SECURITY.md` before changing anything about network binding, origin checks, or path validation. Summary:
- Default bind is `127.0.0.1`. `CCC_BIND_HOST=0.0.0.0` requires opt-in + prints a warning.
- Same-origin check on every POST (`_check_same_origin`).
- `/api/open` clamps paths to explicit repo/session context and command-center log directories.

## Conventions

- `server.py` is stdlib-only on purpose — no pip dependencies at runtime. Don't import `requests`, `pydantic`, `fastapi`, etc. `urllib` + `http.server` + `json` cover it.
- `static/index.html` is a single-file app by design (no bundler, no npm). Inline CSS/JS is expected. Don't split it into modules without a strong reason.
- `hooks/` scripts run inside Claude Code's hook pipeline — they must exit fast and never prompt.
- The Morning view (`morning.py`, `morning_store.py`, `static/morning/`) is a **gitignored opt-in plugin** for one user's workflow. Don't reference it in the README or treat it as part of the core.

## Never block a turn on a polling loop

Don't wait for something by holding a foreground Bash call open:

```bash
# WRONG — holds the turn open for hours
while true; do wt ls -q QUEUE ...; sleep 120; done
```

A foreground tool child keeps the turn alive, and CCC treats a live turn as
"input will land at the next boundary". A loop that polls for minutes or hours
means that boundary never arrives, so every message queued to that session sits
on "sending…" for as long as the loop runs. Three of these in one session held
its queue for over four hours.

Use `run_in_background: true`, or the `Monitor` tool, or just end the turn and
check on the next one. If a loop genuinely must run in the foreground, bound it
to minutes — never hours.

(`_tool_child_blocks_inject` now force-delivers after 10 minutes, so this
degrades instead of wedging. Don't rely on it: it's a backstop, not a licence.)

## Testing

### Fast Local Unit Tests vs. CI Smoke Suite
- **Local Machine**: Always run **fast, targeted unit tests** for the specific module you are touching (e.g. `python3 -m pytest tests/test_<feature>.py`). Targeted tests finish in < 1s with minimal memory and zero disk lockups.
- **Heavy End-to-End Suites (`tests/test_smoke.py`)**: Do **not** run full `tests/test_smoke.py` in the background during local development. It consumes > 4GB RAM, spawns multiple subprocesses and mock servers, and chokes local disk I/O, freezing the local Command Center server.
- **GitHub Actions CI**: Full multi-OS compile checks (`py-compile`), the complete unit test suite (`unittest`), and the end-to-end server smoke tests (`smoke`) run automatically on GitHub Actions runners in isolated cloud VMs on every push and PR.

Don't mock external systems (`gh`, `claude`, `pkood`) in unit tests. Keep tests focused on fast import-time correctness and specific module invariants.

Running the suite locally with stdlib `python3 -m unittest discover` (Python 3.12+) floods the output with thousands of `ResourceWarning: unclosed database` lines — the test suite reloads `server.py`/`ccc_server` modules many times per run, and each reload drops the previous module's cached sqlite3 connections without closing them. `unittest.main()`'s `TestProgram` defaults to `warnings='default'` whenever `sys.warnoptions` is empty, and that `simplefilter('default')` call wipes any `warnings.filterwarnings()` set inside the test package before the run starts — so filtering from Python code doesn't stick. Set `sys.warnoptions` yourself via the environment instead, which suppresses the flood without hiding real assertion failures:

```bash
PYTHONWARNINGS="ignore::ResourceWarning" python3 -m unittest discover
```

### Browser / UI verification

To verify UI changes visually, this repo uses **puppeteer** (dependency `puppeteer`), via `snapshot.js` — `node snapshot.js` launches headless Chrome, loads `http://127.0.0.1:8090`, and writes `snapshot.png`. Puppeteer's browser lives in `~/.cache/puppeteer` (separate from any Playwright cache). The `chrome-devtools` MCP also works (drives real Chrome) for interactive checks.

CCC uses Puppeteer 25, which no longer exposes `page.waitForTimeout()`. For a
short delay in an ad-hoc verification script, use
`await new Promise((resolve) => setTimeout(resolve, ms))`; prefer
`page.waitForSelector()`, `page.waitForFunction()`, or `page.waitForNetworkIdle()`
when a specific condition is available.

**Do not reach for Playwright.** It is *not* a CCC dependency — "cannot import playwright" / "Playwright browser executable missing" means you picked the wrong tool, not that something is broken. Use `snapshot.js` or chrome-devtools. **Chromium is sufficient**; no WebKit/Firefox needed.

## Performance gates

Every "CCC is slow" incident has been the same bug: a user-facing path doing
`O(all conversations/sessions)` work — a subprocess fork (`ps`/`lsof`/`gh`/`git`),
a full transcript parse, or a whole-list rebuild — **per item, uncached**.
Invisible at test scale (tiny fixtures), seconds in production (1000+ transcripts).

Rules when touching any path that scans `~/.claude/projects` or session state:
- **Gate by candidacy**: only do live/liveness work for sessions that could be
  live (`_discover_live_session_ids()` + a recent-mtime window), not all rows.
- **Cache by `(mtime, size)`** and persist to disk (see `_conv_meta_cache`,
  `_STATS_FILE_CACHE`) so a restart re-parses only changed files.
- **Never spawn a subprocess per row**; batch (one `ps -A`) or memoise.
- **Pass the cheap flags**: don't trigger PR/worktree/effective resolution for a
  view that doesn't render them.

`tests/test_perf_budget.py` enforces this with call-count invariants (not just
latency). The committed `scripts/pre-push.sh` runs it before every push (shared
gate via `.git/hooks/pre-push`). If it fails, restore the gate — don't relax the
bound. Add a call-count test there for any new all-conversations/all-sessions path.

## Restart matrix — report this on EVERY fix

A committed fix is not a live fix. Python code is loaded once at process
start, so a change sits inert until the process that runs it is restarted.
**End every fix with these three lines**, so nobody has to guess whether what
they just changed is actually running:

```
Dashboard server restart needed:  Y/N
Worker restart needed:            Y/N
WatchTower server restart needed: Y/N
```

How to decide — the three services and what each one loads:

| Service | launchd label | Runs | Restart when you touched |
|---|---|---|---|
| **Dashboard** | `com.github.claude-command-center` | `server.py` + the HTTP API | `server.py` or any module it imports |
| **Worker** | `com.github.claude-command-center.worker` | `ccc_worker.py`, owns engine execution + the shared Codex app-server | `ccc_worker.py`, `worker_engines.py`, `control_plane.py` — **and `server.py`**, see the gotcha below |
| **WatchTower** | `ai.watchtower.watcher` | the `wt` queue daemon on `:8787` | WatchTower's own code (separate repo). CCC changes never need this — default **N** |

**The gotcha that gets missed:** `worker_engines.py` does a lazy `import server`
(`EngineHost._legacy()`), so the worker runs its **own copy** of `server.py`'s
module-level state. A `server.py` fix that runs on an engine path is therefore
**Y for both** the dashboard and the worker. Restarting only the dashboard
leaves the old code live in the worker, which looks exactly like "the fix
didn't work."

**No restart needed (default N everywhere):** `static/*` (served from disk per
request — a browser reload is enough), `docs/`, `changelog.d/`, `tests/`,
markdown. Frontend-only fixes are `N/N/N`.

```bash
launchctl kickstart -k gui/$(id -u)/com.github.claude-command-center.worker
launchctl kickstart -k gui/$(id -u)/com.github.claude-command-center
```

Restart the worker **first**: it is the one holding engine subprocesses, and
the dashboard reconnects to it. Note that restarting the worker marks running
queue items "needs reconciliation" (one click on Reconcile), so only do it when
the change actually requires it.

## Finishing a change — does it need a deploy?

Depends entirely on what you touched. Most changes ship the moment you `git push origin main`. Only `.app`-shell changes need a real release.

| You touched… | How users get it | What you owe |
|---|---|---|
| `server.py`, `static/`, `hooks/`, `install.sh`, `run.sh` (server + dashboard + install) | curl users: next `./run.sh` (install does `git pull --ff-only`). brew users: next `brew upgrade ccc`. DMG users: same path — the .app spawns `~/.ccc/.../run.sh` which is git-tracked. | Just `git push origin main`. No DMG rebuild, no release. |
| `docs/` (landing page, public docs) | GitHub Pages picks it up in ~1 min after push | `git push origin main`. |
| `docs/appcast.xml` | Same as `docs/` — but this is what Sparkle reads. | Push, then verify `curl -s https://ccc.amirfish.ai/appcast.xml` returns the new entry. |
| `scripts/macapp/main.swift`, `scripts/build-dmg.sh`, `scripts/release-dmg.sh`, `scripts/macapp/vendor/Sparkle.framework` (the .app shell or DMG build flow) | **DMG users get it ONLY via Sparkle auto-update**, which only fires when you ship a new versioned DMG with an EdDSA signature in the appcast. | Bump version → `./scripts/release-dmg.sh X.Y.Z` → `gh release create vX.Y.Z` with the DMG attached → commit + push `docs/appcast.xml`. See `docs/RELEASING.md` for the full sequence. |
| `infra/telemetry-worker/` (Cloudflare Worker) | The Worker is independent of `main`. Pushing the repo does NOT deploy it. | `cd infra/telemetry-worker && npx wrangler deploy`. |
| Homebrew formula | Formula lives at `github.com/amirfish1/homebrew-ccc`, NOT this repo. | Push there (separate repo). brew users get it on `brew upgrade ccc`. |
| `changelog.d/*`, `tests/`, `README.md`, `CLAUDE.md`, `AGENTS.md`, `pyproject.toml`/`server.py` version bumps | On push to main | Just `git push origin main`. Bumping versions touches a release cycle — see `docs/RELEASING.md`. |

**Quick rule of thumb:**
- Touched anything in `scripts/macapp/` or `scripts/build-dmg.sh`? → **You owe a Sparkle release** (`docs/RELEASING.md`).
- Touched `infra/telemetry-worker/`? → **Run `wrangler deploy`** separately.
- Everything else? → **`git push origin main`** and you're done.

If you're unsure, default to pushing then checking the table — `git push` is reversible (`git revert`); a half-shipped release is harder to clean up.

**Note:** the `hunch_*` names below are MCP tools, not shell commands. In Codex sessions they appear as `mcp__hunch__*`; there is no `hunch_context` CLI — never invoke them via the shell.

<!-- HUNCH:START — auto-generated, do not edit by hand -->
## 🧠 Hunch (Engineering Memory)

This repo has **Hunch** — a curated graph of *why* the code is the way it is (decisions, bug history, invariants). It currently holds **36 decisions, 0 bugs, 8 constraints, 12 components, 0 policies, 8 open findings**.

**Consult Hunch via the `hunch_*` MCP tools — pick by MOMENT, not from memory:**

**Orient (session/task start):**
- `hunch_context(target)` — the minimal relevant slice for what you're about to do; a task phrase falls back to the closest graph matches. **Call FIRST.**
- `hunch_structure(target?)` — the indexed shape of the repo/dir/file/symbol — orient from the graph, not grep rounds.
- `hunch_runbook(task)` — the proven steps for a recurring task, before re-deriving them.
- `hunch_escalations()` — the decisions only the HUMAN can make (including one exact imported ADR at a time, topic conflicts, and policy calls). Normally empty; when it isn't, ASK the user inline — an entry is a question, silence is never approval. Apply an ADR answer only through `hunch_review_imported_adr` with its printed source and review hashes.
- `hunch now` (CLI) — recent decisions + the live roadmap; `hunch log` — the memory-move timeline (every capture/adopt/supersede/prune/repair, each revertable).

**Before designing / choosing an approach:**
- `hunch_why(target)` — why a file/symbol is shaped this way (decisions, bugs, constraints) — including what was already REJECTED.
- `hunch_current_decision(topic)` — the one live answer for a topic (history + rejected included).
- `hunch_bug_lineage(symptom_or_symbol)` — has this failed before? what was the root cause?
- `hunch_compare(candidates)` — rank candidate branches/commits by fewest invariant hits.
- `hunch_query(query)` — free-text search when nothing above fits.

**Before editing:**
- `hunch_check_constraints(scope)` and `hunch_get_dependents(symbol)` / `hunch_blast_radius(target)` — invariants in scope + who you'd break. (The pre-edit hook injects this per file automatically; call these for PLANNING breadth.)
- `hunch_findings(scope?)` — known-but-unfixed gaps in the area (past audits, measurements, incidents) so you inherit them instead of re-discovering them.

**Before committing / merging:**
- `hunch_conformance()` — does the code still SATISFY recorded intent? Run before and after a refactor.
- `hunch_policy_evaluate(policy_id?, active_only?)` / `hunch_policy_plan(policy_id)` / `hunch_policy_card(policy_id)` / `hunch_policy_proof(policy_id)` — evaluate canonical policy, inspect the planned corpus, review the evidence/uncertainty card, and inspect raw replay receipts; only an explicit human activation grants authority.
- `hunch_pr_impact(base?)` / `hunch_merge_verdict(...)` — a change's memory surface; would it re-open a closed bug?

**Build the Constitution review queue:**
- `hunch constitution bootstrap --since 90d --max-candidates 3` (CLI) — normalize recent structured human evidence into at most three non-active policy candidates; add `--history` for exact, human-identifier-grounded fix/revert deltas or explicit dependency retirements. Coincidence/ambiguity stays uncompilable; neither path grants authority.
- `hunch constitution ingest --since 90d [--instructions] [--from export.json]` (CLI) — normalize corrections/failures plus bounded committed instructions/ADRs and strict local review/conversation/PR exports into Git-native evidence; raw prose is hash-only, unsupported intent remains uncompilable, and no policy is minted.

**After deciding / when corrected:**
- `hunch_capture_decision(topic?)` → `hunch_record_decision(...)` — interview first, then write; status `proposed` = roadmap intent (shows in `hunch now`).
- `hunch_record_correction(...)` — a human correction becomes an ENFORCED rule (Never Twice), not a one-session memory.
- `hunch_record_finding(...)` — an OBSERVATION with no code change (an audit that found a gap, a measured number, an incident) becomes durable memory anchored to a date + evidence; `/audit` runs the ritual.
- `hunch_timeline(target)` — decision history when investigating how something evolved.

### ⛔ Top invariants (do not break)
- **[warning]** Never spawn a subprocess per row and never do O(all sessions/conversations) work uncached on a path that scans ~/.claude/projects or session state; gate by candidacy (recent-mtime window), cache by (mtime, size) persisted to disk, batch subprocess calls into one _(scope: ccc_server/ask.py; con_0496274e58)_
- **[warning]** server.py changes require restarting BOTH the dashboard (com.github.claude-command-center) AND the worker (com.github.claude-command-center.worker), never just the dashboard _(scope: server.py; con_2cc63a5abf)_
- **[warning]** When bounding a headless `claude -p` subprocess to a read-only toolset, `--allowedTools` alone does NOT restrict the toolset — you must also pass `--disallowedTools` to actually block Bash/Write/Edit/etc; `--allowedTools "Read,Grep,Glob"` combined with `--permission-mode dontAsk` still let the model run Bash successfully in a direct empirical test _(scope: **; con_418e0377d4)_
- **[warning]** Never spawn a subprocess per row and never do O(all sessions/conversations) work uncached on a path that scans ~/.claude/projects or session state; gate by candidacy, cache by (mtime, size), batch subprocess calls _(scope: server.py; con_627861dec9)_
- **[warning]** Never git add -A, git add ., or git commit -a in this repo; stage by explicit path and commit with git commit --only, and for partial-file staging (git apply --cached / git add -p) commit immediately after with no other commands in between _(scope: **; con_9ff65026e6)_
- **[warning]** Never `git add -A`, `git add .`, or `git commit -a` in this repo; stage by explicit path and commit with `git commit --only <paths>` _(scope: **; con_db5f0fc0be)_
- **[warning]** Never add a manual refresh button to fix UI staleness in CCC; fix the staleness at its source with auto-refresh instead _(scope: **; con_e3a02ac292)_
- **[advisory]** Fix broken infra/tooling (a script, a launchd job, a missing dependency) the same turn you find it — don't ask the user first and don't just report it _(scope: **; con_cc564ad105)_

_Hunch updates itself from commits and test failures. Records carry provenance + confidence; treat low-confidence items as advisory._
<!-- HUNCH:END -->