CLAUDE.md · diff

git:20260729.2bf2d56 to git:20260811.3fec151

26 added, 0 removed. Audit A to A.

# CLAUDE.md
This file provides guidance to Claude Code when working with the MulmoClaude repository.
## Project Overview
MulmoClaude is a text/task-driven agent app with rich visual output. It uses **Claude Code Agent SDK** as the LLM core and **gui-chat-protocol** as the plugin layer. Shared code is published as `@mulmobridge/*` npm packages under `packages/`.
**Core philosophy**: The workspace is the database. Files are the source of truth. Claude is the intelligent interface.
## Key Commands
- **Dev server**: `npm run dev` (runs both client and server concurrently)
- **Lint**: `yarn lint` / **Format**: `yarn format` / **Typecheck**: `yarn typecheck` / **Build**: `yarn build`
- **Unit tests**: `yarn test` (node:test, server handlers + utils)
- **E2E tests**: `yarn test:e2e` (Playwright, browser UI tests — no backend needed)
**IMPORTANT**: After modifying any source code, always run `yarn format`, `yarn lint`, `yarn typecheck`, and `yarn build` before considering the task done.
**IMPORTANT**: Always write error handling for all `fetch` calls. Handle both network errors (try/catch) and HTTP errors (`!response.ok`).
## Key Rules (always apply)
### Shared utilities — check before reinventing
Before writing a new helper, scan [`docs/shared-utils.md`](docs/shared-utils.md). If a similar helper exists, use it. When you add a new shared helper, append a 1-line entry to that catalog **in the same PR**. Skipping this is how `truncate()` ended up with 6 implementations (#1304).
### Constants — no magic literals
- **Time**: NEVER use raw numbers (`1000`, `60000`, `3600000`). Import from `server/utils/time.ts`
- **Strings**: scheduler types, event types, API routes, tool names — use existing `as const` objects
- **Paths**: use `WORKSPACE_PATHS` / `WORKSPACE_DIRS` / `WORKSPACE_FILES` — never hardcode
### File I/O — domain modules only
NEVER use raw `fs.readFile` / `fs.writeFile` in route handlers. Use `server/utils/files/<domain>-io.ts`. All writes go through `writeFileAtomic`.
### Network I/O — centralized helpers
- **Frontend → Server**: use `src/utils/api.ts` (`apiGet`, `apiPost`, etc.) — auto-attaches bearer token
- **MCP → Server**: use `postJson()` with `AUTH_HEADER`
- **Server → External**: use `AbortController` for timeouts, check `response.ok`
### Cross-platform
- Build paths with `node:path` (`path.join`, `path.resolve`) — NEVER concatenate `/`
- Atomic writes: tmp file alongside destination, not in `os.tmpdir()`
- Package exports: include `"require"` and `"default"` conditions (Docker CJS mode)
### Code style
- Functions under 20 lines; split into smaller functions if needed
- `const` over `let`; never `var`
- Extract pure logic into exported helpers for testability
- Honour `sonarjs/cognitive-complexity` threshold (error at >15)
- No re-export barrel files without specific reason
### GitHub posts
NEVER escape backticks with `\`` in `gh` commands. Use single-quoted heredoc (`<<'EOF'`).
### Error-recovery know-how — keep `error-recovery.md` in sync
When you (or a PR you're reviewing) adds a new diagnostic / fix for a recurring failure mode — sandbox auth, build ordering, plugin install, anything the agent might hit at runtime — also add a section (or extend an existing one) in [`packages/core/assets/helps/error-recovery.md`](packages/core/assets/helps/error-recovery.md). The agent reads that file BEFORE asking the user a clarifying question on a tool failure (see the "When a tool call fails" section in `server/prompts/system/system.md`), so know-how that lives only in a PR description / commit message / `docs/` is invisible to it. Bump `@mulmoclaude/core` whenever `assets/helps/*` changes (it ships to npm via `files: ["dist", "assets"]`).
### Edit-time deeper rules (read when relevant)
- **Lint warnings + `eslint-disable` etiquette** → [`docs/lint-policy.md`](docs/lint-policy.md)
- **UI control sizes + chrome row layout** → [`docs/ui-controls.md`](docs/ui-controls.md)
- **UI region naming + testid discipline + when to update layout art** → [`docs/ui-cheatsheet.md`](docs/ui-cheatsheet.md) (also the source of truth for the ASCII layout map)
- **i18n (all 8 locales in lockstep, add / rename / remove keys, new locale registration)** → [`docs/i18n.md`](docs/i18n.md)
- **Reproducing Windows-host FS bugs inside Linux Docker containers on CI** (dangling NTFS junctions, WSL2 + native `dockerd` setup, gotchas) → [`docs/windows-docker-ci.md`](docs/windows-docker-ci.md)
## Package dependency direction (always apply)
The monorepo has three package families. **Dependencies flow in ONE direction only** — violating this creates uphill imports, parallel-build races, and the tier-ordering dance that #1789 / #1795 had to dismantle.
```
▲ depends on
host │ (server/, src/, packages/mulmoclaude)
────── │
plugins │ (packages/plugins/*-plugin)
────── │
shared core │ (@mulmoclaude/core — formerly the 7 packages/services/*)
no deps │ (leaf libs: @mulmobridge/protocol, @receptron/task-scheduler, etc.)
```
**Rules:**
- A **plugin** (`packages/plugins/<name>-plugin`) MAY import `@mulmoclaude/core/<subpath>` (or any leaf lib). It MUST NOT import another `*-plugin`. Cross-plugin sharing goes through core.
- **Shared core** (`@mulmoclaude/core` — provides `./collection`, `./collection/server`, `./collection-watchers`, `./skill-bridge`, `./notifier`, `./scheduler`, `./whisper`, `./whisper/client`, `./workspace-setup`, `./workspace-setup/slug`, `./file-change-publisher`) MUST NOT import any `*-plugin`. If a plugin owns code that core / another plugin needs, **pull it OUT of the plugin into core** (the `isSafeActionTemplatePath` / `discoverCollections` extraction in #1795 is the canonical pattern), don't import uphill.
- **Browser-safe surfaces of core** stay on dedicated subpaths (`@mulmoclaude/core/whisper/client`, `@mulmoclaude/core/workspace-setup/slug`). Everything else under `@mulmoclaude/core/*` is server-only.
- **Host** (`server/`, `src/`, `packages/mulmoclaude`) MAY import anything below it. Host code stays generic — provider-specific code belongs in the relevant plugin, not in `server/`.
When the build complains "Cannot find module `@mulmoclaude/foo`" cold, the cause is almost always an uphill or peer import. **Don't patch with a new tier or a `--first=foo` flag** — surface the import and move the code instead. Plan record: [`plans/done/refactor-shared-core.md`](plans/done/refactor-shared-core.md).
## Server Logging
Use `log.{error,warn,info,debug}(prefix, msg, data?)`. Never call `console.*` directly. Full reference: [`docs/logging.md`](docs/logging.md).
## Architecture (quick map)
| File | Purpose |
|---|---|
| `server/agent/index.ts` | Agent loop, MCP server creation |
| `server/agent/mcp-server.ts` | stdio JSON-RPC MCP bridge |
| `server/api/routes/agent.ts` | `POST /api/agent` → SSE stream |
| `server/workspace/paths.ts` | Workspace path constants |
| `server/utils/time.ts` | Time constants + timeout presets |
| `src/config/apiRoutes.ts` | API endpoint path constants |
| `src/config/roles.ts` | Role definitions |
| `src/App.vue` | Main UI |
Full layout + workspace tree + process map: [`docs/developer.md`](docs/developer.md).
## When you do certain tasks (read the dedicated doc)
| Task | Doc |
|---|---|
| **Create or edit a plugin** | [`docs/plugin-development.md`](docs/plugin-development.md) (built-in + runtime, scaffold sync, host aggregators, "extract shared code into core" recipe) |
| **Add a workspace package, debug a tier order issue** | [`docs/build-orchestration.md`](docs/build-orchestration.md) |
| **Release the app** (`vX.Y.Z`) | `/release-app` skill |
| **Publish `mulmoclaude` to npm** | `/publish-mulmoclaude` skill |
| **Publish a shared `@mulmoclaude/*` or `@mulmobridge/*` npm package** | `/publish` skill — tag `@scope/name@X.Y.Z` (no `v`), GH release with `--latest=false` |
| **Work out WHAT to publish** — who depends on what, whether a dependent needs republishing, why app code only ships via the launcher | [`docs/package-releases.md`](docs/package-releases.md). Start with `yarn audit:releases --code-only`. |
| **Add / write an e2e test** (mock or live) | [`docs/developer.md#e2e-testing-playwright`](docs/developer.md#e2e-testing-playwright) for mock, [`docs/e2e-live-testing.md`](docs/e2e-live-testing.md) for live (must-read before adding a `e2e-live/tests/*.spec.ts`) |
| **Manual-test scenarios that can't be automated** | [`docs/manual-testing.md`](docs/manual-testing.md) |
| **Work on remote host** (drive MulmoClaude from a phone over the Firestore command channel) | [`docs/remote-host.md`](docs/remote-host.md) (auth model, command loop, handler table, mobile custom-view postMessage bridge) |
| **Reference the centralised constants** (`API_ROUTES`, `TOOL_NAMES`, `WORKSPACE_DIRS`, `PUBSUB_CHANNELS`, `EVENT_TYPES`, `SCHEDULE_TYPES`) | [`docs/developer.md#centralized-constants`](docs/developer.md#centralized-constants). For the four plugin-aware aggregators, edit the plugin's `meta.ts` — never the host record. |
### `chore(release)` commits — never bump the launcher preemptively
A `chore(release)` commit that publishes a shared workspace package (e.g. `@mulmoclaude/core`, `@mulmoclaude/collection-plugin`) MUST bump only that package's `version` and the launcher's DEP RANGE for it (to keep the `launcherSync.mjs` workspace-lockstep invariant green). It MUST NOT bump the launcher's OWN `version` field — that field is reserved for the `/publish-mulmoclaude` workflow that actually publishes the npm launcher.
Rationale: the launcher-sync gate enforces `launcherRange.lowerBound == workspace.version` for dep ratchet, but it never touches or checks the launcher's OWN `version`. Bumping the launcher preemptively "for tidiness" while skipping the actual npm publish creates a silent drift where `packages/mulmoclaude/package.json` runs ahead of npm's latest — future readers can't tell what the next actual publish should be, and end up either skipping the drafted-but-unpublished identity (leaving numbering gaps) or publishing a version whose intent no longer matches the current code. See #1945 for the pattern that motivated this rule.
When to bump `packages/mulmoclaude/package.json`'s `version`:
- Inside the `/publish-mulmoclaude` flow, right before the actual `npm publish`. That commit becomes the identity of the release.
- Never as part of a `chore(release)` that publishes only shared packages.
### Publish order — always bottom-up, launcher last
Publish a dependency BEFORE anything that imports it. Getting this backwards ships a
package whose code calls an export that does not exist on npm yet.
```text
@mulmoclaude/common → @mulmoclaude/markdown-utils → @mulmoclaude/core → @mulmoclaude/*-plugin ─┐
@mulmobridge/protocol → @mulmobridge/client → @mulmobridge/<service> ──────────────────────────┤
@mulmobridge/webhook-runtime → the 6 webhook bridges ──────────────────────────────────────────┤
mulmoclaude (launcher)
```
**The launcher is always last**, and it is not optional when app code moved: its `files`
include `server/` and `src/`, so a change under those directories reaches npm users ONLY
through `/publish-mulmoclaude` — no amount of package publishing delivers it.
A dependent usually does NOT need republishing (a `1.x` caret floats across minors, so
`^1.9.0` picks up `1.10.0` on its own). It DOES when it imports something the published
dependency lacks. That case is load-bearing, not tidiness: in #2643, `server/remoteHost/`
started importing `startResilientHostRunner`, which `@mulmoclaude/core@1.9.0` does not
have — so core 1.10.0 had to ship, with every declared range swept, before the launcher
could be published at all.
Full graph, per-state drift detection and the tag rules → [`docs/package-releases.md`](docs/package-releases.md).
### Before any release, ask what is actually drifting
`yarn audit:releases --code-only` compares every publishable workspace against its
release tag. A `version` equal to npm's latest does NOT mean the source matches what
shipped — only the tag says that, which is why the tool reports a missing tag as its own
finding rather than as "clean". This is not hypothetical: the polynomial-ReDoS fix in
`@mulmoclaude/markdown-utils` (CodeQL #402) sat unpublished with nothing pointing at it,
and `core` depends on that package at RUNTIME rather than bundling it — so every npm
consumer kept resolving the unfixed copy.
Full graph, the "does a dependent need republishing too?" rules, and the launcher's
special role (app code under `server/` / `src/` ships ONLY through a `mulmoclaude`
publish) → [`docs/package-releases.md`](docs/package-releases.md).
### Internal dep ranges — always track the latest published version
Every declared range on a workspace-internal package (`@mulmoclaude/*`, `@mulmobridge/*`, `mulmoclaude`) MUST equal `^<latest version published to npm>`, in **every** `package.json` that declares it — `dependencies`, `devDependencies` and `peerDependencies` alike, in bridges and plugins, not just the launcher.
Whenever you publish a workspace package, sweep every consumer's range to the new version in the same PR.
Rationale: a caret range on a `0.x` package does **not** float across minor versions — `^0.23.0` resolves to `>=0.23.0 <0.24.0`. So a stale range doesn't merely look untidy, it *pins consumers to an old line* and silently withholds everything published since. This is not hypothetical: `mulmoclaude@1.3.0` shipped `@mulmoclaude/core: ^0.23.0` while npm had already served 0.24 through 0.28, so npm-installed users could not receive any of it. The `launcherSync.mjs` gate only checks the launcher, so nothing catches the same drift in the other ~50 workspaces.
To audit before a release:
```bash
# for each internal dep name, compare every declared range against npm's latest
npm view <pkg> version --registry https://registry.npmjs.org/
```
A range update reaches users only through that consumer's own next release, so it does not force an immediate republish of all 50 packages — but it MUST be in the tree before the consumer is published next.
+ ### A plugin declares host-provided packages as `peer` + `dev` — never `dependencies`
+
+ A `packages/plugins/*-plugin` is always installed **alongside a host** — `mulmoclaude` or
+ `mulmoterminal` — and both hosts declare `@mulmoclaude/core` themselves. So core is supplied by
+ the host, and a plugin MUST declare it as:
+
+ ```jsonc
+ "peerDependencies": { "@mulmoclaude/core": "^<latest>" }, // the host must provide it
+ "devDependencies": { "@mulmoclaude/core": "^<latest>" } // so the plugin builds/tests standalone
+ ```
+
+ and MUST NOT list it under `dependencies`. Same for anything else the host owns
+ (`gui-chat-protocol`, `vue`, `echarts` — see the existing `peerDependencies` blocks).
+
+ Rationale: `dependencies` makes npm install a **second copy of core nested under the plugin**,
+ so the plugin and the host each get their own module instance. Anything core keeps in module
+ state (registries, watchers, caches) then silently exists twice, and the plugin talks to the
+ copy the host never sees. A peer range instead *fails loudly* when the host is too old, which is
+ the behaviour you want. `check:launcher-sync` verifies the launcher satisfies these peers
+ ("no peer-dep violations"); nothing catches a wrongly-placed `dependencies` entry, so it is on
+ you at review time.
+
+ `collection-plugin` is the reference shape. When a plugin imports core, moving the entry out of
+ `dependencies` means **adding** it to `peerDependencies` and `devDependencies` — deleting it
+ outright leaves an imported package undeclared.
+
### Tag every publish — no untagged releases
Every `npm publish` of a workspace package MUST be accompanied by a git tag `@scope/name@X.Y.Z` (no `v` prefix) on the published commit, plus a GH release (`--latest=false`). The `/publish` skill does this — do NOT publish by hand and skip the tag.
Rationale: the tag is the ONLY reliable marker of "what commit this npm version was cut from". Answering *"which packages changed since their last release and need republishing?"* is a `git diff <name>@<version> HEAD -- <dir>` — which is impossible when the tag is missing. This is not hypothetical: the `1.0.0` plugins (`@mulmoclaude/*-plugin`) were published to npm without `@…@1.0.0` tags (a bulk `0.x → 1.0.0` re-version), so release-drift detection for them had to fall back to guessing the version-bump commit. `version` in `package.json` == npm's latest tells you it was published, but NOT whether the current source differs from what shipped — only the tag does.
When you discover a past publish that was never tagged, create the tag retroactively on the commit that bumped `version` to the published value (best effort), so future drift detection works.